This commit is contained in:
2026-04-26 01:15:01 +03:30
parent 72588fe12c
commit 2cd96ceec6
11 changed files with 338 additions and 380 deletions
+5 -4
View File
@@ -2,12 +2,12 @@
SECRET_KEY=your-secret-key-change-in-production SECRET_KEY=your-secret-key-change-in-production
DEBUG=1 DEBUG=1
DOCKER_VERSION=develop DOCKER_VERSION=develop
ALLOWED_HOSTS=node.crop-logic.ir,crop-logic.ir,localhost,127.0.0.1,0.0.0.0 ALLOWED_HOSTS=node.crop-logic.ir,crop-logic.ir,localhost,127.0.0.1,0.0.0.0,web,backend-web
# Database (MySQL) # Database (MySQL)
DB_ENGINE=django.db.backends.mysql DB_ENGINE=django.db.backends.mysql
DB_NAME=backend DB_NAME=croplogic
DB_USER=backend DB_USER=croplogic
DB_PASSWORD=changeme DB_PASSWORD=changeme
DB_HOST=db DB_HOST=db
DB_PORT=3306 DB_PORT=3306
@@ -27,7 +27,8 @@ ACCESS_CONTROL_AUTHZ_BATCH_PATH=/v1/data/croplogic/authz/batch_decision
ACCESS_CONTROL_AUTHZ_TIMEOUT=30 ACCESS_CONTROL_AUTHZ_TIMEOUT=30
ACCESS_CONTROL_AUTHZ_CACHE_TIMEOUT=300 ACCESS_CONTROL_AUTHZ_CACHE_TIMEOUT=300
AI_SERVICE_BASE_URL=https://ai.example.com AI_SERVICE_BASE_URL=http://ai-web:8000
AI_SERVICE_HOST_HEADER=localhost
AI_SERVICE_API_KEY= AI_SERVICE_API_KEY=
FARM_HUB_SERVICE_BASE_URL=https://farm-hub.example.com FARM_HUB_SERVICE_BASE_URL=https://farm-hub.example.com
+13 -2
View File
@@ -8,9 +8,18 @@ load_dotenv()
BASE_DIR = Path(__file__).resolve().parent.parent BASE_DIR = Path(__file__).resolve().parent.parent
def _get_csv_env(name, default=""):
return [item.strip() for item in os.environ.get(name, default).split(",") if item.strip()]
SECRET_KEY = os.environ.get("SECRET_KEY", "django-insecure-dev-only") SECRET_KEY = os.environ.get("SECRET_KEY", "django-insecure-dev-only")
DEBUG = os.environ.get("DEBUG", "0") == "1" DEBUG = os.environ.get("DEBUG", "0") == "1"
ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "localhost,127.0.0.1").split(",") ALLOWED_HOSTS = list(
dict.fromkeys(
_get_csv_env("ALLOWED_HOSTS", "localhost,127.0.0.1,0.0.0.0")
+ ["web", "backend-web", os.environ.get("HOSTNAME", "")]
)
)
AUTH_USER_MODEL = "account.User" AUTH_USER_MODEL = "account.User"
@@ -178,12 +187,14 @@ ACCESS_CONTROL_AUTHZ_CACHE_TIMEOUT = int(os.getenv("ACCESS_CONTROL_AUTHZ_CACHE_T
EXTERNAL_SERVICES = { EXTERNAL_SERVICES = {
"ai": { "ai": {
"base_url": os.getenv("AI_SERVICE_BASE_URL", ""), "base_url": os.getenv("AI_SERVICE_BASE_URL", "http://ai-web:8000"),
"api_key": os.getenv("AI_SERVICE_API_KEY", ""), "api_key": os.getenv("AI_SERVICE_API_KEY", ""),
"host_header": os.getenv("AI_SERVICE_HOST_HEADER", "localhost"),
}, },
"farm_hub": { "farm_hub": {
"base_url": os.getenv("FARM_HUB_SERVICE_BASE_URL", ""), "base_url": os.getenv("FARM_HUB_SERVICE_BASE_URL", ""),
"api_key": os.getenv("FARM_HUB_SERVICE_API_KEY", ""), "api_key": os.getenv("FARM_HUB_SERVICE_API_KEY", ""),
"host_header": os.getenv("FARM_HUB_SERVICE_HOST_HEADER", ""),
}, },
} }
@@ -0,0 +1,18 @@
# Generated by Django 5.1.15 on 2026-04-25 21:19
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dashboard', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='farmdashboardconfig',
name='row_order',
field=models.JSONField(blank=True, default=list),
),
]
+15 -7
View File
@@ -1,7 +1,7 @@
services: services:
db: db:
image: docker.iranserver.com/mysql:8 image: docker.iranserver.com/mysql:8
container_name: backend-db container_name: croplogic-db
environment: environment:
MYSQL_DATABASE: ${DB_NAME} MYSQL_DATABASE: ${DB_NAME}
MYSQL_USER: ${DB_USER} MYSQL_USER: ${DB_USER}
@@ -9,6 +9,8 @@ services:
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD} MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
volumes: volumes:
- backend_mysql_data:/var/lib/mysql - backend_mysql_data:/var/lib/mysql
ports:
- "3306:3306"
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_ROOT_PASSWORD}"] test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_ROOT_PASSWORD}"]
@@ -51,9 +53,12 @@ services:
- .env - .env
environment: environment:
DOCKER_VERSION: ${DOCKER_VERSION:-production} DOCKER_VERSION: ${DOCKER_VERSION:-production}
DB_HOST: db ALLOWED_HOSTS: ${ALLOWED_HOSTS:-localhost,127.0.0.1,0.0.0.0,web,backend-web}
CELERY_BROKER_URL: ${CELERY_BROKER_URL:-redis://redis:6379/0} AI_SERVICE_BASE_URL: ${AI_SERVICE_BASE_URL:-http://ai-web:8000}
CELERY_RESULT_BACKEND: ${CELERY_RESULT_BACKEND:-redis://redis:6379/0} AI_SERVICE_HOST_HEADER: ${AI_SERVICE_HOST_HEADER:-localhost}
DB_HOST: croplogic-db
CELERY_BROKER_URL: ${CELERY_BROKER_URL:-redis://backend-redis:6379/0}
CELERY_RESULT_BACKEND: ${CELERY_RESULT_BACKEND:-redis://backend-redis:6379/0}
QDRANT_HOST: ${QDRANT_HOST:-qdrant} QDRANT_HOST: ${QDRANT_HOST:-qdrant}
QDRANT_PORT: ${QDRANT_PORT:-6333} QDRANT_PORT: ${QDRANT_PORT:-6333}
SKIP_MIGRATE: "0" SKIP_MIGRATE: "0"
@@ -77,9 +82,12 @@ services:
- .env - .env
environment: environment:
DOCKER_VERSION: ${DOCKER_VERSION:-production} DOCKER_VERSION: ${DOCKER_VERSION:-production}
DB_HOST: db ALLOWED_HOSTS: ${ALLOWED_HOSTS:-localhost,127.0.0.1,0.0.0.0,web,backend-web}
CELERY_BROKER_URL: ${CELERY_BROKER_URL:-redis://redis:6379/0} AI_SERVICE_BASE_URL: ${AI_SERVICE_BASE_URL:-http://ai-web:8000}
CELERY_RESULT_BACKEND: ${CELERY_RESULT_BACKEND:-redis://redis:6379/0} AI_SERVICE_HOST_HEADER: ${AI_SERVICE_HOST_HEADER:-localhost}
DB_HOST: croplogic-db
CELERY_BROKER_URL: ${CELERY_BROKER_URL:-redis://backend-redis:6379/0}
CELERY_RESULT_BACKEND: ${CELERY_RESULT_BACKEND:-redis://backend-redis:6379/0}
CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP: "true" CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP: "true"
SKIP_MIGRATE: "1" SKIP_MIGRATE: "1"
ACCESS_CONTROL_AUTHZ_BASE_URL: http://croplogic-accsess-opa:8181 ACCESS_CONTROL_AUTHZ_BASE_URL: http://croplogic-accsess-opa:8181
+17 -12
View File
@@ -1,16 +1,17 @@
services: services:
db: db:
image: docker.iranserver.com/mysql:8 image: docker.iranserver.com/mysql:8
container_name: backend-db container_name: croplogic-db
environment: environment:
MYSQL_DATABASE: ${DB_NAME:-backend} MYSQL_DATABASE: ${DB_NAME:-croplogic}
MYSQL_USER: ${DB_USER:-backend} MYSQL_USER: ${DB_USER:-croplogic}
MYSQL_PASSWORD: ${DB_PASSWORD:-changeme} MYSQL_PASSWORD: ${DB_PASSWORD:-changeme}
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-changeme} MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-root}
volumes: volumes:
- backend_mysql_data:/var/lib/mysql - backend_mysql_data:/var/lib/mysql
healthcheck: healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_ROOT_PASSWORD:-changeme}"] test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_ROOT_PASSWORD:-root}"]
interval: 5s interval: 5s
timeout: 5s timeout: 5s
retries: 5 retries: 5
@@ -21,7 +22,7 @@ services:
image: docker-mirror.liara.ir/phpmyadmin:latest image: docker-mirror.liara.ir/phpmyadmin:latest
container_name: backend-phpmyadmin container_name: backend-phpmyadmin
environment: environment:
PMA_HOST: db PMA_HOST: croplogic-db
PMA_PORT: 3306 PMA_PORT: 3306
UPLOAD_LIMIT: 64M UPLOAD_LIMIT: 64M
ports: ports:
@@ -68,9 +69,11 @@ services:
- .env - .env
environment: environment:
DOCKER_VERSION: ${DOCKER_VERSION:-develop} DOCKER_VERSION: ${DOCKER_VERSION:-develop}
DB_HOST: db ALLOWED_HOSTS: ${ALLOWED_HOSTS:-localhost,127.0.0.1,0.0.0.0,web,backend-web}
CELERY_BROKER_URL: redis://redis:6379/0 AI_SERVICE_BASE_URL: ${AI_SERVICE_BASE_URL:-http://ai-web:8000}
CELERY_RESULT_BACKEND: redis://redis:6379/0 DB_HOST: croplogic-db
CELERY_BROKER_URL: redis://backend-redis:6379/0
CELERY_RESULT_BACKEND: redis://backend-redis:6379/0
QDRANT_HOST: qdrant QDRANT_HOST: qdrant
QDRANT_PORT: 6333 QDRANT_PORT: 6333
SKIP_MIGRATE: "0" SKIP_MIGRATE: "0"
@@ -101,9 +104,11 @@ services:
- .env - .env
environment: environment:
DOCKER_VERSION: ${DOCKER_VERSION:-develop} DOCKER_VERSION: ${DOCKER_VERSION:-develop}
DB_HOST: db ALLOWED_HOSTS: ${ALLOWED_HOSTS:-localhost,127.0.0.1,0.0.0.0,web,backend-web}
CELERY_BROKER_URL: redis://redis:6379/0 AI_SERVICE_BASE_URL: ${AI_SERVICE_BASE_URL:-http://ai-web:8000}
CELERY_RESULT_BACKEND: redis://redis:6379/0 DB_HOST: croplogic-db
CELERY_BROKER_URL: redis://backend-redis:6379/0
CELERY_RESULT_BACKEND: redis://backend-redis:6379/0
CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP: "true" CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP: "true"
SKIP_MIGRATE: "1" SKIP_MIGRATE: "1"
ACCESS_CONTROL_AUTHZ_BASE_URL: http://croplogic-accsess-opa:8181 ACCESS_CONTROL_AUTHZ_BASE_URL: http://croplogic-accsess-opa:8181
+34 -7
View File
@@ -4,6 +4,7 @@ import requests
from django.conf import settings from django.conf import settings
from .exceptions import ExternalAPIRequestError from .exceptions import ExternalAPIRequestError
from .exceptions import MockDirectoryNotFound, MockFileNotFound
from .mock_loader import MockLoader from .mock_loader import MockLoader
from .services import ServiceRegistry from .services import ServiceRegistry
@@ -26,7 +27,9 @@ class ExternalAPIAdapter:
self._validate_method(request_method) self._validate_method(request_method)
service = self.service_registry.get(service_name) service = self.service_registry.get(service_name)
if getattr(settings, "USE_EXTERNAL_API_MOCK", False): use_mock = getattr(settings, "USE_EXTERNAL_API_MOCK", False) and service_name != "ai"
if use_mock:
try:
mock_response = self.mock_loader.load(service_name=service_name, path=path, method=request_method) mock_response = self.mock_loader.load(service_name=service_name, path=path, method=request_method)
return AdapterResponse( return AdapterResponse(
status_code=mock_response.status_code, status_code=mock_response.status_code,
@@ -34,6 +37,8 @@ class ExternalAPIAdapter:
headers={"X-Mock-File": mock_response.file_path}, headers={"X-Mock-File": mock_response.file_path},
is_mock=True, is_mock=True,
) )
except (MockDirectoryNotFound, MockFileNotFound):
pass
return self._call_real_api( return self._call_real_api(
service=service, service=service,
@@ -47,25 +52,47 @@ class ExternalAPIAdapter:
def _call_real_api(self, service, path, method, payload=None, query=None, headers=None): def _call_real_api(self, service, path, method, payload=None, query=None, headers=None):
base_url = service.get("base_url", "").rstrip("/") base_url = service.get("base_url", "").rstrip("/")
api_key = service.get("api_key", "") api_key = service.get("api_key", "")
host_header = service.get("host_header", "").strip()
if not base_url: if not base_url:
raise ExternalAPIRequestError("External service base_url is not configured.") raise ExternalAPIRequestError("External service base_url is not configured.")
url = f"{base_url}/{str(path).lstrip('/')}" url = f"{base_url}/{str(path).lstrip('/')}"
files = None
request_payload = payload
request_headers = { request_headers = {
"Authorization": f"Bearer {api_key}", "Authorization": f"Bearer {api_key}",
"Content-Type": "application/json", "Content-Type": "application/json",
} }
if host_header:
request_headers["Host"] = host_header
if headers: if headers:
request_headers.update(headers) request_headers.update(headers)
if isinstance(payload, dict) and payload.get("__files__"):
files = payload["__files__"]
request_payload = {
key: value
for key, value in payload.items()
if key != "__files__"
}
request_headers.pop("Content-Type", None)
try: try:
request_kwargs = {
"method": method,
"url": url,
"params": query,
"headers": request_headers,
"timeout": getattr(settings, "EXTERNAL_API_TIMEOUT", 30),
}
if files:
request_kwargs["data"] = request_payload
request_kwargs["files"] = files
else:
request_kwargs["json"] = request_payload
response = requests.request( response = requests.request(
method=method, **request_kwargs,
url=url,
json=payload,
params=query,
headers=request_headers,
timeout=getattr(settings, "EXTERNAL_API_TIMEOUT", 30),
) )
except requests.RequestException as exc: except requests.RequestException as exc:
raise ExternalAPIRequestError(f"External API request failed for '{url}': {exc}") from exc raise ExternalAPIRequestError(f"External API request failed for '{url}': {exc}") from exc
+30 -26
View File
@@ -12,6 +12,8 @@ class ChatSectionSerializer(serializers.Serializer):
frequency = serializers.CharField(required=False, allow_blank=True) frequency = serializers.CharField(required=False, allow_blank=True)
amount = serializers.CharField(required=False, allow_blank=True) amount = serializers.CharField(required=False, allow_blank=True)
timing = serializers.CharField(required=False, allow_blank=True) timing = serializers.CharField(required=False, allow_blank=True)
primaryAction = serializers.CharField(required=False, allow_blank=True)
validityPeriod = serializers.CharField(required=False, allow_blank=True)
expandableExplanation = serializers.CharField(required=False, allow_blank=True) expandableExplanation = serializers.CharField(required=False, allow_blank=True)
@@ -57,40 +59,42 @@ class ConversationDeleteSerializer(serializers.Serializer):
farm_uuid = serializers.UUIDField(read_only=True, allow_null=True) farm_uuid = serializers.UUIDField(read_only=True, allow_null=True)
class ChatTaskSubmitDataSerializer(serializers.Serializer):
task_id = serializers.CharField(required=False, allow_blank=True)
status = serializers.CharField(required=False, allow_blank=True)
status_url = serializers.CharField(required=False, allow_blank=True)
conversation_id = serializers.UUIDField(read_only=True)
message_id = serializers.UUIDField(read_only=True)
farm_uuid = serializers.UUIDField(read_only=True, allow_null=True)
class ChatTaskStatusDataSerializer(serializers.Serializer):
task_id = serializers.CharField(required=False, allow_blank=True)
status = serializers.CharField(required=False, allow_blank=True)
conversation_id = serializers.UUIDField(read_only=True)
farm_uuid = serializers.UUIDField(read_only=True, allow_null=True)
progress = serializers.JSONField(required=False)
result = serializers.JSONField(required=False)
error = serializers.CharField(required=False, allow_blank=True)
class ChatPostSerializer(serializers.Serializer): class ChatPostSerializer(serializers.Serializer):
farm_uuid = serializers.UUIDField(required=False, allow_null=True) farm_uuid = serializers.UUIDField(required=True)
content = serializers.CharField(required=False, allow_blank=True, default="") query = serializers.CharField(required=False, allow_blank=True, default="")
history = serializers.JSONField(required=False)
image_urls = serializers.ListField(
child=serializers.CharField(),
required=False,
default=list,
)
images = serializers.ListField( images = serializers.ListField(
child=serializers.CharField(), child=serializers.CharField(),
required=False, required=False,
default=list, default=list,
) )
conversation_id = serializers.UUIDField(required=False) conversation_id = serializers.UUIDField(required=False)
title = serializers.CharField(required=False, allow_blank=True, max_length=255)
farm_context = serializers.JSONField(required=False)
def validate(self, attrs): def validate(self, attrs):
content = attrs.get("content", "").strip() query = (attrs.get("query") or "").strip()
image_urls = attrs.get("image_urls") or []
images = attrs.get("images") or [] images = attrs.get("images") or []
if not content and not images: history = attrs.get("history", [])
raise serializers.ValidationError("Either content or images is required.")
if isinstance(history, str):
try:
history = serializers.JSONField().to_internal_value(history)
except serializers.ValidationError as exc:
raise serializers.ValidationError({"history": exc.detail}) from exc
if history in (None, ""):
history = []
if not isinstance(history, list):
raise serializers.ValidationError({"history": ["History must be an array or a valid JSON array string."]})
if not query and not image_urls and not images:
raise serializers.ValidationError({"query": ["This field may not be blank unless an image is sent."]})
attrs["query"] = query
attrs["history"] = history
return attrs return attrs
+1 -5
View File
@@ -4,17 +4,13 @@ from .views import (
ChatDetailView, ChatDetailView,
ChatListCreateView, ChatListCreateView,
ChatMessagesView, ChatMessagesView,
ChatTaskCreateView,
ChatTaskStatusView,
ChatView, ChatView,
ContextView, ContextView,
) )
urlpatterns = [ urlpatterns = [
path("context/", ContextView.as_view(), name="farm-ai-assistant-context"), path("context/", ContextView.as_view(), name="farm-ai-assistant-context"),
# path("chat/", ChatView.as_view(), name="farm-ai-assistant-chat"), path("chat/", ChatView.as_view(), name="farm-ai-assistant-chat"),
path("chat/task/", ChatTaskCreateView.as_view(), name="farm-ai-assistant-chat-task-create"),
path("chat/task/<str:task_id>/status/", ChatTaskStatusView.as_view(), name="farm-ai-assistant-chat-task-status"),
path("chats/", ChatListCreateView.as_view(), name="farm-ai-assistant-chat-list-create"), path("chats/", ChatListCreateView.as_view(), name="farm-ai-assistant-chat-list-create"),
path("chats/<uuid:conversation_id>/", ChatDetailView.as_view(), name="farm-ai-assistant-chat-detail"), path("chats/<uuid:conversation_id>/", ChatDetailView.as_view(), name="farm-ai-assistant-chat-detail"),
path("chats/<uuid:conversation_id>/messages/", ChatMessagesView.as_view(), name="farm-ai-assistant-chat-messages"), path("chats/<uuid:conversation_id>/messages/", ChatMessagesView.as_view(), name="farm-ai-assistant-chat-messages"),
+157 -309
View File
@@ -1,10 +1,12 @@
"""Farm AI Assistant API views.""" """Farm AI Assistant API views."""
import json
from copy import deepcopy from copy import deepcopy
from django.db.models import Count from django.db.models import Count
from django.http import Http404 from django.http import Http404
from rest_framework import serializers, status from rest_framework import serializers, status
from rest_framework.exceptions import ParseError
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
@@ -15,13 +17,11 @@ from config.swagger import status_response
from external_api_adapter import request as external_api_request from external_api_adapter import request as external_api_request
from external_api_adapter.exceptions import ExternalAPIRequestError from external_api_adapter.exceptions import ExternalAPIRequestError
from farm_hub.models import FarmHub from farm_hub.models import FarmHub
from .mock_data import CHAT_RESPONSE_DATA, CONTEXT_RESPONSE_DATA from .mock_data import CONTEXT_RESPONSE_DATA
from .models import Conversation, Message from .models import Conversation, Message
from .serializers import ( from .serializers import (
ChatPostSerializer, ChatPostSerializer,
ChatResponseDataSerializer, ChatResponseDataSerializer,
ChatTaskStatusDataSerializer,
ChatTaskSubmitDataSerializer,
ConversationCreateSerializer, ConversationCreateSerializer,
ConversationDeleteSerializer, ConversationDeleteSerializer,
ConversationMessagesSerializer, ConversationMessagesSerializer,
@@ -71,6 +71,14 @@ class ContextView(FarmAccessMixin, APIView):
class ConversationAccessMixin(FarmAccessMixin): class ConversationAccessMixin(FarmAccessMixin):
@staticmethod
def _generate_conversation_title(query):
normalized_query = (query or "").strip()
if not normalized_query:
return "Image"
first_word = normalized_query.split()[0].strip()
return (first_word or normalized_query or "New chat")[:255]
@staticmethod @staticmethod
def _get_conversation(request, conversation_id, farm_uuid=None): def _get_conversation(request, conversation_id, farm_uuid=None):
filters = {"uuid": conversation_id, "owner": request.user} filters = {"uuid": conversation_id, "owner": request.user}
@@ -94,9 +102,11 @@ class ConversationAccessMixin(FarmAccessMixin):
"content", "content",
"items", "items",
"icon", "icon",
"primaryAction",
"frequency", "frequency",
"amount", "amount",
"timing", "timing",
"validityPeriod",
"expandableExplanation", "expandableExplanation",
} }
normalized_sections = [] normalized_sections = []
@@ -119,16 +129,8 @@ class ConversationAccessMixin(FarmAccessMixin):
normalized_sections.append(normalized_section) normalized_sections.append(normalized_section)
return normalized_sections return normalized_sections
def _build_mock_assistant_payload(self, conversation):
payload = deepcopy(CHAT_RESPONSE_DATA)
payload["conversation_id"] = str(conversation.uuid)
payload["farm_uuid"] = self._farm_uuid_or_none(conversation.farm)
return payload
def _get_or_create_conversation(self, request, validated): def _get_or_create_conversation(self, request, validated):
conversation_id = validated.get("conversation_id") conversation_id = validated.get("conversation_id")
farm_context = validated.get("farm_context")
title = validated.get("title", "").strip()
farm = self._get_optional_farm(request, validated.get("farm_uuid")) farm = self._get_optional_farm(request, validated.get("farm_uuid"))
if conversation_id: if conversation_id:
@@ -137,42 +139,130 @@ class ConversationAccessMixin(FarmAccessMixin):
conversation_id, conversation_id,
farm.farm_uuid if farm else None, farm.farm_uuid if farm else None,
) )
updated_fields = []
if farm_context is not None:
conversation.farm_context = farm_context
updated_fields.append("farm_context")
if title:
conversation.title = title
updated_fields.append("title")
if updated_fields:
updated_fields.append("updated_at")
conversation.save(update_fields=updated_fields)
return conversation return conversation
return Conversation.objects.create( return Conversation.objects.create(
owner=request.user, owner=request.user,
farm=farm, farm=farm,
title=title or (validated.get("content", "")[:255]) or "New chat", title=self._generate_conversation_title(validated.get("query", "")),
farm_context=farm_context or {}, farm_context={},
) )
@staticmethod
def _serialize_history_messages(history):
normalized_history = []
for item in history or []:
if not isinstance(item, dict):
continue
role = str(item.get("role") or "").strip()
content = str(item.get("content") or item.get("message") or "").strip()
if not role and not content:
continue
normalized_item = {}
if role:
normalized_item["role"] = role
if content:
normalized_item["content"] = content
if item.get("sections") is not None:
normalized_item["sections"] = item.get("sections")
normalized_history.append(normalized_item)
return normalized_history
@staticmethod @staticmethod
def _build_adapter_payload(request, validated, conversation): def _build_adapter_payload(request, validated, conversation):
payload = { payload = {
"content": validated.get("content", ""), "farm_uuid": str(conversation.farm.farm_uuid) if conversation.farm else "",
"query": validated.get("content", ""), "query": validated.get("query", ""),
"history": ConversationAccessMixin._serialize_history_messages(validated.get("history", [])),
"image_urls": validated.get("image_urls", []),
"images": validated.get("images", []), "images": validated.get("images", []),
"conversation_id": str(conversation.uuid), "conversation_id": str(conversation.uuid),
"user_id": request.user.id, "user_id": request.user.id,
} }
if conversation.farm:
payload["farm_uuid"] = str(conversation.farm.farm_uuid)
if "farm_context" in validated:
payload["farm_context"] = validated.get("farm_context") or {}
if "title" in validated:
payload["title"] = validated.get("title", "")
return payload return payload
@staticmethod
def _attach_uploaded_files(payload, uploaded_images):
if not uploaded_images:
return payload
files = []
for uploaded_image in uploaded_images:
files.append(
(
"images",
(
uploaded_image.name,
uploaded_image,
getattr(uploaded_image, "content_type", "application/octet-stream"),
),
)
)
multipart_payload = dict(payload)
multipart_payload["history"] = json.dumps(payload.get("history", []), ensure_ascii=False)
multipart_payload["image_urls"] = json.dumps(payload.get("image_urls", []), ensure_ascii=False)
multipart_payload["__files__"] = files
return multipart_payload
@staticmethod
def _parse_json_array(value):
if not isinstance(value, str):
return None
try:
parsed = json.loads(value)
except (TypeError, ValueError):
return None
return parsed if isinstance(parsed, list) else None
def _collect_uploaded_images(self, request):
uploaded_images = []
single_image = request.FILES.get("image")
if single_image is not None:
uploaded_images.append(single_image)
uploaded_images.extend(request.FILES.getlist("images"))
return uploaded_images
def _merge_history(self, validated, conversation):
provided_history = validated.get("history", [])
if provided_history:
return self._serialize_history_messages(provided_history)
existing_messages = conversation.messages.order_by("created_at")
return [
{
"role": message.role,
"content": message.content,
**({"sections": message.raw_response.get("sections", [])} if message.role == Message.ROLE_ASSISTANT else {}),
}
for message in existing_messages
if message.content or (message.role == Message.ROLE_ASSISTANT and message.raw_response.get("sections"))
]
def _prepare_chat_input(self, request):
mutable_data = request.data.copy()
for field_name in ("message", "content", "title", "farm_context"):
if field_name in mutable_data:
mutable_data.pop(field_name)
if "history" in mutable_data:
parsed_history = self._parse_json_array(mutable_data.get("history"))
if parsed_history is not None:
mutable_data["history"] = parsed_history
if "image_urls" in mutable_data and isinstance(mutable_data.get("image_urls"), str):
parsed_urls = self._parse_json_array(mutable_data.get("image_urls"))
if parsed_urls is not None:
mutable_data.setlist("image_urls", parsed_urls) if hasattr(mutable_data, "setlist") else mutable_data.__setitem__("image_urls", parsed_urls)
if "images" in mutable_data and isinstance(mutable_data.get("images"), str):
parsed_images = self._parse_json_array(mutable_data.get("images"))
if parsed_images is not None:
mutable_data.setlist("images", parsed_images) if hasattr(mutable_data, "setlist") else mutable_data.__setitem__("images", parsed_images)
return mutable_data
def _extract_assistant_payload(self, adapter_data, conversation): def _extract_assistant_payload(self, adapter_data, conversation):
payload_source = adapter_data payload_source = adapter_data
if isinstance(adapter_data, dict) and isinstance(adapter_data.get("data"), dict): if isinstance(adapter_data, dict) and isinstance(adapter_data.get("data"), dict):
@@ -199,80 +289,6 @@ class ConversationAccessMixin(FarmAccessMixin):
"sections": sections, "sections": sections,
} }
@staticmethod
def _extract_task_submit_payload(adapter_data, conversation, message_id):
payload_source = adapter_data
if isinstance(adapter_data, dict) and isinstance(adapter_data.get("data"), dict):
payload_source = adapter_data["data"]
if not isinstance(payload_source, dict):
payload_source = {}
return {
"task_id": str(payload_source.get("task_id") or ""),
"status": str(payload_source.get("status") or ""),
"status_url": str(payload_source.get("status_url") or ""),
"conversation_id": str(conversation.uuid),
"message_id": str(message_id),
"farm_uuid": ConversationAccessMixin._farm_uuid_or_none(conversation.farm),
}
def _extract_task_status_payload(self, adapter_data, task_id, conversation=None, farm_uuid=None):
payload_source = adapter_data
if isinstance(adapter_data, dict) and isinstance(adapter_data.get("data"), dict):
payload_source = adapter_data["data"]
if not isinstance(payload_source, dict):
payload_source = {}
task_status_payload = {
"task_id": str(payload_source.get("task_id") or task_id),
"status": str(payload_source.get("status") or ""),
}
if conversation:
task_status_payload["conversation_id"] = str(conversation.uuid)
task_status_payload["farm_uuid"] = self._farm_uuid_or_none(conversation.farm)
elif farm_uuid is not None:
task_status_payload["farm_uuid"] = str(farm_uuid)
progress = payload_source.get("progress")
if progress is not None:
task_status_payload["progress"] = progress
elif payload_source.get("message") and task_status_payload["status"] != "SUCCESS":
task_status_payload["progress"] = {"message": payload_source.get("message")}
if payload_source.get("error"):
task_status_payload["error"] = str(payload_source["error"])
result = payload_source.get("result")
if result is not None:
task_status_payload["result"] = result
return task_status_payload
def _extract_structured_task_result(self, adapter_data):
payload_source = adapter_data
if isinstance(adapter_data, dict) and isinstance(adapter_data.get("data"), dict):
payload_source = adapter_data["data"]
if not isinstance(payload_source, dict):
return None
result = payload_source.get("result")
if isinstance(result, dict):
return result
if payload_source.get("status") == "SUCCESS":
content = payload_source.get("content")
sections = payload_source.get("sections")
if content or sections:
return {
"content": content or "",
"sections": sections or [],
}
return None
@staticmethod @staticmethod
def _serialize_chat_message(message): def _serialize_chat_message(message):
raw_response = message.raw_response if isinstance(message.raw_response, dict) else {} raw_response = message.raw_response if isinstance(message.raw_response, dict) else {}
@@ -288,62 +304,6 @@ class ConversationAccessMixin(FarmAccessMixin):
"created_at": message.created_at, "created_at": message.created_at,
} }
@staticmethod
def _find_user_message_for_task(request, task_id, farm_uuid):
filters = {
"conversation__owner": request.user,
"role": Message.ROLE_USER,
"raw_response__task_id": task_id,
}
if farm_uuid:
filters["farm__farm_uuid"] = farm_uuid
else:
filters["farm__isnull"] = True
return (
Message.objects.select_related("conversation", "farm")
.filter(**filters)
.order_by("-created_at")
.first()
)
def _persist_task_result(self, user_message, task_id, result):
assistant_payload = self._extract_assistant_payload(result, user_message.conversation)
assistant_message = (
user_message.conversation.messages.filter(
role=Message.ROLE_ASSISTANT,
raw_response__task_id=task_id,
)
.order_by("-created_at")
.first()
)
if assistant_message is None:
assistant_message = Message.objects.create(
conversation=user_message.conversation,
farm=user_message.farm,
role=Message.ROLE_ASSISTANT,
content=assistant_payload.get("content", ""),
raw_response={},
)
assistant_payload["message_id"] = str(assistant_message.uuid)
assistant_payload["task_id"] = task_id
assistant_message.content = assistant_payload.get("content", "")
assistant_message.raw_response = assistant_payload
assistant_message.save(update_fields=["content", "raw_response"])
conversation = user_message.conversation
if not conversation.title:
conversation.title = (
user_message.content or assistant_payload.get("content", "") or "New chat"
)[:255]
conversation.save(update_fields=["title", "updated_at"])
else:
conversation.save(update_fields=["updated_at"])
return assistant_payload
class ChatListCreateView(ConversationAccessMixin, APIView): class ChatListCreateView(ConversationAccessMixin, APIView):
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
@@ -459,27 +419,47 @@ class ChatView(ConversationAccessMixin, APIView):
responses={200: status_response("FarmAiAssistantChatResponse", data=ChatResponseDataSerializer())}, responses={200: status_response("FarmAiAssistantChatResponse", data=ChatResponseDataSerializer())},
) )
def post(self, request): def post(self, request):
serializer = ChatPostSerializer(data=request.data) try:
chat_input = self._prepare_chat_input(request)
except ParseError:
return Response(
{
"status": "error",
"data": {
"message": "Invalid JSON body. Use valid JSON and remove extra trailing characters.",
},
},
status=status.HTTP_400_BAD_REQUEST,
)
serializer = ChatPostSerializer(data=chat_input)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
validated = serializer.validated_data validated = serializer.validated_data
conversation = self._get_or_create_conversation(request, validated) conversation = self._get_or_create_conversation(request, validated)
history = self._merge_history(validated, conversation)
uploaded_images = self._collect_uploaded_images(request)
user_message = Message.objects.create( user_message = Message.objects.create(
conversation=conversation, conversation=conversation,
farm=conversation.farm, farm=conversation.farm,
role=Message.ROLE_USER, role=Message.ROLE_USER,
content=validated.get("content", ""), content=validated.get("query", ""),
images=validated.get("images", []), images=validated.get("image_urls", []) + validated.get("images", []),
raw_response={"farm_uuid": self._farm_uuid_or_none(conversation.farm)}, raw_response={
"farm_uuid": self._farm_uuid_or_none(conversation.farm),
"history": history,
},
) )
adapter_payload = self._build_adapter_payload(request, validated, conversation) adapter_payload = self._build_adapter_payload(request, validated, conversation)
adapter_payload["history"] = history
adapter_payload = self._attach_uploaded_files(adapter_payload, uploaded_images)
try: try:
adapter_response = external_api_request( adapter_response = external_api_request(
"ai", "ai",
"/rag/chat", "/api/rag/chat/",
method="POST", method="POST",
payload=adapter_payload, payload=adapter_payload,
) )
@@ -493,9 +473,16 @@ class ChatView(ConversationAccessMixin, APIView):
) )
assistant_payload = self._extract_assistant_payload(adapter_response.data, conversation) assistant_payload = self._extract_assistant_payload(adapter_response.data, conversation)
response_status_code = adapter_response.status_code response_status_code = adapter_response.status_code
except ExternalAPIRequestError: except ExternalAPIRequestError as exc:
assistant_payload = self._build_mock_assistant_payload(conversation) return Response(
response_status_code = status.HTTP_200_OK {
"status": "error",
"data": {
"message": str(exc) or "External AI service is unavailable.",
},
},
status=status.HTTP_503_SERVICE_UNAVAILABLE,
)
assistant_message = Message.objects.create( assistant_message = Message.objects.create(
conversation=conversation, conversation=conversation,
@@ -509,7 +496,7 @@ class ChatView(ConversationAccessMixin, APIView):
assistant_message.save(update_fields=["raw_response"]) assistant_message.save(update_fields=["raw_response"])
if not conversation.title: if not conversation.title:
conversation.title = (validated.get("content", "") or assistant_payload.get("content", "") or "New chat")[:255] conversation.title = self._generate_conversation_title(validated.get("query", ""))
conversation.save(update_fields=["title", "updated_at"]) conversation.save(update_fields=["title", "updated_at"])
else: else:
conversation.save(update_fields=["updated_at"]) conversation.save(update_fields=["updated_at"])
@@ -521,142 +508,3 @@ class ChatView(ConversationAccessMixin, APIView):
}, },
status=response_status_code, status=response_status_code,
) )
class ChatTaskCreateView(ConversationAccessMixin, APIView):
permission_classes = [IsAuthenticated]
@extend_schema(
tags=["Farm AI Assistant"],
request=ChatPostSerializer,
responses={202: status_response("FarmAiAssistantChatTaskCreateResponse", data=ChatTaskSubmitDataSerializer())},
)
def post(self, request):
serializer = ChatPostSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
validated = serializer.validated_data
conversation = self._get_or_create_conversation(request, validated)
user_message = Message.objects.create(
conversation=conversation,
farm=conversation.farm,
role=Message.ROLE_USER,
content=validated.get("content", ""),
images=validated.get("images", []),
raw_response={"farm_uuid": self._farm_uuid_or_none(conversation.farm)},
)
adapter_payload = self._build_adapter_payload(request, validated, conversation)
try:
adapter_response = external_api_request(
"ai",
"/rag/chat/generate",
method="POST",
payload=adapter_payload,
)
except ExternalAPIRequestError:
return Response(
{
"status": "error",
"data": {
"message": "External AI service is unavailable.",
},
},
status=status.HTTP_503_SERVICE_UNAVAILABLE,
)
if adapter_response.status_code >= 400:
return Response(
{
"status": "error",
"data": adapter_response.data,
},
status=adapter_response.status_code,
)
task_payload = self._extract_task_submit_payload(
adapter_response.data,
conversation,
user_message.uuid,
)
user_message.raw_response = task_payload
user_message.save(update_fields=["raw_response"])
conversation.save(update_fields=["updated_at"])
return Response(
{
"status": "success",
"data": task_payload,
},
status=adapter_response.status_code,
)
class ChatTaskStatusView(ConversationAccessMixin, APIView):
permission_classes = [IsAuthenticated]
@extend_schema(
tags=["Farm AI Assistant"],
parameters=[
OpenApiParameter(name="task_id", type=OpenApiTypes.STR, location=OpenApiParameter.PATH),
OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=False, default="11111111-1111-1111-1111-111111111111"),
],
responses={200: status_response("FarmAiAssistantChatTaskStatusResponse", data=ChatTaskStatusDataSerializer())},
)
def get(self, request, task_id):
farm = self._get_optional_farm(request, request.query_params.get("farm_uuid"))
try:
query = {}
if farm:
query["farm_uuid"] = str(farm.farm_uuid)
adapter_response = external_api_request(
"ai",
f"/tasks/{task_id}/status",
method="GET",
query=query,
)
except ExternalAPIRequestError:
return Response(
{
"status": "error",
"data": {
"message": "External AI service is unavailable.",
},
},
status=status.HTTP_503_SERVICE_UNAVAILABLE,
)
if adapter_response.status_code >= 400:
return Response(
{
"status": "error",
"data": adapter_response.data,
},
status=adapter_response.status_code,
)
farm_uuid = farm.farm_uuid if farm else None
user_message = self._find_user_message_for_task(request, task_id, farm_uuid)
conversation = user_message.conversation if user_message else None
task_status_payload = self._extract_task_status_payload(
adapter_response.data,
task_id,
conversation=conversation,
farm_uuid=farm_uuid,
)
result = self._extract_structured_task_result(adapter_response.data)
if result is not None:
task_status_payload["result"] = result
if user_message and task_status_payload.get("status") == "SUCCESS" and isinstance(result, dict):
assistant_payload = self._persist_task_result(user_message, task_id, result)
task_status_payload["result"] = assistant_payload
return Response(
{
"status": "success",
"data": task_status_payload,
},
status=adapter_response.status_code,
)
@@ -0,0 +1,23 @@
# Generated by Django 5.1.15 on 2026-04-25 21:19
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('farm_alerts', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='anomalydetection',
name='severity',
field=models.CharField(choices=[('info', 'Info'), ('warning', 'Warning'), ('error', 'Error'), ('success', 'Success')], default='warning', max_length=32),
),
migrations.AlterField(
model_name='farmalert',
name='color',
field=models.CharField(choices=[('info', 'Info'), ('warning', 'Warning'), ('error', 'Error'), ('success', 'Success')], default='info', max_length=32),
),
]
@@ -0,0 +1,17 @@
# Generated by Django 5.1.15 on 2026-04-25 21:19
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('sensor_catalog', '0003_sensorcatalog_code'),
]
operations = [
migrations.AlterModelOptions(
name='sensorcatalog',
options={'ordering': ['code']},
),
]