From 2846db164737228a8b6ba8bdb47ea8bb3fb9433e Mon Sep 17 00:00:00 2001 From: Mohammad Sajad Pourajam Date: Wed, 25 Mar 2026 15:43:00 +0330 Subject: [PATCH] UPDATE --- Dockerfile | 2 + account/urls.py | 4 +- entrypoint.sh | 9 + farm_ai_assistant/migrations/0001_initial.py | 49 +++++ farm_ai_assistant/migrations/__init__.py | 0 farm_ai_assistant/models.py | 52 +++++ farm_ai_assistant/serializers.py | 58 ++++++ farm_ai_assistant/urls.py | 4 +- farm_ai_assistant/views.py | 207 ++++++++++++++----- 9 files changed, 325 insertions(+), 60 deletions(-) create mode 100644 entrypoint.sh create mode 100644 farm_ai_assistant/migrations/0001_initial.py create mode 100644 farm_ai_assistant/migrations/__init__.py create mode 100644 farm_ai_assistant/models.py create mode 100644 farm_ai_assistant/serializers.py diff --git a/Dockerfile b/Dockerfile index 81b56c7..a951b15 100644 --- a/Dockerfile +++ b/Dockerfile @@ -38,8 +38,10 @@ RUN pip config --user set global.index-url https://package-mirror.liara.ir/repos RUN pip install -r requirements.txt +COPY entrypoint.sh /app/entrypoint.sh COPY . . EXPOSE 8000 +ENTRYPOINT ["sh", "/app/entrypoint.sh"] CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000"] diff --git a/account/urls.py b/account/urls.py index 5545e5a..e8b667e 100644 --- a/account/urls.py +++ b/account/urls.py @@ -4,6 +4,6 @@ from .views import AccountView, ProfileView urlpatterns = [ path("profile/", ProfileView.as_view(), name="profile-update"), - path("/", AccountView.as_view(), name="account-detail"), - path("", AccountView.as_view(), name="account-list"), + # path("/", AccountView.as_view(), name="account-detail"), + # path("", AccountView.as_view(), name="account-list"), ] diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..5eaa21d --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,9 @@ +#!/bin/sh +set -e +if [ "${SKIP_MIGRATE}" != "1" ]; then + echo "Running migrations..." + python manage.py migrate --noinput --fake-initial + echo "Migrations done." +fi +echo "Starting command: $*" +exec "$@" diff --git a/farm_ai_assistant/migrations/0001_initial.py b/farm_ai_assistant/migrations/0001_initial.py new file mode 100644 index 0000000..2a5c7ce --- /dev/null +++ b/farm_ai_assistant/migrations/0001_initial.py @@ -0,0 +1,49 @@ +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Conversation", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("uuid", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True)), + ("title", models.CharField(blank=True, default="", max_length=255)), + ("farm_context", models.JSONField(blank=True, default=dict)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("owner", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="farm_ai_conversations", to=settings.AUTH_USER_MODEL)), + ], + options={ + "db_table": "farm_ai_conversations", + "ordering": ["-updated_at", "-created_at"], + }, + ), + migrations.CreateModel( + name="Message", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("uuid", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True)), + ("role", models.CharField(choices=[("user", "User"), ("assistant", "Assistant")], max_length=32)), + ("content", models.TextField(blank=True, default="")), + ("images", models.JSONField(blank=True, default=list)), + ("raw_response", models.JSONField(blank=True, default=dict)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("conversation", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="messages", to="farm_ai_assistant.conversation")), + ], + options={ + "db_table": "farm_ai_messages", + "ordering": ["created_at", "id"], + }, + ), + ] diff --git a/farm_ai_assistant/migrations/__init__.py b/farm_ai_assistant/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/farm_ai_assistant/models.py b/farm_ai_assistant/models.py new file mode 100644 index 0000000..ce69b27 --- /dev/null +++ b/farm_ai_assistant/models.py @@ -0,0 +1,52 @@ +import uuid + +from django.conf import settings +from django.db import models + + +class Conversation(models.Model): + uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True) + owner = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="farm_ai_conversations", + ) + title = models.CharField(max_length=255, blank=True, default="") + farm_context = models.JSONField(default=dict, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "farm_ai_conversations" + ordering = ["-updated_at", "-created_at"] + + def __str__(self): + return self.title or f"Conversation {self.uuid}" + + +class Message(models.Model): + ROLE_USER = "user" + ROLE_ASSISTANT = "assistant" + ROLE_CHOICES = ( + (ROLE_USER, "User"), + (ROLE_ASSISTANT, "Assistant"), + ) + + uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True) + conversation = models.ForeignKey( + Conversation, + on_delete=models.CASCADE, + related_name="messages", + ) + role = models.CharField(max_length=32, choices=ROLE_CHOICES) + content = models.TextField(blank=True, default="") + images = models.JSONField(default=list, blank=True) + raw_response = models.JSONField(default=dict, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "farm_ai_messages" + ordering = ["created_at", "id"] + + def __str__(self): + return f"{self.role}: {self.uuid}" diff --git a/farm_ai_assistant/serializers.py b/farm_ai_assistant/serializers.py new file mode 100644 index 0000000..44b701d --- /dev/null +++ b/farm_ai_assistant/serializers.py @@ -0,0 +1,58 @@ +from rest_framework import serializers + +from .models import Conversation, Message + + +class ConversationListSerializer(serializers.ModelSerializer): + conversation_id = serializers.UUIDField(source="uuid", read_only=True) + last_message_preview = serializers.SerializerMethodField() + message_count = serializers.IntegerField(read_only=True) + + class Meta: + model = Conversation + fields = [ + "conversation_id", + "title", + "farm_context", + "message_count", + "last_message_preview", + "created_at", + "updated_at", + ] + + def get_last_message_preview(self, obj): + last_message = getattr(obj, "last_message", None) + if last_message is None: + last_message = obj.messages.order_by("-created_at", "-id").first() + if last_message is None: + return "" + return (last_message.content or "")[:120] + + +class MessageSerializer(serializers.ModelSerializer): + message_id = serializers.UUIDField(source="uuid", read_only=True) + conversation_id = serializers.UUIDField(source="conversation.uuid", read_only=True) + + class Meta: + model = Message + fields = [ + "message_id", + "conversation_id", + "role", + "content", + "images", + "raw_response", + "created_at", + ] + + +class ChatPostSerializer(serializers.Serializer): + content = serializers.CharField(required=False, allow_blank=True, default="") + images = serializers.ListField( + child=serializers.CharField(), + required=False, + default=list, + ) + conversation_id = serializers.UUIDField(required=False) + title = serializers.CharField(required=False, allow_blank=True, max_length=255) + farm_context = serializers.JSONField(required=False) diff --git a/farm_ai_assistant/urls.py b/farm_ai_assistant/urls.py index 09fa7d9..28baefc 100644 --- a/farm_ai_assistant/urls.py +++ b/farm_ai_assistant/urls.py @@ -1,8 +1,10 @@ from django.urls import path -from .views import ChatView, ContextView +from .views import ChatListView, ChatMessagesView, ChatView, ContextView urlpatterns = [ path("context/", ContextView.as_view(), name="farm-ai-assistant-context"), path("chat/", ChatView.as_view(), name="farm-ai-assistant-chat"), + path("chats/", ChatListView.as_view(), name="farm-ai-assistant-chat-list"), + path("chats//messages/", ChatMessagesView.as_view(), name="farm-ai-assistant-chat-messages"), ] diff --git a/farm_ai_assistant/views.py b/farm_ai_assistant/views.py index f32a35e..73d141c 100644 --- a/farm_ai_assistant/views.py +++ b/farm_ai_assistant/views.py @@ -1,42 +1,22 @@ -""" -Farm AI Assistant API views. -No database. All responses are static mock data. -Response format: {"status": "success", "data": }. HTTP 200 only. -No processing, validation, or use of input parameters in responses. -""" +"""Farm AI Assistant API views.""" -from django.http import HttpResponse +from django.db.models import Count, OuterRef, Subquery +from django.http import Http404, HttpResponse from rest_framework import serializers, status +from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView from drf_spectacular.types import OpenApiTypes -from drf_spectacular.utils import extend_schema +from drf_spectacular.utils import OpenApiParameter, extend_schema from config.swagger import status_response from external_api_adapter import request as external_api_request from .mock_data import CONTEXT_RESPONSE_DATA +from .models import Conversation, Message +from .serializers import ChatPostSerializer, ConversationListSerializer, MessageSerializer class ContextView(APIView): - """ - GET endpoint for farm context (Farm AI Assistant bar). - - Purpose: - Returns static farm context for the Farm AI Assistant UI bar: - soilType, waterEC, selectedCrop, growthStage, lastIrrigationStatus. - Used when loading the farm-ai-assistant page to populate the context strip. - - Input parameters: - None. Query parameters, if sent, are not read or used. - - Response structure: - - status: string, always "success". - - data: object with keys soilType, waterEC, selectedCrop, - growthStage, lastIrrigationStatus (all strings). - - No processing or validation is performed on inputs. - """ - @extend_schema( tags=["Farm AI Assistant"], responses={200: status_response("FarmAiAssistantContextResponse", data=serializers.JSONField())}, @@ -48,50 +28,163 @@ class ContextView(APIView): ) -class ChatView(APIView): - """ - POST endpoint for Farm AI Assistant chat (send message, get structured reply). - - Purpose: - Accepts user message (and optional images, conversation_id, farm_context) - and returns a static structured reply with sections (recommendation, - list, warning) for rendering as cards in the chat UI. No AI or - computation; response is fixed. - - Input parameters (body, JSON; all optional except conceptually content): - - content: string. Location: body. User message text. Not read or used. - - images: array of strings (URLs or base64). Location: body. Not read. - - conversation_id: string. Location: body. Conversation id. Not used. - - farm_context: object (soilType, waterEC, selectedCrop, growthStage, - lastIrrigationStatus). Location: body. Not read or used. - - Response structure: - - status: string, always "success". - - data: object with message_id, conversation_id, content (string), - sections (array of section objects). Each section has type, title, - icon, and type-specific fields (content, items, frequency, amount, - timing, expandableExplanation). - - No processing or validation is performed on inputs. Input values are - not used in the response. - """ +class ChatListView(APIView): + permission_classes = [IsAuthenticated] @extend_schema( tags=["Farm AI Assistant"], - request=OpenApiTypes.OBJECT, + responses={200: status_response("FarmAiAssistantConversationListResponse", data=ConversationListSerializer(many=True))}, + ) + def get(self, request): + last_message_subquery = Message.objects.filter(conversation=OuterRef("pk")).order_by("-created_at", "-id") + conversations = ( + Conversation.objects.filter(owner=request.user) + .annotate( + message_count=Count("messages"), + last_message=Subquery(last_message_subquery.values("content")[:1]), + ) + .order_by("-updated_at", "-created_at") + ) + + serializer = ConversationListSerializer(conversations, many=True) + return Response({"status": "success", "data": serializer.data}, status=status.HTTP_200_OK) + + +class ChatMessagesView(APIView): + permission_classes = [IsAuthenticated] + + def _get_conversation(self, request, conversation_id): + try: + return Conversation.objects.get(uuid=conversation_id, owner=request.user) + except Conversation.DoesNotExist as exc: + raise Http404("Conversation not found") from exc + + @extend_schema( + tags=["Farm AI Assistant"], + parameters=[ + OpenApiParameter(name="conversation_id", type=OpenApiTypes.UUID, location=OpenApiParameter.PATH), + ], + responses={200: status_response("FarmAiAssistantMessageListResponse", data=MessageSerializer(many=True))}, + ) + def get(self, request, conversation_id): + conversation = self._get_conversation(request, conversation_id) + messages = conversation.messages.all() + serializer = MessageSerializer(messages, many=True) + return Response({"status": "success", "data": serializer.data}, status=status.HTTP_200_OK) + + +class ChatView(APIView): + permission_classes = [IsAuthenticated] + + def _get_conversation(self, request, conversation_id): + try: + return Conversation.objects.get(uuid=conversation_id, owner=request.user) + except Conversation.DoesNotExist as exc: + raise Http404("Conversation not found") from exc + + @extend_schema( + tags=["Farm AI Assistant"], + responses={200: status_response("FarmAiAssistantConversationListAliasResponse", data=ConversationListSerializer(many=True))}, + ) + def get(self, request): + return ChatListView().get(request) + + @extend_schema( + tags=["Farm AI Assistant"], + request=ChatPostSerializer, responses={200: status_response("FarmAiAssistantChatResponse", data=serializers.JSONField())}, ) def post(self, request): + serializer = ChatPostSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + validated = serializer.validated_data + conversation_id = validated.get("conversation_id") + farm_context = validated.get("farm_context") + title = validated.get("title", "") + + if conversation_id: + conversation = self._get_conversation(request, conversation_id) + 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) + else: + conversation = Conversation.objects.create( + owner=request.user, + title=title or (validated.get("content", "")[:255]), + farm_context=farm_context or {}, + ) + + user_message = Message.objects.create( + conversation=conversation, + role=Message.ROLE_USER, + content=validated.get("content", ""), + images=validated.get("images", []), + ) + + adapter_payload = dict(request.data) + adapter_payload["conversation_id"] = str(conversation.uuid) adapter_response = external_api_request( "ai", "/rag/chat", method="POST", - payload=request.data, + payload=adapter_payload, ) + if isinstance(adapter_response.data, dict) and "body" in adapter_response.data: + conversation.save(update_fields=["updated_at"]) return HttpResponse( adapter_response.data["body"], status=adapter_response.status_code, content_type=adapter_response.data.get("content_type", "text/plain; charset=utf-8"), ) - return Response(adapter_response.data, status=adapter_response.status_code) + + assistant_content = "" + if isinstance(adapter_response.data, dict): + assistant_content = adapter_response.data.get("content") or "" + if not assistant_content and isinstance(adapter_response.data.get("data"), dict): + assistant_content = adapter_response.data["data"].get("content") or "" + + assistant_message = Message.objects.create( + conversation=conversation, + role=Message.ROLE_ASSISTANT, + content=assistant_content, + raw_response=adapter_response.data if isinstance(adapter_response.data, dict) else {"body": str(adapter_response.data)}, + ) + + if not conversation.title: + conversation.title = (validated.get("content", "") or assistant_content or "New chat")[:255] + conversation.save(update_fields=["title", "updated_at"]) + else: + conversation.save(update_fields=["updated_at"]) + + response_data = adapter_response.data + if isinstance(response_data, dict): + data = response_data.get("data") + if isinstance(data, dict): + data.setdefault("conversation_id", str(conversation.uuid)) + data.setdefault("user_message_id", str(user_message.uuid)) + data.setdefault("assistant_message_id", str(assistant_message.uuid)) + else: + response_data = { + "conversation_id": str(conversation.uuid), + "user_message_id": str(user_message.uuid), + "assistant_message_id": str(assistant_message.uuid), + "response": response_data, + } + else: + response_data = { + "conversation_id": str(conversation.uuid), + "user_message_id": str(user_message.uuid), + "assistant_message_id": str(assistant_message.uuid), + "response": response_data, + } + + return Response(response_data, status=adapter_response.status_code)