From 72588fe12c96efd84bed6ba9f877916d452d4534 Mon Sep 17 00:00:00 2001 From: Mohammad Sajad Pourajam Date: Thu, 23 Apr 2026 15:53:59 +0330 Subject: [PATCH] UPDATE --- LANDING_PAGE_FARM_AI_ASSISTANT_API.md | 651 ++++++++++++++++++++++++++ farm_ai_assistant/serializers.py | 18 +- farm_ai_assistant/tests.py | 196 +++++++- farm_ai_assistant/views.py | 97 ++-- 4 files changed, 892 insertions(+), 70 deletions(-) create mode 100644 LANDING_PAGE_FARM_AI_ASSISTANT_API.md diff --git a/LANDING_PAGE_FARM_AI_ASSISTANT_API.md b/LANDING_PAGE_FARM_AI_ASSISTANT_API.md new file mode 100644 index 0000000..7e91da0 --- /dev/null +++ b/LANDING_PAGE_FARM_AI_ASSISTANT_API.md @@ -0,0 +1,651 @@ +# راهنمای اتصال Landing Page به Farm AI Assistant و Auth API + +این فایل برای تیم فرانت/لندینگ نوشته شده تا بتوانند از داخل Docker Network به بک‌اند وصل شوند و APIهای احراز هویت و Farm AI Assistant را مصرف کنند. + +## 1) اتصال از طریق Docker Network + +بک‌اند در `docker-compose.yaml` روی network خارجی `crop_network` اجرا می‌شود و سرویس وب آن: + +- service name: `web` +- container name: `backend-web` +- internal port: `8000` + +### نکته مهم + +اگر فرانت هم داخل Docker اجرا می‌شود، از داخل کانتینر نباید از `localhost:8000` استفاده کنید، چون `localhost` به همان کانتینر فرانت اشاره می‌کند، نه به بک‌اند. + +### آدرس پیشنهادی برای اتصال داخل Docker Network + +```text +http://backend-web:8000 +``` + +در بسیاری از موارد `http://web:8000` هم کار می‌کند، اما چون `container_name` به‌صورت صریح `backend-web` تعریف شده، برای اتصال بین دو سرویس روی `crop_network` بهتر است از همین آدرس استفاده شود. + +### اگر network هنوز ساخته نشده است + +```bash +docker network create crop_network +``` + +### نمونه اتصال سرویس فرانت به همان network + +```yaml +services: + landing: + build: . + container_name: landing-web + environment: + BACKEND_BASE_URL: http://backend-web:8000 + networks: + - crop_network + +networks: + crop_network: + external: true +``` + +### پیشنهاد برای env فرانت + +```env +BACKEND_BASE_URL=http://backend-web:8000 +``` + +### اگر درخواست از خود مرورگر کاربر ارسال می‌شود + +اگر فرانت در مرورگر API را مستقیم صدا می‌زند، معمولاً باید از آدرس host-mapped استفاده کنید: + +```text +http://localhost:8000 +``` + +اما اگر درخواست از سمت سرور فرانت/SSR/Nuxt/Next داخل کانتینر ارسال می‌شود، از این آدرس استفاده کنید: + +```text +http://backend-web:8000 +``` + +--- + +## 2) Base URL + +### برای ارتباط container-to-container + +```text +http://backend-web:8000 +``` + +### Prefixهای موردنیاز + +- Auth: `/api/auth/` +- Farm AI Assistant: `/api/farm-ai-assistant/` + +--- + +## 3) جریان کلی برای Landing Page + +برای چت landing page، ارسال `farm_uuid` الزامی نیست. + +یعنی اگر کاربر داخل landing page پیام بدهد: + +- فقط کافی است کاربر لاگین باشد +- `farm_uuid` را ارسال نکنید +- سیستم conversation را به‌صورت landing conversation با `farm = null` ذخیره می‌کند +- در responseها معمولاً `farm_uuid` برابر `null` خواهد بود + +### فلو پیشنهادی + +1. کاربر با `register` یا `login` توکن بگیرد. +2. توکن را در هدر `Authorization: Bearer ` بفرستید. +3. برای شروع چت landing از `POST /api/farm-ai-assistant/chat/task/` بدون `farm_uuid` استفاده کنید. +4. `task_id` و `conversation_id` را از پاسخ نگه دارید. +5. با `GET /api/farm-ai-assistant/chat/task/{task_id}/status/` وضعیت را poll کنید. +6. برای history از `GET /api/farm-ai-assistant/chats/{conversation_id}/messages/` استفاده کنید. +7. برای لیست چت‌های landing از `GET /api/farm-ai-assistant/chats/` بدون `farm_uuid` استفاده کنید. + +--- + +## 4) Authentication APIs + +## 4.1) Register + +- Method: `POST` +- URL: `/api/auth/register/` +- Auth: نیاز ندارد + +### Request + +```json +{ + "username": "landing_user", + "email": "landing@example.com", + "phone_number": "09120000000", + "password": "secret123", + "first_name": "Landing", + "last_name": "User" +} +``` + +### Response 201 + +```json +{ + "code": 201, + "msg": "success", + "data": { + "id": 1, + "username": "landing_user", + "email": "landing@example.com", + "first_name": "Landing", + "last_name": "User", + "phone_number": "09120000000" + }, + "token": "" +} +``` + +### خطاهای رایج + +- `400`: username تکراری +- `400`: email تکراری +- `400`: phone_number تکراری + +--- + +## 4.2) Login + +- Method: `POST` +- URL: `/api/auth/login/` +- Auth: نیاز ندارد + +### Request + +`identifier` می‌تواند `username` یا `email` یا `phone_number` باشد. + +```json +{ + "identifier": "landing_user", + "password": "secret123" +} +``` + +### Response 200 + +```json +{ + "code": 200, + "msg": "success", + "data": { + "id": 1, + "username": "landing_user", + "email": "landing@example.com", + "first_name": "Landing", + "last_name": "User", + "phone_number": "09120000000" + }, + "token": "" +} +``` + +### خطای رایج + +```json +{ + "code": 401, + "msg": "Invalid credentials." +} +``` + +--- + +## 5) هدر احراز هویت برای Farm AI Assistant + +تمام endpointهای Farm AI Assistant نیاز به لاگین دارند: + +```http +Authorization: Bearer +Content-Type: application/json +``` + +--- + +## 6) Farm AI Assistant APIs + +## 6.1) ارسال پیام و ساخت task + +- Method: `POST` +- URL: `/api/farm-ai-assistant/chat/task/` +- Auth: لازم است +- برای landing page: `farm_uuid` را ارسال نکنید + +### Request نمونه برای landing + +```json +{ + "content": "برای شروع کشاورزی چه محصولی مناسب‌تر است؟", + "images": [], + "title": "Landing chat", + "farm_context": { + "source": "landing", + "page": "home" + } +} +``` + +### Request با ادامه conversation قبلی + +```json +{ + "conversation_id": "4b9f4d2f-2e5f-4f7a-ae24-6e7fd9c3e0f1", + "content": "برای منطقه کم‌آب چه پیشنهادی داری؟", + "images": [] +} +``` + +### فیلدها + +| فیلد | نوع | اجباری | توضیح | +|---|---|---|---| +| `farm_uuid` | `uuid` | خیر | برای landing ارسال نشود | +| `content` | `string` | اختیاری* | متن پیام | +| `images` | `string[]` | اختیاری | لیست URL/base64/path | +| `conversation_id` | `uuid` | اختیاری | ادامه چت قبلی | +| `title` | `string` | اختیاری | عنوان چت جدید | +| `farm_context` | `object` | اختیاری | context اضافی UI | + +`*` حداقل یکی از `content` یا `images` باید ارسال شود. + +### Response 202 + +```json +{ + "status": "success", + "data": { + "task_id": "farm-ai-chat-task-123", + "status": "PENDING", + "status_url": "/api/tasks/farm-ai-chat-task-123/status/", + "conversation_id": "4b9f4d2f-2e5f-4f7a-ae24-6e7fd9c3e0f1", + "message_id": "5d3f7a8c-9f2e-4d0a-b56f-1f2c2f9c1a22", + "farm_uuid": null + } +} +``` + +### خطاهای رایج + +- `400`: اگر نه `content` باشد نه `images` +- `404`: اگر `conversation_id` متعلق به کاربر نباشد +- `503`: اگر سرویس خارجی AI در دسترس نباشد + +--- + +## 6.2) بررسی وضعیت task + +- Method: `GET` +- URL: `/api/farm-ai-assistant/chat/task/{task_id}/status/` +- Auth: لازم است +- برای landing page: بدون `farm_uuid` + +### Request + +```http +GET /api/farm-ai-assistant/chat/task/farm-ai-chat-task-123/status/ +Authorization: Bearer +``` + +### Response در حالت pending + +```json +{ + "status": "success", + "data": { + "task_id": "farm-ai-chat-task-123", + "status": "PENDING", + "conversation_id": "4b9f4d2f-2e5f-4f7a-ae24-6e7fd9c3e0f1", + "farm_uuid": null, + "progress": { + "message": "Processing request" + } + } +} +``` + +### Response در حالت success + +```json +{ + "status": "success", + "data": { + "task_id": "farm-ai-chat-task-123", + "status": "SUCCESS", + "conversation_id": "4b9f4d2f-2e5f-4f7a-ae24-6e7fd9c3e0f1", + "farm_uuid": null, + "result": { + "message_id": "9f3f8f61-cc71-4f70-a650-2f4dc6f4e5c2", + "conversation_id": "4b9f4d2f-2e5f-4f7a-ae24-6e7fd9c3e0f1", + "farm_uuid": null, + "task_id": "farm-ai-chat-task-123", + "content": "Here is the recommended plan.", + "sections": [ + { + "type": "recommendation", + "title": "Irrigation Plan", + "icon": "droplet", + "frequency": "3 times per week", + "amount": "15 liters per plant", + "timing": "Early morning", + "expandableExplanation": "Loamy soil holds moisture well, so moderate frequency is enough." + } + ] + } + } +} +``` + +### Response در حالت failure + +```json +{ + "status": "success", + "data": { + "task_id": "farm-ai-chat-task-123", + "status": "FAILURE", + "conversation_id": "4b9f4d2f-2e5f-4f7a-ae24-6e7fd9c3e0f1", + "farm_uuid": null, + "error": "something went wrong" + } +} +``` + +### پیشنهاد برای polling + +- هر `2` تا `3` ثانیه یک بار status را چک کنید +- وقتی `status` برابر `SUCCESS` یا `FAILURE` شد polling را متوقف کنید + +--- + +## 6.3) دریافت لیست chatها + +- Method: `GET` +- URL: `/api/farm-ai-assistant/chats/` +- Auth: لازم است +- برای landing page: بدون `farm_uuid` + +### رفتار + +اگر `farm_uuid` ارسال نشود: + +- فقط conversationهای landing همان کاربر برمی‌گردند +- یعنی فقط رکوردهایی که مزرعه ندارند + +### Request + +```http +GET /api/farm-ai-assistant/chats/ +Authorization: Bearer +``` + +### Response 200 + +```json +{ + "status": "success", + "data": [ + { + "id": "4b9f4d2f-2e5f-4f7a-ae24-6e7fd9c3e0f1", + "farm_uuid": null, + "message_count": 4 + } + ] +} +``` + +--- + +## 6.4) ساخت conversation خالی + +- Method: `POST` +- URL: `/api/farm-ai-assistant/chats/` +- Auth: لازم است +- برای landing page: بدون `farm_uuid` + +### Request + +```json +{ + "title": "مشاوره اولیه", + "farm_context": { + "source": "landing" + } +} +``` + +### Response 201 + +```json +{ + "status": "success", + "data": { + "id": "4b9f4d2f-2e5f-4f7a-ae24-6e7fd9c3e0f1", + "farm_uuid": null, + "message_count": 0 + } +} +``` + +--- + +## 6.5) حذف conversation + +- Method: `DELETE` +- URL: `/api/farm-ai-assistant/chats/{conversation_id}/` +- Auth: لازم است +- برای landing page: بدون `farm_uuid` + +### رفتار + +اگر `farm_uuid` نفرستید، فقط conversationهای landing همان کاربر قابل حذف هستند. + +### Request + +```http +DELETE /api/farm-ai-assistant/chats/4b9f4d2f-2e5f-4f7a-ae24-6e7fd9c3e0f1/ +Authorization: Bearer +``` + +### Response 200 + +```json +{ + "status": "success", + "data": { + "conversation_id": "4b9f4d2f-2e5f-4f7a-ae24-6e7fd9c3e0f1", + "farm_uuid": null + } +} +``` + +--- + +## 6.6) دریافت پیام‌های یک conversation + +- Method: `GET` +- URL: `/api/farm-ai-assistant/chats/{conversation_id}/messages/` +- Auth: لازم است +- برای landing page: بدون `farm_uuid` + +### رفتار + +اگر `farm_uuid` ارسال نشود، فقط conversationهای landing همان کاربر لود می‌شوند. + +### Request + +```http +GET /api/farm-ai-assistant/chats/4b9f4d2f-2e5f-4f7a-ae24-6e7fd9c3e0f1/messages/ +Authorization: Bearer +``` + +### Response 200 + +```json +{ + "status": "success", + "data": { + "conversation_id": "4b9f4d2f-2e5f-4f7a-ae24-6e7fd9c3e0f1", + "farm_uuid": null, + "messages": [ + { + "message_id": "11111111-1111-1111-1111-111111111111", + "conversation_id": "4b9f4d2f-2e5f-4f7a-ae24-6e7fd9c3e0f1", + "farm_uuid": null, + "role": "user", + "content": "برای شروع کشاورزی چه چیزی پیشنهاد می‌کنی؟", + "sections": [], + "images": [], + "created_at": "2025-03-27T12:00:00Z" + }, + { + "message_id": "22222222-2222-2222-2222-222222222222", + "conversation_id": "4b9f4d2f-2e5f-4f7a-ae24-6e7fd9c3e0f1", + "farm_uuid": null, + "role": "assistant", + "content": "Here is the recommended plan.", + "sections": [ + { + "type": "list", + "title": "Important Notes", + "items": [ + "Avoid watering at noon", + "Check leaf stress every two days" + ] + } + ], + "images": [], + "created_at": "2025-03-27T12:00:05Z" + } + ] + } +} +``` + +--- + +## 7) مثال کامل فرانت برای Landing Chat + +## 7.1) Register + +```bash +curl -X POST 'http://backend-web:8000/api/auth/register/' \ + -H 'Content-Type: application/json' \ + -d '{ + "username": "landing_user", + "email": "landing@example.com", + "phone_number": "09120000000", + "password": "secret123", + "first_name": "Landing", + "last_name": "User" + }' +``` + +## 7.2) Login + +```bash +curl -X POST 'http://backend-web:8000/api/auth/login/' \ + -H 'Content-Type: application/json' \ + -d '{ + "identifier": "landing_user", + "password": "secret123" + }' +``` + +## 7.3) Create task + +```bash +curl -X POST 'http://backend-web:8000/api/farm-ai-assistant/chat/task/' \ + -H 'Authorization: Bearer ' \ + -H 'Content-Type: application/json' \ + -d '{ + "content": "برای شروع کشاورزی چه محصولی مناسب است؟", + "farm_context": { + "source": "landing", + "page": "home" + } + }' +``` + +## 7.4) Poll task status + +```bash +curl -X GET 'http://backend-web:8000/api/farm-ai-assistant/chat/task/farm-ai-chat-task-123/status/' \ + -H 'Authorization: Bearer ' +``` + +## 7.5) Get chat list + +```bash +curl -X GET 'http://backend-web:8000/api/farm-ai-assistant/chats/' \ + -H 'Authorization: Bearer ' +``` + +## 7.6) Get messages + +```bash +curl -X GET 'http://backend-web:8000/api/farm-ai-assistant/chats/4b9f4d2f-2e5f-4f7a-ae24-6e7fd9c3e0f1/messages/' \ + -H 'Authorization: Bearer ' +``` + +## 7.7) Delete conversation + +```bash +curl -X DELETE 'http://backend-web:8000/api/farm-ai-assistant/chats/4b9f4d2f-2e5f-4f7a-ae24-6e7fd9c3e0f1/' \ + -H 'Authorization: Bearer ' +``` + +--- + +## 8) رفتار `farm_uuid` در Landing Page + +برای endpointهای زیر در landing page لازم نیست `farm_uuid` ارسال شود: + +- `POST /api/farm-ai-assistant/chat/task/` +- `GET /api/farm-ai-assistant/chat/task/{task_id}/status/` +- `GET /api/farm-ai-assistant/chats/` +- `POST /api/farm-ai-assistant/chats/` +- `DELETE /api/farm-ai-assistant/chats/{conversation_id}/` +- `GET /api/farm-ai-assistant/chats/{conversation_id}/messages/` + +### نتیجه این رفتار + +- چت به کاربر وابسته است +- چت به مزرعه خاصی وابسته نیست +- در response مقدار `farm_uuid` معمولاً `null` است + +--- + +## 9) چیزی که فرانت باید نگه دارد + +بعد از login این موارد را در state یا storage نگه دارید: + +- `token` +- `user` + +بعد از `POST /chat/task/` این موارد را نگه دارید: + +- `conversation_id` +- `task_id` +- `message_id` + +برای ادامه chat: + +- `conversation_id` را در درخواست بعدی دوباره بفرستید + +--- + +## 10) نکات نهایی + +- تمام APIهای Farm AI Assistant به توکن نیاز دارند. +- برای landing page، `farm_uuid` را نفرستید. +- اگر فرانت داخل Docker است، آدرس بک‌اند را `http://backend-web:8000` بگذارید. +- اگر فرانت از داخل browser مستقیم درخواست می‌زند، معمولاً `http://localhost:8000` لازم است. +- `localhost` داخل کانتینر فرانت، آدرس بک‌اند نیست. + diff --git a/farm_ai_assistant/serializers.py b/farm_ai_assistant/serializers.py index e4fa2b3..25ca088 100644 --- a/farm_ai_assistant/serializers.py +++ b/farm_ai_assistant/serializers.py @@ -17,12 +17,12 @@ class ChatSectionSerializer(serializers.Serializer): class ConversationSummarySerializer(serializers.Serializer): id = serializers.UUIDField(source="uuid", read_only=True) - farm_uuid = serializers.UUIDField(source="farm.farm_uuid", read_only=True) + farm_uuid = serializers.UUIDField(source="farm.farm_uuid", read_only=True, allow_null=True) message_count = serializers.IntegerField(read_only=True) class ConversationCreateSerializer(serializers.Serializer): - farm_uuid = serializers.UUIDField(required=True) + farm_uuid = serializers.UUIDField(required=False, allow_null=True) title = serializers.CharField(required=False, allow_blank=True, max_length=255) farm_context = serializers.JSONField(required=False) @@ -30,7 +30,7 @@ class ConversationCreateSerializer(serializers.Serializer): class ChatHistoryMessageSerializer(serializers.Serializer): message_id = serializers.UUIDField(read_only=True) conversation_id = serializers.UUIDField(read_only=True) - farm_uuid = serializers.UUIDField(read_only=True) + farm_uuid = serializers.UUIDField(read_only=True, allow_null=True) role = serializers.ChoiceField(choices=Message.ROLE_CHOICES, read_only=True) content = serializers.CharField(read_only=True, allow_blank=True) sections = ChatSectionSerializer(many=True, read_only=True) @@ -40,21 +40,21 @@ class ChatHistoryMessageSerializer(serializers.Serializer): class ConversationMessagesSerializer(serializers.Serializer): conversation_id = serializers.UUIDField(read_only=True) - farm_uuid = serializers.UUIDField(read_only=True) + farm_uuid = serializers.UUIDField(read_only=True, allow_null=True) messages = ChatHistoryMessageSerializer(many=True, read_only=True) class ChatResponseDataSerializer(serializers.Serializer): message_id = serializers.UUIDField(read_only=True) conversation_id = serializers.UUIDField(read_only=True) - farm_uuid = serializers.UUIDField(read_only=True) + farm_uuid = serializers.UUIDField(read_only=True, allow_null=True) content = serializers.CharField(read_only=True, allow_blank=True) sections = ChatSectionSerializer(many=True, read_only=True) class ConversationDeleteSerializer(serializers.Serializer): conversation_id = serializers.UUIDField(read_only=True) - farm_uuid = serializers.UUIDField(read_only=True) + farm_uuid = serializers.UUIDField(read_only=True, allow_null=True) class ChatTaskSubmitDataSerializer(serializers.Serializer): @@ -63,21 +63,21 @@ class ChatTaskSubmitDataSerializer(serializers.Serializer): 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) + 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) + 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): - farm_uuid = serializers.UUIDField(required=True) + farm_uuid = serializers.UUIDField(required=False, allow_null=True) content = serializers.CharField(required=False, allow_blank=True, default="") images = serializers.ListField( child=serializers.CharField(), diff --git a/farm_ai_assistant/tests.py b/farm_ai_assistant/tests.py index 93a9bb4..d4dee3e 100644 --- a/farm_ai_assistant/tests.py +++ b/farm_ai_assistant/tests.py @@ -5,11 +5,18 @@ from rest_framework.test import APIRequestFactory, force_authenticate from farm_hub.models import FarmHub, FarmType from .models import Conversation, Message -from .views import ChatTaskStatusView +from .views import ( + ChatDetailView, + ChatListCreateView, + ChatMessagesView, + ChatTaskCreateView, + ChatTaskStatusView, + ContextView, +) @override_settings(USE_EXTERNAL_API_MOCK=True) -class ChatTaskStatusViewTests(TestCase): +class FarmAiAssistantOptionalFarmUuidTests(TestCase): def setUp(self): self.factory = APIRequestFactory() self.user = get_user_model().objects.create_user( @@ -24,14 +31,94 @@ class ChatTaskStatusViewTests(TestCase): farm_type=self.farm_type, name="Farm 1", ) - self.conversation = Conversation.objects.create( + + def test_context_allows_missing_farm_uuid(self): + request = self.factory.get("/api/farm-ai-assistant/context/") + force_authenticate(request, user=self.user) + + response = ContextView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["status"], "success") + self.assertIsNone(response.data["data"]["farm_uuid"]) + + def test_chat_task_create_allows_missing_farm_uuid_for_landing_chat(self): + request = self.factory.post( + "/api/farm-ai-assistant/chat/task/", + {"content": "Give me a landing page recommendation"}, + format="json", + ) + force_authenticate(request, user=self.user) + + response = ChatTaskCreateView.as_view()(request) + + self.assertEqual(response.status_code, 202) + self.assertEqual(response.data["status"], "success") + self.assertEqual(response.data["data"]["task_id"], "farm-ai-chat-task-123") + self.assertIsNone(response.data["data"]["farm_uuid"]) + + conversation = Conversation.objects.get(uuid=response.data["data"]["conversation_id"]) + self.assertIsNone(conversation.farm) + self.assertEqual(conversation.owner_id, self.user.id) + + user_message = conversation.messages.get(role=Message.ROLE_USER) + self.assertIsNone(user_message.farm) + self.assertIsNone(user_message.raw_response["farm_uuid"]) + + def test_status_success_without_farm_uuid_persists_assistant_message(self): + conversation = Conversation.objects.create( owner=self.user, - farm=self.farm, - title="Irrigation chat", + farm=None, + title="Landing chat", farm_context={}, ) - self.user_message = Message.objects.create( - conversation=self.conversation, + Message.objects.create( + conversation=conversation, + farm=None, + role=Message.ROLE_USER, + content="What should I plant?", + raw_response={ + "task_id": "farm-ai-chat-task-123", + "status": "PENDING", + "status_url": "/api/tasks/farm-ai-chat-task-123/status/", + "farm_uuid": None, + }, + ) + + request = self.factory.get("/api/farm-ai-assistant/chat/task/farm-ai-chat-task-123/status/") + force_authenticate(request, user=self.user) + + response = ChatTaskStatusView.as_view()(request, task_id="farm-ai-chat-task-123") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["status"], "success") + self.assertEqual(response.data["data"]["task_id"], "farm-ai-chat-task-123") + self.assertEqual(response.data["data"]["status"], "SUCCESS") + self.assertEqual(response.data["data"]["conversation_id"], str(conversation.uuid)) + self.assertIsNone(response.data["data"]["farm_uuid"]) + self.assertEqual(response.data["data"]["result"]["content"], "Here is the recommended plan.") + self.assertEqual(response.data["data"]["result"]["task_id"], "farm-ai-chat-task-123") + + assistant_message = ( + conversation.messages.filter(role=Message.ROLE_ASSISTANT) + .order_by("-created_at") + .first() + ) + self.assertIsNotNone(assistant_message) + self.assertIsNone(assistant_message.farm) + self.assertEqual(assistant_message.content, "Here is the recommended plan.") + self.assertIsNone(assistant_message.raw_response["farm_uuid"]) + self.assertEqual(assistant_message.raw_response["task_id"], "farm-ai-chat-task-123") + + def test_status_success_with_farm_uuid_still_works_for_farm_chat(self): + conversation = Conversation.objects.create( + owner=self.user, + farm=self.farm, + title="Farm chat", + farm_context={}, + ) + Message.objects.create( + conversation=conversation, farm=self.farm, role=Message.ROLE_USER, content="What is the best irrigation plan?", @@ -43,7 +130,6 @@ class ChatTaskStatusViewTests(TestCase): }, ) - def test_status_success_uses_chat_mock_result_and_persists_assistant_message(self): request = self.factory.get( f"/api/farm-ai-assistant/chat/task/farm-ai-chat-task-123/status/?farm_uuid={self.farm.farm_uuid}" ) @@ -52,23 +138,83 @@ class ChatTaskStatusViewTests(TestCase): response = ChatTaskStatusView.as_view()(request, task_id="farm-ai-chat-task-123") self.assertEqual(response.status_code, 200) - self.assertEqual(response.data["status"], "success") - self.assertEqual(response.data["data"]["task_id"], "farm-ai-chat-task-123") - self.assertEqual(response.data["data"]["status"], "SUCCESS") - self.assertEqual(response.data["data"]["conversation_id"], str(self.conversation.uuid)) + self.assertEqual(response.data["data"]["conversation_id"], str(conversation.uuid)) self.assertEqual(response.data["data"]["farm_uuid"], str(self.farm.farm_uuid)) - self.assertEqual(response.data["data"]["result"]["content"], "Here is the recommended plan.") - self.assertEqual(len(response.data["data"]["result"]["sections"]), 3) - self.assertEqual(response.data["data"]["result"]["task_id"], "farm-ai-chat-task-123") - assistant_message = ( - self.conversation.messages.filter(role=Message.ROLE_ASSISTANT) - .order_by("-created_at") - .first() + def test_chat_list_create_messages_and_delete_work_without_farm_uuid(self): + landing_conversation = Conversation.objects.create( + owner=self.user, + farm=None, + title="Landing chat", + farm_context={"source": "landing"}, ) - self.assertIsNotNone(assistant_message) - self.assertEqual(assistant_message.farm_id, self.farm.id) - self.assertEqual(assistant_message.content, "Here is the recommended plan.") - self.assertEqual(assistant_message.raw_response["task_id"], "farm-ai-chat-task-123") - self.assertEqual(assistant_message.raw_response["farm_uuid"], str(self.farm.farm_uuid)) - self.assertEqual(len(assistant_message.raw_response["sections"]), 3) + Message.objects.create( + conversation=landing_conversation, + farm=None, + role=Message.ROLE_USER, + content="Hello from landing", + raw_response={"farm_uuid": None}, + ) + farm_conversation = Conversation.objects.create( + owner=self.user, + farm=self.farm, + title="Farm chat", + farm_context={}, + ) + Message.objects.create( + conversation=farm_conversation, + farm=self.farm, + role=Message.ROLE_USER, + content="Hello from farm", + raw_response={"farm_uuid": str(self.farm.farm_uuid)}, + ) + + list_request = self.factory.get("/api/farm-ai-assistant/chats/") + force_authenticate(list_request, user=self.user) + list_response = ChatListCreateView.as_view()(list_request) + + self.assertEqual(list_response.status_code, 200) + self.assertEqual(len(list_response.data["data"]), 1) + self.assertEqual(list_response.data["data"][0]["id"], str(landing_conversation.uuid)) + self.assertIsNone(list_response.data["data"][0]["farm_uuid"]) + + create_request = self.factory.post( + "/api/farm-ai-assistant/chats/", + {"title": "New landing conversation"}, + format="json", + ) + force_authenticate(create_request, user=self.user) + create_response = ChatListCreateView.as_view()(create_request) + + self.assertEqual(create_response.status_code, 201) + self.assertIsNone(create_response.data["data"]["farm_uuid"]) + + created_conversation = Conversation.objects.get(uuid=create_response.data["data"]["id"]) + self.assertIsNone(created_conversation.farm) + + messages_request = self.factory.get( + f"/api/farm-ai-assistant/chats/{landing_conversation.uuid}/messages/" + ) + force_authenticate(messages_request, user=self.user) + messages_response = ChatMessagesView.as_view()( + messages_request, + conversation_id=landing_conversation.uuid, + ) + + self.assertEqual(messages_response.status_code, 200) + self.assertEqual(messages_response.data["data"]["conversation_id"], str(landing_conversation.uuid)) + self.assertIsNone(messages_response.data["data"]["farm_uuid"]) + self.assertEqual(len(messages_response.data["data"]["messages"]), 1) + self.assertIsNone(messages_response.data["data"]["messages"][0]["farm_uuid"]) + + delete_request = self.factory.delete(f"/api/farm-ai-assistant/chats/{landing_conversation.uuid}/") + force_authenticate(delete_request, user=self.user) + delete_response = ChatDetailView.as_view()( + delete_request, + conversation_id=landing_conversation.uuid, + ) + + self.assertEqual(delete_response.status_code, 200) + self.assertEqual(delete_response.data["data"]["conversation_id"], str(landing_conversation.uuid)) + self.assertIsNone(delete_response.data["data"]["farm_uuid"]) + self.assertFalse(Conversation.objects.filter(uuid=landing_conversation.uuid).exists()) diff --git a/farm_ai_assistant/views.py b/farm_ai_assistant/views.py index 39177e3..4456fd0 100644 --- a/farm_ai_assistant/views.py +++ b/farm_ai_assistant/views.py @@ -34,11 +34,21 @@ class FarmAccessMixin: def _get_farm(request, farm_uuid): if not farm_uuid: raise serializers.ValidationError({"farm_uuid": ["This field is required."]}) + return FarmAccessMixin._get_optional_farm(request, farm_uuid) + + @staticmethod + def _get_optional_farm(request, farm_uuid): + if not farm_uuid: + return None try: return FarmHub.objects.get(farm_uuid=farm_uuid, owner=request.user) except FarmHub.DoesNotExist as exc: raise Http404("Farm not found") from exc + @staticmethod + def _farm_uuid_or_none(farm): + return str(farm.farm_uuid) if farm else None + class ContextView(FarmAccessMixin, APIView): permission_classes = [IsAuthenticated] @@ -46,14 +56,14 @@ class ContextView(FarmAccessMixin, APIView): @extend_schema( tags=["Farm AI Assistant"], parameters=[ - OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=True, default="11111111-1111-1111-1111-111111111111"), + OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=False, default="11111111-1111-1111-1111-111111111111"), ], responses={200: status_response("FarmAiAssistantContextResponse", data=serializers.JSONField())}, ) def get(self, request): - farm = self._get_farm(request, request.query_params.get("farm_uuid")) + farm = self._get_optional_farm(request, request.query_params.get("farm_uuid")) data = deepcopy(CONTEXT_RESPONSE_DATA) - data["farm_uuid"] = str(farm.farm_uuid) + data["farm_uuid"] = self._farm_uuid_or_none(farm) return Response( {"status": "success", "data": data}, status=status.HTTP_200_OK, @@ -66,6 +76,8 @@ class ConversationAccessMixin(FarmAccessMixin): filters = {"uuid": conversation_id, "owner": request.user} if farm_uuid: filters["farm__farm_uuid"] = farm_uuid + else: + filters["farm__isnull"] = True try: return Conversation.objects.select_related("farm").get(**filters) except Conversation.DoesNotExist as exc: @@ -110,17 +122,21 @@ class ConversationAccessMixin(FarmAccessMixin): def _build_mock_assistant_payload(self, conversation): payload = deepcopy(CHAT_RESPONSE_DATA) payload["conversation_id"] = str(conversation.uuid) - payload["farm_uuid"] = str(conversation.farm.farm_uuid) + payload["farm_uuid"] = self._farm_uuid_or_none(conversation.farm) return payload def _get_or_create_conversation(self, request, validated): conversation_id = validated.get("conversation_id") farm_context = validated.get("farm_context") title = validated.get("title", "").strip() - farm = self._get_farm(request, validated.get("farm_uuid")) + farm = self._get_optional_farm(request, validated.get("farm_uuid")) if conversation_id: - conversation = self._get_conversation(request, conversation_id, farm.farm_uuid) + conversation = self._get_conversation( + request, + conversation_id, + farm.farm_uuid if farm else None, + ) updated_fields = [] if farm_context is not None: conversation.farm_context = farm_context @@ -143,13 +159,14 @@ class ConversationAccessMixin(FarmAccessMixin): @staticmethod def _build_adapter_payload(request, validated, conversation): payload = { - "farm_uuid": str(conversation.farm.farm_uuid), "content": validated.get("content", ""), "query": validated.get("content", ""), "images": validated.get("images", []), "conversation_id": str(conversation.uuid), "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: @@ -177,7 +194,7 @@ class ConversationAccessMixin(FarmAccessMixin): return { "message_id": "", "conversation_id": str(conversation.uuid), - "farm_uuid": str(conversation.farm.farm_uuid), + "farm_uuid": self._farm_uuid_or_none(conversation.farm), "content": content, "sections": sections, } @@ -197,7 +214,7 @@ class ConversationAccessMixin(FarmAccessMixin): "status_url": str(payload_source.get("status_url") or ""), "conversation_id": str(conversation.uuid), "message_id": str(message_id), - "farm_uuid": str(conversation.farm.farm_uuid), + "farm_uuid": ConversationAccessMixin._farm_uuid_or_none(conversation.farm), } def _extract_task_status_payload(self, adapter_data, task_id, conversation=None, farm_uuid=None): @@ -214,8 +231,8 @@ class ConversationAccessMixin(FarmAccessMixin): } if conversation: task_status_payload["conversation_id"] = str(conversation.uuid) - task_status_payload["farm_uuid"] = str(conversation.farm.farm_uuid) - elif farm_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") @@ -263,7 +280,7 @@ class ConversationAccessMixin(FarmAccessMixin): return { "message_id": str(message.uuid), "conversation_id": str(message.conversation.uuid), - "farm_uuid": str(message.farm.farm_uuid), + "farm_uuid": ConversationAccessMixin._farm_uuid_or_none(message.farm), "role": message.role, "content": message.content, "sections": ConversationAccessMixin._normalize_sections(sections), @@ -273,14 +290,18 @@ class ConversationAccessMixin(FarmAccessMixin): @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( - conversation__owner=request.user, - farm__farm_uuid=farm_uuid, - role=Message.ROLE_USER, - raw_response__task_id=task_id, - ) + .filter(**filters) .order_by("-created_at") .first() ) @@ -329,12 +350,12 @@ class ChatListCreateView(ConversationAccessMixin, APIView): @extend_schema( tags=["Farm AI Assistant"], parameters=[ - OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=True, default="11111111-1111-1111-1111-111111111111"), + OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=False, default="11111111-1111-1111-1111-111111111111"), ], responses={200: status_response("FarmAiAssistantConversationListResponse", data=ConversationSummarySerializer(many=True))}, ) def get(self, request): - farm = self._get_farm(request, request.query_params.get("farm_uuid")) + farm = self._get_optional_farm(request, request.query_params.get("farm_uuid")) conversations = ( Conversation.objects.filter(owner=request.user, farm=farm) .annotate(message_count=Count("messages")) @@ -353,7 +374,7 @@ class ChatListCreateView(ConversationAccessMixin, APIView): serializer.is_valid(raise_exception=True) validated = serializer.validated_data - farm = self._get_farm(request, validated.get("farm_uuid")) + farm = self._get_optional_farm(request, validated.get("farm_uuid")) conversation = Conversation.objects.create( owner=request.user, farm=farm, @@ -378,13 +399,13 @@ class ChatMessagesView(ConversationAccessMixin, APIView): tags=["Farm AI Assistant"], parameters=[ OpenApiParameter(name="conversation_id", type=OpenApiTypes.UUID, location=OpenApiParameter.PATH), - OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=True, default="11111111-1111-1111-1111-111111111111"), + OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=False, default="11111111-1111-1111-1111-111111111111"), ], responses={200: status_response("FarmAiAssistantMessageListResponse", data=ConversationMessagesSerializer())}, ) def get(self, request, conversation_id): - farm = self._get_farm(request, request.query_params.get("farm_uuid")) - conversation = self._get_conversation(request, conversation_id, farm.farm_uuid) + farm = self._get_optional_farm(request, request.query_params.get("farm_uuid")) + conversation = self._get_conversation(request, conversation_id, farm.farm_uuid if farm else None) messages = conversation.messages.select_related("farm").all() serialized_messages = [self._serialize_chat_message(message) for message in messages] return Response( @@ -392,7 +413,7 @@ class ChatMessagesView(ConversationAccessMixin, APIView): "status": "success", "data": { "conversation_id": str(conversation.uuid), - "farm_uuid": str(farm.farm_uuid), + "farm_uuid": self._farm_uuid_or_none(farm), "messages": serialized_messages, }, }, @@ -407,15 +428,15 @@ class ChatDetailView(ConversationAccessMixin, APIView): tags=["Farm AI Assistant"], parameters=[ OpenApiParameter(name="conversation_id", type=OpenApiTypes.UUID, location=OpenApiParameter.PATH), - OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=True, default="11111111-1111-1111-1111-111111111111"), + OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=False, default="11111111-1111-1111-1111-111111111111"), ], responses={200: status_response("FarmAiAssistantConversationDeleteResponse", data=ConversationDeleteSerializer())}, ) def delete(self, request, conversation_id): - farm = self._get_farm(request, request.query_params.get("farm_uuid")) - conversation = self._get_conversation(request, conversation_id, farm.farm_uuid) + farm = self._get_optional_farm(request, request.query_params.get("farm_uuid")) + conversation = self._get_conversation(request, conversation_id, farm.farm_uuid if farm else None) deleted_conversation_id = str(conversation.uuid) - deleted_farm_uuid = str(conversation.farm.farm_uuid) + deleted_farm_uuid = self._farm_uuid_or_none(conversation.farm) conversation.delete() return Response( { @@ -450,7 +471,7 @@ class ChatView(ConversationAccessMixin, APIView): role=Message.ROLE_USER, content=validated.get("content", ""), images=validated.get("images", []), - raw_response={"farm_uuid": str(conversation.farm.farm_uuid)}, + raw_response={"farm_uuid": self._farm_uuid_or_none(conversation.farm)}, ) adapter_payload = self._build_adapter_payload(request, validated, conversation) @@ -522,7 +543,7 @@ class ChatTaskCreateView(ConversationAccessMixin, APIView): role=Message.ROLE_USER, content=validated.get("content", ""), images=validated.get("images", []), - raw_response={"farm_uuid": str(conversation.farm.farm_uuid)}, + raw_response={"farm_uuid": self._farm_uuid_or_none(conversation.farm)}, ) adapter_payload = self._build_adapter_payload(request, validated, conversation) @@ -578,18 +599,21 @@ class ChatTaskStatusView(ConversationAccessMixin, APIView): 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=True, default="11111111-1111-1111-1111-111111111111"), + 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_farm(request, request.query_params.get("farm_uuid")) + 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={"farm_uuid": str(farm.farm_uuid)}, + query=query, ) except ExternalAPIRequestError: return Response( @@ -611,13 +635,14 @@ class ChatTaskStatusView(ConversationAccessMixin, APIView): status=adapter_response.status_code, ) - user_message = self._find_user_message_for_task(request, task_id, farm.farm_uuid) + 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.farm_uuid, + farm_uuid=farm_uuid, ) result = self._extract_structured_task_result(adapter_response.data)