From 27b7fe7a13e43f773d691befe43b54eed8210bab Mon Sep 17 00:00:00 2001 From: ixgadmin Date: Wed, 29 Jul 2026 16:48:48 +0000 Subject: [PATCH] Initial commit: IXG Platform --- .dockerignore | 7 + .gitignore | 12 ++ Dockerfile | 24 +++ SESSION_CHECKPOINT.md | 99 +++++++++ apps/ai/__init__.py | 0 apps/ai/admin.py | 24 +++ apps/ai/agent_service.py | 148 +++++++++++++ apps/ai/apps.py | 6 + apps/ai/migrations/0001_initial.py | 34 +++ apps/ai/migrations/0002_agent_config.py | 45 ++++ apps/ai/migrations/__init__.py | 0 apps/ai/models.py | 70 +++++++ apps/ai/serializers.py | 41 ++++ apps/ai/urls.py | 12 ++ apps/ai/views.py | 116 +++++++++++ apps/batches/__init__.py | 0 apps/batches/admin.py | 9 + apps/batches/apps.py | 6 + apps/batches/migrations/0001_initial.py | 38 ++++ apps/batches/migrations/__init__.py | 0 apps/batches/models.py | 40 ++++ apps/batches/serializers.py | 56 +++++ apps/batches/urls.py | 8 + apps/batches/views.py | 31 +++ apps/buyer/__init__.py | 0 apps/buyer/apps.py | 7 + apps/buyer/urls.py | 14 ++ apps/buyer/views.py | 117 +++++++++++ apps/capa/__init__.py | 0 apps/capa/admin.py | 8 + apps/capa/apps.py | 6 + apps/capa/migrations/0001_initial.py | 38 ++++ apps/capa/migrations/__init__.py | 0 apps/capa/models.py | 29 +++ apps/capa/serializers.py | 9 + apps/capa/urls.py | 8 + apps/capa/views.py | 9 + apps/core/__init__.py | 0 apps/core/apps.py | 6 + apps/core/middleware.py | 24 +++ apps/documents/__init__.py | 0 apps/documents/admin.py | 18 ++ apps/documents/apps.py | 6 + apps/documents/migrations/0001_initial.py | 72 +++++++ apps/documents/migrations/__init__.py | 0 apps/documents/models.py | 81 +++++++ apps/documents/serializers.py | 32 +++ apps/documents/urls.py | 10 + apps/documents/views.py | 73 +++++++ apps/esg/__init__.py | 0 apps/esg/admin.py | 7 + apps/esg/apps.py | 6 + apps/esg/migrations/0001_initial.py | 36 ++++ apps/esg/migrations/__init__.py | 0 apps/esg/models.py | 22 ++ apps/esg/serializers.py | 9 + apps/esg/urls.py | 8 + apps/esg/views.py | 9 + apps/notifications/__init__.py | 0 apps/notifications/admin.py | 8 + apps/notifications/apps.py | 6 + apps/notifications/migrations/0001_initial.py | 34 +++ apps/notifications/migrations/__init__.py | 0 apps/notifications/models.py | 20 ++ apps/notifications/serializers.py | 9 + apps/notifications/urls.py | 8 + apps/notifications/views.py | 12 ++ apps/ops/__init__.py | 0 apps/ops/apps.py | 7 + apps/ops/urls.py | 18 ++ apps/ops/views.py | 197 ++++++++++++++++++ apps/qc/__init__.py | 0 apps/qc/admin.py | 8 + apps/qc/apps.py | 6 + apps/qc/migrations/0001_initial.py | 35 ++++ apps/qc/migrations/__init__.py | 0 apps/qc/models.py | 27 +++ apps/qc/serializers.py | 49 +++++ apps/qc/urls.py | 8 + apps/qc/views.py | 13 ++ apps/shipments/__init__.py | 0 apps/shipments/admin.py | 8 + apps/shipments/apps.py | 6 + apps/shipments/migrations/0001_initial.py | 38 ++++ apps/shipments/migrations/__init__.py | 0 apps/shipments/models.py | 33 +++ apps/shipments/serializers.py | 9 + apps/shipments/urls.py | 8 + apps/shipments/views.py | 9 + apps/subscriptions/__init__.py | 0 apps/subscriptions/admin.py | 9 + apps/subscriptions/apps.py | 6 + apps/subscriptions/migrations/0001_initial.py | 35 ++++ apps/subscriptions/migrations/__init__.py | 0 apps/subscriptions/models.py | 33 +++ apps/subscriptions/serializers.py | 11 + apps/subscriptions/urls.py | 8 + apps/subscriptions/views.py | 15 ++ apps/users/__init__.py | 0 apps/users/admin.py | 25 +++ apps/users/apps.py | 6 + apps/users/auth.py | 30 +++ apps/users/migrations/0001_initial.py | 49 +++++ .../migrations/0002_alter_user_managers.py | 20 ++ apps/users/migrations/__init__.py | 0 apps/users/models.py | 55 +++++ apps/users/serializers.py | 32 +++ apps/users/urls.py | 8 + apps/users/views.py | 25 +++ create_agent.py | 22 ++ docker-compose.yml | 102 +++++++++ dsp_platform/__init__.py | 3 + dsp_platform/asgi.py | 5 + dsp_platform/celery.py | 8 + dsp_platform/settings.py | 161 ++++++++++++++ dsp_platform/urls.py | 30 +++ dsp_platform/wsgi.py | 5 + manage.py | 10 + nginx/nginx.conf | 26 +++ requirements.txt | 25 +++ scripts/entrypoint.sh | 21 ++ templates/ops/base_ops.html | 59 ++++++ templates/ops/batch_detail.html | 110 ++++++++++ templates/ops/batch_list.html | 85 ++++++++ templates/ops/capa_detail.html | 49 +++++ templates/ops/capa_list.html | 58 ++++++ templates/ops/dashboard.html | 111 ++++++++++ templates/ops/document_list.html | 57 +++++ templates/ops/esg_list.html | 41 ++++ templates/ops/qc_detail.html | 28 +++ templates/ops/qc_list.html | 58 ++++++ templates/ops/shipment_detail.html | 29 +++ templates/ops/shipment_list.html | 58 ++++++ templates/portal/base_portal.html | 56 +++++ templates/portal/batch_detail.html | 73 +++++++ templates/portal/batch_list.html | 58 ++++++ templates/portal/chat.html | 159 ++++++++++++++ templates/portal/chat_embed.html | 108 ++++++++++ templates/portal/dashboard.html | 74 +++++++ templates/portal/document_list.html | 53 +++++ templates/portal/subscription.html | 26 +++ 141 files changed, 4201 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 SESSION_CHECKPOINT.md create mode 100644 apps/ai/__init__.py create mode 100644 apps/ai/admin.py create mode 100644 apps/ai/agent_service.py create mode 100644 apps/ai/apps.py create mode 100644 apps/ai/migrations/0001_initial.py create mode 100644 apps/ai/migrations/0002_agent_config.py create mode 100644 apps/ai/migrations/__init__.py create mode 100644 apps/ai/models.py create mode 100644 apps/ai/serializers.py create mode 100644 apps/ai/urls.py create mode 100644 apps/ai/views.py create mode 100644 apps/batches/__init__.py create mode 100644 apps/batches/admin.py create mode 100644 apps/batches/apps.py create mode 100644 apps/batches/migrations/0001_initial.py create mode 100644 apps/batches/migrations/__init__.py create mode 100644 apps/batches/models.py create mode 100644 apps/batches/serializers.py create mode 100644 apps/batches/urls.py create mode 100644 apps/batches/views.py create mode 100644 apps/buyer/__init__.py create mode 100644 apps/buyer/apps.py create mode 100644 apps/buyer/urls.py create mode 100644 apps/buyer/views.py create mode 100644 apps/capa/__init__.py create mode 100644 apps/capa/admin.py create mode 100644 apps/capa/apps.py create mode 100644 apps/capa/migrations/0001_initial.py create mode 100644 apps/capa/migrations/__init__.py create mode 100644 apps/capa/models.py create mode 100644 apps/capa/serializers.py create mode 100644 apps/capa/urls.py create mode 100644 apps/capa/views.py create mode 100644 apps/core/__init__.py create mode 100644 apps/core/apps.py create mode 100644 apps/core/middleware.py create mode 100644 apps/documents/__init__.py create mode 100644 apps/documents/admin.py create mode 100644 apps/documents/apps.py create mode 100644 apps/documents/migrations/0001_initial.py create mode 100644 apps/documents/migrations/__init__.py create mode 100644 apps/documents/models.py create mode 100644 apps/documents/serializers.py create mode 100644 apps/documents/urls.py create mode 100644 apps/documents/views.py create mode 100644 apps/esg/__init__.py create mode 100644 apps/esg/admin.py create mode 100644 apps/esg/apps.py create mode 100644 apps/esg/migrations/0001_initial.py create mode 100644 apps/esg/migrations/__init__.py create mode 100644 apps/esg/models.py create mode 100644 apps/esg/serializers.py create mode 100644 apps/esg/urls.py create mode 100644 apps/esg/views.py create mode 100644 apps/notifications/__init__.py create mode 100644 apps/notifications/admin.py create mode 100644 apps/notifications/apps.py create mode 100644 apps/notifications/migrations/0001_initial.py create mode 100644 apps/notifications/migrations/__init__.py create mode 100644 apps/notifications/models.py create mode 100644 apps/notifications/serializers.py create mode 100644 apps/notifications/urls.py create mode 100644 apps/notifications/views.py create mode 100644 apps/ops/__init__.py create mode 100644 apps/ops/apps.py create mode 100644 apps/ops/urls.py create mode 100644 apps/ops/views.py create mode 100644 apps/qc/__init__.py create mode 100644 apps/qc/admin.py create mode 100644 apps/qc/apps.py create mode 100644 apps/qc/migrations/0001_initial.py create mode 100644 apps/qc/migrations/__init__.py create mode 100644 apps/qc/models.py create mode 100644 apps/qc/serializers.py create mode 100644 apps/qc/urls.py create mode 100644 apps/qc/views.py create mode 100644 apps/shipments/__init__.py create mode 100644 apps/shipments/admin.py create mode 100644 apps/shipments/apps.py create mode 100644 apps/shipments/migrations/0001_initial.py create mode 100644 apps/shipments/migrations/__init__.py create mode 100644 apps/shipments/models.py create mode 100644 apps/shipments/serializers.py create mode 100644 apps/shipments/urls.py create mode 100644 apps/shipments/views.py create mode 100644 apps/subscriptions/__init__.py create mode 100644 apps/subscriptions/admin.py create mode 100644 apps/subscriptions/apps.py create mode 100644 apps/subscriptions/migrations/0001_initial.py create mode 100644 apps/subscriptions/migrations/__init__.py create mode 100644 apps/subscriptions/models.py create mode 100644 apps/subscriptions/serializers.py create mode 100644 apps/subscriptions/urls.py create mode 100644 apps/subscriptions/views.py create mode 100644 apps/users/__init__.py create mode 100644 apps/users/admin.py create mode 100644 apps/users/apps.py create mode 100644 apps/users/auth.py create mode 100644 apps/users/migrations/0001_initial.py create mode 100644 apps/users/migrations/0002_alter_user_managers.py create mode 100644 apps/users/migrations/__init__.py create mode 100644 apps/users/models.py create mode 100644 apps/users/serializers.py create mode 100644 apps/users/urls.py create mode 100644 apps/users/views.py create mode 100644 create_agent.py create mode 100644 docker-compose.yml create mode 100644 dsp_platform/__init__.py create mode 100644 dsp_platform/asgi.py create mode 100644 dsp_platform/celery.py create mode 100644 dsp_platform/settings.py create mode 100644 dsp_platform/urls.py create mode 100644 dsp_platform/wsgi.py create mode 100755 manage.py create mode 100644 nginx/nginx.conf create mode 100644 requirements.txt create mode 100755 scripts/entrypoint.sh create mode 100644 templates/ops/base_ops.html create mode 100644 templates/ops/batch_detail.html create mode 100644 templates/ops/batch_list.html create mode 100644 templates/ops/capa_detail.html create mode 100644 templates/ops/capa_list.html create mode 100644 templates/ops/dashboard.html create mode 100644 templates/ops/document_list.html create mode 100644 templates/ops/esg_list.html create mode 100644 templates/ops/qc_detail.html create mode 100644 templates/ops/qc_list.html create mode 100644 templates/ops/shipment_detail.html create mode 100644 templates/ops/shipment_list.html create mode 100644 templates/portal/base_portal.html create mode 100644 templates/portal/batch_detail.html create mode 100644 templates/portal/batch_list.html create mode 100644 templates/portal/chat.html create mode 100644 templates/portal/chat_embed.html create mode 100644 templates/portal/dashboard.html create mode 100644 templates/portal/document_list.html create mode 100644 templates/portal/subscription.html diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..26c7d23 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +venv/ +.git/ +__pycache__/ +*.pyc +.env +.gitignore +README.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0752166 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +venv/ +__pycache__/ +*.pyc +*.pyo +.env +config/secrets/ +staticfiles/ +media/ +*.egg-info/ +dist/ +build/ +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b819f60 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,24 @@ +FROM python:3.11-slim + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc postgresql-client netcat-openbsd curl \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +RUN mkdir -p /data/ixg-documents + +COPY scripts/entrypoint.sh /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/entrypoint.sh + +EXPOSE 8000 + +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] diff --git a/SESSION_CHECKPOINT.md b/SESSION_CHECKPOINT.md new file mode 100644 index 0000000..8ab7531 --- /dev/null +++ b/SESSION_CHECKPOINT.md @@ -0,0 +1,99 @@ +# IXG Platform — Mission Complete Summary + +## VPS +- **IP**: 194.164.95.50 +- **OS**: Ubuntu 26.04 +- **Project**: /root/work/ixg_platform/ + +## Services Running +| Container | Port | Notes | +|-----------|------|-------| +| ixg-platform-app | 8000 | Django + Gunicorn | +| ixg-platform-db | 5433 | PostgreSQL 15 | +| ixg-platform-redis | 6379 | Redis 7 | +| ixg-platform-celery-worker | — | Celery worker | +| ixg-platform-celery-beat | — | Celery beat scheduler | + +## Nginx (Host) +- **Config**: /etc/nginx/sites-enabled/ixg +- **Proxied routes** → Django on port 8000: + - /admin/ — Django Admin (Grappelli) + - /oidc/ — OIDC auth flow + - /ops/ — Ops Portal + - /portal/ — Buyer Portal + - /static/ — Static files + - /grappelli/ — Grappelli admin theme + - /platform/ — Legacy Django root + +## Authentication +- **Provider**: Keycloak 26.6.1 (realm: ixg) on port 8443 +- **Admin console**: https://auth.veripath.co.uk/admin/veripath/console/ +- **Keycloak admin**: admin / test123 +- **OIDC Client**: ixg-platform (secret: VGEvBE7M7AaNKde8F5t0zAXWMpgnCYCX) +- **Django superuser**: admin@ixg.local / Admin123! +- **Test user**: testuser / Test123! (test@ixg.local) +- **Matthew**: mstickels@yahoo.co.uk (role=admin) + +## What Was Built + +### Phase 1 — Foundation +- Django project (5.2) with 12 models across 9 apps +- Docker Compose orchestration (app, Celery worker/beat, PostgreSQL, Redis) +- Keycloak OIDC integration (mozilla-django-oidc) +- Grappelli-themed Django admin +- Custom User model (email-based, UUID PK, roles: buyer/ops/admin) +- RBAC middleware for role-based route access + +### Phase 2 — REST API +- DRF serializers, viewsets, and URLs for all models +- Pagination (20/page), filtering (django-filter), search, ordering +- QC auto-CAPA logic: QC fail auto-creates CAPA record +- Token + Session authentication + +### Phase 3 — Ops Portal (/ops/) +- Dashboard with key metrics (batches by status, QC pass/fail, active shipments, open CAPAs) +- List/detail views for Batches, QC Results, Shipments, CAPA Records, Documents, ESG Reports +- Search, filter, pagination on all list views +- Related data displayed on detail pages + +### Phase 4 — Buyer Portal (/portal/) +- Buyer-facing dashboard (batch count, active shipments, recent documents, subscription) +- Batches list/detail with shipments, documents, ESG reports +- Documents list (buyer-visible only, by visibility field) +- Subscription status view + +### Phase 5 — AI Chatbot +- AgentConfig model with encrypted API key storage (django-encrypted-model-fields) +- Agent admin UI at /admin/ai/agentconfig/ +- Agent service with provider dispatch: OpenAI, DeepSeek, Google Gemini, Ollama, Custom +- Default agent IXG Assistant configured with DeepSeek API +- Chat API: POST /api/ai-interactions/chat/ +- Standalone chat page: /portal/chat/ +- Embeddable widget: /portal/chat/embed/ +- Chat history tracked per user/session via AIInteraction model + +## API Endpoints +| Endpoint | Methods | Purpose | +|----------|---------|---------| +| /api/auth/ | POST | Get auth token | +| /api/users/ | GET/POST/PUT/DELETE | User management | +| /api/subscriptions/ | GET/POST/PUT/DELETE | Subscriptions | +| /api/batches/ | GET/POST/PUT/DELETE | Processing batches | +| /api/qc-results/ | GET/POST/PUT/DELETE | QC results | +| /api/shipments/ | GET/POST/PUT/DELETE | Shipments | +| /api/documents/ | GET/POST/PUT/DELETE | Documents | +| /api/capa-records/ | GET/POST/PUT/DELETE | CAPA records | +| /api/esg-reports/ | GET/POST/PUT/DELETE | ESG reports | +| /api/ai-interactions/ | GET | Chat history | +| /api/ai-interactions/chat/ | POST | Send chat message | +| /api/agents/ | GET/POST/PUT/DELETE | Agent configs | + +## Outstanding Tasks +1. **Chat UI debugging** — JS fetch to chat API needs session cookie auth to work in browser; works with token auth +2. **Email notifications** — SMTP outgoing (QC fail alerts, shipment updates, CAPA assignments) + IMAP incoming (certificate PDF auto-import). Requires domain + Fasthosts mail hosting or transactional API (SendGrid/Mailgun) +3. **Compliance automation** — CoA auto-generation, EUDR due diligence checks. Needs domain expertise +4. **Polish** — UI refinement, form validation, error handling, loading states across both portals + +## Reference Documents +- This document: /root/work/ixg_platform/SESSION_CHECKPOINT.md +- Wiki development plan: http://194.164.95.50/en/home/development/plan diff --git a/apps/ai/__init__.py b/apps/ai/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/ai/admin.py b/apps/ai/admin.py new file mode 100644 index 0000000..260afad --- /dev/null +++ b/apps/ai/admin.py @@ -0,0 +1,24 @@ + +from django.contrib import admin +from .models import AIInteraction, AgentConfig + + +@admin.register(AIInteraction) +class AIInteractionAdmin(admin.ModelAdmin): + list_display = ["user", "message_role", "tokens_used", "created_at"] + list_filter = ["message_role"] + + +@admin.register(AgentConfig) +class AgentConfigAdmin(admin.ModelAdmin): + list_display = ["name", "provider", "model_name", "is_active", "is_default", "created_at"] + list_filter = ["provider", "is_active", "is_default"] + search_fields = ["name"] + fieldsets = ( + (None, {"fields": ("name", "description", "provider")}), + ("API Configuration", {"fields": ("api_key", "api_secret", "base_url")}), + ("Model", {"fields": ("model_name", "temperature", "max_tokens")}), + ("Prompt", {"fields": ("system_prompt",)}), + ("Capabilities", {"fields": ("capabilities",)}), + ("Settings", {"fields": ("is_active", "is_default", "user")}), + ) diff --git a/apps/ai/agent_service.py b/apps/ai/agent_service.py new file mode 100644 index 0000000..940004a --- /dev/null +++ b/apps/ai/agent_service.py @@ -0,0 +1,148 @@ + +import json +import re +import time +from typing import Optional + +from .models import AgentConfig + + +class AgentService: + + @classmethod + def execute_prompt( + cls, + config: AgentConfig, + prompt: str, + history: Optional[list] = None, + system_prompt_override: Optional[str] = None, + temperature: Optional[float] = None, + max_tokens: Optional[int] = None, + ): + if not config or not config.is_active: + return False, "Agent not found or inactive", None + + system = system_prompt_override or config.system_prompt or "" + temp = temperature if temperature is not None else config.temperature + tokens = max_tokens if max_tokens is not None else config.max_tokens + + messages = [{"role": "system", "content": system}] + if history: + for msg in history[-20:]: + messages.append({"role": msg.get("role", "user"), "content": msg.get("content", "")}) + messages.append({"role": "user", "content": prompt}) + + try: + if config.provider == AgentConfig.Provider.OPENAI: + response = cls._call_openai(config, messages, temp, tokens) + elif config.provider == AgentConfig.Provider.DEEPSEEK: + response = cls._call_deepseek(config, messages, temp, tokens) + elif config.provider == AgentConfig.Provider.GOOGLE: + response = cls._call_google(config, messages, temp, tokens) + elif config.provider == AgentConfig.Provider.OLLAMA: + response = cls._call_ollama(config, messages, temp, tokens) + elif config.provider == AgentConfig.Provider.CUSTOM: + response = cls._call_custom(config, messages, temp, tokens) + else: + return False, f"Unsupported provider: {config.provider}", None + + return True, response.get("content", ""), response.get("tokens_used") + + except Exception as e: + return False, f"Error calling {config.provider}: {str(e)}", None + + @classmethod + def _call_openai(cls, config, messages, temperature, max_tokens): + from openai import OpenAI + client = OpenAI(api_key=config.api_key or None) + resp = client.chat.completions.create( + model=config.model_name, + messages=messages, + temperature=temperature, + max_tokens=max_tokens, + ) + return { + "content": resp.choices[0].message.content or "", + "tokens_used": resp.usage.total_tokens if resp.usage else 0, + } + + @classmethod + def _call_deepseek(cls, config, messages, temperature, max_tokens): + from openai import OpenAI + client = OpenAI( + api_key=config.api_key or None, + base_url=config.base_url or "https://api.deepseek.com", + ) + resp = client.chat.completions.create( + model=config.model_name or "deepseek-chat", + messages=messages, + temperature=temperature, + max_tokens=max_tokens, + ) + return { + "content": resp.choices[0].message.content or "", + "tokens_used": resp.usage.total_tokens if resp.usage else 0, + } + + @classmethod + def _call_google(cls, config, messages, temperature, max_tokens): + from google import genai + client = genai.Client(api_key=config.api_key or None) + system_msg = "" + chat_messages = [] + for m in messages: + if m["role"] == "system": + system_msg += m["content"] + "\n" + else: + chat_messages.append({"role": m["role"], "parts": [m["content"]]}) + model = client.models.generate_content( + model=config.model_name or "gemini-2.0-flash", + contents=chat_messages, + config={ + "system_instruction": system_msg.strip() if system_msg else None, + "temperature": temperature, + "max_output_tokens": max_tokens, + }, + ) + return { + "content": model.text or "", + "tokens_used": 0, + } + + @classmethod + def _call_ollama(cls, config, messages, temperature, max_tokens): + import requests + url = (config.base_url or "http://localhost:11434") + "/api/chat" + payload = { + "model": config.model_name or "llama3", + "messages": messages, + "options": { + "temperature": temperature, + "num_predict": max_tokens, + }, + } + resp = requests.post(url, json=payload, timeout=120) + resp.raise_for_status() + data = resp.json() + return { + "content": data.get("message", {}).get("content", ""), + "tokens_used": 0, + } + + @classmethod + def _call_custom(cls, config, messages, temperature, max_tokens): + from openai import OpenAI + client = OpenAI( + api_key=config.api_key or "fake-key", + base_url=config.base_url or "http://localhost:8000/v1", + ) + resp = client.chat.completions.create( + model=config.model_name or "custom-model", + messages=messages, + temperature=temperature, + max_tokens=max_tokens, + ) + return { + "content": resp.choices[0].message.content or "", + "tokens_used": resp.usage.total_tokens if resp.usage else 0, + } diff --git a/apps/ai/apps.py b/apps/ai/apps.py new file mode 100644 index 0000000..7f52c7b --- /dev/null +++ b/apps/ai/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + +class AiConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.ai" + verbose_name = "Ai" diff --git a/apps/ai/migrations/0001_initial.py b/apps/ai/migrations/0001_initial.py new file mode 100644 index 0000000..d47148c --- /dev/null +++ b/apps/ai/migrations/0001_initial.py @@ -0,0 +1,34 @@ +# Generated by Django 5.2.12 on 2026-07-29 12:03 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='AIInteraction', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('session_id', models.CharField(blank=True, max_length=255)), + ('message_role', models.CharField(choices=[('user', 'User'), ('assistant', 'Assistant')], max_length=10)), + ('message_content', models.TextField()), + ('tokens_used', models.IntegerField(default=0)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ai_interactions', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'ai_interactions', + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/apps/ai/migrations/0002_agent_config.py b/apps/ai/migrations/0002_agent_config.py new file mode 100644 index 0000000..f66131f --- /dev/null +++ b/apps/ai/migrations/0002_agent_config.py @@ -0,0 +1,45 @@ +# Generated by Django 5.2.12 on 2026-07-29 14:39 + +import django.db.models.deletion +import encrypted_model_fields.fields +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ai', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='AgentConfig', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('name', models.CharField(max_length=100)), + ('description', models.TextField(blank=True)), + ('provider', models.CharField(choices=[('openai', 'OpenAI'), ('google', 'Google Gemini'), ('deepseek', 'DeepSeek'), ('ollama', 'Ollama'), ('custom', 'Custom')], default='openai', max_length=20)), + ('api_key', encrypted_model_fields.fields.EncryptedCharField(blank=True, default='')), + ('api_secret', encrypted_model_fields.fields.EncryptedCharField(blank=True, default='')), + ('base_url', models.URLField(blank=True, default='', max_length=500)), + ('model_name', models.CharField(default='gpt-4o', max_length=100)), + ('system_prompt', models.TextField(blank=True, default='')), + ('temperature', models.FloatField(default=0.7)), + ('max_tokens', models.IntegerField(default=2000)), + ('capabilities', models.JSONField(blank=True, default=list)), + ('is_active', models.BooleanField(default=True)), + ('is_default', models.BooleanField(default=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='agent_configs', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Agent Configuration', + 'verbose_name_plural': 'Agent Configurations', + 'db_table': 'agent_configs', + 'constraints': [models.UniqueConstraint(fields=('user', 'name'), name='unique_agent_per_user')], + }, + ), + ] diff --git a/apps/ai/migrations/__init__.py b/apps/ai/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/ai/models.py b/apps/ai/models.py new file mode 100644 index 0000000..ec515e1 --- /dev/null +++ b/apps/ai/models.py @@ -0,0 +1,70 @@ + +import uuid +from django.conf import settings +from django.db import models +from encrypted_model_fields.fields import EncryptedCharField + + +class AIInteraction(models.Model): + class MessageRole(models.TextChoices): + USER = "user", "User" + ASSISTANT = "assistant", "Assistant" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="ai_interactions") + session_id = models.CharField(max_length=255, blank=True) + message_role = models.CharField(max_length=10, choices=MessageRole.choices) + message_content = models.TextField() + tokens_used = models.IntegerField(default=0) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "ai_interactions" + ordering = ["-created_at"] + + def __str__(self): + return f"AI {self.message_role} - {self.user.email}" + + +class AgentConfig(models.Model): + class Provider(models.TextChoices): + OPENAI = "openai", "OpenAI" + GOOGLE = "google", "Google Gemini" + DEEPSEEK = "deepseek", "DeepSeek" + OLLAMA = "ollama", "Ollama" + CUSTOM = "custom", "Custom" + + id = models.AutoField(primary_key=True) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, + null=True, blank=True, related_name="agent_configs" + ) + name = models.CharField(max_length=100) + description = models.TextField(blank=True) + provider = models.CharField(max_length=20, choices=Provider.choices, default=Provider.OPENAI) + api_key = EncryptedCharField(max_length=500, blank=True, default="") + api_secret = EncryptedCharField(max_length=500, blank=True, default="") + base_url = models.URLField(max_length=500, blank=True, default="") + model_name = models.CharField(max_length=100, default="gpt-4o") + system_prompt = models.TextField(blank=True, default="") + temperature = models.FloatField(default=0.7) + max_tokens = models.IntegerField(default=2000) + capabilities = models.JSONField(default=list, blank=True) + is_active = models.BooleanField(default=True) + is_default = models.BooleanField(default=False) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "agent_configs" + verbose_name = "Agent Configuration" + verbose_name_plural = "Agent Configurations" + constraints = [ + models.UniqueConstraint( + fields=["user", "name"], + name="unique_agent_per_user" + ), + ] + + def __str__(self): + return self.name diff --git a/apps/ai/serializers.py b/apps/ai/serializers.py new file mode 100644 index 0000000..9e64f55 --- /dev/null +++ b/apps/ai/serializers.py @@ -0,0 +1,41 @@ + +from rest_framework import serializers +from .models import AIInteraction, AgentConfig + + +class AIInteractionSerializer(serializers.ModelSerializer): + class Meta: + model = AIInteraction + fields = "__all__" + read_only_fields = ["id", "created_at"] + + +class ChatRequestSerializer(serializers.Serializer): + agent_id = serializers.IntegerField(required=False) + message = serializers.CharField() + session_id = serializers.CharField(required=False, allow_blank=True) + history = serializers.ListField(required=False, default=list) + system_prompt_override = serializers.CharField(required=False, allow_blank=True) + temperature = serializers.FloatField(required=False) + max_tokens = serializers.IntegerField(required=False) + + +class AgentConfigSerializer(serializers.ModelSerializer): + class Meta: + model = AgentConfig + fields = ["id", "name", "description", "provider", "model_name", + "temperature", "max_tokens", "system_prompt", + "capabilities", "is_active", "is_default", + "base_url", "created_at", "updated_at"] + read_only_fields = ["id", "created_at", "updated_at"] + + +class AgentConfigWriteSerializer(serializers.ModelSerializer): + class Meta: + model = AgentConfig + fields = "__all__" + read_only_fields = ["id", "created_at", "updated_at"] + extra_kwargs = { + "api_key": {"write_only": True}, + "api_secret": {"write_only": True}, + } diff --git a/apps/ai/urls.py b/apps/ai/urls.py new file mode 100644 index 0000000..e8f7076 --- /dev/null +++ b/apps/ai/urls.py @@ -0,0 +1,12 @@ + +from rest_framework.routers import DefaultRouter +from django.urls import path, include +from . import views + +router = DefaultRouter() +router.register("ai-interactions", views.AIInteractionViewSet, basename="ai-interaction") +router.register("agents", views.AgentConfigViewSet, basename="agent") + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/apps/ai/views.py b/apps/ai/views.py new file mode 100644 index 0000000..e0a6a72 --- /dev/null +++ b/apps/ai/views.py @@ -0,0 +1,116 @@ + +import uuid +from django.db import models +from rest_framework import viewsets, permissions, status +from rest_framework.decorators import action +from rest_framework.response import Response + +from .models import AIInteraction, AgentConfig +from .serializers import ( + AIInteractionSerializer, ChatRequestSerializer, + AgentConfigSerializer, AgentConfigWriteSerializer, +) +from .agent_service import AgentService + + +class AIInteractionViewSet(viewsets.ReadOnlyModelViewSet): + queryset = AIInteraction.objects.all() + serializer_class = AIInteractionSerializer + permission_classes = [permissions.IsAuthenticated] + + def get_queryset(self): + qs = AIInteraction.objects.filter(user=self.request.user) + session = self.request.query_params.get("session_id") + if session: + qs = qs.filter(session_id=session) + return qs + + @action(detail=False, methods=["post"]) + def chat(self, request): + serializer = ChatRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + agent_id = serializer.validated_data.get("agent_id") + message = serializer.validated_data["message"] + session_id = serializer.validated_data.get("session_id") or str(uuid.uuid4()) + history = serializer.validated_data.get("history", []) + system_prompt_override = serializer.validated_data.get("system_prompt_override", "") + temperature = serializer.validated_data.get("temperature") + max_tokens = serializer.validated_data.get("max_tokens") + + agent = None + if agent_id: + agent = AgentConfig.objects.filter(id=agent_id, is_active=True).first() + else: + agent = AgentConfig.objects.filter(is_default=True, is_active=True).first() + + if not agent: + return Response( + {"error": "No active agent found. Create an agent or set one as default."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + AIInteraction.objects.create( + user=request.user, + session_id=session_id, + message_role=AIInteraction.MessageRole.USER, + message_content=message, + ) + + success, response_text, tokens_used = AgentService.execute_prompt( + config=agent, + prompt=message, + history=history, + system_prompt_override=system_prompt_override or None, + temperature=temperature, + max_tokens=max_tokens, + ) + + if not success: + return Response( + {"error": response_text, "session_id": session_id}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + AIInteraction.objects.create( + user=request.user, + session_id=session_id, + message_role=AIInteraction.MessageRole.ASSISTANT, + message_content=response_text, + tokens_used=tokens_used or 0, + ) + + return Response({ + "response": response_text, + "session_id": session_id, + "tokens_used": tokens_used, + "agent": agent.name, + }) + + @action(detail=False, methods=["get"]) + def sessions(self, request): + sessions = ( + AIInteraction.objects.filter(user=request.user) + .values("session_id") + .distinct() + .order_by("-created_at")[:20] + ) + return Response([s["session_id"] for s in sessions if s["session_id"]]) + + +class AgentConfigViewSet(viewsets.ModelViewSet): + queryset = AgentConfig.objects.all() + permission_classes = [permissions.IsAuthenticated] + + def get_serializer_class(self): + if self.request.user.role in ("admin", "ops") or self.request.user.is_superuser: + return AgentConfigWriteSerializer + return AgentConfigSerializer + + def get_queryset(self): + user = self.request.user + if user.role in ("admin", "ops") or user.is_superuser: + return AgentConfig.objects.all() + return AgentConfig.objects.filter( + models.Q(user=user) | models.Q(user__isnull=True, is_active=True) + ) diff --git a/apps/batches/__init__.py b/apps/batches/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/batches/admin.py b/apps/batches/admin.py new file mode 100644 index 0000000..7cd56db --- /dev/null +++ b/apps/batches/admin.py @@ -0,0 +1,9 @@ +from django.contrib import admin +from .models import ProcessingBatch + + +@admin.register(ProcessingBatch) +class ProcessingBatchAdmin(admin.ModelAdmin): + list_display = ["batch_reference", "grade", "weight_kg", "status", "harvest_date", "created_at"] + list_filter = ["status", "grade"] + search_fields = ["batch_reference"] diff --git a/apps/batches/apps.py b/apps/batches/apps.py new file mode 100644 index 0000000..d2eeb33 --- /dev/null +++ b/apps/batches/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + +class BatchesConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.batches" + verbose_name = "Batches" diff --git a/apps/batches/migrations/0001_initial.py b/apps/batches/migrations/0001_initial.py new file mode 100644 index 0000000..e847f6d --- /dev/null +++ b/apps/batches/migrations/0001_initial.py @@ -0,0 +1,38 @@ +# Generated by Django 5.2.12 on 2026-07-29 12:02 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='ProcessingBatch', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('batch_reference', models.CharField(max_length=50, unique=True)), + ('harvest_date', models.DateField(blank=True, null=True)), + ('weight_kg', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)), + ('grade', models.CharField(blank=True, choices=[('A', 'Grade A'), ('B', 'Grade B'), ('C', 'Grade C')], max_length=5, null=True)), + ('status', models.CharField(choices=[('harvested', 'Harvested'), ('processing', 'Processing'), ('qc_pass', 'QC Pass'), ('qc_fail', 'QC Fail'), ('ready_to_ship', 'Ready to Ship'), ('in_transit', 'In Transit'), ('at_uk_port', 'At UK Port'), ('customs', 'Customs'), ('delivered', 'Delivered')], default='harvested', max_length=20)), + ('origin_facility', models.CharField(default='ASI - Katsina State, Nigeria', max_length=255)), + ('notes', models.TextField(blank=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='batches_created', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'processing_batches', + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/apps/batches/migrations/__init__.py b/apps/batches/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/batches/models.py b/apps/batches/models.py new file mode 100644 index 0000000..e98e34b --- /dev/null +++ b/apps/batches/models.py @@ -0,0 +1,40 @@ +import uuid +from django.conf import settings +from django.db import models + + +class ProcessingBatch(models.Model): + class Grade(models.TextChoices): + A = "A", "Grade A" + B = "B", "Grade B" + C = "C", "Grade C" + + class Status(models.TextChoices): + HARVESTED = "harvested", "Harvested" + PROCESSING = "processing", "Processing" + QC_PASS = "qc_pass", "QC Pass" + QC_FAIL = "qc_fail", "QC Fail" + READY_TO_SHIP = "ready_to_ship", "Ready to Ship" + IN_TRANSIT = "in_transit", "In Transit" + AT_UK_PORT = "at_uk_port", "At UK Port" + CUSTOMS = "customs", "Customs" + DELIVERED = "delivered", "Delivered" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + batch_reference = models.CharField(max_length=50, unique=True) + harvest_date = models.DateField(null=True, blank=True) + weight_kg = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True) + grade = models.CharField(max_length=5, choices=Grade.choices, null=True, blank=True) + status = models.CharField(max_length=20, choices=Status.choices, default=Status.HARVESTED) + origin_facility = models.CharField(max_length=255, default="ASI - Katsina State, Nigeria") + notes = models.TextField(blank=True) + created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, related_name="batches_created") + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "processing_batches" + ordering = ["-created_at"] + + def __str__(self): + return self.batch_reference diff --git a/apps/batches/serializers.py b/apps/batches/serializers.py new file mode 100644 index 0000000..06d20e3 --- /dev/null +++ b/apps/batches/serializers.py @@ -0,0 +1,56 @@ +from rest_framework import serializers +from .models import ProcessingBatch + + +class ProcessingBatchListSerializer(serializers.ModelSerializer): + qc_status = serializers.SerializerMethodField() + shipment_status = serializers.SerializerMethodField() + + class Meta: + model = ProcessingBatch + fields = ["id", "batch_reference", "origin_facility", "grade", "weight_kg", "harvest_date", "status", "qc_status", "shipment_status", "created_at"] + + def get_qc_status(self, obj): + qc = obj.qc_results.first() + if qc: + return "pass" if qc.overall_pass else "fail" + return "pending" + + def get_shipment_status(self, obj): + shipment = obj.shipments.first() + return shipment.status if shipment else None + + +class ProcessingBatchDetailSerializer(serializers.ModelSerializer): + created_by_name = serializers.CharField(source="created_by.full_name", read_only=True) + qc_results = serializers.SerializerMethodField() + shipments = serializers.SerializerMethodField() + documents = serializers.SerializerMethodField() + + class Meta: + model = ProcessingBatch + fields = "__all__" + + def get_qc_results(self, obj): + qc = obj.qc_results.first() + if not qc: + return None + from apps.qc.serializers import QCResultSerializer + return QCResultSerializer(qc).data + + def get_shipments(self, obj): + shipments = obj.shipments.all() + from apps.shipments.serializers import ShipmentSerializer + return ShipmentSerializer(shipments, many=True).data + + def get_documents(self, obj): + docs = obj.documents.all() + from apps.documents.serializers import DocumentSerializer + return DocumentSerializer(docs, many=True).data + + +class ProcessingBatchWriteSerializer(serializers.ModelSerializer): + class Meta: + model = ProcessingBatch + fields = ["id", "batch_reference", "harvest_date", "weight_kg", "grade", "origin_facility", "notes", "status"] + read_only_fields = ["id"] diff --git a/apps/batches/urls.py b/apps/batches/urls.py new file mode 100644 index 0000000..1e057fe --- /dev/null +++ b/apps/batches/urls.py @@ -0,0 +1,8 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from . import views +router = DefaultRouter() +router.register('batches', views.ProcessingBatchViewSet) +urlpatterns = [ + path('', include(router.urls)), +] diff --git a/apps/batches/views.py b/apps/batches/views.py new file mode 100644 index 0000000..9d37411 --- /dev/null +++ b/apps/batches/views.py @@ -0,0 +1,31 @@ +from rest_framework import viewsets, permissions, filters +from django_filters.rest_framework import DjangoFilterBackend +from .models import ProcessingBatch +from .serializers import ProcessingBatchListSerializer, ProcessingBatchDetailSerializer, ProcessingBatchWriteSerializer + + +class ProcessingBatchViewSet(viewsets.ModelViewSet): + queryset = ProcessingBatch.objects.prefetch_related("qc_results", "shipments", "documents").all() + permission_classes = [permissions.IsAuthenticated] + filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter] + filterset_fields = ["status", "grade"] + search_fields = ["batch_reference"] + ordering_fields = ["created_at", "harvest_date"] + ordering = ["-created_at"] + + def get_serializer_class(self): + if self.action == "list": + return ProcessingBatchListSerializer + if self.action in ("create", "update", "partial_update"): + return ProcessingBatchWriteSerializer + return ProcessingBatchDetailSerializer + + def perform_create(self, serializer): + serializer.save(created_by=self.request.user) + + def get_queryset(self): + user = self.request.user + qs = super().get_queryset() + if user.role == "buyer": + return qs # Buyers can read all batches per spec + return qs diff --git a/apps/buyer/__init__.py b/apps/buyer/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/buyer/apps.py b/apps/buyer/apps.py new file mode 100644 index 0000000..06f75ad --- /dev/null +++ b/apps/buyer/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class BuyerConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.buyer' + verbose_name = 'Buyer Portal' diff --git a/apps/buyer/urls.py b/apps/buyer/urls.py new file mode 100644 index 0000000..369bc42 --- /dev/null +++ b/apps/buyer/urls.py @@ -0,0 +1,14 @@ +from django.urls import path +from . import views + +app_name = 'portal' + +urlpatterns = [ + path("chat/", views.ChatView.as_view(), name="chat"), + path("chat/embed/", views.ChatEmbedView.as_view(), name="chat_embed"), + path('', views.DashboardView.as_view(), name='dashboard'), + path('batches/', views.BatchListView.as_view(), name='batch_list'), + path('batches//', views.BatchDetailView.as_view(), name='batch_detail'), + path('documents/', views.DocumentListView.as_view(), name='document_list'), + path('subscription/', views.SubscriptionView.as_view(), name='subscription'), +] diff --git a/apps/buyer/views.py b/apps/buyer/views.py new file mode 100644 index 0000000..95485a9 --- /dev/null +++ b/apps/buyer/views.py @@ -0,0 +1,117 @@ +from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin +from django.db.models import Count +from django.views.generic import TemplateView, ListView, DetailView + +from apps.batches.models import ProcessingBatch +from apps.shipments.models import Shipment +from apps.documents.models import Document +from apps.esg.models import ESGFieldReport +from apps.subscriptions.models import Subscription + + +class BuyerRequiredMixin(UserPassesTestMixin): + def test_func(self): + user = self.request.user + return user.is_authenticated and (user.role in ("buyer", "admin") or user.is_superuser) + + +class DashboardView(BuyerRequiredMixin, TemplateView): + template_name = "portal/dashboard.html" + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx["total_batches"] = ProcessingBatch.objects.count() + ctx["recent_batches"] = ProcessingBatch.objects.order_by("-created_at")[:5] + ctx["documents"] = Document.objects.filter(visibility="buyer").order_by("-uploaded_at")[:5] + ctx["active_shipments"] = Shipment.objects.exclude(status="delivered").count() + ctx["subscription"] = Subscription.objects.filter(user=self.request.user).first() + return ctx + + +class BatchListView(BuyerRequiredMixin, ListView): + model = ProcessingBatch + template_name = "portal/batch_list.html" + context_object_name = "batches" + paginate_by = 25 + + def get_queryset(self): + qs = ProcessingBatch.objects.all() + status = self.request.GET.get("status") + q = self.request.GET.get("q") + if status: + qs = qs.filter(status=status) + if q: + qs = qs.filter(batch_reference__icontains=q) + return qs + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx["status_choices"] = ProcessingBatch.Status.choices + ctx["current_status"] = self.request.GET.get("status", "") + ctx["current_q"] = self.request.GET.get("q", "") + return ctx + + +class BatchDetailView(BuyerRequiredMixin, DetailView): + model = ProcessingBatch + template_name = "portal/batch_detail.html" + context_object_name = "batch" + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + b = self.object + ctx["shipments"] = Shipment.objects.filter(batch=b) + ctx["documents"] = Document.objects.filter(batch=b, visibility="buyer") + ctx["esg_reports"] = ESGFieldReport.objects.filter(batch=b) + return ctx + + +class DocumentListView(BuyerRequiredMixin, ListView): + model = Document + template_name = "portal/document_list.html" + context_object_name = "documents" + paginate_by = 25 + + def get_queryset(self): + qs = Document.objects.filter(visibility="buyer").select_related("batch", "uploaded_by") + doc_type = self.request.GET.get("doc_type") + if doc_type: + qs = qs.filter(doc_type=doc_type) + return qs + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx["doc_type_choices"] = Document.DocType.choices + ctx["current_doc_type"] = self.request.GET.get("doc_type", "") + return ctx + + +class SubscriptionView(BuyerRequiredMixin, DetailView): + model = Subscription + template_name = "portal/subscription.html" + context_object_name = "sub" + + def get_object(self): + return Subscription.objects.filter(user=self.request.user).first() + + +class ChatView(BuyerRequiredMixin, TemplateView): + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + from apps.ai.models import AgentConfig + user = self.request.user + if user.role in ("admin", "ops") or user.is_superuser: + ctx["agents"] = AgentConfig.objects.filter(is_active=True) + else: + ctx["agents"] = AgentConfig.objects.filter(is_active=True, user__isnull=True) + return ctx + template_name = "portal/chat.html" + + +class ChatEmbedView(BuyerRequiredMixin, TemplateView): + template_name = "portal/chat_embed.html" + + def dispatch(self, request, *args, **kwargs): + response = super().dispatch(request, *args, **kwargs) + response["X-Frame-Options"] = "ALLOWALL" + return response diff --git a/apps/capa/__init__.py b/apps/capa/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/capa/admin.py b/apps/capa/admin.py new file mode 100644 index 0000000..41c0a94 --- /dev/null +++ b/apps/capa/admin.py @@ -0,0 +1,8 @@ +from django.contrib import admin +from .models import CAPARecord + + +@admin.register(CAPARecord) +class CAPARecordAdmin(admin.ModelAdmin): + list_display = ["batch", "issue_description", "status", "due_date", "created_at"] + list_filter = ["status"] diff --git a/apps/capa/apps.py b/apps/capa/apps.py new file mode 100644 index 0000000..c2f381b --- /dev/null +++ b/apps/capa/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + +class CapaConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.capa" + verbose_name = "Capa" diff --git a/apps/capa/migrations/0001_initial.py b/apps/capa/migrations/0001_initial.py new file mode 100644 index 0000000..30539be --- /dev/null +++ b/apps/capa/migrations/0001_initial.py @@ -0,0 +1,38 @@ +# Generated by Django 5.2.12 on 2026-07-29 12:02 + +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('batches', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='CAPARecord', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('issue_description', models.TextField()), + ('root_cause', models.TextField(blank=True)), + ('corrective_action', models.TextField(blank=True)), + ('preventive_action', models.TextField(blank=True)), + ('status', models.CharField(choices=[('open', 'Open'), ('in_progress', 'In Progress'), ('closed', 'Closed')], default='open', max_length=20)), + ('assigned_to', models.CharField(blank=True, max_length=255)), + ('due_date', models.DateField(blank=True, null=True)), + ('closed_at', models.DateTimeField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('batch', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='capa_records', to='batches.processingbatch')), + ], + options={ + 'verbose_name': 'CAPA Record', + 'verbose_name_plural': 'CAPA Records', + 'db_table': 'capa_records', + }, + ), + ] diff --git a/apps/capa/migrations/__init__.py b/apps/capa/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/capa/models.py b/apps/capa/models.py new file mode 100644 index 0000000..4241f03 --- /dev/null +++ b/apps/capa/models.py @@ -0,0 +1,29 @@ +import uuid +from django.db import models + + +class CAPARecord(models.Model): + class Status(models.TextChoices): + OPEN = "open", "Open" + IN_PROGRESS = "in_progress", "In Progress" + CLOSED = "closed", "Closed" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + batch = models.ForeignKey("batches.ProcessingBatch", on_delete=models.CASCADE, related_name="capa_records") + issue_description = models.TextField() + root_cause = models.TextField(blank=True) + corrective_action = models.TextField(blank=True) + preventive_action = models.TextField(blank=True) + status = models.CharField(max_length=20, choices=Status.choices, default=Status.OPEN) + assigned_to = models.CharField(max_length=255, blank=True) + due_date = models.DateField(null=True, blank=True) + closed_at = models.DateTimeField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "capa_records" + verbose_name = "CAPA Record" + verbose_name_plural = "CAPA Records" + + def __str__(self): + return f"CAPA {self.batch.batch_reference} - {self.status}" diff --git a/apps/capa/serializers.py b/apps/capa/serializers.py new file mode 100644 index 0000000..3b2e278 --- /dev/null +++ b/apps/capa/serializers.py @@ -0,0 +1,9 @@ +from rest_framework import serializers +from .models import CAPARecord + + +class CAPARecordSerializer(serializers.ModelSerializer): + class Meta: + model = CAPARecord + fields = "__all__" + read_only_fields = ["id", "created_at"] diff --git a/apps/capa/urls.py b/apps/capa/urls.py new file mode 100644 index 0000000..f0e99ee --- /dev/null +++ b/apps/capa/urls.py @@ -0,0 +1,8 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from . import views +router = DefaultRouter() +router.register('capa-records', views.CAPARecordViewSet) +urlpatterns = [ + path('', include(router.urls)), +] diff --git a/apps/capa/views.py b/apps/capa/views.py new file mode 100644 index 0000000..13d16e7 --- /dev/null +++ b/apps/capa/views.py @@ -0,0 +1,9 @@ +from rest_framework import viewsets, permissions +from .models import CAPARecord +from .serializers import CAPARecordSerializer + + +class CAPARecordViewSet(viewsets.ModelViewSet): + queryset = CAPARecord.objects.all() + serializer_class = CAPARecordSerializer + permission_classes = [permissions.IsAuthenticated] diff --git a/apps/core/__init__.py b/apps/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/core/apps.py b/apps/core/apps.py new file mode 100644 index 0000000..6c77955 --- /dev/null +++ b/apps/core/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + +class CoreConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.core" + verbose_name = "Core" diff --git a/apps/core/middleware.py b/apps/core/middleware.py new file mode 100644 index 0000000..b675ce2 --- /dev/null +++ b/apps/core/middleware.py @@ -0,0 +1,24 @@ +import re +from django.shortcuts import redirect +from django.conf import settings + + +class RBACMiddleware: + ROLE_ROUTES = { + "admin": [r"^/admin/", r"^/ops/", r"^/portal/"], + "ops": [r"^/ops/"], + "buyer": [r"^/portal/"], + } + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + if request.user.is_authenticated and not request.user.is_staff: + path = request.path_info + user_role = getattr(request.user, "role", None) + for role, patterns in self.ROLE_ROUTES.items(): + for pattern in patterns: + if re.match(pattern, path) and user_role != role and not request.user.is_superuser: + return redirect(settings.LOGIN_REDIRECT_URL) + return self.get_response(request) diff --git a/apps/documents/__init__.py b/apps/documents/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/documents/admin.py b/apps/documents/admin.py new file mode 100644 index 0000000..266c4bd --- /dev/null +++ b/apps/documents/admin.py @@ -0,0 +1,18 @@ +from django.contrib import admin +from .models import CertificateOfAnalysis, RegulatoryCert, Document + + +@admin.register(CertificateOfAnalysis) +class CoAAdmin(admin.ModelAdmin): + list_display = ["batch", "file_name", "issuer", "issue_date"] + list_filter = ["issuer"] + +@admin.register(RegulatoryCert) +class RegulatoryCertAdmin(admin.ModelAdmin): + list_display = ["batch", "cert_type", "cert_reference", "issue_date"] + list_filter = ["cert_type"] + +@admin.register(Document) +class DocumentAdmin(admin.ModelAdmin): + list_display = ["file_name", "doc_type", "batch", "visibility", "uploaded_at"] + list_filter = ["doc_type", "visibility"] diff --git a/apps/documents/apps.py b/apps/documents/apps.py new file mode 100644 index 0000000..3bebe48 --- /dev/null +++ b/apps/documents/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + +class DocumentsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.documents" + verbose_name = "Documents" diff --git a/apps/documents/migrations/0001_initial.py b/apps/documents/migrations/0001_initial.py new file mode 100644 index 0000000..d7c05f0 --- /dev/null +++ b/apps/documents/migrations/0001_initial.py @@ -0,0 +1,72 @@ +# Generated by Django 5.2.12 on 2026-07-29 12:02 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('batches', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='CertificateOfAnalysis', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('file_url', models.TextField()), + ('file_name', models.CharField(max_length=255)), + ('issuer', models.CharField(blank=True, max_length=255)), + ('issue_date', models.DateField(blank=True, null=True)), + ('expiry_date', models.DateField(blank=True, null=True)), + ('uploaded_at', models.DateTimeField(auto_now_add=True)), + ('batch', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='certificates_of_analysis', to='batches.processingbatch')), + ('uploaded_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name_plural': 'Certificates of Analysis', + 'db_table': 'certificates_of_analysis', + }, + ), + migrations.CreateModel( + name='Document', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('doc_type', models.CharField(choices=[('coa', 'Certificate of Analysis'), ('regulatory', 'Regulatory'), ('invoice', 'Invoice'), ('packing_list', 'Packing List'), ('bl', 'Bill of Lading'), ('insurance', 'Insurance'), ('other', 'Other')], max_length=20)), + ('file_url', models.TextField()), + ('file_name', models.CharField(max_length=255)), + ('file_size_bytes', models.IntegerField(blank=True, null=True)), + ('visibility', models.CharField(choices=[('buyer', 'Buyer'), ('ops', 'Operations'), ('admin', 'Admin')], default='buyer', max_length=10)), + ('uploaded_at', models.DateTimeField(auto_now_add=True)), + ('batch', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='documents', to='batches.processingbatch')), + ('uploaded_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'documents', + }, + ), + migrations.CreateModel( + name='RegulatoryCert', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('cert_type', models.CharField(choices=[('phytosanitary', 'Phytosanitary'), ('export_permit', 'Export Permit'), ('organic', 'Organic'), ('fair_trade', 'Fair Trade'), ('eudr_dds', 'EUDR DDS'), ('other', 'Other')], max_length=20)), + ('file_url', models.TextField()), + ('file_name', models.CharField(max_length=255)), + ('cert_reference', models.CharField(blank=True, max_length=255)), + ('issue_date', models.DateField(blank=True, null=True)), + ('expiry_date', models.DateField(blank=True, null=True)), + ('uploaded_at', models.DateTimeField(auto_now_add=True)), + ('batch', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='regulatory_certs', to='batches.processingbatch')), + ], + options={ + 'verbose_name': 'Regulatory Certificate', + 'db_table': 'regulatory_certs', + }, + ), + ] diff --git a/apps/documents/migrations/__init__.py b/apps/documents/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/documents/models.py b/apps/documents/models.py new file mode 100644 index 0000000..6c2ae50 --- /dev/null +++ b/apps/documents/models.py @@ -0,0 +1,81 @@ +import uuid +from django.conf import settings +from django.db import models + + +class CertificateOfAnalysis(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + batch = models.ForeignKey("batches.ProcessingBatch", on_delete=models.CASCADE, related_name="certificates_of_analysis") + file_url = models.TextField() + file_name = models.CharField(max_length=255) + issuer = models.CharField(max_length=255, blank=True) + issue_date = models.DateField(null=True, blank=True) + expiry_date = models.DateField(null=True, blank=True) + uploaded_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True) + uploaded_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "certificates_of_analysis" + verbose_name_plural = "Certificates of Analysis" + + def __str__(self): + return f"CoA - {self.batch.batch_reference} - {self.file_name}" + + +class RegulatoryCert(models.Model): + class CertType(models.TextChoices): + PHYTOSANITARY = "phytosanitary", "Phytosanitary" + EXPORT_PERMIT = "export_permit", "Export Permit" + ORGANIC = "organic", "Organic" + FAIR_TRADE = "fair_trade", "Fair Trade" + EUDR_DDS = "eudr_dds", "EUDR DDS" + OTHER = "other", "Other" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + batch = models.ForeignKey("batches.ProcessingBatch", on_delete=models.CASCADE, related_name="regulatory_certs") + cert_type = models.CharField(max_length=20, choices=CertType.choices) + file_url = models.TextField() + file_name = models.CharField(max_length=255) + cert_reference = models.CharField(max_length=255, blank=True) + issue_date = models.DateField(null=True, blank=True) + expiry_date = models.DateField(null=True, blank=True) + uploaded_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "regulatory_certs" + verbose_name = "Regulatory Certificate" + + def __str__(self): + return f"{self.get_cert_type_display()} - {self.batch.batch_reference}" + + +class Document(models.Model): + class DocType(models.TextChoices): + COA = "coa", "Certificate of Analysis" + REGULATORY = "regulatory", "Regulatory" + INVOICE = "invoice", "Invoice" + PACKING_LIST = "packing_list", "Packing List" + BL = "bl", "Bill of Lading" + INSURANCE = "insurance", "Insurance" + OTHER = "other", "Other" + + class Visibility(models.TextChoices): + BUYER = "buyer", "Buyer" + OPS = "ops", "Operations" + ADMIN = "admin", "Admin" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + batch = models.ForeignKey("batches.ProcessingBatch", on_delete=models.CASCADE, null=True, blank=True, related_name="documents") + doc_type = models.CharField(max_length=20, choices=DocType.choices) + file_url = models.TextField() + file_name = models.CharField(max_length=255) + file_size_bytes = models.IntegerField(null=True, blank=True) + visibility = models.CharField(max_length=10, choices=Visibility.choices, default=Visibility.BUYER) + uploaded_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True) + uploaded_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "documents" + + def __str__(self): + return f"{self.get_doc_type_display()} - {self.file_name}" diff --git a/apps/documents/serializers.py b/apps/documents/serializers.py new file mode 100644 index 0000000..1e56bda --- /dev/null +++ b/apps/documents/serializers.py @@ -0,0 +1,32 @@ +from rest_framework import serializers +from .models import CertificateOfAnalysis, RegulatoryCert, Document + + +class CertificateOfAnalysisSerializer(serializers.ModelSerializer): + class Meta: + model = CertificateOfAnalysis + fields = "__all__" + read_only_fields = ["id", "uploaded_at"] + + +class RegulatoryCertSerializer(serializers.ModelSerializer): + class Meta: + model = RegulatoryCert + fields = "__all__" + read_only_fields = ["id", "uploaded_at"] + + +class DocumentSerializer(serializers.ModelSerializer): + uploaded_by_name = serializers.CharField(source="uploaded_by.full_name", read_only=True) + + class Meta: + model = Document + fields = "__all__" + read_only_fields = ["id", "uploaded_at"] + + +class DocumentUploadSerializer(serializers.Serializer): + file = serializers.FileField() + batch_id = serializers.UUIDField(required=False, allow_null=True) + doc_type = serializers.ChoiceField(choices=Document.DocType.choices) + visibility = serializers.ChoiceField(choices=Document.Visibility.choices, default=Document.Visibility.BUYER) diff --git a/apps/documents/urls.py b/apps/documents/urls.py new file mode 100644 index 0000000..1f3fe6f --- /dev/null +++ b/apps/documents/urls.py @@ -0,0 +1,10 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from . import views +router = DefaultRouter() +router.register('documents', views.DocumentViewSet) +router.register('certificates-of-analysis', views.CertificateOfAnalysisViewSet) +router.register('regulatory-certs', views.RegulatoryCertViewSet) +urlpatterns = [ + path('', include(router.urls)), +] diff --git a/apps/documents/views.py b/apps/documents/views.py new file mode 100644 index 0000000..8f0009a --- /dev/null +++ b/apps/documents/views.py @@ -0,0 +1,73 @@ +import os +import uuid +from django.conf import settings +from rest_framework import viewsets, permissions, status +from rest_framework.decorators import action +from rest_framework.parsers import MultiPartParser, FormParser +from rest_framework.response import Response +from .models import Document, CertificateOfAnalysis, RegulatoryCert +from .serializers import DocumentSerializer, DocumentUploadSerializer, CertificateOfAnalysisSerializer, RegulatoryCertSerializer + + +class DocumentViewSet(viewsets.ModelViewSet): + queryset = Document.objects.all() + serializer_class = DocumentSerializer + permission_classes = [permissions.IsAuthenticated] + + def get_queryset(self): + user = self.request.user + qs = Document.objects.all() + if user.role == "buyer": + return qs.filter(visibility__in=["buyer"]) + return qs + + @action(detail=False, methods=["post"], parser_classes=[MultiPartParser, FormParser]) + def upload(self, request): + serializer = DocumentUploadSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + file = serializer.validated_data["file"] + batch_id = serializer.validated_data.get("batch_id") + doc_type = serializer.validated_data["doc_type"] + visibility = serializer.validated_data.get("visibility", "buyer") + + file_ext = file.name.split(".")[-1].lower() + allowed = ["pdf", "jpg", "jpeg", "png", "xlsx"] + if file_ext not in allowed: + return Response({"error": f"File type .{file_ext} not allowed"}, status=status.HTTP_400_BAD_REQUEST) + + if file.size > 50 * 1024 * 1024: + return Response({"error": "File exceeds 50MB limit"}, status=status.HTTP_400_BAD_REQUEST) + + batch_dir = os.path.join(settings.MEDIA_ROOT, "ixg-documents", str(batch_id or "uncategorized")) + os.makedirs(batch_dir, exist_ok=True) + + filename = f"{uuid.uuid4()}_{file.name}" + filepath = os.path.join(batch_dir, filename) + with open(filepath, "wb+") as dest: + for chunk in file.chunks(): + dest.write(chunk) + + doc = Document.objects.create( + batch_id=batch_id, + doc_type=doc_type, + file_url=f"/media/ixg-documents/{batch_id or uncategorized}/{filename}", + file_name=file.name, + file_size_bytes=file.size, + visibility=visibility, + uploaded_by=request.user, + ) + + return Response(DocumentSerializer(doc).data, status=status.HTTP_201_CREATED) + + +class CertificateOfAnalysisViewSet(viewsets.ModelViewSet): + queryset = CertificateOfAnalysis.objects.all() + serializer_class = CertificateOfAnalysisSerializer + permission_classes = [permissions.IsAuthenticated] + + +class RegulatoryCertViewSet(viewsets.ModelViewSet): + queryset = RegulatoryCert.objects.all() + serializer_class = RegulatoryCertSerializer + permission_classes = [permissions.IsAuthenticated] diff --git a/apps/esg/__init__.py b/apps/esg/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/esg/admin.py b/apps/esg/admin.py new file mode 100644 index 0000000..f0c17ca --- /dev/null +++ b/apps/esg/admin.py @@ -0,0 +1,7 @@ +from django.contrib import admin +from .models import ESGFieldReport + + +@admin.register(ESGFieldReport) +class ESGFieldReportAdmin(admin.ModelAdmin): + list_display = ["batch", "women_employed", "total_workers", "fair_wage_paid", "report_date"] diff --git a/apps/esg/apps.py b/apps/esg/apps.py new file mode 100644 index 0000000..aa5aeff --- /dev/null +++ b/apps/esg/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + +class EsgConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.esg" + verbose_name = "Esg" diff --git a/apps/esg/migrations/0001_initial.py b/apps/esg/migrations/0001_initial.py new file mode 100644 index 0000000..efa7db3 --- /dev/null +++ b/apps/esg/migrations/0001_initial.py @@ -0,0 +1,36 @@ +# Generated by Django 5.2.12 on 2026-07-29 12:02 + +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('batches', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='ESGFieldReport', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('women_employed', models.IntegerField(default=0)), + ('total_workers', models.IntegerField(default=0)), + ('fair_wage_paid', models.BooleanField(default=False)), + ('safety_equipment_used', models.BooleanField(default=False)), + ('community_notes', models.TextField(blank=True)), + ('reported_by', models.CharField(blank=True, max_length=255)), + ('report_date', models.DateField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('batch', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='esg_reports', to='batches.processingbatch')), + ], + options={ + 'verbose_name': 'ESG Field Report', + 'db_table': 'esg_field_reports', + }, + ), + ] diff --git a/apps/esg/migrations/__init__.py b/apps/esg/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/esg/models.py b/apps/esg/models.py new file mode 100644 index 0000000..5642d74 --- /dev/null +++ b/apps/esg/models.py @@ -0,0 +1,22 @@ +import uuid +from django.db import models + + +class ESGFieldReport(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + batch = models.ForeignKey("batches.ProcessingBatch", on_delete=models.CASCADE, related_name="esg_reports") + women_employed = models.IntegerField(default=0) + total_workers = models.IntegerField(default=0) + fair_wage_paid = models.BooleanField(default=False) + safety_equipment_used = models.BooleanField(default=False) + community_notes = models.TextField(blank=True) + reported_by = models.CharField(max_length=255, blank=True) + report_date = models.DateField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "esg_field_reports" + verbose_name = "ESG Field Report" + + def __str__(self): + return f"ESG - {self.batch.batch_reference} - {self.report_date}" diff --git a/apps/esg/serializers.py b/apps/esg/serializers.py new file mode 100644 index 0000000..924945e --- /dev/null +++ b/apps/esg/serializers.py @@ -0,0 +1,9 @@ +from rest_framework import serializers +from .models import ESGFieldReport + + +class ESGFieldReportSerializer(serializers.ModelSerializer): + class Meta: + model = ESGFieldReport + fields = "__all__" + read_only_fields = ["id", "created_at"] diff --git a/apps/esg/urls.py b/apps/esg/urls.py new file mode 100644 index 0000000..4520706 --- /dev/null +++ b/apps/esg/urls.py @@ -0,0 +1,8 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from . import views +router = DefaultRouter() +router.register('esg-reports', views.ESGFieldReportViewSet) +urlpatterns = [ + path('', include(router.urls)), +] diff --git a/apps/esg/views.py b/apps/esg/views.py new file mode 100644 index 0000000..123380c --- /dev/null +++ b/apps/esg/views.py @@ -0,0 +1,9 @@ +from rest_framework import viewsets, permissions +from .models import ESGFieldReport +from .serializers import ESGFieldReportSerializer + + +class ESGFieldReportViewSet(viewsets.ModelViewSet): + queryset = ESGFieldReport.objects.all() + serializer_class = ESGFieldReportSerializer + permission_classes = [permissions.IsAuthenticated] diff --git a/apps/notifications/__init__.py b/apps/notifications/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/notifications/admin.py b/apps/notifications/admin.py new file mode 100644 index 0000000..1eff3ed --- /dev/null +++ b/apps/notifications/admin.py @@ -0,0 +1,8 @@ +from django.contrib import admin +from .models import Notification + + +@admin.register(Notification) +class NotificationAdmin(admin.ModelAdmin): + list_display = ["user", "title", "type", "is_read", "created_at"] + list_filter = ["is_read", "type"] diff --git a/apps/notifications/apps.py b/apps/notifications/apps.py new file mode 100644 index 0000000..72164b2 --- /dev/null +++ b/apps/notifications/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + +class NotificationsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.notifications" + verbose_name = "Notifications" diff --git a/apps/notifications/migrations/0001_initial.py b/apps/notifications/migrations/0001_initial.py new file mode 100644 index 0000000..35d3e21 --- /dev/null +++ b/apps/notifications/migrations/0001_initial.py @@ -0,0 +1,34 @@ +# Generated by Django 5.2.12 on 2026-07-29 12:03 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Notification', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('type', models.CharField(blank=True, max_length=50)), + ('title', models.CharField(max_length=255)), + ('message', models.TextField()), + ('is_read', models.BooleanField(default=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'notifications', + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/apps/notifications/migrations/__init__.py b/apps/notifications/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/notifications/models.py b/apps/notifications/models.py new file mode 100644 index 0000000..de2a4bf --- /dev/null +++ b/apps/notifications/models.py @@ -0,0 +1,20 @@ +import uuid +from django.conf import settings +from django.db import models + + +class Notification(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="notifications") + type = models.CharField(max_length=50, blank=True) + title = models.CharField(max_length=255) + message = models.TextField() + is_read = models.BooleanField(default=False) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "notifications" + ordering = ["-created_at"] + + def __str__(self): + return f"Notification: {self.title}" diff --git a/apps/notifications/serializers.py b/apps/notifications/serializers.py new file mode 100644 index 0000000..53900dd --- /dev/null +++ b/apps/notifications/serializers.py @@ -0,0 +1,9 @@ +from rest_framework import serializers +from .models import Notification + + +class NotificationSerializer(serializers.ModelSerializer): + class Meta: + model = Notification + fields = "__all__" + read_only_fields = ["id", "created_at"] diff --git a/apps/notifications/urls.py b/apps/notifications/urls.py new file mode 100644 index 0000000..731b7e1 --- /dev/null +++ b/apps/notifications/urls.py @@ -0,0 +1,8 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from . import views +router = DefaultRouter() +router.register('notifications', views.NotificationViewSet) +urlpatterns = [ + path('', include(router.urls)), +] diff --git a/apps/notifications/views.py b/apps/notifications/views.py new file mode 100644 index 0000000..8d2d383 --- /dev/null +++ b/apps/notifications/views.py @@ -0,0 +1,12 @@ +from rest_framework import viewsets, permissions +from .models import Notification +from .serializers import NotificationSerializer + + +class NotificationViewSet(viewsets.ModelViewSet): + queryset = Notification.objects.all() + serializer_class = NotificationSerializer + permission_classes = [permissions.IsAuthenticated] + + def get_queryset(self): + return Notification.objects.filter(user=self.request.user) diff --git a/apps/ops/__init__.py b/apps/ops/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/ops/apps.py b/apps/ops/apps.py new file mode 100644 index 0000000..7e6d578 --- /dev/null +++ b/apps/ops/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class OpsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.ops' + verbose_name = 'Operations Portal' diff --git a/apps/ops/urls.py b/apps/ops/urls.py new file mode 100644 index 0000000..3dcb5d2 --- /dev/null +++ b/apps/ops/urls.py @@ -0,0 +1,18 @@ +from django.urls import path +from . import views + +app_name = 'ops' + +urlpatterns = [ + path('', views.DashboardView.as_view(), name='dashboard'), + path('batches/', views.BatchListView.as_view(), name='batch_list'), + path('batches//', views.BatchDetailView.as_view(), name='batch_detail'), + path('qc/', views.QCListView.as_view(), name='qc_list'), + path('qc//', views.QCDetailView.as_view(), name='qc_detail'), + path('shipments/', views.ShipmentListView.as_view(), name='shipment_list'), + path('shipments//', views.ShipmentDetailView.as_view(), name='shipment_detail'), + path('capa/', views.CAPAListView.as_view(), name='capa_list'), + path('capa//', views.CAPADetailView.as_view(), name='capa_detail'), + path('documents/', views.DocumentListView.as_view(), name='document_list'), + path('esg/', views.ESGListView.as_view(), name='esg_list'), +] diff --git a/apps/ops/views.py b/apps/ops/views.py new file mode 100644 index 0000000..06a148c --- /dev/null +++ b/apps/ops/views.py @@ -0,0 +1,197 @@ +from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin +from django.db.models import Count, Q +from django.utils import timezone +from django.views.generic import TemplateView, ListView, DetailView +from django.shortcuts import get_object_or_404 + +from apps.batches.models import ProcessingBatch +from apps.qc.models import QCResult +from apps.shipments.models import Shipment +from apps.capa.models import CAPARecord +from apps.documents.models import Document +from apps.esg.models import ESGFieldReport +from apps.notifications.models import Notification + + +class OpsRequiredMixin(UserPassesTestMixin): + def test_func(self): + user = self.request.user + return user.is_authenticated and (user.role in ('ops', 'admin') or user.is_superuser) + + +class DashboardView(OpsRequiredMixin, TemplateView): + template_name = 'ops/dashboard.html' + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx['total_batches'] = ProcessingBatch.objects.count() + ctx['batches_by_status'] = { + s: ProcessingBatch.objects.filter(status=s).count() + for s, _ in ProcessingBatch.Status.choices + } + ctx['recent_qc'] = QCResult.objects.select_related('batch').order_by('-tested_at')[:10] + ctx['qc_summary'] = QCResult.objects.aggregate( + passed=Count('id', filter=Q(overall_pass=True)), + failed=Count('id', filter=Q(overall_pass=False)), + ) + ctx['active_shipments'] = Shipment.objects.exclude(status='delivered').count() + ctx['open_capas'] = CAPARecord.objects.filter(status__in=('open', 'in_progress')).count() + ctx['recent_notifications'] = Notification.objects.filter( + user=self.request.user, is_read=False + )[:5] + ctx['recent_batches'] = ProcessingBatch.objects.order_by('-created_at')[:5] + return ctx + + +class BatchListView(OpsRequiredMixin, ListView): + model = ProcessingBatch + template_name = 'ops/batch_list.html' + context_object_name = 'batches' + paginate_by = 25 + + def get_queryset(self): + qs = ProcessingBatch.objects.all() + status = self.request.GET.get('status') + grade = self.request.GET.get('grade') + search = self.request.GET.get('q') + if status: + qs = qs.filter(status=status) + if grade: + qs = qs.filter(grade=grade) + if search: + qs = qs.filter(batch_reference__icontains=search) + return qs + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx['status_choices'] = ProcessingBatch.Status.choices + ctx['grade_choices'] = ProcessingBatch.Grade.choices + ctx['current_status'] = self.request.GET.get('status', '') + ctx['current_grade'] = self.request.GET.get('grade', '') + ctx['current_q'] = self.request.GET.get('q', '') + return ctx + + +class BatchDetailView(OpsRequiredMixin, DetailView): + model = ProcessingBatch + template_name = 'ops/batch_detail.html' + context_object_name = 'batch' + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + batch = self.object + ctx['qc_results'] = QCResult.objects.filter(batch=batch) + ctx['shipments'] = Shipment.objects.filter(batch=batch) + ctx['capa_records'] = CAPARecord.objects.filter(batch=batch) + ctx['documents'] = Document.objects.filter(batch=batch) + ctx['esg_reports'] = ESGFieldReport.objects.filter(batch=batch) + return ctx + + +class QCListView(OpsRequiredMixin, ListView): + model = QCResult + template_name = 'ops/qc_list.html' + context_object_name = 'results' + paginate_by = 25 + + def get_queryset(self): + qs = QCResult.objects.select_related('batch').all() + passed = self.request.GET.get('passed') + if passed == '1': + qs = qs.filter(overall_pass=True) + elif passed == '0': + qs = qs.filter(overall_pass=False) + return qs + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx['current_passed'] = self.request.GET.get('passed', '') + return ctx + + +class QCDetailView(OpsRequiredMixin, DetailView): + model = QCResult + template_name = 'ops/qc_detail.html' + context_object_name = 'result' + + +class ShipmentListView(OpsRequiredMixin, ListView): + model = Shipment + template_name = 'ops/shipment_list.html' + context_object_name = 'shipments' + paginate_by = 25 + + def get_queryset(self): + qs = Shipment.objects.select_related('batch').all() + status = self.request.GET.get('status') + if status: + qs = qs.filter(status=status) + return qs + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx['status_choices'] = Shipment.Status.choices + ctx['current_status'] = self.request.GET.get('status', '') + return ctx + + +class ShipmentDetailView(OpsRequiredMixin, DetailView): + model = Shipment + template_name = 'ops/shipment_detail.html' + context_object_name = 'shipment' + + +class CAPAListView(OpsRequiredMixin, ListView): + model = CAPARecord + template_name = 'ops/capa_list.html' + context_object_name = 'records' + paginate_by = 25 + + def get_queryset(self): + qs = CAPARecord.objects.select_related('batch').all() + status = self.request.GET.get('status') + if status: + qs = qs.filter(status=status) + return qs + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx['status_choices'] = CAPARecord.Status.choices + ctx['current_status'] = self.request.GET.get('status', '') + return ctx + + +class CAPADetailView(OpsRequiredMixin, DetailView): + model = CAPARecord + template_name = 'ops/capa_detail.html' + context_object_name = 'record' + + +class DocumentListView(OpsRequiredMixin, ListView): + model = Document + template_name = 'ops/document_list.html' + context_object_name = 'documents' + paginate_by = 25 + + def get_queryset(self): + qs = Document.objects.select_related('batch', 'uploaded_by').all() + doc_type = self.request.GET.get('doc_type') + if doc_type: + qs = qs.filter(doc_type=doc_type) + return qs + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx['doc_type_choices'] = Document.DocType.choices + ctx['current_doc_type'] = self.request.GET.get('doc_type', '') + return ctx + + +class ESGListView(OpsRequiredMixin, ListView): + model = ESGFieldReport + template_name = 'ops/esg_list.html' + context_object_name = 'reports' + paginate_by = 25 + + def get_queryset(self): + return ESGFieldReport.objects.select_related('batch').all() diff --git a/apps/qc/__init__.py b/apps/qc/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/qc/admin.py b/apps/qc/admin.py new file mode 100644 index 0000000..b8f7b60 --- /dev/null +++ b/apps/qc/admin.py @@ -0,0 +1,8 @@ +from django.contrib import admin +from .models import QCResult + + +@admin.register(QCResult) +class QCResultAdmin(admin.ModelAdmin): + list_display = ["batch", "overall_pass", "ffa_percentage", "moisture_percentage", "peroxide_value", "tested_at"] + list_filter = ["overall_pass", "visual_grade"] diff --git a/apps/qc/apps.py b/apps/qc/apps.py new file mode 100644 index 0000000..0f3b681 --- /dev/null +++ b/apps/qc/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + +class QcConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.qc" + verbose_name = "Qc" diff --git a/apps/qc/migrations/0001_initial.py b/apps/qc/migrations/0001_initial.py new file mode 100644 index 0000000..8af4174 --- /dev/null +++ b/apps/qc/migrations/0001_initial.py @@ -0,0 +1,35 @@ +# Generated by Django 5.2.12 on 2026-07-29 12:02 + +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('batches', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='QCResult', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('ffa_percentage', models.DecimalField(decimal_places=2, max_digits=5)), + ('moisture_percentage', models.DecimalField(decimal_places=2, max_digits=5)), + ('peroxide_value', models.DecimalField(decimal_places=2, max_digits=5)), + ('visual_grade', models.CharField(choices=[('A', 'A'), ('B', 'B'), ('C', 'C'), ('Fail', 'Fail')], max_length=5)), + ('overall_pass', models.BooleanField()), + ('tested_by', models.CharField(blank=True, max_length=255)), + ('tested_at', models.DateTimeField(auto_now_add=True)), + ('lab_reference', models.CharField(blank=True, max_length=255)), + ('batch', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='qc_results', to='batches.processingbatch')), + ], + options={ + 'db_table': 'qc_results', + }, + ), + ] diff --git a/apps/qc/migrations/__init__.py b/apps/qc/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/qc/models.py b/apps/qc/models.py new file mode 100644 index 0000000..acfb594 --- /dev/null +++ b/apps/qc/models.py @@ -0,0 +1,27 @@ +import uuid +from django.db import models + + +class QCResult(models.Model): + class VisualGrade(models.TextChoices): + A = "A", "A" + B = "B", "B" + C = "C", "C" + FAIL = "Fail", "Fail" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + batch = models.ForeignKey("batches.ProcessingBatch", on_delete=models.CASCADE, related_name="qc_results") + ffa_percentage = models.DecimalField(max_digits=5, decimal_places=2) + moisture_percentage = models.DecimalField(max_digits=5, decimal_places=2) + peroxide_value = models.DecimalField(max_digits=5, decimal_places=2) + visual_grade = models.CharField(max_length=5, choices=VisualGrade.choices) + overall_pass = models.BooleanField() + tested_by = models.CharField(max_length=255, blank=True) + tested_at = models.DateTimeField(auto_now_add=True) + lab_reference = models.CharField(max_length=255, blank=True) + + class Meta: + db_table = "qc_results" + + def __str__(self): + return f"QC for {self.batch.batch_reference}: {PASS if self.overall_pass else FAIL}" diff --git a/apps/qc/serializers.py b/apps/qc/serializers.py new file mode 100644 index 0000000..83398cb --- /dev/null +++ b/apps/qc/serializers.py @@ -0,0 +1,49 @@ +from rest_framework import serializers +from .models import QCResult +from apps.batches.models import ProcessingBatch +from apps.capa.models import CAPARecord + + +class QCResultSerializer(serializers.ModelSerializer): + class Meta: + model = QCResult + fields = "__all__" + read_only_fields = ["id", "overall_pass", "tested_at"] + + +class QCResultCreateSerializer(serializers.ModelSerializer): + class Meta: + model = QCResult + fields = ["batch", "ffa_percentage", "moisture_percentage", "peroxide_value", "visual_grade", "tested_by", "lab_reference"] + + def create(self, validated_data): + ffa = validated_data["ffa_percentage"] + moisture = validated_data["moisture_percentage"] + peroxide = validated_data["peroxide_value"] + visual = validated_data["visual_grade"] + + overall_pass = all([ + ffa <= 3, + moisture <= 0.1, + peroxide <= 10, + visual != "Fail", + ]) + validated_data["overall_pass"] = overall_pass + + qc_result = super().create(validated_data) + + batch = validated_data["batch"] + if overall_pass: + batch.status = ProcessingBatch.Status.QC_PASS + else: + batch.status = ProcessingBatch.Status.QC_FAIL + batch.save() + + if not overall_pass: + CAPARecord.objects.create( + batch=batch, + issue_description=f"QC failure on batch {batch.batch_reference}", + status=CAPARecord.Status.OPEN, + ) + + return qc_result diff --git a/apps/qc/urls.py b/apps/qc/urls.py new file mode 100644 index 0000000..96dfe07 --- /dev/null +++ b/apps/qc/urls.py @@ -0,0 +1,8 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from . import views +router = DefaultRouter() +router.register('qc-results', views.QCResultViewSet) +urlpatterns = [ + path('', include(router.urls)), +] diff --git a/apps/qc/views.py b/apps/qc/views.py new file mode 100644 index 0000000..37cb2d4 --- /dev/null +++ b/apps/qc/views.py @@ -0,0 +1,13 @@ +from rest_framework import viewsets, permissions +from .models import QCResult +from .serializers import QCResultSerializer, QCResultCreateSerializer + + +class QCResultViewSet(viewsets.ModelViewSet): + queryset = QCResult.objects.all() + permission_classes = [permissions.IsAuthenticated] + + def get_serializer_class(self): + if self.action == "create": + return QCResultCreateSerializer + return QCResultSerializer diff --git a/apps/shipments/__init__.py b/apps/shipments/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/shipments/admin.py b/apps/shipments/admin.py new file mode 100644 index 0000000..9649214 --- /dev/null +++ b/apps/shipments/admin.py @@ -0,0 +1,8 @@ +from django.contrib import admin +from .models import Shipment + + +@admin.register(Shipment) +class ShipmentAdmin(admin.ModelAdmin): + list_display = ["batch", "status", "vessel_name", "etd_nigeria", "eta_uk"] + list_filter = ["status"] diff --git a/apps/shipments/apps.py b/apps/shipments/apps.py new file mode 100644 index 0000000..ec7e984 --- /dev/null +++ b/apps/shipments/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + +class ShipmentsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.shipments" + verbose_name = "Shipments" diff --git a/apps/shipments/migrations/0001_initial.py b/apps/shipments/migrations/0001_initial.py new file mode 100644 index 0000000..3b2d7d9 --- /dev/null +++ b/apps/shipments/migrations/0001_initial.py @@ -0,0 +1,38 @@ +# Generated by Django 5.2.12 on 2026-07-29 12:02 + +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('batches', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Shipment', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('status', models.CharField(choices=[('booked', 'Booked'), ('loading', 'Loading'), ('departed', 'Departed'), ('in_transit', 'In Transit'), ('arrived_uk', 'Arrived UK'), ('customs_clearance', 'Customs Clearance'), ('delivered', 'Delivered')], default='booked', max_length=20)), + ('origin_port', models.CharField(default='Apapa Port, Lagos', max_length=255)), + ('destination_port', models.CharField(default='Port of Felixstowe', max_length=255)), + ('vessel_name', models.CharField(blank=True, max_length=255)), + ('container_ref', models.CharField(blank=True, max_length=255)), + ('etd_nigeria', models.DateField(blank=True, null=True)), + ('eta_uk', models.DateField(blank=True, null=True)), + ('bl_number', models.CharField(blank=True, max_length=255)), + ('freight_forwarder', models.CharField(blank=True, max_length=255)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('batch', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='shipments', to='batches.processingbatch')), + ], + options={ + 'db_table': 'shipments', + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/apps/shipments/migrations/__init__.py b/apps/shipments/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/shipments/models.py b/apps/shipments/models.py new file mode 100644 index 0000000..206a49b --- /dev/null +++ b/apps/shipments/models.py @@ -0,0 +1,33 @@ +import uuid +from django.db import models + + +class Shipment(models.Model): + class Status(models.TextChoices): + BOOKED = "booked", "Booked" + LOADING = "loading", "Loading" + DEPARTED = "departed", "Departed" + IN_TRANSIT = "in_transit", "In Transit" + ARRIVED_UK = "arrived_uk", "Arrived UK" + CUSTOMS_CLEARANCE = "customs_clearance", "Customs Clearance" + DELIVERED = "delivered", "Delivered" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + batch = models.ForeignKey("batches.ProcessingBatch", on_delete=models.CASCADE, related_name="shipments") + status = models.CharField(max_length=20, choices=Status.choices, default=Status.BOOKED) + origin_port = models.CharField(max_length=255, default="Apapa Port, Lagos") + destination_port = models.CharField(max_length=255, default="Port of Felixstowe") + vessel_name = models.CharField(max_length=255, blank=True) + container_ref = models.CharField(max_length=255, blank=True) + etd_nigeria = models.DateField(null=True, blank=True) + eta_uk = models.DateField(null=True, blank=True) + bl_number = models.CharField(max_length=255, blank=True) + freight_forwarder = models.CharField(max_length=255, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "shipments" + ordering = ["-created_at"] + + def __str__(self): + return f"Shipment {self.batch.batch_reference} - {self.status}" diff --git a/apps/shipments/serializers.py b/apps/shipments/serializers.py new file mode 100644 index 0000000..29de367 --- /dev/null +++ b/apps/shipments/serializers.py @@ -0,0 +1,9 @@ +from rest_framework import serializers +from .models import Shipment + + +class ShipmentSerializer(serializers.ModelSerializer): + class Meta: + model = Shipment + fields = "__all__" + read_only_fields = ["id", "created_at"] diff --git a/apps/shipments/urls.py b/apps/shipments/urls.py new file mode 100644 index 0000000..3ade10e --- /dev/null +++ b/apps/shipments/urls.py @@ -0,0 +1,8 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from . import views +router = DefaultRouter() +router.register('shipments', views.ShipmentViewSet) +urlpatterns = [ + path('', include(router.urls)), +] diff --git a/apps/shipments/views.py b/apps/shipments/views.py new file mode 100644 index 0000000..850ace0 --- /dev/null +++ b/apps/shipments/views.py @@ -0,0 +1,9 @@ +from rest_framework import viewsets, permissions +from .models import Shipment +from .serializers import ShipmentSerializer + + +class ShipmentViewSet(viewsets.ModelViewSet): + queryset = Shipment.objects.all() + serializer_class = ShipmentSerializer + permission_classes = [permissions.IsAuthenticated] diff --git a/apps/subscriptions/__init__.py b/apps/subscriptions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/subscriptions/admin.py b/apps/subscriptions/admin.py new file mode 100644 index 0000000..f50f817 --- /dev/null +++ b/apps/subscriptions/admin.py @@ -0,0 +1,9 @@ +from django.contrib import admin +from .models import Subscription + + +@admin.register(Subscription) +class SubscriptionAdmin(admin.ModelAdmin): + list_display = ["user", "plan", "status", "current_period_end", "created_at"] + list_filter = ["plan", "status"] + search_fields = ["user__email", "user__full_name"] diff --git a/apps/subscriptions/apps.py b/apps/subscriptions/apps.py new file mode 100644 index 0000000..54df31a --- /dev/null +++ b/apps/subscriptions/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + +class SubscriptionsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.subscriptions" + verbose_name = "Subscriptions" diff --git a/apps/subscriptions/migrations/0001_initial.py b/apps/subscriptions/migrations/0001_initial.py new file mode 100644 index 0000000..d45919f --- /dev/null +++ b/apps/subscriptions/migrations/0001_initial.py @@ -0,0 +1,35 @@ +# Generated by Django 5.2.12 on 2026-07-29 12:02 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Subscription', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('plan', models.CharField(choices=[('observer', 'Observer'), ('starter', 'Starter'), ('professional', 'Professional'), ('enterprise', 'Enterprise')], max_length=20)), + ('stripe_subscription_id', models.CharField(blank=True, max_length=255, null=True, unique=True)), + ('stripe_price_id', models.CharField(blank=True, max_length=255)), + ('status', models.CharField(choices=[('active', 'Active'), ('cancelled', 'Cancelled'), ('past_due', 'Past Due'), ('trialing', 'Trialing')], default='trialing', max_length=20)), + ('current_period_end', models.DateTimeField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='subscriptions', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'subscriptions', + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/apps/subscriptions/migrations/__init__.py b/apps/subscriptions/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/subscriptions/models.py b/apps/subscriptions/models.py new file mode 100644 index 0000000..ec89061 --- /dev/null +++ b/apps/subscriptions/models.py @@ -0,0 +1,33 @@ +import uuid +from django.conf import settings +from django.db import models + + +class Subscription(models.Model): + class Plan(models.TextChoices): + OBSERVER = "observer", "Observer" + STARTER = "starter", "Starter" + PROFESSIONAL = "professional", "Professional" + ENTERPRISE = "enterprise", "Enterprise" + + class Status(models.TextChoices): + ACTIVE = "active", "Active" + CANCELLED = "cancelled", "Cancelled" + PAST_DUE = "past_due", "Past Due" + TRIALING = "trialing", "Trialing" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="subscriptions") + plan = models.CharField(max_length=20, choices=Plan.choices) + stripe_subscription_id = models.CharField(max_length=255, unique=True, null=True, blank=True) + stripe_price_id = models.CharField(max_length=255, blank=True) + status = models.CharField(max_length=20, choices=Status.choices, default=Status.TRIALING) + current_period_end = models.DateTimeField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "subscriptions" + ordering = ["-created_at"] + + def __str__(self): + return f"{self.user.email} - {self.plan} ({self.status})" diff --git a/apps/subscriptions/serializers.py b/apps/subscriptions/serializers.py new file mode 100644 index 0000000..3b80bf2 --- /dev/null +++ b/apps/subscriptions/serializers.py @@ -0,0 +1,11 @@ +from rest_framework import serializers +from .models import Subscription + + +class SubscriptionSerializer(serializers.ModelSerializer): + user_email = serializers.EmailField(source="user.email", read_only=True) + + class Meta: + model = Subscription + fields = ["id", "user", "user_email", "plan", "status", "stripe_subscription_id", "current_period_end", "created_at"] + read_only_fields = ["id", "created_at"] diff --git a/apps/subscriptions/urls.py b/apps/subscriptions/urls.py new file mode 100644 index 0000000..91167fc --- /dev/null +++ b/apps/subscriptions/urls.py @@ -0,0 +1,8 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from . import views +router = DefaultRouter() +router.register('subscriptions', views.SubscriptionViewSet) +urlpatterns = [ + path('', include(router.urls)), +] diff --git a/apps/subscriptions/views.py b/apps/subscriptions/views.py new file mode 100644 index 0000000..6bd5c68 --- /dev/null +++ b/apps/subscriptions/views.py @@ -0,0 +1,15 @@ +from rest_framework import viewsets, permissions +from .models import Subscription +from .serializers import SubscriptionSerializer + + +class SubscriptionViewSet(viewsets.ModelViewSet): + queryset = Subscription.objects.all() + serializer_class = SubscriptionSerializer + permission_classes = [permissions.IsAuthenticated] + + def get_queryset(self): + user = self.request.user + if user.role == "admin": + return Subscription.objects.all() + return Subscription.objects.filter(user=user) diff --git a/apps/users/__init__.py b/apps/users/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/users/admin.py b/apps/users/admin.py new file mode 100644 index 0000000..e0b3d42 --- /dev/null +++ b/apps/users/admin.py @@ -0,0 +1,25 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin as BaseUserAdmin +from .models import User + + +@admin.register(User) +class UserAdmin(BaseUserAdmin): + list_display = ["email", "full_name", "company_name", "role", "is_active", "created_at"] + list_filter = ["role", "is_active"] + search_fields = ["email", "full_name", "company_name"] + ordering = ["-created_at"] + fieldsets = ( + (None, {"fields": ("email", "password")}), + ("Personal Info", {"fields": ("full_name", "company_name")}), + ("Permissions", {"fields": ("role", "is_active", "is_staff", "is_superuser", "groups", "user_permissions")}), + ("Integrations", {"fields": ("stripe_customer_id", "keycloak_id")}), + ("Important dates", {"fields": ("last_login", "created_at", "updated_at")}), + ) + readonly_fields = ["created_at", "updated_at", "keycloak_id"] + add_fieldsets = ( + (None, { + "classes": ("wide",), + "fields": ("email", "full_name", "company_name", "role", "password1", "password2"), + }), + ) diff --git a/apps/users/apps.py b/apps/users/apps.py new file mode 100644 index 0000000..4decc45 --- /dev/null +++ b/apps/users/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + +class UsersConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.users" + verbose_name = "Users" diff --git a/apps/users/auth.py b/apps/users/auth.py new file mode 100644 index 0000000..4c284cc --- /dev/null +++ b/apps/users/auth.py @@ -0,0 +1,30 @@ +from mozilla_django_oidc.auth import OIDCAuthenticationBackend + + +class KeycloakOIDCBackend(OIDCAuthenticationBackend): + def create_user(self, claims): + email = claims.get("email", "") + user = self.UserModel.objects.create_user( + email=email, + full_name=claims.get("name", ""), + keycloak_id=claims.get("sub", ""), + role=claims.get("role", "buyer"), + is_staff=True, + ) + return user + + def update_user(self, user, claims): + user.email = claims.get("email", user.email) + user.full_name = claims.get("name", user.full_name) + user.is_staff = True + role = claims.get("role", user.role) + if role in dict(self.UserModel.Roles.choices): + user.role = role + user.save() + return user + + def filter_users_by_claims(self, claims): + email = claims.get("email") + if not email: + return self.UserModel.objects.none() + return self.UserModel.objects.filter(email__iexact=email) diff --git a/apps/users/migrations/0001_initial.py b/apps/users/migrations/0001_initial.py new file mode 100644 index 0000000..882da62 --- /dev/null +++ b/apps/users/migrations/0001_initial.py @@ -0,0 +1,49 @@ +# Generated by Django 5.2.12 on 2026-07-29 12:02 + +import django.contrib.auth.models +import django.utils.timezone +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='User', + fields=[ + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('email', models.EmailField(max_length=254, unique=True)), + ('full_name', models.CharField(blank=True, max_length=255)), + ('company_name', models.CharField(blank=True, max_length=255)), + ('role', models.CharField(choices=[('buyer', 'Buyer'), ('ops', 'Operations'), ('admin', 'Administrator')], default='buyer', max_length=20)), + ('stripe_customer_id', models.CharField(blank=True, max_length=255, null=True)), + ('keycloak_id', models.CharField(blank=True, max_length=255, null=True, unique=True)), + ('is_active', models.BooleanField(default=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'User', + 'verbose_name_plural': 'Users', + 'db_table': 'users', + 'ordering': ['-created_at'], + }, + managers=[ + ('objects', django.contrib.auth.models.UserManager()), + ], + ), + ] diff --git a/apps/users/migrations/0002_alter_user_managers.py b/apps/users/migrations/0002_alter_user_managers.py new file mode 100644 index 0000000..d4a49a4 --- /dev/null +++ b/apps/users/migrations/0002_alter_user_managers.py @@ -0,0 +1,20 @@ +# Generated by Django 5.2.12 on 2026-07-29 12:39 + +import apps.users.models +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0001_initial'), + ] + + operations = [ + migrations.AlterModelManagers( + name='user', + managers=[ + ('objects', apps.users.models.UserManager()), + ], + ), + ] diff --git a/apps/users/migrations/__init__.py b/apps/users/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/users/models.py b/apps/users/models.py new file mode 100644 index 0000000..3162e26 --- /dev/null +++ b/apps/users/models.py @@ -0,0 +1,55 @@ +import uuid +from django.contrib.auth.models import AbstractUser, UserManager as BaseUserManager +from django.db import models + + +class UserManager(BaseUserManager): + def create_user(self, email, password=None, **extra_fields): + if not email: + raise ValueError("Email is required") + email = self.normalize_email(email) + user = self.model(email=email, **extra_fields) + user.set_password(password) + user.save(using=self._db) + return user + + def create_superuser(self, email, password=None, **extra_fields): + extra_fields.setdefault("is_staff", True) + extra_fields.setdefault("is_superuser", True) + return self.create_user(email, password, **extra_fields) + + +class User(AbstractUser): + class Roles(models.TextChoices): + BUYER = "buyer", "Buyer" + OPS = "ops", "Operations" + ADMIN = "admin", "Administrator" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + email = models.EmailField(unique=True) + full_name = models.CharField(max_length=255, blank=True) + company_name = models.CharField(max_length=255, blank=True) + role = models.CharField(max_length=20, choices=Roles.choices, default=Roles.BUYER) + stripe_customer_id = models.CharField(max_length=255, blank=True, null=True) + keycloak_id = models.CharField(max_length=255, unique=True, null=True, blank=True) + is_active = models.BooleanField(default=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + username = None + first_name = None + last_name = None + + objects = UserManager() + + USERNAME_FIELD = "email" + REQUIRED_FIELDS = [] + + def __str__(self): + return self.full_name or self.email + + class Meta: + db_table = "users" + verbose_name = "User" + verbose_name_plural = "Users" + ordering = ["-created_at"] diff --git a/apps/users/serializers.py b/apps/users/serializers.py new file mode 100644 index 0000000..5efdcd3 --- /dev/null +++ b/apps/users/serializers.py @@ -0,0 +1,32 @@ +from rest_framework import serializers +from .models import User + + +class UserSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = ["id", "email", "full_name", "company_name", "role", "is_active", "created_at"] + read_only_fields = ["id", "created_at"] + + +class UserDetailSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = ["id", "email", "full_name", "company_name", "role", "stripe_customer_id", "is_active", "created_at", "updated_at"] + read_only_fields = ["id", "created_at", "updated_at"] + + +class UserCreateSerializer(serializers.ModelSerializer): + password = serializers.CharField(write_only=True, required=False) + + class Meta: + model = User + fields = ["email", "full_name", "company_name", "role", "password"] + + def create(self, validated_data): + password = validated_data.pop("password", None) + user = User.objects.create_user(**validated_data) + if password: + user.set_password(password) + user.save() + return user diff --git a/apps/users/urls.py b/apps/users/urls.py new file mode 100644 index 0000000..2b8b30c --- /dev/null +++ b/apps/users/urls.py @@ -0,0 +1,8 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from . import views +router = DefaultRouter() +router.register('users', views.UserViewSet) +urlpatterns = [ + path('', include(router.urls)), +] diff --git a/apps/users/views.py b/apps/users/views.py new file mode 100644 index 0000000..5374ef6 --- /dev/null +++ b/apps/users/views.py @@ -0,0 +1,25 @@ +from rest_framework import viewsets, mixins, permissions +from .models import User +from .serializers import UserSerializer, UserDetailSerializer, UserCreateSerializer + + +class UserViewSet(mixins.ListModelMixin, + mixins.RetrieveModelMixin, + mixins.UpdateModelMixin, + viewsets.GenericViewSet): + queryset = User.objects.all() + serializer_class = UserSerializer + permission_classes = [permissions.IsAuthenticated] + + def get_serializer_class(self): + if self.action == "retrieve": + return UserDetailSerializer + if self.action == "create": + return UserCreateSerializer + return UserSerializer + + def get_queryset(self): + user = self.request.user + if user.role == "admin": + return User.objects.all() + return User.objects.filter(id=user.id) diff --git a/create_agent.py b/create_agent.py new file mode 100644 index 0000000..1f6089b --- /dev/null +++ b/create_agent.py @@ -0,0 +1,22 @@ +from apps.ai.models import AgentConfig + +agent, created = AgentConfig.objects.get_or_create( + name="IXG Assistant", + defaults={ + "description": "Default IXG platform AI assistant", + "provider": AgentConfig.Provider.DEEPSEEK, + "api_key": "sk-46ab1f52da81495fa1f712354b0cdcef", + "model_name": "deepseek-chat", + "system_prompt": "You are a helpful assistant for the IXG platform, a Nigerian ginger export company.", + "temperature": 0.7, + "max_tokens": 2000, + "is_active": True, + "is_default": True, + "capabilities": ["chat"], + } +) + +if created: + print("Created agent: " + agent.name + " (ID: " + str(agent.id) + ")") +else: + print("Agent already exists: " + agent.name + " (ID: " + str(agent.id) + ")") diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1e2744a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,102 @@ +services: + ixg-postgres: + image: postgres:15-alpine + container_name: ixg-platform-db + restart: unless-stopped + networks: + - client-infra + volumes: + - postgres_data:/var/lib/postgresql/data + environment: + POSTGRES_DB: ixg_platform + POSTGRES_USER: postgres + POSTGRES_PASSWORD: GYV9G8IuEbcTr39k2NxGbcLpYilD1mvhSmkPFuCs2fM= + ports: + - "127.0.0.1:5433:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 5 + + ixg-redis: + image: redis:7-alpine + container_name: ixg-platform-redis + restart: unless-stopped + networks: + - client-infra + ports: + - "127.0.0.1:6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 5 + + app: + build: . + container_name: ixg-platform-app + restart: unless-stopped + networks: + - client-infra + depends_on: + ixg-postgres: + condition: service_healthy + ixg-redis: + condition: service_healthy + volumes: + - .:/app + - ./staticfiles:/app/staticfiles + - ./media:/app/media + - ./documents:/data/ixg-documents + env_file: + - ./config/secrets/env.production + environment: + DB_HOST: ixg-postgres + DB_PORT: 5432 + ports: + - "0.0.0.0:8000:8000" + + celery_worker: + build: . + container_name: ixg-platform-celery-worker + restart: unless-stopped + networks: + - client-infra + depends_on: + - ixg-postgres + - ixg-redis + volumes: + - .:/app + - ./documents:/data/ixg-documents + env_file: + - ./config/secrets/env.production + environment: + DB_HOST: ixg-postgres + DB_PORT: 5432 + command: celery -A dsp_platform worker -l info + + celery_beat: + build: . + container_name: ixg-platform-celery-beat + restart: unless-stopped + networks: + - client-infra + depends_on: + - ixg-postgres + - ixg-redis + volumes: + - .:/app + env_file: + - ./config/secrets/env.production + environment: + DB_HOST: ixg-postgres + DB_PORT: 5432 + command: celery -A dsp_platform beat -l info + +volumes: + postgres_data: + +networks: + client-infra: + external: true diff --git a/dsp_platform/__init__.py b/dsp_platform/__init__.py new file mode 100644 index 0000000..370372a --- /dev/null +++ b/dsp_platform/__init__.py @@ -0,0 +1,3 @@ +from .celery import app as celery_app + +__all__ = ["celery_app"] diff --git a/dsp_platform/asgi.py b/dsp_platform/asgi.py new file mode 100644 index 0000000..6d2d9d3 --- /dev/null +++ b/dsp_platform/asgi.py @@ -0,0 +1,5 @@ +import os +from django.core.asgi import get_asgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "dsp_platform.settings") +application = get_asgi_application() diff --git a/dsp_platform/celery.py b/dsp_platform/celery.py new file mode 100644 index 0000000..a232dbe --- /dev/null +++ b/dsp_platform/celery.py @@ -0,0 +1,8 @@ +import os +from celery import Celery + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "dsp_platform.settings") + +app = Celery("dsp_platform") +app.config_from_object("django.conf:settings", namespace="CELERY") +app.autodiscover_tasks() diff --git a/dsp_platform/settings.py b/dsp_platform/settings.py new file mode 100644 index 0000000..c02cff6 --- /dev/null +++ b/dsp_platform/settings.py @@ -0,0 +1,161 @@ +import os +from pathlib import Path +import environ + +env = environ.Env() + +BASE_DIR = Path(__file__).resolve().parent.parent + +dsp_env = os.environ.get("DSP_ENV", "production") +env_file = BASE_DIR / f"config/secrets/env.{dsp_env}" +if env_file.exists(): + environ.Env.read_env(str(env_file)) +else: + environ.Env.read_env(str(BASE_DIR / "config/secrets/env.production")) + +SECRET_KEY = env("DJANGO_SECRET_KEY", default="insecure-dev-key-change-in-production") +DEBUG = env.bool("DJANGO_DEBUG", default=False) +ALLOWED_HOSTS = env.list("DJANGO_ALLOWED_HOSTS", default=["194.164.95.50", "localhost", "127.0.0.1"]) + +INSTALLED_APPS = [ + "grappelli", + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.humanize", + "django.contrib.staticfiles", + "django_extensions", + "rest_framework", + "rest_framework.authtoken", + "mozilla_django_oidc", + "corsheaders", + "django_filters", + "apps.core", + "apps.users", + "apps.subscriptions", + "apps.batches", + "apps.qc", + "apps.shipments", + "apps.documents", + "apps.capa", + "apps.esg", + "apps.ai", + "apps.notifications", + "apps.ops", + "apps.buyer", + "encrypted_model_fields", +] + +MIDDLEWARE = [ + "corsheaders.middleware.CorsMiddleware", + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "apps.core.middleware.RBACMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +ROOT_URLCONF = "dsp_platform.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [BASE_DIR / "templates"], + "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", + ], + }, + }, +] + +WSGI_APPLICATION = "dsp_platform.wsgi.application" + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": env("DB_NAME", default="ixg_platform"), + "USER": env("DB_USER", default="postgres"), + "PASSWORD": env("DB_PASSWORD", default=""), + "HOST": env("DB_HOST", default="127.0.0.1"), + "PORT": env.int("DB_PORT", default=5432), + } +} + +AUTH_PASSWORD_VALIDATORS = [ + {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, +] + +LANGUAGE_CODE = "en-gb" +TIME_ZONE = "Europe/London" +USE_I18N = True +USE_TZ = True + +STATIC_URL = "/static/" +STATIC_ROOT = BASE_DIR / "staticfiles" +MEDIA_URL = "/media/" +MEDIA_ROOT = BASE_DIR / "media" + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +AUTH_USER_MODEL = "users.User" + +AUTHENTICATION_BACKENDS = [ + "apps.users.auth.KeycloakOIDCBackend", + "django.contrib.auth.backends.ModelBackend", +] + +OIDC_OP_AUTHORIZATION_ENDPOINT = env("OIDC_OP_AUTHORIZATION_ENDPOINT", default="http://194.164.95.50:8443/realms/ixg/protocol/openid-connect/auth") +OIDC_OP_TOKEN_ENDPOINT = env("OIDC_OP_TOKEN_ENDPOINT", default="http://194.164.95.50:8443/realms/ixg/protocol/openid-connect/token") +OIDC_OP_USER_ENDPOINT = env("OIDC_OP_USER_ENDPOINT", default="http://194.164.95.50:8443/realms/ixg/protocol/openid-connect/userinfo") +OIDC_OP_JWKS_ENDPOINT = env("OIDC_OP_JWKS_ENDPOINT", default="http://194.164.95.50:8443/realms/ixg/protocol/openid-connect/certs") +OIDC_OP_LOGOUT_ENDPOINT = env("OIDC_OP_LOGOUT_ENDPOINT", default="http://194.164.95.50:8443/realms/ixg/protocol/openid-connect/logout") +OIDC_RP_SIGN_ALGO = "RS256" +OIDC_RP_CLIENT_ID = env("OIDC_RP_CLIENT_ID", default="ixg-platform") +OIDC_RP_CLIENT_SECRET = env("OIDC_RP_CLIENT_SECRET", default="") +OIDC_RP_SCOPES = "openid email profile" +OIDC_USE_NONCE = True +OIDC_CREATE_USER = True +OIDC_STORE_ACCESS_TOKEN = True +OIDC_STORE_ID_TOKEN = True +LOGIN_URL = "oidc_authentication_init" +LOGOUT_REDIRECT_URL = "/" +LOGIN_REDIRECT_URL = "/admin/" + +KEYCLOAK_SERVER_URL = env("KEYCLOAK_SERVER_URL", default="http://194.164.95.50:8443") +KEYCLOAK_REALM = env("KEYCLOAK_REALM", default="ixg") + +REST_FRAMEWORK = { + "DEFAULT_AUTHENTICATION_CLASSES": [ + "rest_framework.authentication.SessionAuthentication", + "rest_framework.authentication.TokenAuthentication", + ], + "DEFAULT_PERMISSION_CLASSES": [ + "rest_framework.permissions.IsAuthenticated", + ], + "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination", + "PAGE_SIZE": 20, +} + +CELERY_BROKER_URL = env("CELERY_BROKER_URL", default="redis://127.0.0.1:6379/0") +CELERY_RESULT_BACKEND = env("CELERY_RESULT_BACKEND", default="redis://127.0.0.1:6379/0") +CELERY_ACCEPT_CONTENT = ["json"] +CELERY_TASK_SERIALIZER = "json" +CELERY_RESULT_SERIALIZER = "json" +CELERY_TIMEZONE = "Europe/London" + +CORS_ALLOWED_ORIGINS = env.list("CORS_ALLOWED_ORIGINS", default=["http://194.164.95.50"]) +CORS_ALLOW_CREDENTIALS = True + +STRIPE_SECRET_KEY = env("STRIPE_SECRET_KEY", default="") +STRIPE_WEBHOOK_SECRET = env("STRIPE_WEBHOOK_SECRET", default="") +FIELD_ENCRYPTION_KEY = env("ENCRYPTION_KEY", default="") diff --git a/dsp_platform/urls.py b/dsp_platform/urls.py new file mode 100644 index 0000000..9eb88c5 --- /dev/null +++ b/dsp_platform/urls.py @@ -0,0 +1,30 @@ +from django.contrib import admin +from django.http import HttpResponseRedirect +from django.urls import path, include +from django.conf import settings +from django.conf.urls.static import static +from rest_framework.authtoken import views as drf_auth + +urlpatterns = [ + path("", lambda r: HttpResponseRedirect("/admin/")), + path("grappelli/", include("grappelli.urls")), + path("admin/", admin.site.urls), + path("oidc/", include("mozilla_django_oidc.urls")), + path("api/auth/", drf_auth.obtain_auth_token), + path("api/", include("apps.users.urls")), + path("api/", include("apps.subscriptions.urls")), + path("api/", include("apps.batches.urls")), + path("api/", include("apps.qc.urls")), + path("api/", include("apps.shipments.urls")), + path("api/", include("apps.documents.urls")), + path("api/", include("apps.capa.urls")), + path("api/", include("apps.esg.urls")), + path("api/", include("apps.ai.urls")), + path("api/", include("apps.notifications.urls")), + path("ops/", include("apps.ops.urls")), + path("portal/", include("apps.buyer.urls")), +] + +if settings.DEBUG: + urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) + urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) diff --git a/dsp_platform/wsgi.py b/dsp_platform/wsgi.py new file mode 100644 index 0000000..046e224 --- /dev/null +++ b/dsp_platform/wsgi.py @@ -0,0 +1,5 @@ +import os +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "dsp_platform.settings") +application = get_wsgi_application() diff --git a/manage.py b/manage.py new file mode 100755 index 0000000..cfa6299 --- /dev/null +++ b/manage.py @@ -0,0 +1,10 @@ +#!/usr/bin/env python +import os, sys + +def main(): + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "dsp_platform.settings") + from django.core.management import execute_from_command_line + execute_from_command_line(sys.argv) + +if __name__ == "__main__": + main() diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 0000000..194defa --- /dev/null +++ b/nginx/nginx.conf @@ -0,0 +1,26 @@ +upstream django_app { + server 127.0.0.1:8000; +} + +server { + listen 80; + server_name 194.164.95.50; + client_max_body_size 50M; + + location /static/ { + alias /app/staticfiles/; + } + + location /media/ { + alias /app/media/; + } + + location / { + proxy_pass http://django_app; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_redirect off; + } +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f49084d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,25 @@ +asgiref==3.11.1 +Django==5.2.12 +django-environ==0.13.0 +django-extensions==3.2.3 +django-grappelli +django-cors-headers +djangorestframework==3.16.1 +django-filter==24.3 +PyJWT==2.12.0 +requests==2.33.0 +gunicorn==22.0.0 +celery==5.4.0 +redis==5.0.1 +psycopg2-binary==2.9.11 +sqlparse==0.5.5 +mozilla-django-oidc +stripe==8.8.0 +python-dateutil==2.8.2 +markdown==3.7 +bleach==6.2.0 + +django-encrypted-model-fields +openai +google-genai +cryptography diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh new file mode 100755 index 0000000..c7807af --- /dev/null +++ b/scripts/entrypoint.sh @@ -0,0 +1,21 @@ +#!/bin/bash +set -e + +echo "Waiting for PostgreSQL..." +while ! nc -z "$DB_HOST" "$DB_PORT" 2>/dev/null; do + sleep 1 +done +echo "PostgreSQL is ready." + +echo "Running migrations..." +python manage.py migrate --noinput || true + +echo "Collecting static files..." +python manage.py collectstatic --noinput + +echo "Starting Gunicorn..." +exec gunicorn --bind 0.0.0.0:8000 \ + --workers 2 \ + --timeout 300 \ + --access-logfile - \ + dsp_platform.wsgi:application diff --git a/templates/ops/base_ops.html b/templates/ops/base_ops.html new file mode 100644 index 0000000..2aa398b --- /dev/null +++ b/templates/ops/base_ops.html @@ -0,0 +1,59 @@ + + + + + +{% block title %}IXG Operations{% endblock %} + + + + + + + + +
+
+ +
+ Admin + {{ request.user.full_name|default:request.user.email }} + Logout +
+
+
+ {% block content %}{% endblock %} +
+
+ + + + diff --git a/templates/ops/batch_detail.html b/templates/ops/batch_detail.html new file mode 100644 index 0000000..b9fdc19 --- /dev/null +++ b/templates/ops/batch_detail.html @@ -0,0 +1,110 @@ +{% extends 'ops/base_ops.html' %} +{% block title %}{{ batch.batch_reference }} - IXG Operations{% endblock %} +{% block content %} + + + +
+
+
+ + + + + + + + +
Status{{ batch.get_status_display }}
Grade{{ batch.grade|default:'Not graded' }}
Weight{{ batch.weight_kg|default:'-' }} kg
Origin Facility{{ batch.origin_facility }}
Harvest Date{{ batch.harvest_date|date:'d/m/Y'|default:'-' }}
Created By{{ batch.created_by.full_name|default:batch.created_by.email|default:'-' }}
Created At{{ batch.created_at|date:'d/m/Y H:i' }}
+
+
+
+
+
Notes
+

{{ batch.notes|linebreaks|default:'No notes' }}

+
+
+
+ +
+
+
+
QC Results ({{ qc_results|length }})
+ {% if qc_results %} + + + + {% for r in qc_results %} + + + + + + + + {% endfor %} + +
ResultFFA%Moisture%PeroxideDate
{% if r.overall_pass %}Pass{% else %}Fail{% endif %}{{ r.ffa_percentage }}{{ r.moisture_percentage }}{{ r.peroxide_value }}{{ r.tested_at|date:'d/m/Y' }}
+ {% else %}

No QC results

{% endif %} +
+
+
+
+
Shipments ({{ shipments|length }})
+ {% if shipments %} + + + + {% for s in shipments %} + + + + + + + {% endfor %} + +
StatusVesselETDETA
{{ s.get_status_display }}{{ s.vessel_name|default:'-' }}{{ s.etd_nigeria|date:'d/m/Y'|default:'-' }}{{ s.eta_uk|date:'d/m/Y'|default:'-' }}
+ {% else %}

No shipments

{% endif %} +
+
+
+ +
+
+
+
CAPA Records ({{ capa_records|length }})
+ {% if capa_records %} + + + + {% for c in capa_records %} + + + + + + {% endfor %} + +
IssueStatusDue Date
{{ c.issue_description|truncatewords:10 }}{{ c.get_status_display }}{{ c.due_date|date:'d/m/Y'|default:'-' }}
+ {% else %}

No CAPA records

{% endif %} +
+
+
+
+
Documents ({{ documents|length }})
+ {% if documents %} +
    + {% for d in documents %} +
  • {{ d.file_name }} {{ d.get_doc_type_display }}
  • + {% endfor %} +
+ {% else %}

No documents

{% endif %} +
+
+
+ +{% endblock %} diff --git a/templates/ops/batch_list.html b/templates/ops/batch_list.html new file mode 100644 index 0000000..9793d4e --- /dev/null +++ b/templates/ops/batch_list.html @@ -0,0 +1,85 @@ +{% extends 'ops/base_ops.html' %} +{% block title %}Batches - IXG Operations{% endblock %} +{% block content %} + + + +
+
+
+ +
+
+ +
+
+ +
+
+ + Clear +
+
+
+ +
+ + + + + + + + + + + + + + {% for b in batches %} + + + + + + + + + + {% empty %} + + {% endfor %} + +
ReferenceGradeWeight (kg)StatusHarvest DateCreated
{{ b.batch_reference }}{{ b.grade|default:'-' }}{{ b.weight_kg|default:'-' }}{{ b.get_status_display }}{{ b.harvest_date|date:'d/m/Y'|default:'-' }}{{ b.created_at|date:'d/m/Y' }}View
No batches found
+
+ +{% if page_obj.paginator.num_pages > 1 %} + +{% endif %} + +{% endblock %} diff --git a/templates/ops/capa_detail.html b/templates/ops/capa_detail.html new file mode 100644 index 0000000..58e2cdb --- /dev/null +++ b/templates/ops/capa_detail.html @@ -0,0 +1,49 @@ +{% extends 'ops/base_ops.html' %} +{% block title %}CAPA Detail - IXG Operations{% endblock %} +{% block content %} + + + +
+
+
+
Issue Description
+

{{ record.issue_description|linebreaks }}

+
+ {% if record.root_cause %} +
+
Root Cause
+

{{ record.root_cause|linebreaks }}

+
+ {% endif %} + {% if record.corrective_action %} +
+
Corrective Action
+

{{ record.corrective_action|linebreaks }}

+
+ {% endif %} + {% if record.preventive_action %} +
+
Preventive Action
+

{{ record.preventive_action|linebreaks }}

+
+ {% endif %} +
+
+
+ + + + + + + {% if record.closed_at %}{% endif %} +
Batch{{ record.batch.batch_reference }}
Status{{ record.get_status_display }}
Assigned To{{ record.assigned_to|default:'-' }}
Due Date{{ record.due_date|date:'d/m/Y'|default:'-' }}
Created{{ record.created_at|date:'d/m/Y H:i' }}
Closed{{ record.closed_at|date:'d/m/Y H:i' }}
+
+
+
+ +{% endblock %} diff --git a/templates/ops/capa_list.html b/templates/ops/capa_list.html new file mode 100644 index 0000000..5415aed --- /dev/null +++ b/templates/ops/capa_list.html @@ -0,0 +1,58 @@ +{% extends 'ops/base_ops.html' %} +{% block title %}CAPA Records - IXG Operations{% endblock %} +{% block content %} + + + +
+
+
+ +
+
+ + Clear +
+
+
+ +
+ + + + + + {% for r in records %} + + + + + + + + + + {% empty %} + + {% endfor %} + +
BatchIssueStatusAssigned ToDue DateCreated
{{ r.batch.batch_reference }}{{ r.issue_description|truncatewords:15 }}{{ r.get_status_display }}{{ r.assigned_to|default:'-' }}{{ r.due_date|date:'d/m/Y'|default:'-' }}{{ r.created_at|date:'d/m/Y' }}View
No CAPA records
+
+ +{% if page_obj.paginator.num_pages > 1 %} + +{% endif %} + +{% endblock %} diff --git a/templates/ops/dashboard.html b/templates/ops/dashboard.html new file mode 100644 index 0000000..6b860cd --- /dev/null +++ b/templates/ops/dashboard.html @@ -0,0 +1,111 @@ +{% extends 'ops/base_ops.html' %} +{% block title %}Dashboard - IXG Operations{% endblock %} +{% block content %} + + + +
+
+
+
{{ qc_summary.passed }}
+
QC Passed
+
+
+
+
+
{{ qc_summary.failed }}
+
QC Failed
+
+
+
+
+
{{ active_shipments }}
+
Active Shipments
+
+
+
+
+
{{ open_capas }}
+
Open CAPAs
+
+
+
+ +
+
+
+
Batches by Status
+ + {% for label, count in batches_by_status.items %} + + + + + {% empty %} + + {% endfor %} +
{{ label }}{{ count }}
No batches yet
+
+
+
+
+
Recent Batches
+ + + + {% for b in recent_batches %} + + + + + + + {% empty %} + + {% endfor %} + +
ReferenceStatusGradeCreated
{{ b.batch_reference }}{{ b.get_status_display }}{{ b.grade|default:'-' }}{{ b.created_at|date:'d/m/Y' }}
No batches yet
+
+
+
+ +
+
+
+
Recent QC Results
+ + + + {% for r in recent_qc %} + + + + + + + {% empty %} + + {% endfor %} + +
BatchResultFFA%Date
{{ r.batch.batch_reference }}{% if r.overall_pass %}Pass{% else %}Fail{% endif %}{{ r.ffa_percentage }}{{ r.tested_at|date:'d/m/Y' }}
No QC results yet
+
+
+
+
+
Unread Notifications
+ {% for n in recent_notifications %} +
+ {{ n.title }} +

{{ n.message|truncatewords:20 }}

+
+ {% empty %} +

No unread notifications

+ {% endfor %} +
+
+
+ +{% endblock %} diff --git a/templates/ops/document_list.html b/templates/ops/document_list.html new file mode 100644 index 0000000..3bccc01 --- /dev/null +++ b/templates/ops/document_list.html @@ -0,0 +1,57 @@ +{% extends 'ops/base_ops.html' %} +{% block title %}Documents - IXG Operations{% endblock %} +{% block content %} + + + +
+
+
+ +
+
+ + Clear +
+
+
+ +
+ + + + + + {% for d in documents %} + + + + + + + + + {% empty %} + + {% endfor %} + +
File NameTypeBatchVisibilityUploaded ByUploaded
{{ d.file_name }}{{ d.get_doc_type_display }}{{ d.batch.batch_reference|default:'-' }}{{ d.get_visibility_display }}{{ d.uploaded_by.full_name|default:d.uploaded_by.email|default:'-' }}{{ d.uploaded_at|date:'d/m/Y' }}
No documents
+
+ +{% if page_obj.paginator.num_pages > 1 %} + +{% endif %} + +{% endblock %} diff --git a/templates/ops/esg_list.html b/templates/ops/esg_list.html new file mode 100644 index 0000000..95de33d --- /dev/null +++ b/templates/ops/esg_list.html @@ -0,0 +1,41 @@ +{% extends 'ops/base_ops.html' %} +{% block title %}ESG Reports - IXG Operations{% endblock %} +{% block content %} + + + +
+ + + + + + {% for r in reports %} + + + + + + + + + + {% empty %} + + {% endfor %} + +
BatchWomen EmployedTotal WorkersFair WageSafety EquipmentReport DateReported By
{{ r.batch.batch_reference }}{{ r.women_employed }}{{ r.total_workers }}{% if r.fair_wage_paid %}Yes{% else %}No{% endif %}{% if r.safety_equipment_used %}Yes{% else %}No{% endif %}{{ r.report_date|date:'d/m/Y'|default:'-' }}{{ r.reported_by|default:'-' }}
No ESG reports
+
+ +{% if page_obj.paginator.num_pages > 1 %} + +{% endif %} + +{% endblock %} diff --git a/templates/ops/qc_detail.html b/templates/ops/qc_detail.html new file mode 100644 index 0000000..d8f4138 --- /dev/null +++ b/templates/ops/qc_detail.html @@ -0,0 +1,28 @@ +{% extends 'ops/base_ops.html' %} +{% block title %}QC Detail - IXG Operations{% endblock %} +{% block content %} + + + +
+
+
+ + + + + + + + + + +
Batch{{ result.batch.batch_reference }}
Overall Result{% if result.overall_pass %}Pass{% else %}Fail{% endif %}
FFA %{{ result.ffa_percentage }}
Moisture %{{ result.moisture_percentage }}
Peroxide Value{{ result.peroxide_value }}
Visual Grade{{ result.visual_grade }}
Tested By{{ result.tested_by|default:'-' }}
Tested At{{ result.tested_at|date:'d/m/Y H:i' }}
Lab Reference{{ result.lab_reference|default:'-' }}
+
+
+
+ +{% endblock %} diff --git a/templates/ops/qc_list.html b/templates/ops/qc_list.html new file mode 100644 index 0000000..9cf6b4e --- /dev/null +++ b/templates/ops/qc_list.html @@ -0,0 +1,58 @@ +{% extends 'ops/base_ops.html' %} +{% block title %}QC Results - IXG Operations{% endblock %} +{% block content %} + + + +
+
+
+ +
+
+ + Clear +
+
+
+ +
+ + + + + + {% for r in results %} + + + + + + + + + + + {% empty %} + + {% endfor %} + +
BatchResultFFA%Moisture%PeroxideVisualTested
{{ r.batch.batch_reference }}{% if r.overall_pass %}Pass{% else %}Fail{% endif %}{{ r.ffa_percentage }}{{ r.moisture_percentage }}{{ r.peroxide_value }}{{ r.visual_grade }}{{ r.tested_at|date:'d/m/Y' }}View
No QC results
+
+ +{% if page_obj.paginator.num_pages > 1 %} + +{% endif %} + +{% endblock %} diff --git a/templates/ops/shipment_detail.html b/templates/ops/shipment_detail.html new file mode 100644 index 0000000..fd90ad9 --- /dev/null +++ b/templates/ops/shipment_detail.html @@ -0,0 +1,29 @@ +{% extends 'ops/base_ops.html' %} +{% block title %}Shipment Detail - IXG Operations{% endblock %} +{% block content %} + + + +
+
+
+ + + + + + + + + + + +
Batch{{ shipment.batch.batch_reference }}
Status{{ shipment.get_status_display }}
Origin Port{{ shipment.origin_port }}
Destination Port{{ shipment.destination_port }}
Vessel Name{{ shipment.vessel_name|default:'-' }}
Container Ref{{ shipment.container_ref|default:'-' }}
ETD Nigeria{{ shipment.etd_nigeria|date:'d/m/Y'|default:'-' }}
ETA UK{{ shipment.eta_uk|date:'d/m/Y'|default:'-' }}
Bill of Lading{{ shipment.bl_number|default:'-' }}
Freight Forwarder{{ shipment.freight_forwarder|default:'-' }}
+
+
+
+ +{% endblock %} diff --git a/templates/ops/shipment_list.html b/templates/ops/shipment_list.html new file mode 100644 index 0000000..3fa4b00 --- /dev/null +++ b/templates/ops/shipment_list.html @@ -0,0 +1,58 @@ +{% extends 'ops/base_ops.html' %} +{% block title %}Shipments - IXG Operations{% endblock %} +{% block content %} + + + +
+
+
+ +
+
+ + Clear +
+
+
+ +
+ + + + + + {% for s in shipments %} + + + + + + + + + + {% empty %} + + {% endfor %} + +
BatchStatusVesselContainerETD NigeriaETA UK
{{ s.batch.batch_reference }}{{ s.get_status_display }}{{ s.vessel_name|default:'-' }}{{ s.container_ref|default:'-' }}{{ s.etd_nigeria|date:'d/m/Y'|default:'-' }}{{ s.eta_uk|date:'d/m/Y'|default:'-' }}View
No shipments
+
+ +{% if page_obj.paginator.num_pages > 1 %} + +{% endif %} + +{% endblock %} diff --git a/templates/portal/base_portal.html b/templates/portal/base_portal.html new file mode 100644 index 0000000..c37f820 --- /dev/null +++ b/templates/portal/base_portal.html @@ -0,0 +1,56 @@ + + + + + +{% block title %}IXG Buyer Portal{% endblock %} + + + + + + + + +
+
+ +
+ {{ request.user.full_name|default:request.user.email }} + Logout +
+
+
+ {% block content %}{% endblock %} +
+
+ + + + \ No newline at end of file diff --git a/templates/portal/batch_detail.html b/templates/portal/batch_detail.html new file mode 100644 index 0000000..be2a6e4 --- /dev/null +++ b/templates/portal/batch_detail.html @@ -0,0 +1,73 @@ +{% extends 'portal/base_portal.html' %} +{% block title %}{{ batch.batch_reference }} - IXG Buyer Portal{% endblock %} +{% block content %} + + + +
+
+
+ + + + + + + +
Status{{ batch.get_status_display }}
Grade{{ batch.grade|default:'Not graded' }}
Weight{{ batch.weight_kg|default:'-' }} kg
Origin Facility{{ batch.origin_facility }}
Harvest Date{{ batch.harvest_date|date:'d/m/Y'|default:'-' }}
Notes{{ batch.notes|linebreaks|default:'None' }}
+
+
+
+
+
Shipments
+ {% if shipments %} + + + + {% for s in shipments %} + + + + + + + {% endfor %} + +
StatusVesselETDETA
{{ s.get_status_display }}{{ s.vessel_name|default:'-' }}{{ s.etd_nigeria|date:'d/m/Y'|default:'-' }}{{ s.eta_uk|date:'d/m/Y'|default:'-' }}
+ {% else %}

No shipments

{% endif %} +
+
+
Documents
+ {% if documents %} +
    + {% for d in documents %} +
  • {{ d.file_name }} {{ d.get_doc_type_display }}
  • + {% endfor %} +
+ {% else %}

No documents

{% endif %} +
+
+
ESG Reports
+ {% if esg_reports %} + + + + {% for r in esg_reports %} + + + + + + + {% endfor %} + +
DateWomenTotal WorkersFair Wage
{{ r.report_date|date:'d/m/Y'|default:'-' }}{{ r.women_employed }}{{ r.total_workers }}{% if r.fair_wage_paid %}Yes{% else %}No{% endif %}
+ {% else %}

No ESG reports

{% endif %} +
+
+
+ +{% endblock %} \ No newline at end of file diff --git a/templates/portal/batch_list.html b/templates/portal/batch_list.html new file mode 100644 index 0000000..7986ef3 --- /dev/null +++ b/templates/portal/batch_list.html @@ -0,0 +1,58 @@ +{% extends 'portal/base_portal.html' %} +{% block title %}Batches - IXG Buyer Portal{% endblock %} +{% block content %} + + + +
+
+
+ +
+
+ +
+
+ + Clear +
+
+
+ +
+ + + + {% for b in batches %} + + + + + + + + + {% empty %} + + {% endfor %} + +
ReferenceGradeWeightStatusHarvest Date
{{ b.batch_reference }}{{ b.grade|default:'-' }}{{ b.weight_kg|default:'-' }} kg{{ b.get_status_display }}{{ b.harvest_date|date:'d/m/Y'|default:'-' }}View
No batches found
+
+ +{% if page_obj.paginator.num_pages > 1 %} + +{% endif %} + +{% endblock %} \ No newline at end of file diff --git a/templates/portal/chat.html b/templates/portal/chat.html new file mode 100644 index 0000000..dff79ec --- /dev/null +++ b/templates/portal/chat.html @@ -0,0 +1,159 @@ +{% extends 'portal/base_portal.html' %} +{% load static %} +{% block title %}AI Chat - IXG Buyer Portal{% endblock %} +{% block content %} + + + +
+
+
+
+
+ +

Select an agent and start a conversation.

+
+
+
+ + +
+
+
+
+
+
Chat Sessions
+
+

No previous sessions

+
+
+
+
+ + + + + +{% endblock %} diff --git a/templates/portal/chat_embed.html b/templates/portal/chat_embed.html new file mode 100644 index 0000000..91d2885 --- /dev/null +++ b/templates/portal/chat_embed.html @@ -0,0 +1,108 @@ + + + + + + +AI Chat + + + + + + +
+
+ AI Assistant + +
+
+
+
How can I help you? +
+
+ +
+ + +
+
+ + + + diff --git a/templates/portal/dashboard.html b/templates/portal/dashboard.html new file mode 100644 index 0000000..385c564 --- /dev/null +++ b/templates/portal/dashboard.html @@ -0,0 +1,74 @@ +{% extends 'portal/base_portal.html' %} +{% block title %}Dashboard - IXG Buyer Portal{% endblock %} +{% block content %} + + + +
+
+
+
{{ total_batches }}
+
Total Batches
+
+
+
+
+
{{ active_shipments }}
+
Active Shipments
+
+
+
+
+
{{ documents|length }}
+
Recent Documents
+
+
+
+ +
+
+
+
Recent Batches
+ + + + {% for b in recent_batches %} + + + + + + + {% empty %} + + {% endfor %} + +
ReferenceGradeStatusHarvest Date
{{ b.batch_reference }}{{ b.grade|default:'-' }}{{ b.get_status_display }}{{ b.harvest_date|date:'d/m/Y'|default:'-' }}
No batches
+
+
+
+
+
Recent Documents
+ {% for d in documents %} +
+ {{ d.file_name }}
+ {{ d.get_doc_type_display }} · {{ d.uploaded_at|date:'d/m/Y' }} +
+ {% empty %} +

No documents available

+ {% endfor %} +
+ {% if subscription %} +
+
Subscription
+ {{ subscription.get_status_display }} + {{ subscription.get_plan_display }} +
+ {% endif %} +
+
+ +{% endblock %} \ No newline at end of file diff --git a/templates/portal/document_list.html b/templates/portal/document_list.html new file mode 100644 index 0000000..0e78d6b --- /dev/null +++ b/templates/portal/document_list.html @@ -0,0 +1,53 @@ +{% extends 'portal/base_portal.html' %} +{% block title %}Documents - IXG Buyer Portal{% endblock %} +{% block content %} + + + +
+
+
+ +
+
+ + Clear +
+
+
+ +
+ + + + {% for d in documents %} + + + + + + + {% empty %} + + {% endfor %} + +
File NameTypeBatchUploaded
{{ d.file_name }}{{ d.get_doc_type_display }}{{ d.batch.batch_reference|default:'-' }}{{ d.uploaded_at|date:'d/m/Y' }}
No documents available
+
+ +{% if page_obj.paginator.num_pages > 1 %} + +{% endif %} + +{% endblock %} \ No newline at end of file diff --git a/templates/portal/subscription.html b/templates/portal/subscription.html new file mode 100644 index 0000000..2d14ae5 --- /dev/null +++ b/templates/portal/subscription.html @@ -0,0 +1,26 @@ +{% extends 'portal/base_portal.html' %} +{% block title %}Subscription - IXG Buyer Portal{% endblock %} +{% block content %} + + + +
+
+
+ {% if sub %} + + + + + +
Plan{{ sub.get_plan_display }}
Status{{ sub.get_status_display }}
Current Period End{{ sub.current_period_end|date:'d/m/Y'|default:'-' }}
Since{{ sub.created_at|date:'d/m/Y' }}
+ {% else %} +

You don't have an active subscription.

+ {% endif %} +
+
+
+ +{% endblock %} \ No newline at end of file