Initial commit: IXG Platform
This commit is contained in:
commit
27b7fe7a13
141 changed files with 4201 additions and 0 deletions
7
.dockerignore
Normal file
7
.dockerignore
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
venv/
|
||||||
|
.git/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
|
.gitignore
|
||||||
|
README.md
|
||||||
12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
.env
|
||||||
|
config/secrets/
|
||||||
|
staticfiles/
|
||||||
|
media/
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.DS_Store
|
||||||
24
Dockerfile
Normal file
24
Dockerfile
Normal 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
99
SESSION_CHECKPOINT.md
Normal 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
0
apps/ai/__init__.py
Normal file
24
apps/ai/admin.py
Normal file
24
apps/ai/admin.py
Normal 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
148
apps/ai/agent_service.py
Normal 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
6
apps/ai/apps.py
Normal 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"
|
||||||
34
apps/ai/migrations/0001_initial.py
Normal file
34
apps/ai/migrations/0001_initial.py
Normal 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'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
45
apps/ai/migrations/0002_agent_config.py
Normal file
45
apps/ai/migrations/0002_agent_config.py
Normal 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')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
0
apps/ai/migrations/__init__.py
Normal file
0
apps/ai/migrations/__init__.py
Normal file
70
apps/ai/models.py
Normal file
70
apps/ai/models.py
Normal 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
41
apps/ai/serializers.py
Normal 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
12
apps/ai/urls.py
Normal 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
116
apps/ai/views.py
Normal 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
0
apps/batches/__init__.py
Normal file
9
apps/batches/admin.py
Normal file
9
apps/batches/admin.py
Normal 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
6
apps/batches/apps.py
Normal 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"
|
||||||
38
apps/batches/migrations/0001_initial.py
Normal file
38
apps/batches/migrations/0001_initial.py
Normal 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'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
0
apps/batches/migrations/__init__.py
Normal file
0
apps/batches/migrations/__init__.py
Normal file
40
apps/batches/models.py
Normal file
40
apps/batches/models.py
Normal 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
|
||||||
56
apps/batches/serializers.py
Normal file
56
apps/batches/serializers.py
Normal 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
8
apps/batches/urls.py
Normal 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
31
apps/batches/views.py
Normal 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
0
apps/buyer/__init__.py
Normal file
7
apps/buyer/apps.py
Normal file
7
apps/buyer/apps.py
Normal 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
14
apps/buyer/urls.py
Normal 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
117
apps/buyer/views.py
Normal 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
0
apps/capa/__init__.py
Normal file
8
apps/capa/admin.py
Normal file
8
apps/capa/admin.py
Normal 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
6
apps/capa/apps.py
Normal 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"
|
||||||
38
apps/capa/migrations/0001_initial.py
Normal file
38
apps/capa/migrations/0001_initial.py
Normal 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',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
0
apps/capa/migrations/__init__.py
Normal file
0
apps/capa/migrations/__init__.py
Normal file
29
apps/capa/models.py
Normal file
29
apps/capa/models.py
Normal 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
9
apps/capa/serializers.py
Normal 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
8
apps/capa/urls.py
Normal 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
9
apps/capa/views.py
Normal 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
0
apps/core/__init__.py
Normal file
6
apps/core/apps.py
Normal file
6
apps/core/apps.py
Normal 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
24
apps/core/middleware.py
Normal 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)
|
||||||
0
apps/documents/__init__.py
Normal file
0
apps/documents/__init__.py
Normal file
18
apps/documents/admin.py
Normal file
18
apps/documents/admin.py
Normal 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
6
apps/documents/apps.py
Normal 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"
|
||||||
72
apps/documents/migrations/0001_initial.py
Normal file
72
apps/documents/migrations/0001_initial.py
Normal 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',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
0
apps/documents/migrations/__init__.py
Normal file
0
apps/documents/migrations/__init__.py
Normal file
81
apps/documents/models.py
Normal file
81
apps/documents/models.py
Normal 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}"
|
||||||
32
apps/documents/serializers.py
Normal file
32
apps/documents/serializers.py
Normal 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
10
apps/documents/urls.py
Normal 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
73
apps/documents/views.py
Normal 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
0
apps/esg/__init__.py
Normal file
7
apps/esg/admin.py
Normal file
7
apps/esg/admin.py
Normal 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
6
apps/esg/apps.py
Normal 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"
|
||||||
36
apps/esg/migrations/0001_initial.py
Normal file
36
apps/esg/migrations/0001_initial.py
Normal 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',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
0
apps/esg/migrations/__init__.py
Normal file
0
apps/esg/migrations/__init__.py
Normal file
22
apps/esg/models.py
Normal file
22
apps/esg/models.py
Normal 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
9
apps/esg/serializers.py
Normal 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
8
apps/esg/urls.py
Normal 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
9
apps/esg/views.py
Normal 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]
|
||||||
0
apps/notifications/__init__.py
Normal file
0
apps/notifications/__init__.py
Normal file
8
apps/notifications/admin.py
Normal file
8
apps/notifications/admin.py
Normal 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"]
|
||||||
6
apps/notifications/apps.py
Normal file
6
apps/notifications/apps.py
Normal 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"
|
||||||
34
apps/notifications/migrations/0001_initial.py
Normal file
34
apps/notifications/migrations/0001_initial.py
Normal 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'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
0
apps/notifications/migrations/__init__.py
Normal file
0
apps/notifications/migrations/__init__.py
Normal file
20
apps/notifications/models.py
Normal file
20
apps/notifications/models.py
Normal 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}"
|
||||||
9
apps/notifications/serializers.py
Normal file
9
apps/notifications/serializers.py
Normal 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"]
|
||||||
8
apps/notifications/urls.py
Normal file
8
apps/notifications/urls.py
Normal 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)),
|
||||||
|
]
|
||||||
12
apps/notifications/views.py
Normal file
12
apps/notifications/views.py
Normal 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
0
apps/ops/__init__.py
Normal file
7
apps/ops/apps.py
Normal file
7
apps/ops/apps.py
Normal 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
18
apps/ops/urls.py
Normal 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
197
apps/ops/views.py
Normal 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
0
apps/qc/__init__.py
Normal file
8
apps/qc/admin.py
Normal file
8
apps/qc/admin.py
Normal 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
6
apps/qc/apps.py
Normal 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"
|
||||||
35
apps/qc/migrations/0001_initial.py
Normal file
35
apps/qc/migrations/0001_initial.py
Normal 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',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
0
apps/qc/migrations/__init__.py
Normal file
0
apps/qc/migrations/__init__.py
Normal file
27
apps/qc/models.py
Normal file
27
apps/qc/models.py
Normal 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
49
apps/qc/serializers.py
Normal 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
8
apps/qc/urls.py
Normal 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
13
apps/qc/views.py
Normal 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
|
||||||
0
apps/shipments/__init__.py
Normal file
0
apps/shipments/__init__.py
Normal file
8
apps/shipments/admin.py
Normal file
8
apps/shipments/admin.py
Normal 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
6
apps/shipments/apps.py
Normal 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"
|
||||||
38
apps/shipments/migrations/0001_initial.py
Normal file
38
apps/shipments/migrations/0001_initial.py
Normal 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'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
0
apps/shipments/migrations/__init__.py
Normal file
0
apps/shipments/migrations/__init__.py
Normal file
33
apps/shipments/models.py
Normal file
33
apps/shipments/models.py
Normal 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}"
|
||||||
9
apps/shipments/serializers.py
Normal file
9
apps/shipments/serializers.py
Normal 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
8
apps/shipments/urls.py
Normal 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
9
apps/shipments/views.py
Normal 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]
|
||||||
0
apps/subscriptions/__init__.py
Normal file
0
apps/subscriptions/__init__.py
Normal file
9
apps/subscriptions/admin.py
Normal file
9
apps/subscriptions/admin.py
Normal 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"]
|
||||||
6
apps/subscriptions/apps.py
Normal file
6
apps/subscriptions/apps.py
Normal 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"
|
||||||
35
apps/subscriptions/migrations/0001_initial.py
Normal file
35
apps/subscriptions/migrations/0001_initial.py
Normal 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'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
0
apps/subscriptions/migrations/__init__.py
Normal file
0
apps/subscriptions/migrations/__init__.py
Normal file
33
apps/subscriptions/models.py
Normal file
33
apps/subscriptions/models.py
Normal 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})"
|
||||||
11
apps/subscriptions/serializers.py
Normal file
11
apps/subscriptions/serializers.py
Normal 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"]
|
||||||
8
apps/subscriptions/urls.py
Normal file
8
apps/subscriptions/urls.py
Normal 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)),
|
||||||
|
]
|
||||||
15
apps/subscriptions/views.py
Normal file
15
apps/subscriptions/views.py
Normal 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
0
apps/users/__init__.py
Normal file
25
apps/users/admin.py
Normal file
25
apps/users/admin.py
Normal 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
Loading…
Add table
Reference in a new issue