Initial commit: IXG Platform

This commit is contained in:
ixgadmin 2026-07-29 16:48:48 +00:00
commit 27b7fe7a13
141 changed files with 4201 additions and 0 deletions

7
.dockerignore Normal file
View file

@ -0,0 +1,7 @@
venv/
.git/
__pycache__/
*.pyc
.env
.gitignore
README.md

12
.gitignore vendored Normal file
View file

@ -0,0 +1,12 @@
venv/
__pycache__/
*.pyc
*.pyo
.env
config/secrets/
staticfiles/
media/
*.egg-info/
dist/
build/
.DS_Store

24
Dockerfile Normal file
View file

@ -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"]

99
SESSION_CHECKPOINT.md Normal file
View file

@ -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

0
apps/ai/__init__.py Normal file
View file

24
apps/ai/admin.py Normal file
View file

@ -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")}),
)

148
apps/ai/agent_service.py Normal file
View file

@ -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,
}

6
apps/ai/apps.py Normal file
View file

@ -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"

View file

@ -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'],
},
),
]

View file

@ -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')],
},
),
]

View file

70
apps/ai/models.py Normal file
View file

@ -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

41
apps/ai/serializers.py Normal file
View file

@ -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},
}

12
apps/ai/urls.py Normal file
View file

@ -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)),
]

116
apps/ai/views.py Normal file
View file

@ -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)
)

0
apps/batches/__init__.py Normal file
View file

9
apps/batches/admin.py Normal file
View file

@ -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"]

6
apps/batches/apps.py Normal file
View file

@ -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"

View file

@ -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'],
},
),
]

View file

40
apps/batches/models.py Normal file
View file

@ -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

View file

@ -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"]

8
apps/batches/urls.py Normal file
View file

@ -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)),
]

31
apps/batches/views.py Normal file
View file

@ -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

0
apps/buyer/__init__.py Normal file
View file

7
apps/buyer/apps.py Normal file
View file

@ -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'

14
apps/buyer/urls.py Normal file
View file

@ -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/<uuid:pk>/', views.BatchDetailView.as_view(), name='batch_detail'),
path('documents/', views.DocumentListView.as_view(), name='document_list'),
path('subscription/', views.SubscriptionView.as_view(), name='subscription'),
]

117
apps/buyer/views.py Normal file
View file

@ -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

0
apps/capa/__init__.py Normal file
View file

8
apps/capa/admin.py Normal file
View file

@ -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"]

6
apps/capa/apps.py Normal file
View file

@ -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"

View file

@ -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',
},
),
]

View file

29
apps/capa/models.py Normal file
View file

@ -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}"

9
apps/capa/serializers.py Normal file
View file

@ -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"]

8
apps/capa/urls.py Normal file
View file

@ -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)),
]

9
apps/capa/views.py Normal file
View file

@ -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]

0
apps/core/__init__.py Normal file
View file

6
apps/core/apps.py Normal file
View file

@ -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"

24
apps/core/middleware.py Normal file
View file

@ -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)

View file

18
apps/documents/admin.py Normal file
View file

@ -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"]

6
apps/documents/apps.py Normal file
View file

@ -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"

View file

@ -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',
},
),
]

View file

81
apps/documents/models.py Normal file
View file

@ -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}"

View file

@ -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)

10
apps/documents/urls.py Normal file
View file

@ -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)),
]

73
apps/documents/views.py Normal file
View file

@ -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]

0
apps/esg/__init__.py Normal file
View file

7
apps/esg/admin.py Normal file
View file

@ -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"]

6
apps/esg/apps.py Normal file
View file

@ -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"

View file

@ -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',
},
),
]

View file

22
apps/esg/models.py Normal file
View file

@ -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}"

9
apps/esg/serializers.py Normal file
View file

@ -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"]

8
apps/esg/urls.py Normal file
View file

@ -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)),
]

9
apps/esg/views.py Normal file
View file

@ -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]

View file

View file

@ -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"]

View file

@ -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"

View file

@ -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'],
},
),
]

View file

@ -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}"

View file

@ -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"]

View file

@ -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)),
]

View file

@ -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)

0
apps/ops/__init__.py Normal file
View file

7
apps/ops/apps.py Normal file
View file

@ -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'

18
apps/ops/urls.py Normal file
View file

@ -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/<uuid:pk>/', views.BatchDetailView.as_view(), name='batch_detail'),
path('qc/', views.QCListView.as_view(), name='qc_list'),
path('qc/<uuid:pk>/', views.QCDetailView.as_view(), name='qc_detail'),
path('shipments/', views.ShipmentListView.as_view(), name='shipment_list'),
path('shipments/<uuid:pk>/', views.ShipmentDetailView.as_view(), name='shipment_detail'),
path('capa/', views.CAPAListView.as_view(), name='capa_list'),
path('capa/<uuid:pk>/', 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'),
]

197
apps/ops/views.py Normal file
View file

@ -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()

0
apps/qc/__init__.py Normal file
View file

8
apps/qc/admin.py Normal file
View file

@ -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"]

6
apps/qc/apps.py Normal file
View file

@ -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"

View file

@ -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',
},
),
]

View file

27
apps/qc/models.py Normal file
View file

@ -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}"

49
apps/qc/serializers.py Normal file
View file

@ -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

8
apps/qc/urls.py Normal file
View file

@ -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)),
]

13
apps/qc/views.py Normal file
View file

@ -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

View file

8
apps/shipments/admin.py Normal file
View file

@ -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"]

6
apps/shipments/apps.py Normal file
View file

@ -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"

View file

@ -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'],
},
),
]

View file

33
apps/shipments/models.py Normal file
View file

@ -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}"

View file

@ -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"]

8
apps/shipments/urls.py Normal file
View file

@ -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)),
]

9
apps/shipments/views.py Normal file
View file

@ -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]

View file

View file

@ -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"]

View file

@ -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"

View file

@ -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'],
},
),
]

View file

@ -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})"

View file

@ -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"]

View file

@ -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)),
]

View file

@ -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)

0
apps/users/__init__.py Normal file
View file

25
apps/users/admin.py Normal file
View file

@ -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"),
}),
)

Some files were not shown because too many files have changed in this diff Show more