diff --git a/.env.example b/.env.example index f29ff05..e69e019 100644 --- a/.env.example +++ b/.env.example @@ -24,8 +24,8 @@ EXTERNAL_API_TIMEOUT=30 AI_SERVICE_BASE_URL=https://ai.example.com AI_SERVICE_API_KEY= -SENSOR_HUB_SERVICE_BASE_URL=https://sensor-hub.example.com -SENSOR_HUB_SERVICE_API_KEY= +FARM_HUB_SERVICE_BASE_URL=https://farm-hub.example.com +FARM_HUB_SERVICE_API_KEY= CROP_ZONE_CHUNK_AREA_SQM=10000 CROP_ZONE_TASK_STALE_SECONDS=300 diff --git a/Backend.md b/Backend.md index 33e0e8b..7109740 100644 --- a/Backend.md +++ b/Backend.md @@ -4,7 +4,7 @@ - Backend Service - AI Service -- SensorHub Service +- FarmHub Service جدا بودن این سرویس‌ها باعث می‌شود هر بخش بتواند به‌صورت مستقل توسعه داده شود، در صورت افزایش بار به‌صورت جداگانه مقیاس‌پذیر باشد و در صورت بروز مشکل در یک سرویس، سایر سرویس‌ها دچار اختلال نشوند. @@ -16,7 +16,7 @@ ### وظایف اصلی - دریافت درخواست‌ها از سمت کلاینت -- ارتباط با سرویس‌های **AI** و **SensorHub** +- ارتباط با سرویس‌های **AI** و **FarmHub** - پردازش و مدیریت خروجی سرویس‌ها - آماده‌سازی و ارسال پاسخ نهایی به کلاینت @@ -124,9 +124,9 @@ --- -## 3. SensorHub Service +## 3. FarmHub Service -سرویس **SensorHub** مسئول دریافت، ذخیره و مدیریت داده‌های سنسورهای مزرعه است. +سرویس **FarmHub** مسئول دریافت، ذخیره و مدیریت داده‌های سنسورهای مزرعه است. به دلیل حساسیت بالای این داده‌ها و اهمیت **عدم از دست رفتن اطلاعات سنسورها**، این سرویس به‌صورت مستقل از سایر بخش‌ها پیاده‌سازی شده است. diff --git a/Backend.txt b/Backend.txt index 33e0e8b..7109740 100644 --- a/Backend.txt +++ b/Backend.txt @@ -4,7 +4,7 @@ - Backend Service - AI Service -- SensorHub Service +- FarmHub Service جدا بودن این سرویس‌ها باعث می‌شود هر بخش بتواند به‌صورت مستقل توسعه داده شود، در صورت افزایش بار به‌صورت جداگانه مقیاس‌پذیر باشد و در صورت بروز مشکل در یک سرویس، سایر سرویس‌ها دچار اختلال نشوند. @@ -16,7 +16,7 @@ ### وظایف اصلی - دریافت درخواست‌ها از سمت کلاینت -- ارتباط با سرویس‌های **AI** و **SensorHub** +- ارتباط با سرویس‌های **AI** و **FarmHub** - پردازش و مدیریت خروجی سرویس‌ها - آماده‌سازی و ارسال پاسخ نهایی به کلاینت @@ -124,9 +124,9 @@ --- -## 3. SensorHub Service +## 3. FarmHub Service -سرویس **SensorHub** مسئول دریافت، ذخیره و مدیریت داده‌های سنسورهای مزرعه است. +سرویس **FarmHub** مسئول دریافت، ذخیره و مدیریت داده‌های سنسورهای مزرعه است. به دلیل حساسیت بالای این داده‌ها و اهمیت **عدم از دست رفتن اطلاعات سنسورها**، این سرویس به‌صورت مستقل از سایر بخش‌ها پیاده‌سازی شده است. diff --git a/Dockerfile b/Dockerfile index 0ca88a3..eceae14 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,12 +11,7 @@ printf '%s\n' \ 'deb https://mirror-linux.runflare.com/debian/ bookworm main contrib non-free non-free-firmware' \ 'deb https://mirror-linux.runflare.com/debian/ bookworm-updates main contrib non-free non-free-firmware' \ 'deb https://mirror-linux.runflare.com/debian-security/ bookworm-security main contrib non-free non-free-firmware' \ -'' \ -'deb [trusted=yes] https://mirror2.chabokan.net/debian bookworm main contrib non-free non-free-firmware' \ -'deb [trusted=yes] https://mirror2.chabokan.net/debian-security bookworm-security main contrib non-free non-free-firmware' \ -'' \ -'deb http://mirror.iranserver.com/debian/ bookworm main contrib non-free non-free-firmware' \ -'deb-src http://mirror.iranserver.com/debian/ bookworm main contrib non-free non-free-firmware' \ + > /etc/apt/sources.list # System deps for MySQL client (pkg-config required by mysqlclient to find libs) diff --git a/FRONTEND_API_CHANGES.md b/FRONTEND_API_CHANGES.md new file mode 100644 index 0000000..a1bed9b --- /dev/null +++ b/FRONTEND_API_CHANGES.md @@ -0,0 +1,890 @@ +# مستند تحویل تغییرات بک‌اند به فرانت + +این فایل خلاصه و جمع‌بندی دقیقی از تغییرات انجام‌شده در بک‌اند است تا تیم فرانت بتواند بدون بررسی diffها، مصرف APIها را به‌روزرسانی کند. + +--- + +## جمع‌بندی خیلی کوتاه + +بزرگ‌ترین تغییر این release این است که محور اصلی داده‌ها از `sensor` به `farm` منتقل شده است. + +یعنی: + +- مسیر `sensor-hub` حذف شده و به `farm-hub` تغییر کرده است. +- در endpointهای مختلف، `sensor_uuid` حذف و `farm_uuid` جایگزین شده است. +- چند API که قبلا عمومی یا mock بودند، حالا per-farm و وابسته به مزرعه‌ی انتخاب‌شده هستند. +- بعضی responseها فیلد `farm_uuid` را هم برمی‌گردانند تا state فرانت دقیق‌تر بماند. +- بعضی endpointهایی که در داک قبلی/پست‌من وجود داشتند، الان route فعال ندارند و نباید از سمت فرانت صدا زده شوند. + +--- + +## اقدام لازم برای فرانت + +فرانت باید از این به بعد یک `farm_uuid` فعال/انتخاب‌شده در state سراسری داشته باشد و آن را در درخواست‌های مرتبط ارسال کند. + +در عمل: + +1. بعد از گرفتن لیست مزارع از `GET /api/farm-hub/`، یک مزرعه‌ی active/current انتخاب کنید. +2. `farm_uuid` همان مزرعه را برای dashboard، crop zoning، AI assistant، irrigation recommendation و fertilization recommendation ارسال کنید. +3. هر جایی که قبلا `sensor_uuid` استفاده می‌شد، باید با `farm_uuid` جایگزین شود. + +--- + +## Breaking Changes + +### 1) تغییر مسیر اصلی ماژول مزرعه + +- قبلی: `/api/sensor-hub/` +- جدید: `/api/farm-hub/` + +اگر فرانت هنوز route قبلی را صدا بزند، دیگر کار نخواهد کرد. + +--- + +### 2) حذف `sensor_uuid` از crop zoning + +در crop zoning دیگر `sensor_uuid` معتبر نیست. + +- قبلی: + +```http +GET /api/crop-zoning/area/?sensor_uuid= +``` + +- جدید: + +```http +GET /api/crop-zoning/area/?farm_uuid= +``` + +--- + +### 3) dashboard config و dashboard cards حالا per-farm هستند + +این دو endpoint دیگر global نیستند و بدون `farm_uuid` نباید مصرف شوند: + +- `GET /api/farm-dashboard-config/?farm_uuid=...` +- `PATCH /api/farm-dashboard-config/` +- `GET /api/farm-dashboard/?farm_uuid=...` + +نکته مهم: + +- در `GET`، `farm_uuid` باید در query باشد. +- در `PATCH`، `farm_uuid` باید داخل body باشد. + +--- + +### 4) Farm AI Assistant حالا کاملا farm-scoped است + +تمام flow چت حالا به مزرعه گره خورده است. + +یعنی: + +- لیست چت‌ها per-farm است. +- ساخت conversation جدید بدون `farm_uuid` ممکن نیست. +- گرفتن پیام‌ها و حذف conversation هم نیاز به `farm_uuid` دارد. +- responseها در چند endpoint جدیدا `farm_uuid` برمی‌گردانند. + +--- + +### 5) Recommendation APIها بدون `farm_uuid` معتبر نیستند + +برای هر دو ماژول: + +- `fertilization-recommendation` +- `irrigation-recommendation` + +الان `farm_uuid` اجباری شده است: + +- در `config` به‌صورت query param +- در `recommend` به‌صورت body +- در `status` به‌صورت query param + +--- + +## نکته مهم درباره routeهای فعال + +چند endpoint در کد view وجود دارند یا در داک‌های قدیمی/پست‌من دیده می‌شوند، ولی الان route فعال ندارند. فرانت نباید روی آن‌ها حساب کند. + +### routeهای غیرفعال فعلی + +- `POST /api/farm-ai-assistant/chat/` +- `GET /api/farm-dashboard/cards/` +- `POST /api/crop-zoning/zones/initial/` +- `POST /api/crop-zoning/zones/water-need/` +- `POST /api/crop-zoning/zones/soil-quality/` +- `POST /api/crop-zoning/zones/cultivation-risk/` + +### routeهای فعال فعلی + +فقط routeهایی که در این فایل آمده‌اند مبنای فرانت باشند. + +--- + +## 1) Farm Hub + +### هدف + +ماژول جدید `farm-hub` جایگزین `sensor-hub` شده و موجودیت اصلی فرانت برای انتخاب مزرعه، نمایش سنسورها، ساخت مزرعه و مدیریت فعال/غیرفعال بودن مزرعه است. + +### Base Path + +```text +/api/farm-hub/ +``` + +### endpointهای فعال + +| Method | Path | توضیح | +|---|---|---| +| GET | `/api/farm-hub/` | لیست مزارع کاربر | +| POST | `/api/farm-hub/` | ساخت مزرعه جدید | +| GET | `/api/farm-hub/{farm_uuid}/` | جزئیات مزرعه | +| PATCH | `/api/farm-hub/{farm_uuid}/` | آپدیت مزرعه | +| DELETE | `/api/farm-hub/{farm_uuid}/` | حذف مزرعه | +| POST | `/api/farm-hub/active/` | فعال‌کردن مزرعه | +| POST | `/api/farm-hub/deactive/` | غیرفعال‌کردن مزرعه | + +### ساختار response مزرعه + +```json +{ + "farm_uuid": "550e8400-e29b-41d4-a716-446655440000", + "name": "مزرعه نمونه", + "is_active": true, + "customization": {}, + "farm_type": { + "uuid": "11111111-1111-1111-1111-111111111111", + "name": "زراعی", + "description": "", + "metadata": {} + }, + "products": [ + { + "uuid": "22222222-2222-2222-2222-222222222222", + "name": "گندم", + "description": "", + "metadata": {} + } + ], + "sensors": [ + { + "uuid": "33333333-3333-3333-3333-333333333333", + "name": "Station 1", + "sensor_type": "weather_station", + "is_active": true, + "specifications": {}, + "power_source": {}, + "customization": {}, + "last_updated": "2025-02-18T12:00:00Z" + } + ], + "last_updated": "2025-02-18T12:00:00Z" +} +``` + +### نکات مهم برای فرانت + +- شناسه اصلی مزرعه `farm_uuid` است. +- هر مزرعه `farm_type`، `products` و `sensors` را به‌صورت nested برمی‌گرداند. +- سنسورها دیگر top-level resource مستقل برای UI نیستند؛ داخل خود مزرعه برمی‌گردند. +- endpoint جداگانه‌ای برای catalog نوع مزرعه/محصول در این diff اضافه نشده است. + +### ساخت مزرعه + +نمونه body: + +```json +{ + "name": "farm-1", + "farm_type_uuid": "11111111-1111-1111-1111-111111111111", + "product_uuids": [ + "22222222-2222-2222-2222-222222222222" + ], + "customization": { + "report_interval_sec": 300 + }, + "sensors": [ + { + "name": "Station 1", + "sensor_type": "weather_station", + "is_active": true, + "specifications": { + "model": "FH-1" + }, + "power_source": { + "type": "battery" + }, + "customization": { + "report_interval_sec": 300 + } + } + ], + "area_geojson": { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [51.418934, 35.706815], + [51.423054, 35.691062], + [51.384258, 35.689389], + [51.418934, 35.706815] + ] + ] + } + } +} +``` + +### تغییر مهم در create farm + +اگر `area_geojson` ارسال شود: + +- مزرعه ساخته می‌شود +- zoning اولیه هم برای همان مزرعه ساخته می‌شود +- در response، فیلد `zoning` هم برمی‌گردد + +این برای فرانت خیلی مهم است چون بعد از ساخت مزرعه می‌تواند مستقیم zoning اولیه را برای map استفاده کند و لازم نیست منتظر call جداگانه باشد. + +### فعال/غیرفعال کردن مزرعه + +body هر دو endpoint: + +```json +{ + "farm_uuid": "550e8400-e29b-41d4-a716-446655440000" +} +``` + +--- + +## 2) Crop Zoning + +### Base Path + +```text +/api/crop-zoning/ +``` + +### endpointهای فعال + +| Method | Path | توضیح | +|---|---|---| +| GET | `/api/crop-zoning/area/?farm_uuid=...&page=1&page_size=10` | گرفتن area + task status + zones | +| GET | `/api/crop-zoning/products/` | گرفتن لیست محصولات قابل کشت | +| GET | `/api/crop-zoning/zones/{zone_id}/details/` | جزئیات یک زون | + +### breaking change + +فقط `farm_uuid` معتبر است و request باید روی مزرعه‌ی متعلق به همان کاربر انجام شود. + +### نمونه request + +```http +GET /api/crop-zoning/area/?farm_uuid=&page=1&page_size=10 +``` + +### نمونه response + +```json +{ + "status": "success", + "data": { + "task": { + "status": "SUCCESS", + "stage": "completed", + "stage_label": "پردازش همه زون‌ها کامل شده است", + "area_uuid": "c0eaa4d7-92bf-4542-a60d-6010b45e7c96", + "total_zones": 364, + "completed_zones": 364, + "processing_zones": 0, + "pending_zones": 0, + "failed_zones": 0, + "remaining_zones": 0, + "progress_percent": 100, + "summary": { + "done": 364, + "in_progress": 0, + "remaining": 0, + "failed": 0 + }, + "message": "از مجموع 364 زون، 364 زون پردازش شده، 0 زون در حال پردازش و 0 زون باقی مانده است.", + "failed_zone_errors": [], + "cell_side_km": 0.1 + }, + "area": { + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [] + }, + "properties": { + "center": { + "latitude": 35.69575533, + "longitude": 51.40874867 + }, + "area_sqm": 3109868.97, + "cell_side_km": 0.1, + "area_hectares": 310.9869 + } + }, + "zones": [], + "pagination": { + "page": 1, + "page_size": 10, + "total_pages": 37, + "total_zones": 364, + "returned_zones": 10, + "has_next": true, + "has_previous": false + } + } +} +``` + +### نکات مهم برای فرانت + +- `zones` صفحه‌بندی‌شده است؛ کل زون‌ها در هر response برنمی‌گردند. +- فرانت باید با `task.status` و `task.stage` polling را مدیریت کند. +- `page` و `page_size` هنوز اختیاری هستند. +- اگر مزرعه area نداشته باشد، بک‌اند خودش area/zones را برای همان مزرعه ایجاد می‌کند. + +### خطاها + +#### نبودن `farm_uuid` + +```json +{ + "status": "error", + "message": "farm_uuid is required." +} +``` + +#### پیدا نشدن مزرعه + +```json +{ + "status": "error", + "message": "Farm not found." +} +``` + +--- + +## 3) Farm Dashboard + +### Base Path + +```text +/api/farm-dashboard/ +``` + +### Config Path + +```text +/api/farm-dashboard-config/ +``` + +### تغییر اصلی + +dashboard config دیگر mock/global نیست و برای هر مزرعه جداگانه در دیتابیس ذخیره می‌شود. + +### endpointهای فعال + +| Method | Path | توضیح | +|---|---|---| +| GET | `/api/farm-dashboard-config/?farm_uuid=...` | گرفتن تنظیمات داشبورد همان مزرعه | +| PATCH | `/api/farm-dashboard-config/` | آپدیت partial تنظیمات داشبورد همان مزرعه | +| GET | `/api/farm-dashboard/?farm_uuid=...` | گرفتن داده کارت‌های داشبورد برای همان مزرعه | + +### نکات مهم برای فرانت + +- `GET /api/farm-dashboard/cards/` الان route فعال ندارد؛ فقط base path فعال است. +- در response config، فیلد `farm_uuid` اضافه شده است. +- در `PATCH` حتی اگر فقط یک فیلد را تغییر می‌دهید، باز هم باید `farm_uuid` را بفرستید. + +### GET config + +```http +GET /api/farm-dashboard-config/?farm_uuid= +``` + +### GET config response + +```json +{ + "code": 200, + "msg": "OK", + "data": { + "farm_uuid": "550e8400-e29b-41d4-a716-446655440000", + "disabled_card_ids": [], + "row_order": [ + "overviewKpis", + "weatherAlerts", + "sensorMonitoring", + "sensorCharts", + "alertsWater", + "predictions", + "soilHeatmap", + "ndviRecommendations", + "economic" + ], + "enable_drag_reorder": true + } +} +``` + +### PATCH config sample + +```json +{ + "farm_uuid": "550e8400-e29b-41d4-a716-446655440000", + "disabled_card_ids": [ + "farmWeatherCard" + ] +} +``` + +### rule مهم PATCH + +اگر body فقط این باشد: + +```json +{ + "farm_uuid": "..." +} +``` + +خطای validation می‌گیرید، چون باید حداقل یکی از این‌ها هم باشد: + +- `disabled_card_ids` +- `row_order` +- `enable_drag_reorder` + +### خطاهای متداول + +#### نبودن `farm_uuid` + +```json +{ + "farm_uuid": [ + "This field is required." + ] +} +``` + +#### مزرعه متعلق به کاربر نباشد + +```json +{ + "farm_uuid": [ + "Farm not found." + ] +} +``` + +--- + +## 4) Farm AI Assistant + +### Base Path + +```text +/api/farm-ai-assistant/ +``` + +### endpointهای فعال + +| Method | Path | توضیح | +|---|---|---| +| GET | `/api/farm-ai-assistant/context/?farm_uuid=...` | گرفتن context مزرعه برای نوار/summary | +| GET | `/api/farm-ai-assistant/chats/?farm_uuid=...` | لیست conversationهای همان مزرعه | +| POST | `/api/farm-ai-assistant/chats/` | ساخت conversation جدید | +| GET | `/api/farm-ai-assistant/chats/{conversation_id}/messages/?farm_uuid=...` | گرفتن پیام‌ها | +| DELETE | `/api/farm-ai-assistant/chats/{conversation_id}/?farm_uuid=...` | حذف conversation | +| POST | `/api/farm-ai-assistant/chat/task/` | ایجاد task چت | +| GET | `/api/farm-ai-assistant/chat/task/{task_id}/status/?farm_uuid=...` | گرفتن وضعیت task | + +### نکته خیلی مهم + +`POST /api/farm-ai-assistant/chat/` الان route فعال ندارد. + +پس flow فعلی فرانت باید async باشد: + +1. `POST /chat/task/` +2. polling روی `GET /chat/task/{task_id}/status/` + +### تغییرات مهم response + +فیلد `farm_uuid` در این قسمت‌ها اضافه شده است: + +- conversation summary +- لیست پیام‌ها +- delete response +- task create response +- task status response +- assistant payload نهایی + +### GET context + +```http +GET /api/farm-ai-assistant/context/?farm_uuid= +``` + +نمونه response: + +```json +{ + "status": "success", + "data": { + "farm_uuid": "550e8400-e29b-41d4-a716-446655440000", + "soilType": "Loamy", + "waterEC": "1.2 dS/m", + "selectedCrop": "Tomato", + "growthStage": "Flowering", + "lastIrrigationStatus": "2 days ago" + } +} +``` + +### GET chats response + +```json +{ + "status": "success", + "data": [ + { + "id": "conv-123", + "farm_uuid": "550e8400-e29b-41d4-a716-446655440000", + "message_count": 4 + } + ] +} +``` + +### POST chats request + +```json +{ + "farm_uuid": "550e8400-e29b-41d4-a716-446655440000", + "title": "New chat", + "farm_context": { + "soilType": "Loamy" + } +} +``` + +### POST chat task request + +```json +{ + "farm_uuid": "550e8400-e29b-41d4-a716-446655440000", + "conversation_id": "7a26d99a-8d67-467d-a4a8-4c46b62b6bc2", + "content": "What is the best irrigation plan?", + "images": [], + "farm_context": { + "soilType": "Loamy" + } +} +``` + +### POST chat task response + +```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": "7a26d99a-8d67-467d-a4a8-4c46b62b6bc2", + "message_id": "2cbd4d61-363d-4f7c-a46a-a78cf28f6dd8", + "farm_uuid": "550e8400-e29b-41d4-a716-446655440000" + } +} +``` + +### GET task status response در حالت success + +```json +{ + "status": "success", + "data": { + "task_id": "farm-ai-chat-task-123", + "status": "SUCCESS", + "conversation_id": "7a26d99a-8d67-467d-a4a8-4c46b62b6bc2", + "farm_uuid": "550e8400-e29b-41d4-a716-446655440000", + "result": { + "message_id": "msg-001", + "conversation_id": "7a26d99a-8d67-467d-a4a8-4c46b62b6bc2", + "farm_uuid": "550e8400-e29b-41d4-a716-446655440000", + "content": "Here is the recommended plan.", + "sections": [] + } + } +} +``` + +### نکات مهم برای فرانت + +- conversationها حالا per-farm هستند؛ لیست چت مزرعه A لزوما برای مزرعه B مشترک نیست. +- برای گرفتن messages و delete هم باید `farm_uuid` در query بفرستید. +- در response پیام‌ها، هم top-level و هم هر message شامل `farm_uuid` است. + +### خطاها + +- اگر `farm_uuid` نفرستید: `400` +- اگر مزرعه پیدا نشود: در این ماژول معمولا `404` + +--- + +## 5) Fertilization Recommendation + +### Base Path + +```text +/api/fertilization-recommendation/ +``` + +### endpointهای فعال + +| Method | Path | توضیح | +|---|---|---| +| GET | `/api/fertilization-recommendation/config/?farm_uuid=...` | گرفتن داده اولیه فرم | +| POST | `/api/fertilization-recommendation/recommend/` | ساخت recommendation | +| GET | `/api/fertilization-recommendation/recommend/status/{task_id}/?farm_uuid=...` | وضعیت task | + +### breaking change + +`farm_uuid` برای این ماژول اجباری شده است. + +### GET config + +```http +GET /api/fertilization-recommendation/config/?farm_uuid= +``` + +### response config + +```json +{ + "status": "success", + "data": { + "farm_uuid": "550e8400-e29b-41d4-a716-446655440000", + "farmData": { + "soilType": "Loamy", + "organicMatter": "Medium (2.5%)", + "waterEC": "1.2 dS/m" + }, + "growthStages": [], + "cropOptions": [] + } +} +``` + +### POST recommend request + +```json +{ + "farm_uuid": "550e8400-e29b-41d4-a716-446655440000", + "crop_id": "wheat", + "growth_stage": "flowering", + "farm_data": { + "soilType": "Loamy", + "organicMatter": "Medium (2.5%)", + "waterEC": "1.2 dS/m" + } +} +``` + +### نکات مهم برای فرانت + +- اگر `farm_uuid` نفرستید، validation error می‌گیرید. +- در status endpoint هم `farm_uuid` باید query param باشد. +- بک‌اند درخواست/پاسخ recommendation را برای هر مزرعه ذخیره می‌کند، ولی این persistence فعلا response shape فرانت را تغییر نمی‌دهد. + +### نمونه خطا + +```json +{ + "code": 400, + "msg": "داده نامعتبر.", + "data": { + "farm_uuid": [ + "This field is required." + ] + } +} +``` + +--- + +## 6) Irrigation Recommendation + +### Base Path + +```text +/api/irrigation-recommendation/ +``` + +### endpointهای فعال + +| Method | Path | توضیح | +|---|---|---| +| GET | `/api/irrigation-recommendation/config/?farm_uuid=...` | گرفتن داده اولیه فرم | +| POST | `/api/irrigation-recommendation/recommend/` | ساخت recommendation | +| GET | `/api/irrigation-recommendation/recommend/status/{task_id}/?farm_uuid=...` | وضعیت task | + +### breaking change + +`farm_uuid` برای این ماژول هم اجباری شده است. + +### GET config + +```http +GET /api/irrigation-recommendation/config/?farm_uuid= +``` + +### response config + +```json +{ + "status": "success", + "data": { + "farm_uuid": "550e8400-e29b-41d4-a716-446655440000", + "farmInfo": { + "soilType": "Loamy", + "waterQuality": "Medium EC", + "climateZone": "Temperate" + }, + "cropOptions": [] + } +} +``` + +### POST recommend request + +```json +{ + "farm_uuid": "550e8400-e29b-41d4-a716-446655440000", + "crop_id": "wheat", + "farm_data": { + "soilType": "Loamy", + "waterQuality": "Medium EC", + "climateZone": "Temperate" + } +} +``` + +### نکات مهم برای فرانت + +- route جداگانه‌ای برای `task create` در urls فعال نیست؛ همان `POST /recommend/` را استفاده کنید. +- برای status هم حتما `farm_uuid` را در query بفرستید. +- response نهایی ممکن است شامل `plan`، `water_balance`، `raw_response` و `status` باشد. + +### نمونه خطا + +```json +{ + "code": 400, + "msg": "داده نامعتبر.", + "data": { + "farm_uuid": [ + "This field is required." + ] + } +} +``` + +--- + +## الگوی مشترک خطاها + +بسته به ماژول، شکل خطا یکسان نیست. فرانت بهتر است هر دو الگوی زیر را پشتیبانی کند: + +### الگوی 1 + +```json +{ + "status": "error", + "message": "Farm not found." +} +``` + +### الگوی 2 + +```json +{ + "farm_uuid": [ + "Farm not found." + ] +} +``` + +### الگوی 3 + +```json +{ + "detail": "Farm not found" +} +``` + +پس بهتر است parsing خطا فقط روی یک shape ثابت بسته نشود. + +--- + +## وضعیت احراز هویت + +برای endpointهای این فایل، فرانت بهتر است همیشه این هدرها را بفرستد: + +```http +Authorization: Bearer +Content-Type: application/json +``` + +به‌خصوص در این ماژول‌ها: + +- `farm-hub` +- `farm-dashboard` +- `farm-ai-assistant` +- `crop-zoning` +- `fertilization-recommendation` +- `irrigation-recommendation` + +--- + +## چک‌لیست تغییرات لازم در فرانت + +### اجباری + +- همه referenceهای `sensor_uuid` را به `farm_uuid` تغییر دهید. +- همه callهای `sensor-hub` را به `farm-hub` تغییر دهید. +- یک state مرکزی برای `selectedFarm` یا `currentFarm` داشته باشید. +- قبل از callهای dashboard/zoning/assistant/recommendation، `farm_uuid` مزرعه انتخابی را inject کنید. +- flow چت را از sync به async task-based تغییر دهید. + +### مهم + +- `GET /api/farm-dashboard/cards/` را با `GET /api/farm-dashboard/?farm_uuid=...` جایگزین کنید. +- در dashboard config patch، `farm_uuid` را داخل body بفرستید. +- اگر روی routeهای crop zoning قدیمی مثل `zones/water-need` حساب کرده‌اید، آن‌ها را از flow حذف کنید. + +### بهتر است + +- هندل خطا را tolerant بنویسید چون shape خطا بین ماژول‌ها یکسان نیست. +- `farm_uuid` برگشتی responseها را با state فرانت sync نگه دارید. +- بعد از create farm اگر response شامل `zoning` بود، مستقیم آن را برای map استفاده کنید. + +--- + +## جمع‌بندی نهایی + +اگر بخواهیم خیلی عملی بگوییم، فرانت برای این release فقط باید این سه اصل را رعایت کند: + +1. همه چیز را بر اساس `farm_uuid` مصرف کند، نه `sensor_uuid`. +2. تمام ماژول‌های اصلی را روی مزرعه‌ی انتخابی کاربر scope کند. +3. فقط routeهای فعال فعلی را مصرف کند، نه routeهایی که در داک‌های قدیمی یا پست‌من قبلی دیده می‌شوند. + +اگر لازم باشد، مرحله بعدی می‌تواند تولید یک نسخه‌ی کوتاه‌تر مخصوص frontend devها باشد که فقط شامل endpoint matrix و نمونه request/response باشد. diff --git a/config/settings.py b/config/settings.py index 25191fe..3f6e483 100644 --- a/config/settings.py +++ b/config/settings.py @@ -27,7 +27,8 @@ INSTALLED_APPS = [ "django.contrib.staticfiles", "auth.apps.AuthConfig", "account.apps.AccountConfig", - "sensor_hub.apps.SensorHubConfig", + "farm_hub.apps.FarmHubConfig", + "plant.apps.PlantConfig", "dashboard", "crop_zoning", "plant_simulator", @@ -146,9 +147,9 @@ EXTERNAL_SERVICES = { "base_url": os.getenv("AI_SERVICE_BASE_URL", ""), "api_key": os.getenv("AI_SERVICE_API_KEY", ""), }, - "sensor_hub": { - "base_url": os.getenv("SENSOR_HUB_SERVICE_BASE_URL", ""), - "api_key": os.getenv("SENSOR_HUB_SERVICE_API_KEY", ""), + "farm_hub": { + "base_url": os.getenv("FARM_HUB_SERVICE_BASE_URL", ""), + "api_key": os.getenv("FARM_HUB_SERVICE_API_KEY", ""), }, } diff --git a/config/urls.py b/config/urls.py index 986e659..d8e66f4 100644 --- a/config/urls.py +++ b/config/urls.py @@ -9,7 +9,7 @@ urlpatterns = [ path("api/docs/redoc/", SpectacularRedocView.as_view(url_name="schema"), name="redoc"), path("api/auth/", include("auth.urls")), path("api/account/", include("account.urls")), - path("api/sensor-hub/", include("sensor_hub.urls")), + path("api/farm-hub/", include("farm_hub.urls")), path("api/farm-dashboard-config/", include("dashboard.urls_config")), path("api/farm-dashboard/", include("dashboard.urls")), path("api/crop-zoning/", include("crop_zoning.urls")), diff --git a/crop_zoning/CROP_ZONING_CODE_LOGIC.md b/crop_zoning/CROP_ZONING_CODE_LOGIC.md index efb85f1..26493c0 100644 --- a/crop_zoning/CROP_ZONING_CODE_LOGIC.md +++ b/crop_zoning/CROP_ZONING_CODE_LOGIC.md @@ -42,7 +42,7 @@ ### کار این view -- `sensor_uuid` را از query params می‌گیرد. +- `farm_uuid` را از query params می‌گیرد. - `page` و `page_size` را هم از query params می‌گیرد. - از service می‌خواهد مطمئن شود برای این sensor یک area معتبر و zoneهای آن وجود دارند. - اگر zoneها وجود نداشته باشند، ساخته می‌شوند. @@ -51,7 +51,7 @@ ### ورودی‌های `AreaView` -- `sensor_uuid`: اجباری +- `farm_uuid`: اجباری - `page`: اختیاری، پیش‌فرض `1` - `page_size`: اختیاری، پیش‌فرض `10` @@ -68,8 +68,8 @@ اگر هر کدام از این موارد رخ بدهد، خطای `400` داده می‌شود: -- `sensor_uuid` ارسال نشده باشد -- `sensor_uuid` معتبر نباشد یا sensor پیدا نشود +- `farm_uuid` ارسال نشده باشد +- `farm_uuid` معتبر نباشد یا farm پیدا نشود - `page` نامعتبر باشد - `page_size` نامعتبر باشد @@ -107,7 +107,7 @@ ### تفاوت با `AreaView` -- `AreaView` بر اساس `sensor_uuid` کار می‌کند و وضعیت taskها را هم برمی‌گرداند. +- `AreaView` بر اساس `farm_uuid` کار می‌کند و وضعیت taskها را هم برمی‌گرداند. - `ZonesInitialView` بیشتر برای ساخت اولیه zoneها از روی polygon مناسب است. --- @@ -532,14 +532,14 @@ metrics را داخل مدل‌های مختلف ذخیره می‌کند: اگر area در دیتابیس وجود داشته باشد ولی zoneهایش از بین رفته باشند یا ساخته نشده باشند، دوباره از روی geometry آن zoneها را می‌سازد. -### `get_sensor_for_uuid(sensor_uuid)` +### `get_farm_for_uuid(farm_uuid)` اعتبارسنجی می‌کند که: -- `sensor_uuid` ارسال شده باشد -- sensor واقعا در دیتابیس وجود داشته باشد +- `farm_uuid` ارسال شده باشد +- farm واقعا در دیتابیس وجود داشته باشد -### `ensure_latest_area_ready_for_processing(sensor_uuid, area_feature=None)` +### `ensure_latest_area_ready_for_processing(farm_uuid, area_feature=None)` این یکی از مهم‌ترین توابع کل فایل است. @@ -648,7 +648,7 @@ payload ساده‌تر برای endpoint اولیه zoneها می‌سازد. اگر بخواهیم کل flow را از ابتدا تا انتها خیلی ساده توضیح بدهیم: -1. فرانت `sensor_uuid` و احتمالا `page` و `page_size` را می‌فرستد. +1. فرانت `farm_uuid` و احتمالا `page` و `page_size` را می‌فرستد. 2. `AreaView` پارامترها را می‌خواند. 3. `ensure_latest_area_ready_for_processing` اجرا می‌شود. 4. اگر area وجود نداشته باشد، area و zoneها ساخته می‌شوند. @@ -714,7 +714,7 @@ payload ساده‌تر برای endpoint اولیه zoneها می‌سازد. ### `_request()` -یک request استاندارد برای `AreaView` با `sensor_uuid` معتبر می‌سازد. +یک request استاندارد برای `AreaView` با `farm_uuid` معتبر می‌سازد. ### `_request_with_pagination(page, page_size)` @@ -724,9 +724,9 @@ payload ساده‌تر برای endpoint اولیه zoneها می‌سازد. ### تست‌های اصلی `AreaView` -#### `test_get_requires_sensor_uuid` +#### `test_get_requires_farm_uuid` -بررسی می‌کند اگر `sensor_uuid` ارسال نشود، پاسخ `400` برگردد. +بررسی می‌کند اگر `farm_uuid` ارسال نشود، پاسخ `400` برگردد. #### `test_get_returns_pending_task_status_until_all_zones_complete` diff --git a/crop_zoning/CROP_ZONING_FRONTEND_API.md b/crop_zoning/CROP_ZONING_FRONTEND_API.md index 3c69f78..ce2737e 100644 --- a/crop_zoning/CROP_ZONING_FRONTEND_API.md +++ b/crop_zoning/CROP_ZONING_FRONTEND_API.md @@ -20,7 +20,7 @@ Content-Type: application/json ## Flow پیشنهادی فرانت -1. ابتدا `GET /area/` را با `sensor_uuid` صدا بزنید. +1. ابتدا `GET /area/` را با `farm_uuid` صدا بزنید. 2. اگر `task.status` برابر `PENDING` یا `PROCESSING` بود، polling انجام دهید. 3. وقتی `task.status` برابر `SUCCESS` شد: - `area` را برای polygon اصلی زمین استفاده کنید. @@ -29,7 +29,7 @@ Content-Type: application/json ## وضعیت‌های Task -- `IDLE`: هنوز area/taskی برای سنسور وجود ندارد. +- `IDLE`: هنوز area/taskی برای مزرعه وجود ندارد. - `PENDING`: تسک ساخته شده ولی پردازش هنوز شروع نشده یا در صف است. - `PROCESSING`: بخشی از زون‌ها در حال پردازش هستند یا برخی کامل شده‌اند. - `SUCCESS`: همه زون‌ها کامل پردازش شده‌اند. @@ -51,18 +51,18 @@ Content-Type: application/json ## 1) Get Area ```http -GET /api/crop-zoning/area/?sensor_uuid=&page=1&page_size=10 +GET /api/crop-zoning/area/?farm_uuid=&page=1&page_size=10 ``` ### Query Params -- `sensor_uuid`: اجباری، UUID سنسور +- `farm_uuid`: اجباری، UUID مزرعه - `page`: اختیاری، شماره صفحه زون‌ها. پیش‌فرض `1` - `page_size`: اختیاری، تعداد زون در هر صفحه. پیش‌فرض `10` ### کاربرد -- گرفتن آخرین area مربوط به سنسور +- گرفتن آخرین area مربوط به مزرعه - ساخت area و zoneها در صورت نبود داده - دریافت وضعیت task - دریافت لیست `zones` به صورت صفحه‌بندی‌شده برای نمایش روی نقشه @@ -175,13 +175,13 @@ GET /api/crop-zoning/area/?sensor_uuid=&page=1&page_size=10 #### صفحه اول با 10 زون در هر صفحه ```http -GET /api/crop-zoning/area/?sensor_uuid=&page=1&page_size=10 +GET /api/crop-zoning/area/?farm_uuid=&page=1&page_size=10 ``` #### صفحه سوم با 25 زون در هر صفحه ```http -GET /api/crop-zoning/area/?sensor_uuid=&page=3&page_size=25 +GET /api/crop-zoning/area/?farm_uuid=&page=3&page_size=25 ``` ### فیلدهای مهم `zones` @@ -215,21 +215,21 @@ GET /api/crop-zoning/area/?sensor_uuid=&page=3&page_size=25 ### خطاها -#### وقتی `sensor_uuid` ارسال نشود +#### وقتی `farm_uuid` ارسال نشود ```json { "status": "error", - "message": "sensor_uuid is required." + "message": "farm_uuid is required." } ``` -#### وقتی سنسور پیدا نشود +#### وقتی مزرعه پیدا نشود ```json { "status": "error", - "message": "Sensor not found." + "message": "Farm not found." } ``` diff --git a/crop_zoning/migrations/0004_croparea_sensor.py b/crop_zoning/migrations/0004_croparea_farm.py similarity index 81% rename from crop_zoning/migrations/0004_croparea_sensor.py rename to crop_zoning/migrations/0004_croparea_farm.py index 83f49e5..613d981 100644 --- a/crop_zoning/migrations/0004_croparea_sensor.py +++ b/crop_zoning/migrations/0004_croparea_farm.py @@ -4,20 +4,20 @@ import django.db.models.deletion class Migration(migrations.Migration): dependencies = [ - ("sensor_hub", "0001_initial"), + ("farm_hub", "0002_seed_default_catalog"), ("crop_zoning", "0003_zone_processing_and_analysis"), ] operations = [ migrations.AddField( model_name="croparea", - name="sensor", + name="farm", field=models.ForeignKey( blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name="crop_areas", - to="sensor_hub.sensor", + to="farm_hub.farmhub", ), ), ] diff --git a/crop_zoning/models.py b/crop_zoning/models.py index 9435dc1..3e5fd7e 100644 --- a/crop_zoning/models.py +++ b/crop_zoning/models.py @@ -1,13 +1,13 @@ import uuid from django.db import models -from sensor_hub.models import Sensor +from farm_hub.models import FarmHub class CropArea(models.Model): uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True) - sensor = models.ForeignKey( - Sensor, + farm = models.ForeignKey( + FarmHub, on_delete=models.CASCADE, related_name="crop_areas", null=True, @@ -74,7 +74,6 @@ class CropZone(models.Model): return self.zone_id - class CropProduct(models.Model): product_id = models.CharField(max_length=64, unique=True) label = models.CharField(max_length=255) @@ -205,7 +204,6 @@ class CropZoneCultivationRiskLayer(models.Model): ordering = ["crop_zone_id"] - class CropZoneAnalysis(models.Model): source = models.CharField(max_length=64, blank=True, default="") external_record_id = models.CharField(max_length=64, blank=True, default="") @@ -224,4 +222,3 @@ class CropZoneAnalysis(models.Model): class Meta: db_table = "crop_zone_analyses" ordering = ["crop_zone_id"] - diff --git a/crop_zoning/postman/crop_zoning.json b/crop_zoning/postman/crop_zoning.json index 940e20a..0f8b118 100644 --- a/crop_zoning/postman/crop_zoning.json +++ b/crop_zoning/postman/crop_zoning.json @@ -1 +1 @@ -{"info":{"name":"Crop Zoning","schema":"https://schema.getpostman.com/json/collection/v2.1.0/collection.json","description":"Crop Zoning API. GET area. GET products. POST zones/initial (crops). POST zones/water-need, soil-quality, cultivation-risk (layer data). GET zones/:zoneId/details (detail panel)."},"item":[{"name":"Get area (GET)","request":{"method":"GET","header":[{"key":"Content-Type","value":"application/json"}],"url":"{{baseUrl}}/api/crop-zoning/area/?sensor_uuid={{sensorUuid}}","description":"Returns task status and area for the requested sensor. If the sensor has no crop-zoning data yet, it creates data and dispatches a Celery task. Only one active task is allowed per sensor."},"response":[{"name":"Success","status":"OK","code":200,"body":"{\n \"status\": \"success\",\n \"data\": {\n \"area\": {\n \"type\": \"Feature\",\n \"properties\": {},\n \"geometry\": {\n \"type\": \"Polygon\",\n \"coordinates\": [[[51.38, 35.68], [51.405, 35.672], [51.41, 35.695], [51.385, 35.71], [51.365, 35.688], [51.38, 35.68]]]\n }\n }\n }\n}"}]},{"name":"Get products (GET)","request":{"method":"GET","header":[{"key":"Content-Type","value":"application/json"}],"url":"{{baseUrl}}/api/crop-zoning/products/","description":"Returns static list of cultivable products (id, label, color) for Legend and zone detail panel."},"response":[{"name":"Success","status":"OK","code":200,"body":"{\n \"status\": \"success\",\n \"data\": {\n \"products\": [\n {\"id\": \"wheat\", \"label\": \"گندم\", \"color\": \"#6bcb77\"},\n {\"id\": \"canola\", \"label\": \"کلزا\", \"color\": \"#ffd93d\"},\n {\"id\": \"saffron\", \"label\": \"زعفران\", \"color\": \"#9b59b6\"}\n ]\n }\n}"}]},{"name":"Zones initial (POST)","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n \"zones\": {\n \"type\": \"FeatureCollection\",\n \"features\": [\n {\n \"type\": \"Feature\",\n \"geometry\": {\n \"type\": \"Polygon\",\n \"coordinates\": [[[51.38, 35.68], [51.3815, 35.68], [51.3815, 35.6815], [51.38, 35.6815], [51.38, 35.68]]]\n },\n \"properties\": {\"index\": 0}\n },\n {\n \"type\": \"Feature\",\n \"geometry\": {\n \"type\": \"Polygon\",\n \"coordinates\": [[[51.3815, 35.68], [51.383, 35.68], [51.383, 35.6815], [51.3815, 35.6815], [51.3815, 35.68]]]\n },\n \"properties\": {\"index\": 1}\n },\n {\n \"type\": \"Feature\",\n \"geometry\": {\n \"type\": \"Polygon\",\n \"coordinates\": [[[51.38, 35.6815], [51.3815, 35.6815], [51.3815, 35.683], [51.38, 35.683], [51.38, 35.6815]]]\n },\n \"properties\": {\"index\": 2}\n }\n ]\n },\n \"products\": [\"wheat\", \"canola\", \"saffron\"]\n}"},"url":"{{baseUrl}}/api/crop-zoning/zones/initial/","description":"Body: zones (FeatureCollection of grid squares), optional products. Returns initial data for map and hover/tooltip (no reason, criteria)."},"response":[{"name":"Success","status":"OK","code":200,"body":"{\n \"status\": \"success\",\n \"data\": {\n \"total_area_hectares\": 23.45,\n \"total_area_sqm\": 234500,\n \"zone_count\": 3,\n \"zones\": [\n {\n \"zoneId\": \"zone-0\",\n \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.38, 35.68], [51.3815, 35.68], [51.3815, 35.6815], [51.38, 35.6815], [51.38, 35.68]]]},\n \"crop\": \"wheat\",\n \"matchPercent\": 85,\n \"waterNeed\": \"۴۵۰۰-۵۵۰۰ m³/ha\",\n \"estimatedProfit\": \"۱۵-۲۵ میلیون/هکتار\"\n },\n {\n \"zoneId\": \"zone-1\",\n \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.3815, 35.68], [51.383, 35.68], [51.383, 35.6815], [51.3815, 35.6815], [51.3815, 35.68]]]},\n \"crop\": \"canola\",\n \"matchPercent\": 78,\n \"waterNeed\": \"۵۰۰۰-۶۰۰۰ m³/ha\",\n \"estimatedProfit\": \"۲۰-۳۵ میلیون/هکتار\"\n },\n {\n \"zoneId\": \"zone-2\",\n \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.38, 35.6815], [51.3815, 35.6815], [51.3815, 35.683], [51.38, 35.683], [51.38, 35.6815]]]},\n \"crop\": \"saffron\",\n \"matchPercent\": 92,\n \"waterNeed\": \"۳۰۰۰-۴۰۰۰ m³/ha\",\n \"estimatedProfit\": \"۵۰-۱۵۰ میلیون/هکتار\"\n }\n ]\n }\n}"}]},{"name":"Zones water-need (POST)","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n \"zones\": {\n \"type\": \"FeatureCollection\",\n \"features\": [\n {\"type\": \"Feature\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.38, 35.68], [51.3815, 35.68], [51.3815, 35.6815], [51.38, 35.6815], [51.38, 35.68]]]}, \"properties\": {\"index\": 0}},\n {\"type\": \"Feature\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.3815, 35.68], [51.383, 35.68], [51.383, 35.6815], [51.3815, 35.6815], [51.3815, 35.68]]]}, \"properties\": {\"index\": 1}},\n {\"type\": \"Feature\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.38, 35.6815], [51.3815, 35.6815], [51.3815, 35.683], [51.38, 35.683], [51.38, 35.6815]]]}, \"properties\": {\"index\": 2}}\n ]\n }\n}"},"url":"{{baseUrl}}/api/crop-zoning/zones/water-need/","description":"Returns water need per zone for water need layer (level, value, color)."},"response":[{"name":"Success","status":"OK","code":200,"body":"{\n \"status\": \"success\",\n \"data\": {\n \"zones\": [\n {\"zoneId\": \"zone-0\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.38, 35.68], [51.3815, 35.68], [51.3815, 35.6815], [51.38, 35.6815], [51.38, 35.68]]]}, \"level\": \"medium\", \"value\": \"۴۵۰۰-۵۵۰۰ m³/ha\", \"color\": \"#0ea5e9\"},\n {\"zoneId\": \"zone-1\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.3815, 35.68], [51.383, 35.68], [51.383, 35.6815], [51.3815, 35.6815], [51.3815, 35.68]]]}, \"level\": \"high\", \"value\": \"۵۰۰۰-۶۰۰۰ m³/ha\", \"color\": \"#0369a1\"},\n {\"zoneId\": \"zone-2\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.38, 35.6815], [51.3815, 35.6815], [51.3815, 35.683], [51.38, 35.683], [51.38, 35.6815]]]}, \"level\": \"low\", \"value\": \"۳۰۰۰-۴۰۰۰ m³/ha\", \"color\": \"#7dd3fc\"}\n ]\n }\n}"}]},{"name":"Zones soil-quality (POST)","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n \"zones\": {\n \"type\": \"FeatureCollection\",\n \"features\": [\n {\"type\": \"Feature\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.38, 35.68], [51.3815, 35.68], [51.3815, 35.6815], [51.38, 35.6815], [51.38, 35.68]]]}, \"properties\": {\"index\": 0}},\n {\"type\": \"Feature\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.3815, 35.68], [51.383, 35.68], [51.383, 35.6815], [51.3815, 35.6815], [51.3815, 35.68]]]}, \"properties\": {\"index\": 1}},\n {\"type\": \"Feature\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.38, 35.6815], [51.3815, 35.6815], [51.3815, 35.683], [51.38, 35.683], [51.38, 35.6815]]]}, \"properties\": {\"index\": 2}}\n ]\n }\n}"},"url":"{{baseUrl}}/api/crop-zoning/zones/soil-quality/","description":"Returns soil quality per zone for soil quality layer (level, score, color)."},"response":[{"name":"Success","status":"OK","code":200,"body":"{\n \"status\": \"success\",\n \"data\": {\n \"zones\": [\n {\"zoneId\": \"zone-0\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.38, 35.68], [51.3815, 35.68], [51.3815, 35.6815], [51.38, 35.6815], [51.38, 35.68]]]}, \"level\": \"high\", \"score\": 88, \"color\": \"#22c55e\"},\n {\"zoneId\": \"zone-1\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.3815, 35.68], [51.383, 35.68], [51.383, 35.6815], [51.3815, 35.6815], [51.3815, 35.68]]]}, \"level\": \"medium\", \"score\": 62, \"color\": \"#eab308\"},\n {\"zoneId\": \"zone-2\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.38, 35.6815], [51.3815, 35.6815], [51.3815, 35.683], [51.38, 35.683], [51.38, 35.6815]]]}, \"level\": \"high\", \"score\": 95, \"color\": \"#22c55e\"}\n ]\n }\n}"}]},{"name":"Zones cultivation-risk (POST)","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n \"zones\": {\n \"type\": \"FeatureCollection\",\n \"features\": [\n {\"type\": \"Feature\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.38, 35.68], [51.3815, 35.68], [51.3815, 35.6815], [51.38, 35.6815], [51.38, 35.68]]]}, \"properties\": {\"index\": 0}},\n {\"type\": \"Feature\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.3815, 35.68], [51.383, 35.68], [51.383, 35.6815], [51.3815, 35.6815], [51.3815, 35.68]]]}, \"properties\": {\"index\": 1}},\n {\"type\": \"Feature\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.38, 35.6815], [51.3815, 35.6815], [51.3815, 35.683], [51.38, 35.683], [51.38, 35.6815]]]}, \"properties\": {\"index\": 2}}\n ]\n }\n}"},"url":"{{baseUrl}}/api/crop-zoning/zones/cultivation-risk/","description":"Returns cultivation risk per zone for risk layer (level, color)."},"response":[{"name":"Success","status":"OK","code":200,"body":"{\n \"status\": \"success\",\n \"data\": {\n \"zones\": [\n {\"zoneId\": \"zone-0\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.38, 35.68], [51.3815, 35.68], [51.3815, 35.6815], [51.38, 35.6815], [51.38, 35.68]]]}, \"level\": \"low\", \"color\": \"#22c55e\"},\n {\"zoneId\": \"zone-1\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.3815, 35.68], [51.383, 35.68], [51.383, 35.6815], [51.3815, 35.6815], [51.3815, 35.68]]]}, \"level\": \"medium\", \"color\": \"#f59e0b\"},\n {\"zoneId\": \"zone-2\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.38, 35.6815], [51.3815, 35.6815], [51.3815, 35.683], [51.38, 35.683], [51.38, 35.6815]]]}, \"level\": \"low\", \"color\": \"#22c55e\"}\n ]\n }\n}"}]},{"name":"Zone details (GET)","request":{"method":"GET","header":[{"key":"Content-Type","value":"application/json"}],"url":"{{baseUrl}}/api/crop-zoning/zones/zone-0/details/","description":"Returns detail data for one zone (reason, criteria, area_hectares) for detail panel and radar chart."},"response":[{"name":"Success","status":"OK","code":200,"body":"{\n \"status\": \"success\",\n \"data\": {\n \"zoneId\": \"zone-0\",\n \"crop\": \"wheat\",\n \"matchPercent\": 85,\n \"waterNeed\": \"۴۵۰۰-۵۵۰۰ m³/ha\",\n \"estimatedProfit\": \"۱۵-۲۵ میلیون/هکتار\",\n \"reason\": \"دمای مناسب، خاک حاصلخیز، دسترسی به آب کافی\",\n \"criteria\": [{\"name\": \"دما\", \"value\": 82}, {\"name\": \"بارش\", \"value\": 75}, {\"name\": \"خاک\", \"value\": 88}, {\"name\": \"آب\", \"value\": 70}],\n \"area_hectares\": 2.25\n }\n}"}]},{"name":"Zone details zone-2 (GET)","request":{"method":"GET","header":[{"key":"Content-Type","value":"application/json"}],"url":"{{baseUrl}}/api/crop-zoning/zones/zone-2/details/","description":"Returns detail data for zone-2 (saffron)."},"response":[{"name":"Success","status":"OK","code":200,"body":"{\n \"status\": \"success\",\n \"data\": {\n \"zoneId\": \"zone-2\",\n \"crop\": \"saffron\",\n \"matchPercent\": 92,\n \"waterNeed\": \"۳۰۰۰-۴۰۰۰ m³/ha\",\n \"estimatedProfit\": \"۵۰-۱۵۰ میلیون/هکتار\",\n \"reason\": \"ارتفاع و آب و هوای خشک مناسب، پتانسیل سود بالا\",\n \"criteria\": [{\"name\": \"دما\", \"value\": 90}, {\"name\": \"بارش\", \"value\": 65}, {\"name\": \"خاک\", \"value\": 95}, {\"name\": \"آب\", \"value\": 85}],\n \"area_hectares\": 2.25\n }\n}"}]}],"variable":[{"key":"baseUrl","value":"http://localhost:8000"},{"key":"sensorUuid","value":"550e8400-e29b-41d4-a716-446655440000"}]} +{"info":{"name":"Crop Zoning","schema":"https://schema.getpostman.com/json/collection/v2.1.0/collection.json","description":"Crop Zoning API. GET area. GET products. POST zones/initial (crops). POST zones/water-need, soil-quality, cultivation-risk (layer data). GET zones/:zoneId/details (detail panel)."},"item":[{"name":"Get area (GET)","request":{"method":"GET","header":[{"key":"Content-Type","value":"application/json"}],"url":"{{baseUrl}}/api/crop-zoning/area/?farm_uuid={{farmUuid}}","description":"Returns task status and area for the requested farm. If the farm has no crop-zoning data yet, it creates data and dispatches a Celery task. Only one active task is allowed per farm."},"response":[{"name":"Success","status":"OK","code":200,"body":"{\n \"status\": \"success\",\n \"data\": {\n \"area\": {\n \"type\": \"Feature\",\n \"properties\": {},\n \"geometry\": {\n \"type\": \"Polygon\",\n \"coordinates\": [[[51.38, 35.68], [51.405, 35.672], [51.41, 35.695], [51.385, 35.71], [51.365, 35.688], [51.38, 35.68]]]\n }\n }\n }\n}"}]},{"name":"Get products (GET)","request":{"method":"GET","header":[{"key":"Content-Type","value":"application/json"}],"url":"{{baseUrl}}/api/crop-zoning/products/","description":"Returns static list of cultivable products (id, label, color) for Legend and zone detail panel."},"response":[{"name":"Success","status":"OK","code":200,"body":"{\n \"status\": \"success\",\n \"data\": {\n \"products\": [\n {\"id\": \"wheat\", \"label\": \"گندم\", \"color\": \"#6bcb77\"},\n {\"id\": \"canola\", \"label\": \"کلزا\", \"color\": \"#ffd93d\"},\n {\"id\": \"saffron\", \"label\": \"زعفران\", \"color\": \"#9b59b6\"}\n ]\n }\n}"}]},{"name":"Zones initial (POST)","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n \"zones\": {\n \"type\": \"FeatureCollection\",\n \"features\": [\n {\n \"type\": \"Feature\",\n \"geometry\": {\n \"type\": \"Polygon\",\n \"coordinates\": [[[51.38, 35.68], [51.3815, 35.68], [51.3815, 35.6815], [51.38, 35.6815], [51.38, 35.68]]]\n },\n \"properties\": {\"index\": 0}\n },\n {\n \"type\": \"Feature\",\n \"geometry\": {\n \"type\": \"Polygon\",\n \"coordinates\": [[[51.3815, 35.68], [51.383, 35.68], [51.383, 35.6815], [51.3815, 35.6815], [51.3815, 35.68]]]\n },\n \"properties\": {\"index\": 1}\n },\n {\n \"type\": \"Feature\",\n \"geometry\": {\n \"type\": \"Polygon\",\n \"coordinates\": [[[51.38, 35.6815], [51.3815, 35.6815], [51.3815, 35.683], [51.38, 35.683], [51.38, 35.6815]]]\n },\n \"properties\": {\"index\": 2}\n }\n ]\n },\n \"products\": [\"wheat\", \"canola\", \"saffron\"]\n}"},"url":"{{baseUrl}}/api/crop-zoning/zones/initial/","description":"Body: zones (FeatureCollection of grid squares), optional products. Returns initial data for map and hover/tooltip (no reason, criteria)."},"response":[{"name":"Success","status":"OK","code":200,"body":"{\n \"status\": \"success\",\n \"data\": {\n \"total_area_hectares\": 23.45,\n \"total_area_sqm\": 234500,\n \"zone_count\": 3,\n \"zones\": [\n {\n \"zoneId\": \"zone-0\",\n \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.38, 35.68], [51.3815, 35.68], [51.3815, 35.6815], [51.38, 35.6815], [51.38, 35.68]]]},\n \"crop\": \"wheat\",\n \"matchPercent\": 85,\n \"waterNeed\": \"۴۵۰۰-۵۵۰۰ m³/ha\",\n \"estimatedProfit\": \"۱۵-۲۵ میلیون/هکتار\"\n },\n {\n \"zoneId\": \"zone-1\",\n \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.3815, 35.68], [51.383, 35.68], [51.383, 35.6815], [51.3815, 35.6815], [51.3815, 35.68]]]},\n \"crop\": \"canola\",\n \"matchPercent\": 78,\n \"waterNeed\": \"۵۰۰۰-۶۰۰۰ m³/ha\",\n \"estimatedProfit\": \"۲۰-۳۵ میلیون/هکتار\"\n },\n {\n \"zoneId\": \"zone-2\",\n \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.38, 35.6815], [51.3815, 35.6815], [51.3815, 35.683], [51.38, 35.683], [51.38, 35.6815]]]},\n \"crop\": \"saffron\",\n \"matchPercent\": 92,\n \"waterNeed\": \"۳۰۰۰-۴۰۰۰ m³/ha\",\n \"estimatedProfit\": \"۵۰-۱۵۰ میلیون/هکتار\"\n }\n ]\n }\n}"}]},{"name":"Zones water-need (POST)","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n \"zones\": {\n \"type\": \"FeatureCollection\",\n \"features\": [\n {\"type\": \"Feature\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.38, 35.68], [51.3815, 35.68], [51.3815, 35.6815], [51.38, 35.6815], [51.38, 35.68]]]}, \"properties\": {\"index\": 0}},\n {\"type\": \"Feature\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.3815, 35.68], [51.383, 35.68], [51.383, 35.6815], [51.3815, 35.6815], [51.3815, 35.68]]]}, \"properties\": {\"index\": 1}},\n {\"type\": \"Feature\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.38, 35.6815], [51.3815, 35.6815], [51.3815, 35.683], [51.38, 35.683], [51.38, 35.6815]]]}, \"properties\": {\"index\": 2}}\n ]\n }\n}"},"url":"{{baseUrl}}/api/crop-zoning/zones/water-need/","description":"Returns water need per zone for water need layer (level, value, color)."},"response":[{"name":"Success","status":"OK","code":200,"body":"{\n \"status\": \"success\",\n \"data\": {\n \"zones\": [\n {\"zoneId\": \"zone-0\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.38, 35.68], [51.3815, 35.68], [51.3815, 35.6815], [51.38, 35.6815], [51.38, 35.68]]]}, \"level\": \"medium\", \"value\": \"۴۵۰۰-۵۵۰۰ m³/ha\", \"color\": \"#0ea5e9\"},\n {\"zoneId\": \"zone-1\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.3815, 35.68], [51.383, 35.68], [51.383, 35.6815], [51.3815, 35.6815], [51.3815, 35.68]]]}, \"level\": \"high\", \"value\": \"۵۰۰۰-۶۰۰۰ m³/ha\", \"color\": \"#0369a1\"},\n {\"zoneId\": \"zone-2\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.38, 35.6815], [51.3815, 35.6815], [51.3815, 35.683], [51.38, 35.683], [51.38, 35.6815]]]}, \"level\": \"low\", \"value\": \"۳۰۰۰-۴۰۰۰ m³/ha\", \"color\": \"#7dd3fc\"}\n ]\n }\n}"}]},{"name":"Zones soil-quality (POST)","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n \"zones\": {\n \"type\": \"FeatureCollection\",\n \"features\": [\n {\"type\": \"Feature\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.38, 35.68], [51.3815, 35.68], [51.3815, 35.6815], [51.38, 35.6815], [51.38, 35.68]]]}, \"properties\": {\"index\": 0}},\n {\"type\": \"Feature\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.3815, 35.68], [51.383, 35.68], [51.383, 35.6815], [51.3815, 35.6815], [51.3815, 35.68]]]}, \"properties\": {\"index\": 1}},\n {\"type\": \"Feature\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.38, 35.6815], [51.3815, 35.6815], [51.3815, 35.683], [51.38, 35.683], [51.38, 35.6815]]]}, \"properties\": {\"index\": 2}}\n ]\n }\n}"},"url":"{{baseUrl}}/api/crop-zoning/zones/soil-quality/","description":"Returns soil quality per zone for soil quality layer (level, score, color)."},"response":[{"name":"Success","status":"OK","code":200,"body":"{\n \"status\": \"success\",\n \"data\": {\n \"zones\": [\n {\"zoneId\": \"zone-0\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.38, 35.68], [51.3815, 35.68], [51.3815, 35.6815], [51.38, 35.6815], [51.38, 35.68]]]}, \"level\": \"high\", \"score\": 88, \"color\": \"#22c55e\"},\n {\"zoneId\": \"zone-1\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.3815, 35.68], [51.383, 35.68], [51.383, 35.6815], [51.3815, 35.6815], [51.3815, 35.68]]]}, \"level\": \"medium\", \"score\": 62, \"color\": \"#eab308\"},\n {\"zoneId\": \"zone-2\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.38, 35.6815], [51.3815, 35.6815], [51.3815, 35.683], [51.38, 35.683], [51.38, 35.6815]]]}, \"level\": \"high\", \"score\": 95, \"color\": \"#22c55e\"}\n ]\n }\n}"}]},{"name":"Zones cultivation-risk (POST)","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n \"zones\": {\n \"type\": \"FeatureCollection\",\n \"features\": [\n {\"type\": \"Feature\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.38, 35.68], [51.3815, 35.68], [51.3815, 35.6815], [51.38, 35.6815], [51.38, 35.68]]]}, \"properties\": {\"index\": 0}},\n {\"type\": \"Feature\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.3815, 35.68], [51.383, 35.68], [51.383, 35.6815], [51.3815, 35.6815], [51.3815, 35.68]]]}, \"properties\": {\"index\": 1}},\n {\"type\": \"Feature\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.38, 35.6815], [51.3815, 35.6815], [51.3815, 35.683], [51.38, 35.683], [51.38, 35.6815]]]}, \"properties\": {\"index\": 2}}\n ]\n }\n}"},"url":"{{baseUrl}}/api/crop-zoning/zones/cultivation-risk/","description":"Returns cultivation risk per zone for risk layer (level, color)."},"response":[{"name":"Success","status":"OK","code":200,"body":"{\n \"status\": \"success\",\n \"data\": {\n \"zones\": [\n {\"zoneId\": \"zone-0\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.38, 35.68], [51.3815, 35.68], [51.3815, 35.6815], [51.38, 35.6815], [51.38, 35.68]]]}, \"level\": \"low\", \"color\": \"#22c55e\"},\n {\"zoneId\": \"zone-1\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.3815, 35.68], [51.383, 35.68], [51.383, 35.6815], [51.3815, 35.6815], [51.3815, 35.68]]]}, \"level\": \"medium\", \"color\": \"#f59e0b\"},\n {\"zoneId\": \"zone-2\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.38, 35.6815], [51.3815, 35.6815], [51.3815, 35.683], [51.38, 35.683], [51.38, 35.6815]]]}, \"level\": \"low\", \"color\": \"#22c55e\"}\n ]\n }\n}"}]},{"name":"Zone details (GET)","request":{"method":"GET","header":[{"key":"Content-Type","value":"application/json"}],"url":"{{baseUrl}}/api/crop-zoning/zones/zone-0/details/","description":"Returns detail data for one zone (reason, criteria, area_hectares) for detail panel and radar chart."},"response":[{"name":"Success","status":"OK","code":200,"body":"{\n \"status\": \"success\",\n \"data\": {\n \"zoneId\": \"zone-0\",\n \"crop\": \"wheat\",\n \"matchPercent\": 85,\n \"waterNeed\": \"۴۵۰۰-۵۵۰۰ m³/ha\",\n \"estimatedProfit\": \"۱۵-۲۵ میلیون/هکتار\",\n \"reason\": \"دمای مناسب، خاک حاصلخیز، دسترسی به آب کافی\",\n \"criteria\": [{\"name\": \"دما\", \"value\": 82}, {\"name\": \"بارش\", \"value\": 75}, {\"name\": \"خاک\", \"value\": 88}, {\"name\": \"آب\", \"value\": 70}],\n \"area_hectares\": 2.25\n }\n}"}]},{"name":"Zone details zone-2 (GET)","request":{"method":"GET","header":[{"key":"Content-Type","value":"application/json"}],"url":"{{baseUrl}}/api/crop-zoning/zones/zone-2/details/","description":"Returns detail data for zone-2 (saffron)."},"response":[{"name":"Success","status":"OK","code":200,"body":"{\n \"status\": \"success\",\n \"data\": {\n \"zoneId\": \"zone-2\",\n \"crop\": \"saffron\",\n \"matchPercent\": 92,\n \"waterNeed\": \"۳۰۰۰-۴۰۰۰ m³/ha\",\n \"estimatedProfit\": \"۵۰-۱۵۰ میلیون/هکتار\",\n \"reason\": \"ارتفاع و آب و هوای خشک مناسب، پتانسیل سود بالا\",\n \"criteria\": [{\"name\": \"دما\", \"value\": 90}, {\"name\": \"بارش\", \"value\": 65}, {\"name\": \"خاک\", \"value\": 95}, {\"name\": \"آب\", \"value\": 85}],\n \"area_hectares\": 2.25\n }\n}"}]}],"variable":[{"key":"baseUrl","value":"http://localhost:8000"},{"key":"farmUuid","value":"550e8400-e29b-41d4-a716-446655440000"}]} diff --git a/crop_zoning/services.py b/crop_zoning/services.py index 891b1be..8441b2e 100644 --- a/crop_zoning/services.py +++ b/crop_zoning/services.py @@ -9,7 +9,7 @@ from kombu.exceptions import OperationalError from django.db import transaction from django.db.models import Prefetch from django.utils import timezone -from sensor_hub.models import Sensor +from farm_hub.models import FarmHub from external_api_adapter.adapter import request as external_request @@ -852,20 +852,25 @@ def create_missing_zones_for_area(crop_area): return list(crop_area.zones.order_by("sequence", "id")) -def get_sensor_for_uuid(sensor_uuid): - if not sensor_uuid: - raise ValueError("sensor_uuid is required.") +def get_farm_for_uuid(farm_uuid, owner=None): + if not farm_uuid: + raise ValueError("farm_uuid is required.") + + filters = {"farm_uuid": farm_uuid} + if owner is not None: + filters["owner"] = owner + try: - return Sensor.objects.get(uuid_sensor=sensor_uuid) - except Sensor.DoesNotExist as exc: - raise ValueError("Sensor not found.") from exc + return FarmHub.objects.get(**filters) + except FarmHub.DoesNotExist as exc: + raise ValueError("Farm not found.") from exc -def ensure_latest_area_ready_for_processing(sensor_uuid, area_feature=None): - sensor = get_sensor_for_uuid(sensor_uuid) - latest_area = CropArea.objects.filter(sensor=sensor).order_by("-created_at", "-id").first() +def ensure_latest_area_ready_for_processing(farm_uuid, area_feature=None, owner=None): + farm = get_farm_for_uuid(farm_uuid, owner=owner) + latest_area = CropArea.objects.filter(farm=farm).order_by("-created_at", "-id").first() if latest_area is None: - latest_area, _ = create_zones_and_dispatch(area_feature or get_default_area_feature(), sensor=sensor) + latest_area, _ = create_zones_and_dispatch(area_feature or get_default_area_feature(), farm=farm) return latest_area zones = create_missing_zones_for_area(latest_area) @@ -889,7 +894,7 @@ def ensure_latest_area_ready_for_processing(sensor_uuid, area_feature=None): return CropArea.objects.get(id=latest_area.id) -def create_zones_and_dispatch(area_feature, cell_side_km=None, sensor=None): +def create_zones_and_dispatch(area_feature, cell_side_km=None, farm=None): ensure_products_exist() area_feature = normalize_area_feature(area_feature) zoning_result = split_area_into_zones(area_feature, cell_side_km=cell_side_km) @@ -897,7 +902,7 @@ def create_zones_and_dispatch(area_feature, cell_side_km=None, sensor=None): with transaction.atomic(): crop_area = CropArea.objects.create( - sensor=sensor, + farm=farm, geometry=area_data["geometry"], points=area_data["points"], center=area_data["center"], diff --git a/crop_zoning/tests.py b/crop_zoning/tests.py index 771f14f..9b4a012 100644 --- a/crop_zoning/tests.py +++ b/crop_zoning/tests.py @@ -1,16 +1,15 @@ +from datetime import timedelta from unittest.mock import patch -from kombu.exceptions import OperationalError - from django.contrib.auth import get_user_model from django.test import TestCase, override_settings from django.utils import timezone -from rest_framework.test import APIRequestFactory -from datetime import timedelta +from kombu.exceptions import OperationalError +from rest_framework.test import APIRequestFactory, force_authenticate from crop_zoning.models import CropArea, CropZone from crop_zoning.views import AreaView, ZonesInitialView -from sensor_hub.models import Sensor +from farm_hub.models import FarmHub, FarmType AREA_GEOJSON = { @@ -69,11 +68,19 @@ class AreaViewTests(TestCase): email="farmer@example.com", phone_number="09120000000", ) - self.sensor = Sensor.objects.create(owner=self.user, name="sensor-1") + self.other_user = get_user_model().objects.create_user( + username="other-farmer", + password="secret123", + email="other@example.com", + phone_number="09120000001", + ) + self.farm_type = FarmType.objects.create(name="زراعی") + self.farm = FarmHub.objects.create(owner=self.user, name="farm-1", farm_type=self.farm_type) + self.other_farm = FarmHub.objects.create(owner=self.other_user, name="farm-2", farm_type=self.farm_type) def _create_area(self, **kwargs): defaults = { - "sensor": self.sensor, + "farm": self.farm, "geometry": AREA_GEOJSON, "points": AREA_GEOJSON["geometry"]["coordinates"][0][:-1], "center": {"longitude": 51.40874867, "latitude": 35.69575533}, @@ -86,18 +93,32 @@ class AreaViewTests(TestCase): return CropArea.objects.create(**defaults) def _request(self): - return self.factory.get(f"/api/crop-zoning/area/?sensor_uuid={self.sensor.uuid_sensor}") + request = self.factory.get(f"/api/crop-zoning/area/?farm_uuid={self.farm.farm_uuid}") + force_authenticate(request, user=self.user) + return request def _request_with_pagination(self, page=1, page_size=10): - return self.factory.get( - f"/api/crop-zoning/area/?sensor_uuid={self.sensor.uuid_sensor}&page={page}&page_size={page_size}" + request = self.factory.get( + f"/api/crop-zoning/area/?farm_uuid={self.farm.farm_uuid}&page={page}&page_size={page_size}" ) + force_authenticate(request, user=self.user) + return request - def test_get_requires_sensor_uuid(self): + def test_get_requires_farm_uuid(self): request = self.factory.get("/api/crop-zoning/area/") + force_authenticate(request, user=self.user) response = AreaView.as_view()(request) self.assertEqual(response.status_code, 400) - self.assertEqual(response.data["message"], "sensor_uuid is required.") + self.assertEqual(response.data["message"], "farm_uuid is required.") + + def test_get_rejects_foreign_farm_uuid(self): + request = self.factory.get(f"/api/crop-zoning/area/?farm_uuid={self.other_farm.farm_uuid}") + force_authenticate(request, user=self.user) + + response = AreaView.as_view()(request) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.data["message"], "Farm not found.") def test_get_returns_pending_task_status_until_all_zones_complete(self): crop_area = self._create_area() @@ -219,7 +240,7 @@ class AreaViewTests(TestCase): mock_dispatch.assert_called_once() @patch("crop_zoning.services.create_zones_and_dispatch") - def test_get_creates_area_when_sensor_has_no_data(self, mock_create): + def test_get_creates_area_when_farm_has_no_data(self, mock_create): created_area = self._create_area(zone_count=0) mock_create.return_value = (created_area, []) @@ -227,7 +248,7 @@ class AreaViewTests(TestCase): self.assertEqual(response.status_code, 200) mock_create.assert_called_once() - self.assertEqual(mock_create.call_args.kwargs["sensor"], self.sensor) + self.assertEqual(mock_create.call_args.kwargs["farm"], self.farm) @patch("crop_zoning.tasks.process_zone_soil_data.delay") def test_each_zone_gets_its_own_task(self, mock_delay): @@ -238,8 +259,8 @@ class AreaViewTests(TestCase): geometry=AREA_GEOJSON["geometry"], points=AREA_GEOJSON["geometry"]["coordinates"][0][:-1], center={"longitude": 51.4087, "latitude": 35.6957}, - area_sqm=150000, - area_hectares=15, + area_sqm=200000, + area_hectares=20, sequence=0, processing_status=CropZone.STATUS_PENDING, task_id="", @@ -250,129 +271,45 @@ class AreaViewTests(TestCase): geometry=AREA_GEOJSON["geometry"], points=AREA_GEOJSON["geometry"]["coordinates"][0][:-1], center={"longitude": 51.4088, "latitude": 35.6958}, - area_sqm=150000, - area_hectares=15, + area_sqm=100000, + area_hectares=10, sequence=1, processing_status=CropZone.STATUS_PENDING, task_id="", ) - class Result: - def __init__(self, task_id): - self.id = task_id - - mock_delay.side_effect = [Result("task-zone-0"), Result("task-zone-1")] - response = AreaView.as_view()(self._request()) self.assertEqual(response.status_code, 200) self.assertEqual(mock_delay.call_count, 2) zone0.refresh_from_db() zone1.refresh_from_db() - self.assertEqual(zone0.task_id, "task-zone-0") - self.assertEqual(zone1.task_id, "task-zone-1") - - @patch("crop_zoning.tasks.process_zone_soil_data.delay", side_effect=OperationalError("redis down")) - def test_get_generates_local_task_id_when_broker_is_unavailable(self, mock_delay): - crop_area = self._create_area(zone_count=1, area_sqm=200000, area_hectares=20) - zone = CropZone.objects.create( - crop_area=crop_area, - zone_id="zone-0", - geometry=AREA_GEOJSON["geometry"], - points=AREA_GEOJSON["geometry"]["coordinates"][0][:-1], - center={"longitude": 51.4087, "latitude": 35.6957}, - area_sqm=200000, - area_hectares=20, - sequence=0, - processing_status=CropZone.STATUS_PENDING, - task_id="", - ) - - response = AreaView.as_view()(self._request()) - - self.assertEqual(response.status_code, 200) - zone.refresh_from_db() - self.assertTrue(zone.task_id) - self.assertEqual(response.data["data"]["task"]["summary"]["remaining"], 1) - self.assertEqual(response.data["data"]["task"]["remaining_zones"], 1) - self.assertEqual(response.data["data"]["task"]["status"], "PENDING") - self.assertIn("Celery broker unavailable", zone.processing_error) - - @patch("crop_zoning.tasks.process_zone_soil_data.delay") - def test_get_stores_task_id_and_reuses_it_on_next_request(self, mock_delay): - crop_area = self._create_area(zone_count=1, area_sqm=200000, area_hectares=20) - zone = CropZone.objects.create( - crop_area=crop_area, - zone_id="zone-0", - geometry=AREA_GEOJSON["geometry"], - points=AREA_GEOJSON["geometry"]["coordinates"][0][:-1], - center={"longitude": 51.4087, "latitude": 35.6957}, - area_sqm=200000, - area_hectares=20, - sequence=0, - processing_status=CropZone.STATUS_PENDING, - task_id="", - ) - - class Result: - id = "persisted-task-id" - - mock_delay.return_value = Result() - - first_response = AreaView.as_view()(self._request()) - self.assertEqual(first_response.status_code, 200) - zone.refresh_from_db() - self.assertEqual(zone.task_id, "persisted-task-id") - self.assertEqual(first_response.data["data"]["task"]["summary"]["done"], 0) - self.assertEqual(first_response.data["data"]["task"]["summary"]["remaining"], 1) - self.assertEqual(mock_delay.call_count, 1) - - second_response = AreaView.as_view()(self._request()) - self.assertEqual(second_response.status_code, 200) - self.assertEqual(second_response.data["data"]["task"]["summary"]["remaining"], 1) - self.assertEqual(second_response.data["data"]["task"]["status"], "PENDING") - self.assertEqual(mock_delay.call_count, 1) + self.assertTrue(zone0.task_id) + self.assertTrue(zone1.task_id) + self.assertNotEqual(zone0.task_id, zone1.task_id) @patch("crop_zoning.services.AsyncResult") - @patch("crop_zoning.tasks.process_zone_soil_data.delay") - def test_get_redispatches_pending_zone_when_shared_task_already_completed(self, mock_delay, mock_async_result): + def test_stale_tasks_are_redispatched(self, mock_async_result): crop_area = self._create_area() - CropZone.objects.create( + stale_time = timezone.now() - timedelta(minutes=10) + stale_zone = CropZone.objects.create( crop_area=crop_area, zone_id="zone-0", geometry=AREA_GEOJSON["geometry"], points=AREA_GEOJSON["geometry"]["coordinates"][0][:-1], center={"longitude": 51.4087, "latitude": 35.6957}, - area_sqm=150000, - area_hectares=15, + area_sqm=200000, + area_hectares=20, sequence=0, - processing_status=CropZone.STATUS_COMPLETED, - task_id="legacy-shared-task-id", + processing_status=CropZone.STATUS_PROCESSING, + task_id="stale-task", ) - stale_zone = CropZone.objects.create( - crop_area=crop_area, - zone_id="zone-1", - geometry=AREA_GEOJSON["geometry"], - points=AREA_GEOJSON["geometry"]["coordinates"][0][:-1], - center={"longitude": 51.4088, "latitude": 35.6958}, - area_sqm=150000, - area_hectares=15, - sequence=1, - processing_status=CropZone.STATUS_PENDING, - task_id="legacy-shared-task-id", - ) - stale_zone.updated_at = timezone.now() - timedelta(minutes=10) - stale_zone.save(update_fields=["updated_at"]) + CropZone.objects.filter(id=stale_zone.id).update(updated_at=stale_time) - class Result: - id = "requeued-zone-1" + mock_async_result.side_effect = OperationalError("broker down") - mock_delay.return_value = Result() - mock_async_result.return_value.state = "SUCCESS" - - response = AreaView.as_view()(self._request()) + with patch("crop_zoning.services.dispatch_zone_processing_tasks") as mock_dispatch: + response = AreaView.as_view()(self._request()) self.assertEqual(response.status_code, 200) - self.assertEqual(mock_delay.call_count, 1) - stale_zone.refresh_from_db() - self.assertEqual(stale_zone.task_id, "requeued-zone-1") + mock_dispatch.assert_called_once_with(zone_ids=[stale_zone.id], force=True) diff --git a/crop_zoning/views.py b/crop_zoning/views.py index 3aa2e92..a73263b 100644 --- a/crop_zoning/views.py +++ b/crop_zoning/views.py @@ -17,8 +17,8 @@ from .services import ( get_products_payload, get_soil_quality_payload, get_water_need_payload, - get_zone_page_request_params, get_zone_details_payload, + get_zone_page_request_params, ) @@ -27,26 +27,26 @@ class AreaView(APIView): tags=["Crop Zoning"], parameters=[ OpenApiParameter( - name="sensor_uuid", + name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=True, - description="UUID سنسور ارسالی کاربر برای گرفتن یا ساخت task فعال همان سنسور.", + description="UUID مزرعه برای گرفتن يا ساخت آخرين پردازش محدوده همان مزرعه.", ), OpenApiParameter( name="page", type=OpenApiTypes.INT, location=OpenApiParameter.QUERY, required=False, - description="شماره صفحه زون‌ها. مقدار پیش‌فرض 1 است.", + description="شماره صفحه زون ها. مقدار پيش فرض 1 است.", ), OpenApiParameter( name="page_size", type=OpenApiTypes.INT, location=OpenApiParameter.QUERY, required=False, - description="تعداد زون در هر صفحه. مقدار پیش‌فرض 10 است.", - ) + description="تعداد زون در هر صفحه. مقدار پيش فرض 10 است.", + ), ], responses={ 200: status_response("CropZoningAreaResponse", data=serializers.JSONField()), @@ -55,10 +55,10 @@ class AreaView(APIView): }, ) def get(self, request): - sensor_uuid = request.query_params.get("sensor_uuid") + farm_uuid = request.query_params.get("farm_uuid") try: page, page_size = get_zone_page_request_params(request.query_params) - crop_area = ensure_latest_area_ready_for_processing(sensor_uuid=sensor_uuid) + crop_area = ensure_latest_area_ready_for_processing(farm_uuid=farm_uuid, owner=request.user) except ValueError as exc: return Response({"status": "error", "message": str(exc)}, status=status.HTTP_400_BAD_REQUEST) except ImproperlyConfigured as exc: diff --git a/dashboard/migrations/0001_initial.py b/dashboard/migrations/0001_initial.py new file mode 100644 index 0000000..29a2f15 --- /dev/null +++ b/dashboard/migrations/0001_initial.py @@ -0,0 +1,36 @@ +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("farm_hub", "0002_seed_default_catalog"), + ] + + operations = [ + migrations.CreateModel( + name="FarmDashboardConfig", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("disabled_card_ids", models.JSONField(blank=True, default=list)), + ("row_order", models.JSONField(default=list)), + ("enable_drag_reorder", models.BooleanField(default=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "farm", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="dashboard_config", + to="farm_hub.farmhub", + ), + ), + ], + options={ + "db_table": "farm_dashboard_configs", + "ordering": ["-updated_at", "-id"], + }, + ), + ] diff --git a/sensor_hub/migrations/__init__.py b/dashboard/migrations/__init__.py similarity index 100% rename from sensor_hub/migrations/__init__.py rename to dashboard/migrations/__init__.py diff --git a/dashboard/models.py b/dashboard/models.py new file mode 100644 index 0000000..04dcf62 --- /dev/null +++ b/dashboard/models.py @@ -0,0 +1,23 @@ +from django.db import models + +from farm_hub.models import FarmHub + + +class FarmDashboardConfig(models.Model): + farm = models.OneToOneField( + FarmHub, + on_delete=models.CASCADE, + related_name="dashboard_config", + ) + disabled_card_ids = models.JSONField(default=list, blank=True) + row_order = models.JSONField(default=list, blank=True) + enable_drag_reorder = models.BooleanField(default=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "farm_dashboard_configs" + ordering = ["-updated_at", "-id"] + + def __str__(self): + return f"Dashboard config for {self.farm.name}" diff --git a/dashboard/postman/farm_dashboard.json b/dashboard/postman/farm_dashboard.json index 6556e80..8f171c2 100644 --- a/dashboard/postman/farm_dashboard.json +++ b/dashboard/postman/farm_dashboard.json @@ -1 +1 @@ -{"info":{"name":"Farm Dashboard","schema":"https://schema.getpostman.com/json/collection/v2.1.0/collection.json","description":"Farm Dashboard API. GET/PATCH config (disabled_card_ids, row_order, enable_drag_reorder). GET all cards. Static mock data only. No database."},"item":[{"name":"Get config","request":{"method":"GET","header":[{"key":"Content-Type","value":"application/json"}],"url":"{{baseUrl}}/api/farm-dashboard-config/","description":"Get dashboard config: disabled_card_ids, row_order, enable_drag_reorder."},"response":[{"name":"Success","status":"OK","code":200,"body":"{\n \"code\": 200,\n \"msg\": \"OK\",\n \"data\": {\n \"disabled_card_ids\": [\"farmWeatherCard\", \"sensorRadarChart\"],\n \"row_order\": [\"overviewKpis\", \"weatherAlerts\", \"sensorMonitoring\", \"sensorCharts\", \"alertsWater\", \"predictions\", \"soilHeatmap\", \"ndviRecommendations\", \"economic\"],\n \"enable_drag_reorder\": true\n }\n}"}]},{"name":"Patch config (disable card)","request":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n \"disabled_card_ids\": [\"farmWeatherCard\", \"sensorRadarChart\"]\n}"},"url":"{{baseUrl}}/api/farm-dashboard-config/","description":"PATCH to update disabled_card_ids. Input is ignored; returns static config."},"response":[{"name":"Success","status":"OK","code":200,"body":"{\n \"code\": 200,\n \"msg\": \"OK\",\n \"data\": {\n \"disabled_card_ids\": [\"farmWeatherCard\", \"sensorRadarChart\"],\n \"row_order\": [\"overviewKpis\", \"weatherAlerts\", \"sensorMonitoring\", \"sensorCharts\", \"alertsWater\", \"predictions\", \"soilHeatmap\", \"ndviRecommendations\", \"economic\"],\n \"enable_drag_reorder\": true\n }\n}"}]},{"name":"Patch config (row order)","request":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n \"row_order\": [\"overviewKpis\", \"weatherAlerts\", \"sensorMonitoring\", \"predictions\", \"sensorCharts\", \"alertsWater\", \"soilHeatmap\", \"ndviRecommendations\", \"economic\"]\n}"},"url":"{{baseUrl}}/api/farm-dashboard-config/","description":"PATCH to update row_order. Input is ignored; returns static config."},"response":[{"name":"Success","status":"OK","code":200,"body":"{\n \"code\": 200,\n \"msg\": \"OK\",\n \"data\": {\n \"disabled_card_ids\": [\"farmWeatherCard\", \"sensorRadarChart\"],\n \"row_order\": [\"overviewKpis\", \"weatherAlerts\", \"sensorMonitoring\", \"sensorCharts\", \"alertsWater\", \"predictions\", \"soilHeatmap\", \"ndviRecommendations\", \"economic\"],\n \"enable_drag_reorder\": true\n }\n}"}]},{"name":"Patch config (enable drag reorder)","request":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n \"enable_drag_reorder\": false\n}"},"url":"{{baseUrl}}/api/farm-dashboard-config/","description":"PATCH to update enable_drag_reorder. Input is ignored; returns static config."},"response":[{"name":"Success","status":"OK","code":200,"body":"{\n \"code\": 200,\n \"msg\": \"OK\",\n \"data\": {\n \"disabled_card_ids\": [\"farmWeatherCard\", \"sensorRadarChart\"],\n \"row_order\": [\"overviewKpis\", \"weatherAlerts\", \"sensorMonitoring\", \"sensorCharts\", \"alertsWater\", \"predictions\", \"soilHeatmap\", \"ndviRecommendations\", \"economic\"],\n \"enable_drag_reorder\": true\n }\n}"}]},{"name":"Get all cards","request":{"method":"GET","header":[{"key":"Content-Type","value":"application/json"}],"url":"{{baseUrl}}/api/farm-dashboard/","description":"Get unified response with all 15 card payloads."},"response":[{"name":"Success","status":"OK","code":200,"body":"{\n \"code\": 200,\n \"msg\": \"OK\",\n \"data\": {\n \"farmOverviewKpis\": {},\n \"farmWeatherCard\": {},\n \"farmAlertsTracker\": {},\n \"sensorValuesList\": {},\n \"sensorRadarChart\": {},\n \"sensorComparisonChart\": {},\n \"anomalyDetectionCard\": {},\n \"farmAlertsTimeline\": {},\n \"waterNeedPrediction\": {},\n \"harvestPredictionCard\": {},\n \"yieldPredictionChart\": {},\n \"soilMoistureHeatmap\": {},\n \"ndviHealthCard\": {},\n \"recommendationsList\": {},\n \"economicOverview\": {}\n }\n}"}]},{"name":"Get all cards (cards path)","request":{"method":"GET","header":[{"key":"Content-Type","value":"application/json"}],"url":"{{baseUrl}}/api/farm-dashboard/cards/","description":"Get unified response with all 15 card payloads. Same as base path."},"response":[{"name":"Success","status":"OK","code":200,"body":"{\n \"code\": 200,\n \"msg\": \"OK\",\n \"data\": {\n \"farmOverviewKpis\": {},\n \"farmWeatherCard\": {},\n \"farmAlertsTracker\": {},\n \"sensorValuesList\": {},\n \"sensorRadarChart\": {},\n \"sensorComparisonChart\": {},\n \"anomalyDetectionCard\": {},\n \"farmAlertsTimeline\": {},\n \"waterNeedPrediction\": {},\n \"harvestPredictionCard\": {},\n \"yieldPredictionChart\": {},\n \"soilMoistureHeatmap\": {},\n \"ndviHealthCard\": {},\n \"recommendationsList\": {},\n \"economicOverview\": {}\n }\n}"}]}],"variable":[{"key":"baseUrl","value":"http://localhost:8000"}]} +{"info":{"name":"Farm Dashboard","schema":"https://schema.getpostman.com/json/collection/v2.1.0/collection.json","description":"Farm Dashboard API. GET/PATCH config (disabled_card_ids, row_order, enable_drag_reorder). GET all cards. Static mock data only. No database."},"item":[{"name":"Get config","request":{"method":"GET","header":[{"key":"Content-Type","value":"application/json"}],"url":"{{baseUrl}}/api/farm-dashboard-config/?farm_uuid={{farmUuid}}","description":"Get dashboard config for a specific farm."},"response":[{"name":"Success","status":"OK","code":200,"body":"{\n \"code\": 200,\n \"msg\": \"OK\",\n \"data\": {\n \"disabled_card_ids\": [\"farmWeatherCard\", \"sensorRadarChart\"],\n \"row_order\": [\"overviewKpis\", \"weatherAlerts\", \"sensorMonitoring\", \"sensorCharts\", \"alertsWater\", \"predictions\", \"soilHeatmap\", \"ndviRecommendations\", \"economic\"],\n \"enable_drag_reorder\": true\n }\n}"}]},{"name":"Patch config (disable card)","request":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n \"disabled_card_ids\": [\n \"farmWeatherCard\",\n \"sensorRadarChart\"\n ],\n \"farm_uuid\": \"{{farmUuid}}\"\n}"},"url":"{{baseUrl}}/api/farm-dashboard-config/","description":"PATCH dashboard config for a specific farm."},"response":[{"name":"Success","status":"OK","code":200,"body":"{\n \"code\": 200,\n \"msg\": \"OK\",\n \"data\": {\n \"disabled_card_ids\": [\"farmWeatherCard\", \"sensorRadarChart\"],\n \"row_order\": [\"overviewKpis\", \"weatherAlerts\", \"sensorMonitoring\", \"sensorCharts\", \"alertsWater\", \"predictions\", \"soilHeatmap\", \"ndviRecommendations\", \"economic\"],\n \"enable_drag_reorder\": true\n }\n}"}]},{"name":"Patch config (row order)","request":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n \"row_order\": [\n \"overviewKpis\",\n \"weatherAlerts\",\n \"sensorMonitoring\",\n \"predictions\",\n \"sensorCharts\",\n \"alertsWater\",\n \"soilHeatmap\",\n \"ndviRecommendations\",\n \"economic\"\n ],\n \"farm_uuid\": \"{{farmUuid}}\"\n}"},"url":"{{baseUrl}}/api/farm-dashboard-config/","description":"PATCH dashboard config for a specific farm."},"response":[{"name":"Success","status":"OK","code":200,"body":"{\n \"code\": 200,\n \"msg\": \"OK\",\n \"data\": {\n \"disabled_card_ids\": [\"farmWeatherCard\", \"sensorRadarChart\"],\n \"row_order\": [\"overviewKpis\", \"weatherAlerts\", \"sensorMonitoring\", \"sensorCharts\", \"alertsWater\", \"predictions\", \"soilHeatmap\", \"ndviRecommendations\", \"economic\"],\n \"enable_drag_reorder\": true\n }\n}"}]},{"name":"Patch config (enable drag reorder)","request":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n \"enable_drag_reorder\": false,\n \"farm_uuid\": \"{{farmUuid}}\"\n}"},"url":"{{baseUrl}}/api/farm-dashboard-config/","description":"PATCH dashboard config for a specific farm."},"response":[{"name":"Success","status":"OK","code":200,"body":"{\n \"code\": 200,\n \"msg\": \"OK\",\n \"data\": {\n \"disabled_card_ids\": [\"farmWeatherCard\", \"sensorRadarChart\"],\n \"row_order\": [\"overviewKpis\", \"weatherAlerts\", \"sensorMonitoring\", \"sensorCharts\", \"alertsWater\", \"predictions\", \"soilHeatmap\", \"ndviRecommendations\", \"economic\"],\n \"enable_drag_reorder\": true\n }\n}"}]},{"name":"Get all cards","request":{"method":"GET","header":[{"key":"Content-Type","value":"application/json"}],"url":"{{baseUrl}}/api/farm-dashboard/?farm_uuid={{farmUuid}}","description":"Get unified response with all 15 card payloads."},"response":[{"name":"Success","status":"OK","code":200,"body":"{\n \"code\": 200,\n \"msg\": \"OK\",\n \"data\": {\n \"farmOverviewKpis\": {},\n \"farmWeatherCard\": {},\n \"farmAlertsTracker\": {},\n \"sensorValuesList\": {},\n \"sensorRadarChart\": {},\n \"sensorComparisonChart\": {},\n \"anomalyDetectionCard\": {},\n \"farmAlertsTimeline\": {},\n \"waterNeedPrediction\": {},\n \"harvestPredictionCard\": {},\n \"yieldPredictionChart\": {},\n \"soilMoistureHeatmap\": {},\n \"ndviHealthCard\": {},\n \"recommendationsList\": {},\n \"economicOverview\": {}\n }\n}"}]},{"name":"Get all cards (cards path)","request":{"method":"GET","header":[{"key":"Content-Type","value":"application/json"}],"url":"{{baseUrl}}/api/farm-dashboard/cards/?farm_uuid={{farmUuid}}","description":"Get unified response with all 15 card payloads. Same as base path."},"response":[{"name":"Success","status":"OK","code":200,"body":"{\n \"code\": 200,\n \"msg\": \"OK\",\n \"data\": {\n \"farmOverviewKpis\": {},\n \"farmWeatherCard\": {},\n \"farmAlertsTracker\": {},\n \"sensorValuesList\": {},\n \"sensorRadarChart\": {},\n \"sensorComparisonChart\": {},\n \"anomalyDetectionCard\": {},\n \"farmAlertsTimeline\": {},\n \"waterNeedPrediction\": {},\n \"harvestPredictionCard\": {},\n \"yieldPredictionChart\": {},\n \"soilMoistureHeatmap\": {},\n \"ndviHealthCard\": {},\n \"recommendationsList\": {},\n \"economicOverview\": {}\n }\n}"}]}],"variable":[{"key":"baseUrl","value":"http://localhost:8000"},{"key":"farmUuid","value":"550e8400-e29b-41d4-a716-446655440000"}]} \ No newline at end of file diff --git a/dashboard/serializers.py b/dashboard/serializers.py index dbd4606..b9821b1 100644 --- a/dashboard/serializers.py +++ b/dashboard/serializers.py @@ -4,6 +4,7 @@ from .mock_data import VALID_CARD_IDS, VALID_ROW_IDS class FarmDashboardConfigSerializer(serializers.Serializer): + farm_uuid = serializers.UUIDField(read_only=True) disabled_card_ids = serializers.ListField( child=serializers.CharField(), allow_empty=True, @@ -40,6 +41,7 @@ class FarmDashboardConfigSerializer(serializers.Serializer): class FarmDashboardConfigPatchSerializer(FarmDashboardConfigSerializer): + farm_uuid = serializers.UUIDField(required=True) disabled_card_ids = serializers.ListField( child=serializers.CharField(), allow_empty=True, @@ -54,6 +56,6 @@ class FarmDashboardConfigPatchSerializer(FarmDashboardConfigSerializer): def validate(self, attrs): attrs = super().validate(attrs) - if not attrs: + if set(attrs.keys()) == {"farm_uuid"}: raise serializers.ValidationError("At least one config field must be provided.") return attrs diff --git a/dashboard/tests.py b/dashboard/tests.py index 1da960d..9706c93 100644 --- a/dashboard/tests.py +++ b/dashboard/tests.py @@ -1,52 +1,105 @@ from copy import deepcopy +from unittest.mock import patch -from django.test import SimpleTestCase -from rest_framework.test import APIRequestFactory +from django.contrib.auth import get_user_model +from django.test import TestCase +from rest_framework.test import APIRequestFactory, force_authenticate -from .mock_data import DEFAULT_CONFIG, reset_config -from .views import FarmDashboardConfigView +from farm_hub.models import FarmHub, FarmType + +from .mock_data import DEFAULT_CONFIG +from .models import FarmDashboardConfig +from .views import FarmDashboardCardsView, FarmDashboardConfigView -class FarmDashboardConfigViewTests(SimpleTestCase): +class DashboardBaseTestCase(TestCase): def setUp(self): self.factory = APIRequestFactory() - reset_config() + self.user = get_user_model().objects.create_user( + username="farmer", + password="secret123", + email="farmer@example.com", + phone_number="09120000000", + ) + self.other_user = get_user_model().objects.create_user( + username="other-farmer", + password="secret123", + email="other@example.com", + phone_number="09120000001", + ) + self.farm_type = FarmType.objects.create(name="زراعی") + self.farm = FarmHub.objects.create(owner=self.user, farm_type=self.farm_type, name="Farm 1") + self.other_farm = FarmHub.objects.create(owner=self.other_user, farm_type=self.farm_type, name="Farm 2") - def tearDown(self): - reset_config() - def test_get_returns_canonical_config(self): - request = self.factory.get("/api/farm-dashboard-config/") +class FarmDashboardConfigViewTests(DashboardBaseTestCase): + def test_get_returns_default_config_and_persists_it(self): + request = self.factory.get(f"/api/farm-dashboard-config/?farm_uuid={self.farm.farm_uuid}") + force_authenticate(request, user=self.user) response = FarmDashboardConfigView.as_view()(request) + expected = deepcopy(DEFAULT_CONFIG) + expected["farm_uuid"] = str(self.farm.farm_uuid) + self.assertEqual(response.status_code, 200) self.assertEqual(response.data["code"], 200) self.assertEqual(response.data["msg"], "OK") - self.assertEqual(response.data["data"], DEFAULT_CONFIG) + self.assertEqual(response.data["data"], expected) + self.assertTrue(FarmDashboardConfig.objects.filter(farm=self.farm).exists()) + + def test_get_requires_farm_uuid(self): + request = self.factory.get("/api/farm-dashboard-config/") + force_authenticate(request, user=self.user) + response = FarmDashboardConfigView.as_view()(request) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.data["farm_uuid"][0], "This field is required.") + + def test_get_rejects_foreign_farm_uuid(self): + request = self.factory.get(f"/api/farm-dashboard-config/?farm_uuid={self.other_farm.farm_uuid}") + force_authenticate(request, user=self.user) + response = FarmDashboardConfigView.as_view()(request) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.data["farm_uuid"][0], "Farm not found.") def test_patch_partial_update_returns_full_final_config(self): request = self.factory.patch( "/api/farm-dashboard-config/", - {"disabled_card_ids": ["farmWeatherCard"]}, + { + "farm_uuid": str(self.farm.farm_uuid), + "disabled_card_ids": ["farmWeatherCard"], + }, format="json", ) + force_authenticate(request, user=self.user) response = FarmDashboardConfigView.as_view()(request) expected = deepcopy(DEFAULT_CONFIG) + expected["farm_uuid"] = str(self.farm.farm_uuid) expected["disabled_card_ids"] = ["farmWeatherCard"] self.assertEqual(response.status_code, 200) self.assertEqual(response.data["data"], expected) + self.assertEqual( + FarmDashboardConfig.objects.get(farm=self.farm).disabled_card_ids, + ["farmWeatherCard"], + ) def test_patch_only_drag_flag_still_returns_full_config(self): request = self.factory.patch( "/api/farm-dashboard-config/", - {"enable_drag_reorder": False}, + { + "farm_uuid": str(self.farm.farm_uuid), + "enable_drag_reorder": False, + }, format="json", ) + force_authenticate(request, user=self.user) response = FarmDashboardConfigView.as_view()(request) expected = deepcopy(DEFAULT_CONFIG) + expected["farm_uuid"] = str(self.farm.farm_uuid) expected["enable_drag_reorder"] = False self.assertEqual(response.status_code, 200) @@ -57,10 +110,43 @@ class FarmDashboardConfigViewTests(SimpleTestCase): def test_patch_rejects_invalid_row_order(self): request = self.factory.patch( "/api/farm-dashboard-config/", - {"row_order": ["overviewKpis"]}, + { + "farm_uuid": str(self.farm.farm_uuid), + "row_order": ["overviewKpis"], + }, format="json", ) + force_authenticate(request, user=self.user) response = FarmDashboardConfigView.as_view()(request) self.assertEqual(response.status_code, 400) self.assertIn("row_order", response.data) + + +class FarmDashboardCardsViewTests(DashboardBaseTestCase): + @patch("dashboard.views.external_api_request") + def test_get_forwards_farm_uuid_to_external_api(self, mock_external_api_request): + mock_external_api_request.return_value.data = {"status": "success", "data": {}} + mock_external_api_request.return_value.status_code = 200 + + request = self.factory.get(f"/api/farm-dashboard/?farm_uuid={self.farm.farm_uuid}") + force_authenticate(request, user=self.user) + + response = FarmDashboardCardsView.as_view()(request) + + self.assertEqual(response.status_code, 200) + mock_external_api_request.assert_called_once_with( + "ai", + "/dashboard-data/status", + method="GET", + query={"farm_uuid": str(self.farm.farm_uuid)}, + ) + + def test_get_requires_farm_uuid(self): + request = self.factory.get("/api/farm-dashboard/") + force_authenticate(request, user=self.user) + + response = FarmDashboardCardsView.as_view()(request) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.data["farm_uuid"][0], "This field is required.") diff --git a/dashboard/views.py b/dashboard/views.py index 4441814..50768f0 100644 --- a/dashboard/views.py +++ b/dashboard/views.py @@ -2,21 +2,59 @@ Farm Dashboard API views. """ -from rest_framework import status -from rest_framework import serializers -from rest_framework.permissions import AllowAny +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.utils import extend_schema, extend_schema_view +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, extend_schema, extend_schema_view from config.swagger import code_response -from .mock_data import get_config, update_config +from external_api_adapter import request as external_api_request +from farm_hub.models import FarmHub +from .mock_data import DEFAULT_CONFIG +from .models import FarmDashboardConfig from .serializers import FarmDashboardConfigPatchSerializer, FarmDashboardConfigSerializer +class FarmAccessMixin: + @staticmethod + def _get_farm(request, farm_uuid): + if not farm_uuid: + raise serializers.ValidationError({"farm_uuid": ["This field is required."]}) + try: + return FarmHub.objects.get(farm_uuid=farm_uuid, owner=request.user) + except FarmHub.DoesNotExist as exc: + raise serializers.ValidationError({"farm_uuid": ["Farm not found."]}) from exc + + @staticmethod + def _get_or_create_dashboard_config(farm): + config, _created = FarmDashboardConfig.objects.get_or_create( + farm=farm, + defaults={ + "disabled_card_ids": DEFAULT_CONFIG["disabled_card_ids"], + "row_order": DEFAULT_CONFIG["row_order"], + "enable_drag_reorder": DEFAULT_CONFIG["enable_drag_reorder"], + }, + ) + return config + + @staticmethod + def _serialize_config(config): + return { + "farm_uuid": str(config.farm.farm_uuid), + "disabled_card_ids": config.disabled_card_ids, + "row_order": config.row_order, + "enable_drag_reorder": config.enable_drag_reorder, + } + + @extend_schema_view( get=extend_schema( tags=["Farm Dashboard"], + parameters=[ + OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=True), + ], responses={200: code_response("FarmDashboardConfigGetResponse", data=FarmDashboardConfigSerializer())}, ), patch=extend_schema( @@ -25,25 +63,43 @@ from .serializers import FarmDashboardConfigPatchSerializer, FarmDashboardConfig responses={200: code_response("FarmDashboardConfigPatchResponse", data=FarmDashboardConfigSerializer())}, ), ) -class FarmDashboardConfigView(APIView): +class FarmDashboardConfigView(FarmAccessMixin, APIView): """ Farm dashboard config endpoints. - GET returns the current config. - PATCH accepts partial updates and returns the full final config. + GET/PATCH are persisted in DB per farm. """ - permission_classes = [AllowAny] + + permission_classes = [IsAuthenticated] def get(self, request): - config = get_config() - return Response({"code": 200, "msg": "OK", "data": config}, status=status.HTTP_200_OK) + farm = self._get_farm(request, request.query_params.get("farm_uuid")) + config = self._get_or_create_dashboard_config(farm) + return Response( + {"code": 200, "msg": "OK", "data": self._serialize_config(config)}, + status=status.HTTP_200_OK, + ) def patch(self, request): serializer = FarmDashboardConfigPatchSerializer(data=request.data) serializer.is_valid(raise_exception=True) - config = update_config(serializer.validated_data) - response_serializer = FarmDashboardConfigSerializer(config) + + farm = self._get_farm(request, serializer.validated_data["farm_uuid"]) + config = self._get_or_create_dashboard_config(farm) + + update_fields = ["updated_at"] + if "disabled_card_ids" in serializer.validated_data: + config.disabled_card_ids = serializer.validated_data["disabled_card_ids"] + update_fields.append("disabled_card_ids") + if "row_order" in serializer.validated_data: + config.row_order = serializer.validated_data["row_order"] + update_fields.append("row_order") + if "enable_drag_reorder" in serializer.validated_data: + config.enable_drag_reorder = serializer.validated_data["enable_drag_reorder"] + update_fields.append("enable_drag_reorder") + config.save(update_fields=update_fields) + return Response( - {"code": 200, "msg": "OK", "data": response_serializer.data}, + {"code": 200, "msg": "OK", "data": self._serialize_config(config)}, status=status.HTTP_200_OK, ) @@ -51,17 +107,26 @@ class FarmDashboardConfigView(APIView): @extend_schema_view( get=extend_schema( tags=["Farm Dashboard"], + parameters=[ + OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=True), + ], responses={200: code_response("FarmDashboardCardsResponse", data=serializers.JSONField())}, ), ) -class FarmDashboardCardsView(APIView): +class FarmDashboardCardsView(FarmAccessMixin, APIView): """ Farm dashboard cards endpoint: GET. - Returns unified response with all 15 card payloads. - No database. Static mock data only. + Requires farm_uuid and forwards it to the external AI service. """ - def get(self, request): - from external_api_adapter import request as external_api_request - adapter_response = external_api_request("ai", "/dashboard-data/status", method="GET") + permission_classes = [IsAuthenticated] + + def get(self, request): + farm = self._get_farm(request, request.query_params.get("farm_uuid")) + adapter_response = external_api_request( + "ai", + "/dashboard-data/status", + method="GET", + query={"farm_uuid": str(farm.farm_uuid)}, + ) return Response(adapter_response.data, status=adapter_response.status_code) diff --git a/docker-compose.yaml b/docker-compose.yaml index 33c6a5d..c66a9b2 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -43,15 +43,6 @@ services: volumes: - backend_redis_data:/data - qdrant: - image: qdrant/qdrant:latest - container_name: backend-qdrant - ports: - - "6333:6333" - - "6334:6334" - volumes: - - backend_qdrant_data:/qdrant/storage - restart: unless-stopped web: build: @@ -81,8 +72,6 @@ services: condition: service_healthy redis: condition: service_healthy - qdrant: - condition: service_started restart: unless-stopped celery: diff --git a/external_api_adapter/README.md b/external_api_adapter/README.md index cd8270b..a16d5ef 100644 --- a/external_api_adapter/README.md +++ b/external_api_adapter/README.md @@ -10,9 +10,9 @@ EXTERNAL_SERVICES = { "base_url": os.getenv("AI_SERVICE_BASE_URL", ""), "api_key": os.getenv("AI_SERVICE_API_KEY", ""), }, - "sensor_hub": { - "base_url": os.getenv("SENSOR_HUB_SERVICE_BASE_URL", ""), - "api_key": os.getenv("SENSOR_HUB_SERVICE_API_KEY", ""), + "farm_hub": { + "base_url": os.getenv("FARM_HUB_SERVICE_BASE_URL", ""), + "api_key": os.getenv("FARM_HUB_SERVICE_API_KEY", ""), }, } ``` diff --git a/external_api_adapter/json/ai/fertilization/recommend/post_400.json b/external_api_adapter/json/ai/fertilization/recommend/post_400.json index 9fdc597..5e4c3f5 100644 --- a/external_api_adapter/json/ai/fertilization/recommend/post_400.json +++ b/external_api_adapter/json/ai/fertilization/recommend/post_400.json @@ -2,7 +2,7 @@ "code": 400, "msg": "داده نامعتبر.", "data": { - "sensor_uuid": [ + "farm_uuid": [ "This field is required." ] } diff --git a/external_api_adapter/json/ai/index.json b/external_api_adapter/json/ai/index.json index d051fb6..91c1a8e 100644 --- a/external_api_adapter/json/ai/index.json +++ b/external_api_adapter/json/ai/index.json @@ -512,42 +512,42 @@ }, { "method": "PUT", - "path": "/api/sensor-data/{uuid_sensor}/", + "path": "/api/sensor-data/{farm_uuid}/", "status_code": 200, "description": "Sensor update put success", "file": "json/mock_data/sensor-data/update-put_200.json" }, { "method": "PUT", - "path": "/api/sensor-data/{uuid_sensor}/", + "path": "/api/sensor-data/{farm_uuid}/", "status_code": 400, "description": "Sensor update put validation error", "file": "json/mock_data/sensor-data/update-put_400.json" }, { "method": "PUT", - "path": "/api/sensor-data/{uuid_sensor}/", + "path": "/api/sensor-data/{farm_uuid}/", "status_code": 404, "description": "Sensor update put location not found", "file": "json/mock_data/sensor-data/update-put_404.json" }, { "method": "PATCH", - "path": "/api/sensor-data/{uuid_sensor}/", + "path": "/api/sensor-data/{farm_uuid}/", "status_code": 200, "description": "Sensor update patch success", "file": "json/mock_data/sensor-data/update-patch_200.json" }, { "method": "PATCH", - "path": "/api/sensor-data/{uuid_sensor}/", + "path": "/api/sensor-data/{farm_uuid}/", "status_code": 400, "description": "Sensor update patch validation error", "file": "json/mock_data/sensor-data/update-patch_400.json" }, { "method": "PATCH", - "path": "/api/sensor-data/{uuid_sensor}/", + "path": "/api/sensor-data/{farm_uuid}/", "status_code": 404, "description": "Sensor update patch location not found", "file": "json/mock_data/sensor-data/update-patch_404.json" diff --git a/external_api_adapter/json/ai/irrigation/recommend/post_400.json b/external_api_adapter/json/ai/irrigation/recommend/post_400.json index 9fdc597..5e4c3f5 100644 --- a/external_api_adapter/json/ai/irrigation/recommend/post_400.json +++ b/external_api_adapter/json/ai/irrigation/recommend/post_400.json @@ -2,7 +2,7 @@ "code": 400, "msg": "داده نامعتبر.", "data": { - "sensor_uuid": [ + "farm_uuid": [ "This field is required." ] } diff --git a/external_api_adapter/json/ai/rag/fertilization/post_400.json b/external_api_adapter/json/ai/rag/fertilization/post_400.json index 6bea070..b82ea08 100644 --- a/external_api_adapter/json/ai/rag/fertilization/post_400.json +++ b/external_api_adapter/json/ai/rag/fertilization/post_400.json @@ -1,5 +1,5 @@ { "code": 400, - "msg": "پارامتر sensor_uuid الزامی است.", + "msg": "پارامتر farm_uuid الزامی است.", "data": null } diff --git a/external_api_adapter/json/ai/rag/irrigation/post_400.json b/external_api_adapter/json/ai/rag/irrigation/post_400.json index 6bea070..b82ea08 100644 --- a/external_api_adapter/json/ai/rag/irrigation/post_400.json +++ b/external_api_adapter/json/ai/rag/irrigation/post_400.json @@ -1,5 +1,5 @@ { "code": 400, - "msg": "پارامتر sensor_uuid الزامی است.", + "msg": "پارامتر farm_uuid الزامی است.", "data": null } diff --git a/external_api_adapter/json/ai/sensor-data/update-patch_200.json b/external_api_adapter/json/ai/sensor-data/update-patch_200.json index 5bc26e3..31fc9cd 100644 --- a/external_api_adapter/json/ai/sensor-data/update-patch_200.json +++ b/external_api_adapter/json/ai/sensor-data/update-patch_200.json @@ -2,7 +2,7 @@ "code": 200, "msg": "success", "data": { - "uuid_sensor": "550e8400-e29b-41d4-a716-446655440000", + "farm_uuid": "550e8400-e29b-41d4-a716-446655440000", "location_id": 12, "soil_moisture": 45.2, "soil_temperature": 22.5, diff --git a/external_api_adapter/json/ai/sensor-data/update-put_200.json b/external_api_adapter/json/ai/sensor-data/update-put_200.json index 5bc26e3..31fc9cd 100644 --- a/external_api_adapter/json/ai/sensor-data/update-put_200.json +++ b/external_api_adapter/json/ai/sensor-data/update-put_200.json @@ -2,7 +2,7 @@ "code": 200, "msg": "success", "data": { - "uuid_sensor": "550e8400-e29b-41d4-a716-446655440000", + "farm_uuid": "550e8400-e29b-41d4-a716-446655440000", "location_id": 12, "soil_moisture": 45.2, "soil_temperature": 22.5, diff --git a/farm_ai_assistant/migrations/0002_conversation_farm_message_farm.py b/farm_ai_assistant/migrations/0002_conversation_farm_message_farm.py new file mode 100644 index 0000000..567ce8e --- /dev/null +++ b/farm_ai_assistant/migrations/0002_conversation_farm_message_farm.py @@ -0,0 +1,34 @@ +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("farm_hub", "0002_seed_default_catalog"), + ("farm_ai_assistant", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="conversation", + name="farm", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="ai_conversations", + to="farm_hub.farmhub", + ), + ), + migrations.AddField( + model_name="message", + name="farm", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="ai_messages", + to="farm_hub.farmhub", + ), + ), + ] diff --git a/farm_ai_assistant/models.py b/farm_ai_assistant/models.py index ce69b27..9283c67 100644 --- a/farm_ai_assistant/models.py +++ b/farm_ai_assistant/models.py @@ -3,6 +3,8 @@ import uuid from django.conf import settings from django.db import models +from farm_hub.models import FarmHub + class Conversation(models.Model): uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True) @@ -11,6 +13,13 @@ class Conversation(models.Model): on_delete=models.CASCADE, related_name="farm_ai_conversations", ) + farm = models.ForeignKey( + FarmHub, + on_delete=models.CASCADE, + related_name="ai_conversations", + null=True, + blank=True, + ) 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) @@ -38,6 +47,13 @@ class Message(models.Model): on_delete=models.CASCADE, related_name="messages", ) + farm = models.ForeignKey( + FarmHub, + on_delete=models.CASCADE, + related_name="ai_messages", + null=True, + blank=True, + ) role = models.CharField(max_length=32, choices=ROLE_CHOICES) content = models.TextField(blank=True, default="") images = models.JSONField(default=list, blank=True) diff --git a/farm_ai_assistant/serializers.py b/farm_ai_assistant/serializers.py index 0b466eb..e4fa2b3 100644 --- a/farm_ai_assistant/serializers.py +++ b/farm_ai_assistant/serializers.py @@ -17,10 +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) message_count = serializers.IntegerField(read_only=True) class ConversationCreateSerializer(serializers.Serializer): + farm_uuid = serializers.UUIDField(required=True) title = serializers.CharField(required=False, allow_blank=True, max_length=255) farm_context = serializers.JSONField(required=False) @@ -28,6 +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) 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) @@ -37,18 +40,21 @@ class ChatHistoryMessageSerializer(serializers.Serializer): class ConversationMessagesSerializer(serializers.Serializer): conversation_id = serializers.UUIDField(read_only=True) + farm_uuid = serializers.UUIDField(read_only=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) 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) class ChatTaskSubmitDataSerializer(serializers.Serializer): @@ -57,18 +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) 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) 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) 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 b2a45df..93a9bb4 100644 --- a/farm_ai_assistant/tests.py +++ b/farm_ai_assistant/tests.py @@ -2,6 +2,8 @@ from django.contrib.auth import get_user_model from django.test import TestCase, override_settings from rest_framework.test import APIRequestFactory, force_authenticate +from farm_hub.models import FarmHub, FarmType + from .models import Conversation, Message from .views import ChatTaskStatusView @@ -16,24 +18,35 @@ class ChatTaskStatusViewTests(TestCase): email="farmer@example.com", phone_number="09120000000", ) + self.farm_type, _ = FarmType.objects.get_or_create(name="زراعی") + self.farm = FarmHub.objects.create( + owner=self.user, + farm_type=self.farm_type, + name="Farm 1", + ) self.conversation = Conversation.objects.create( owner=self.user, + farm=self.farm, title="Irrigation chat", farm_context={}, ) self.user_message = Message.objects.create( conversation=self.conversation, + farm=self.farm, role=Message.ROLE_USER, content="What is the best irrigation plan?", raw_response={ "task_id": "farm-ai-chat-task-123", "status": "PENDING", "status_url": "/api/tasks/farm-ai-chat-task-123/status/", + "farm_uuid": str(self.farm.farm_uuid), }, ) def test_status_success_uses_chat_mock_result_and_persists_assistant_message(self): - request = self.factory.get("/api/farm-ai-assistant/chat/task/farm-ai-chat-task-123/status/") + request = self.factory.get( + f"/api/farm-ai-assistant/chat/task/farm-ai-chat-task-123/status/?farm_uuid={self.farm.farm_uuid}" + ) force_authenticate(request, user=self.user) response = ChatTaskStatusView.as_view()(request, task_id="farm-ai-chat-task-123") @@ -43,6 +56,7 @@ class ChatTaskStatusViewTests(TestCase): 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"]["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") @@ -53,6 +67,8 @@ class ChatTaskStatusViewTests(TestCase): .first() ) 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) diff --git a/farm_ai_assistant/views.py b/farm_ai_assistant/views.py index f7f0f39..8efec8f 100644 --- a/farm_ai_assistant/views.py +++ b/farm_ai_assistant/views.py @@ -14,6 +14,7 @@ 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 external_api_adapter.exceptions import ExternalAPIRequestError +from farm_hub.models import FarmHub from .mock_data import CHAT_RESPONSE_DATA, CONTEXT_RESPONSE_DATA from .models import Conversation, Message from .serializers import ( @@ -28,23 +29,45 @@ from .serializers import ( ) -class ContextView(APIView): +class FarmAccessMixin: + @staticmethod + def _get_farm(request, farm_uuid): + if not farm_uuid: + raise serializers.ValidationError({"farm_uuid": ["This field is required."]}) + try: + return FarmHub.objects.get(farm_uuid=farm_uuid, owner=request.user) + except FarmHub.DoesNotExist as exc: + raise Http404("Farm not found") from exc + + +class ContextView(FarmAccessMixin, APIView): + permission_classes = [IsAuthenticated] + @extend_schema( tags=["Farm AI Assistant"], + parameters=[ + OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=True), + ], responses={200: status_response("FarmAiAssistantContextResponse", data=serializers.JSONField())}, ) def get(self, request): + farm = self._get_farm(request, request.query_params.get("farm_uuid")) + data = deepcopy(CONTEXT_RESPONSE_DATA) + data["farm_uuid"] = str(farm.farm_uuid) return Response( - {"status": "success", "data": CONTEXT_RESPONSE_DATA}, + {"status": "success", "data": data}, status=status.HTTP_200_OK, ) -class ConversationAccessMixin: +class ConversationAccessMixin(FarmAccessMixin): @staticmethod - def _get_conversation(request, conversation_id): + def _get_conversation(request, conversation_id, farm_uuid=None): + filters = {"uuid": conversation_id, "owner": request.user} + if farm_uuid: + filters["farm__farm_uuid"] = farm_uuid try: - return Conversation.objects.get(uuid=conversation_id, owner=request.user) + return Conversation.objects.select_related("farm").get(**filters) except Conversation.DoesNotExist as exc: raise Http404("Conversation not found") from exc @@ -84,18 +107,20 @@ class ConversationAccessMixin: normalized_sections.append(normalized_section) return normalized_sections - def _build_mock_assistant_payload(self, conversation_id): + def _build_mock_assistant_payload(self, conversation): payload = deepcopy(CHAT_RESPONSE_DATA) - payload["conversation_id"] = str(conversation_id) + payload["conversation_id"] = str(conversation.uuid) + payload["farm_uuid"] = str(conversation.farm.farm_uuid) 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")) if conversation_id: - conversation = self._get_conversation(request, conversation_id) + conversation = self._get_conversation(request, conversation_id, farm.farm_uuid) updated_fields = [] if farm_context is not None: conversation.farm_context = farm_context @@ -110,6 +135,7 @@ class ConversationAccessMixin: return Conversation.objects.create( owner=request.user, + farm=farm, title=title or (validated.get("content", "")[:255]) or "New chat", farm_context=farm_context or {}, ) @@ -117,6 +143,7 @@ class ConversationAccessMixin: @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", []), @@ -129,7 +156,7 @@ class ConversationAccessMixin: payload["title"] = validated.get("title", "") return payload - def _extract_assistant_payload(self, adapter_data, conversation_id): + def _extract_assistant_payload(self, adapter_data, conversation): payload_source = adapter_data if isinstance(adapter_data, dict) and isinstance(adapter_data.get("data"), dict): payload_source = adapter_data["data"] @@ -149,13 +176,14 @@ class ConversationAccessMixin: return { "message_id": "", - "conversation_id": str(conversation_id), + "conversation_id": str(conversation.uuid), + "farm_uuid": str(conversation.farm.farm_uuid), "content": content, "sections": sections, } @staticmethod - def _extract_task_submit_payload(adapter_data, conversation_id, message_id): + 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"] @@ -167,11 +195,12 @@ class ConversationAccessMixin: "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_id), + "conversation_id": str(conversation.uuid), "message_id": str(message_id), + "farm_uuid": str(conversation.farm.farm_uuid), } - def _extract_task_status_payload(self, adapter_data, task_id, conversation_id=None): + 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"] @@ -183,8 +212,11 @@ class ConversationAccessMixin: "task_id": str(payload_source.get("task_id") or task_id), "status": str(payload_source.get("status") or ""), } - if conversation_id: - task_status_payload["conversation_id"] = str(conversation_id) + 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"] = str(farm_uuid) progress = payload_source.get("progress") if progress is not None: @@ -231,6 +263,7 @@ class ConversationAccessMixin: return { "message_id": str(message.uuid), "conversation_id": str(message.conversation.uuid), + "farm_uuid": str(message.farm.farm_uuid), "role": message.role, "content": message.content, "sections": ConversationAccessMixin._normalize_sections(sections), @@ -239,11 +272,12 @@ class ConversationAccessMixin: } @staticmethod - def _find_user_message_for_task(request, task_id): + def _find_user_message_for_task(request, task_id, farm_uuid): return ( - Message.objects.select_related("conversation") + 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, ) @@ -252,7 +286,7 @@ class ConversationAccessMixin: ) def _persist_task_result(self, user_message, task_id, result): - assistant_payload = self._extract_assistant_payload(result, user_message.conversation.uuid) + assistant_payload = self._extract_assistant_payload(result, user_message.conversation) assistant_message = ( user_message.conversation.messages.filter( role=Message.ROLE_ASSISTANT, @@ -265,6 +299,7 @@ class ConversationAccessMixin: 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={}, @@ -293,11 +328,15 @@ class ChatListCreateView(ConversationAccessMixin, APIView): @extend_schema( tags=["Farm AI Assistant"], + parameters=[ + OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=True), + ], responses={200: status_response("FarmAiAssistantConversationListResponse", data=ConversationSummarySerializer(many=True))}, ) def get(self, request): + farm = self._get_farm(request, request.query_params.get("farm_uuid")) conversations = ( - Conversation.objects.filter(owner=request.user) + Conversation.objects.filter(owner=request.user, farm=farm) .annotate(message_count=Count("messages")) .order_by("-updated_at", "-created_at") ) @@ -314,8 +353,10 @@ class ChatListCreateView(ConversationAccessMixin, APIView): serializer.is_valid(raise_exception=True) validated = serializer.validated_data + farm = self._get_farm(request, validated.get("farm_uuid")) conversation = Conversation.objects.create( owner=request.user, + farm=farm, title=validated.get("title", "").strip() or "New chat", farm_context=validated.get("farm_context") or {}, ) @@ -323,6 +364,7 @@ class ChatListCreateView(ConversationAccessMixin, APIView): response_serializer = ConversationSummarySerializer( { "uuid": conversation.uuid, + "farm": farm, "message_count": 0, } ) @@ -336,18 +378,21 @@ 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), ], responses={200: status_response("FarmAiAssistantMessageListResponse", data=ConversationMessagesSerializer())}, ) def get(self, request, conversation_id): - conversation = self._get_conversation(request, conversation_id) - messages = conversation.messages.all() + farm = self._get_farm(request, request.query_params.get("farm_uuid")) + conversation = self._get_conversation(request, conversation_id, farm.farm_uuid) + messages = conversation.messages.select_related("farm").all() serialized_messages = [self._serialize_chat_message(message) for message in messages] return Response( { "status": "success", "data": { "conversation_id": str(conversation.uuid), + "farm_uuid": str(farm.farm_uuid), "messages": serialized_messages, }, }, @@ -362,18 +407,22 @@ 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), ], responses={200: status_response("FarmAiAssistantConversationDeleteResponse", data=ConversationDeleteSerializer())}, ) def delete(self, request, conversation_id): - conversation = self._get_conversation(request, conversation_id) + farm = self._get_farm(request, request.query_params.get("farm_uuid")) + conversation = self._get_conversation(request, conversation_id, farm.farm_uuid) deleted_conversation_id = str(conversation.uuid) + deleted_farm_uuid = str(conversation.farm.farm_uuid) conversation.delete() return Response( { "status": "success", "data": { "conversation_id": deleted_conversation_id, + "farm_uuid": deleted_farm_uuid, }, }, status=status.HTTP_200_OK, @@ -397,10 +446,11 @@ class ChatView(ConversationAccessMixin, APIView): user_message = Message.objects.create( conversation=conversation, + farm=conversation.farm, role=Message.ROLE_USER, content=validated.get("content", ""), images=validated.get("images", []), - raw_response={}, + raw_response={"farm_uuid": str(conversation.farm.farm_uuid)}, ) adapter_payload = self._build_adapter_payload(request, validated, conversation) @@ -420,14 +470,15 @@ class ChatView(ConversationAccessMixin, APIView): }, status=adapter_response.status_code, ) - assistant_payload = self._extract_assistant_payload(adapter_response.data, conversation.uuid) + assistant_payload = self._extract_assistant_payload(adapter_response.data, conversation) response_status_code = adapter_response.status_code except ExternalAPIRequestError: - assistant_payload = self._build_mock_assistant_payload(conversation.uuid) + assistant_payload = self._build_mock_assistant_payload(conversation) response_status_code = status.HTTP_200_OK assistant_message = Message.objects.create( conversation=conversation, + farm=conversation.farm, role=Message.ROLE_ASSISTANT, content=assistant_payload.get("content", ""), raw_response={}, @@ -467,10 +518,11 @@ class ChatTaskCreateView(ConversationAccessMixin, APIView): 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={}, + raw_response={"farm_uuid": str(conversation.farm.farm_uuid)}, ) adapter_payload = self._build_adapter_payload(request, validated, conversation) @@ -503,7 +555,7 @@ class ChatTaskCreateView(ConversationAccessMixin, APIView): task_payload = self._extract_task_submit_payload( adapter_response.data, - conversation.uuid, + conversation, user_message.uuid, ) user_message.raw_response = task_payload @@ -526,15 +578,18 @@ 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), ], responses={200: status_response("FarmAiAssistantChatTaskStatusResponse", data=ChatTaskStatusDataSerializer())}, ) def get(self, request, task_id): + farm = self._get_farm(request, request.query_params.get("farm_uuid")) try: adapter_response = external_api_request( "ai", f"/tasks/{task_id}/status", method="GET", + query={"farm_uuid": str(farm.farm_uuid)}, ) except ExternalAPIRequestError: return Response( @@ -556,12 +611,13 @@ class ChatTaskStatusView(ConversationAccessMixin, APIView): status=adapter_response.status_code, ) - user_message = self._find_user_message_for_task(request, task_id) - conversation_id = user_message.conversation.uuid if user_message else None + user_message = self._find_user_message_for_task(request, task_id, farm.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_id=conversation_id, + conversation=conversation, + farm_uuid=farm.farm_uuid, ) result = self._extract_structured_task_result(adapter_response.data) diff --git a/sensor_hub/__init__.py b/farm_hub/__init__.py similarity index 100% rename from sensor_hub/__init__.py rename to farm_hub/__init__.py diff --git a/sensor_hub/apps.py b/farm_hub/apps.py similarity index 61% rename from sensor_hub/apps.py rename to farm_hub/apps.py index 4293831..2b14420 100644 --- a/sensor_hub/apps.py +++ b/farm_hub/apps.py @@ -1,6 +1,6 @@ from django.apps import AppConfig -class SensorHubConfig(AppConfig): +class FarmHubConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" - name = "sensor_hub" + name = "farm_hub" diff --git a/sensor_hub/management/__init__.py b/farm_hub/management/__init__.py similarity index 100% rename from sensor_hub/management/__init__.py rename to farm_hub/management/__init__.py diff --git a/sensor_hub/management/commands/__init__.py b/farm_hub/management/commands/__init__.py similarity index 100% rename from sensor_hub/management/commands/__init__.py rename to farm_hub/management/commands/__init__.py diff --git a/sensor_hub/management/commands/seed_admin_sensor.py b/farm_hub/management/commands/seed_admin_farm.py similarity index 53% rename from sensor_hub/management/commands/seed_admin_sensor.py rename to farm_hub/management/commands/seed_admin_farm.py index 143b064..7a407ff 100644 --- a/sensor_hub/management/commands/seed_admin_sensor.py +++ b/farm_hub/management/commands/seed_admin_farm.py @@ -1,21 +1,20 @@ from django.core.management.base import BaseCommand, CommandError -from sensor_hub.seeds import seed_admin_sensor +from farm_hub.seeds import seed_admin_farm class Command(BaseCommand): - help = "Create or update the default full sensor for the admin user." + help = "Create or update the default farm hub for the admin user." def handle(self, *args, **options): try: - sensor, created = seed_admin_sensor() + farm, created = seed_admin_farm() except ValueError as exc: raise CommandError(str(exc)) from exc action = "created" if created else "updated" self.stdout.write( self.style.SUCCESS( - f"Admin sensor {action}: uuid_sensor={sensor.uuid_sensor}, " - f"name={sensor.name}, owner={sensor.owner.username}" + f"Admin farm {action}: farm_uuid={farm.farm_uuid}, name={farm.name}, owner={farm.owner.username}" ) ) diff --git a/farm_hub/migrations/0001_initial.py b/farm_hub/migrations/0001_initial.py new file mode 100644 index 0000000..8cd03eb --- /dev/null +++ b/farm_hub/migrations/0001_initial.py @@ -0,0 +1,125 @@ +# Generated by Django 5.2.12 on 2026-03-19 15:01 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="FarmType", + 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)), + ("name", models.CharField(db_index=True, max_length=255, unique=True)), + ("description", models.TextField(blank=True, default="")), + ("metadata", models.JSONField(blank=True, default=dict)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ], + options={ + "db_table": "farm_types", + "ordering": ["name"], + }, + ), + migrations.CreateModel( + name="FarmHub", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("farm_uuid", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True)), + ("name", models.CharField(max_length=255)), + ("is_active", models.BooleanField(default=True)), + ("customization", models.JSONField(blank=True, default=dict)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "farm_type", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="farms", + to="farm_hub.farmtype", + ), + ), + ( + "owner", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="farm_hubs", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "db_table": "farm_hubs", + "ordering": ["-created_at"], + }, + ), + migrations.CreateModel( + name="Product", + 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)), + ("name", models.CharField(db_index=True, max_length=255)), + ("description", models.TextField(blank=True, default="")), + ("metadata", models.JSONField(blank=True, default=dict)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "farm_type", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="products", + to="farm_hub.farmtype", + ), + ), + ], + options={ + "db_table": "products", + "ordering": ["name"], + }, + ), + migrations.CreateModel( + name="FarmSensor", + 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)), + ("name", models.CharField(max_length=255)), + ("sensor_type", models.CharField(blank=True, default="", max_length=255)), + ("is_active", models.BooleanField(default=True)), + ("specifications", models.JSONField(blank=True, default=dict)), + ("power_source", models.JSONField(blank=True, default=dict)), + ("customization", models.JSONField(blank=True, default=dict)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "farm", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="sensors", + to="farm_hub.farmhub", + ), + ), + ], + options={ + "db_table": "farm_sensors", + "ordering": ["-created_at"], + }, + ), + migrations.AddField( + model_name="farmhub", + name="products", + field=models.ManyToManyField(blank=True, related_name="farms", to="farm_hub.product"), + ), + migrations.AddConstraint( + model_name="product", + constraint=models.UniqueConstraint(fields=("farm_type", "name"), name="unique_product_per_farm_type"), + ), + ] diff --git a/farm_hub/migrations/0002_seed_default_catalog.py b/farm_hub/migrations/0002_seed_default_catalog.py new file mode 100644 index 0000000..6bcf4d6 --- /dev/null +++ b/farm_hub/migrations/0002_seed_default_catalog.py @@ -0,0 +1,33 @@ +from django.db import migrations + + +FARM_TYPES = { + "زراعی": ["گندم", "ذرت"], + "درختی": ["سیب", "پسته"], + "غرقابی": ["برنج"], +} + + +def seed_catalog(apps, schema_editor): + FarmType = apps.get_model("farm_hub", "FarmType") + Product = apps.get_model("farm_hub", "Product") + + for farm_type_name, products in FARM_TYPES.items(): + farm_type, _ = FarmType.objects.get_or_create(name=farm_type_name) + for product_name in products: + Product.objects.get_or_create(farm_type=farm_type, name=product_name) + + +def unseed_catalog(apps, schema_editor): + FarmType = apps.get_model("farm_hub", "FarmType") + FarmType.objects.filter(name__in=FARM_TYPES.keys()).delete() + + +class Migration(migrations.Migration): + dependencies = [ + ("farm_hub", "0001_initial"), + ] + + operations = [ + migrations.RunPython(seed_catalog, unseed_catalog), + ] diff --git a/farm_hub/migrations/__init__.py b/farm_hub/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/farm_hub/models.py b/farm_hub/models.py new file mode 100644 index 0000000..ebfb2d6 --- /dev/null +++ b/farm_hub/models.py @@ -0,0 +1,95 @@ +import uuid + +from django.conf import settings +from django.db import models + + +class FarmType(models.Model): + uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True) + name = models.CharField(max_length=255, unique=True, db_index=True) + description = models.TextField(blank=True, default="") + metadata = 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_types" + ordering = ["name"] + + def __str__(self): + return self.name + + +class Product(models.Model): + uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True) + farm_type = models.ForeignKey( + FarmType, + on_delete=models.CASCADE, + related_name="products", + ) + name = models.CharField(max_length=255, db_index=True) + description = models.TextField(blank=True, default="") + metadata = 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 = "products" + ordering = ["name"] + constraints = [ + models.UniqueConstraint(fields=["farm_type", "name"], name="unique_product_per_farm_type"), + ] + + def __str__(self): + return self.name + + +class FarmHub(models.Model): + farm_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_hubs", + ) + farm_type = models.ForeignKey( + FarmType, + on_delete=models.PROTECT, + related_name="farms", + ) + name = models.CharField(max_length=255) + is_active = models.BooleanField(default=True) + customization = models.JSONField(default=dict, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + products = models.ManyToManyField(Product, related_name="farms", blank=True) + + class Meta: + db_table = "farm_hubs" + ordering = ["-created_at"] + + def __str__(self): + return f"{self.name} ({self.farm_uuid})" + + +class FarmSensor(models.Model): + uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True) + farm = models.ForeignKey( + FarmHub, + on_delete=models.CASCADE, + related_name="sensors", + ) + name = models.CharField(max_length=255) + sensor_type = models.CharField(max_length=255, blank=True, default="") + is_active = models.BooleanField(default=True) + specifications = models.JSONField(default=dict, blank=True) + power_source = models.JSONField(default=dict, blank=True) + customization = 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_sensors" + ordering = ["-created_at"] + + def __str__(self): + return f"{self.name} ({self.uuid})" diff --git a/farm_hub/postman/farm_hub.json b/farm_hub/postman/farm_hub.json new file mode 100644 index 0000000..8cafcab --- /dev/null +++ b/farm_hub/postman/farm_hub.json @@ -0,0 +1,117 @@ +{ + "info": { + "name": "Farm Hub", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "description": "Farm Hub API. GET list, GET by uuid, POST add, PATCH update, DELETE delete, POST active/deactive. Authenticated user required." + }, + "item": [ + { + "name": "List farms", + "request": { + "method": "GET", + "header": [ + {"key": "Content-Type", "value": "application/json"}, + {"key": "Authorization", "value": "Bearer {{token}}", "description": "Required: user must be authenticated"} + ], + "url": "{{baseUrl}}/api/farm-hub/", + "description": "Get farms for current user." + }, + "response": [ + { + "name": "Success", + "status": "OK", + "code": 200, + "body": "{\n \"code\": 200,\n \"msg\": \"success\",\n \"data\": [\n {\n \"farm_uuid\": \"550e8400-e29b-41d4-a716-446655440000\",\n \"name\": \"مزرعه نمونه\",\n \"is_active\": true,\n \"customization\": {\"report_interval_sec\": 300},\n \"farm_type\": {\"uuid\": \"11111111-1111-1111-1111-111111111111\", \"name\": \"زراعی\", \"description\": \"\", \"metadata\": {}},\n \"products\": [{\"uuid\": \"22222222-2222-2222-2222-222222222222\", \"name\": \"گندم\", \"description\": \"\", \"metadata\": {}}],\n \"sensors\": [\n {\n \"uuid\": \"33333333-3333-3333-3333-333333333333\",\n \"name\": \"Station 1\",\n \"sensor_type\": \"weather_station\",\n \"is_active\": true,\n \"specifications\": {\"model\": \"FH-1\"},\n \"power_source\": {\"type\": \"battery\"},\n \"customization\": {\"report_interval_sec\": 300},\n \"last_updated\": \"2025-02-18T12:00:00Z\"\n }\n ],\n \"last_updated\": \"2025-02-18T12:00:00Z\"\n }\n ]\n}" + } + ] + }, + { + "name": "Get farm details", + "request": { + "method": "GET", + "header": [ + {"key": "Content-Type", "value": "application/json"}, + {"key": "Authorization", "value": "Bearer {{token}}", "description": "Required: user must be authenticated"} + ], + "url": "{{baseUrl}}/api/farm-hub/{{farmUuid}}/", + "description": "Get one farm by farm uuid." + }, + "response": [ + { + "name": "Success", + "status": "OK", + "code": 200, + "body": "{\n \"code\": 200,\n \"msg\": \"success\",\n \"data\": {\n \"farm_uuid\": \"550e8400-e29b-41d4-a716-446655440000\",\n \"name\": \"مزرعه نمونه\",\n \"is_active\": true,\n \"customization\": {\"report_interval_sec\": 300},\n \"farm_type\": {\"uuid\": \"11111111-1111-1111-1111-111111111111\", \"name\": \"زراعی\", \"description\": \"\", \"metadata\": {}},\n \"products\": [{\"uuid\": \"22222222-2222-2222-2222-222222222222\", \"name\": \"گندم\", \"description\": \"\", \"metadata\": {}}],\n \"sensors\": [\n {\n \"uuid\": \"33333333-3333-3333-3333-333333333333\",\n \"name\": \"Station 1\",\n \"sensor_type\": \"weather_station\",\n \"is_active\": true,\n \"specifications\": {\"model\": \"FH-1\"},\n \"power_source\": {\"type\": \"battery\"},\n \"customization\": {\"report_interval_sec\": 300},\n \"last_updated\": \"2025-02-18T12:00:00Z\"\n }\n ],\n \"last_updated\": \"2025-02-18T12:00:00Z\"\n }\n}" + } + ] + }, + { + "name": "Create farm", + "request": { + "method": "POST", + "header": [ + {"key": "Content-Type", "value": "application/json"}, + {"key": "Authorization", "value": "Bearer {{token}}", "description": "Required: user must be authenticated"} + ], + "body": {"mode": "raw", "raw": "{\n \"name\": \"مزرعه شماره 1\",\n \"farm_type_uuid\": \"11111111-1111-1111-1111-111111111111\",\n \"product_uuids\": [\"22222222-2222-2222-2222-222222222222\"],\n \"customization\": {\"report_interval_sec\": 300},\n \"sensors\": [\n {\n \"name\": \"Station 1\",\n \"sensor_type\": \"weather_station\",\n \"is_active\": true,\n \"specifications\": {\"model\": \"FH-1\"},\n \"power_source\": {\"type\": \"battery\"},\n \"customization\": {\"report_interval_sec\": 300}\n }\n ]\n}"}, + "url": "{{baseUrl}}/api/farm-hub/", + "description": "Create a farm with its sensors." + } + }, + { + "name": "Update farm", + "request": { + "method": "PATCH", + "header": [ + {"key": "Content-Type", "value": "application/json"}, + {"key": "Authorization", "value": "Bearer {{token}}", "description": "Required: user must be authenticated"} + ], + "body": {"mode": "raw", "raw": "{}"}, + "url": "{{baseUrl}}/api/farm-hub/{{farmUuid}}/", + "description": "Update farm by farm uuid." + } + }, + { + "name": "Delete farm", + "request": { + "method": "DELETE", + "header": [ + {"key": "Authorization", "value": "Bearer {{token}}", "description": "Required: user must be authenticated"} + ], + "url": "{{baseUrl}}/api/farm-hub/{{farmUuid}}/", + "description": "Delete farm by farm uuid." + } + }, + { + "name": "Activate farm", + "request": { + "method": "POST", + "header": [ + {"key": "Content-Type", "value": "application/json"}, + {"key": "Authorization", "value": "Bearer {{token}}", "description": "Required: user must be authenticated"} + ], + "body": {"mode": "raw", "raw": "{\n \"farm_uuid\": \"550e8400-e29b-41d4-a716-446655440000\"\n}"}, + "url": "{{baseUrl}}/api/farm-hub/active/", + "description": "Activate one farm." + } + }, + { + "name": "Deactivate farm", + "request": { + "method": "POST", + "header": [ + {"key": "Content-Type", "value": "application/json"}, + {"key": "Authorization", "value": "Bearer {{token}}", "description": "Required: user must be authenticated"} + ], + "body": {"mode": "raw", "raw": "{\n \"farm_uuid\": \"550e8400-e29b-41d4-a716-446655440000\"\n}"}, + "url": "{{baseUrl}}/api/farm-hub/deactive/", + "description": "Deactivate one farm." + } + } + ], + "variable": [ + {"key": "baseUrl", "value": "http://localhost:8000"}, + {"key": "token", "value": ""}, + {"key": "farmUuid", "value": "550e8400-e29b-41d4-a716-446655440000"} + ] +} diff --git a/farm_hub/seeds.py b/farm_hub/seeds.py new file mode 100644 index 0000000..7ce9ec5 --- /dev/null +++ b/farm_hub/seeds.py @@ -0,0 +1,110 @@ +import uuid + +from django.db import transaction + +from account.seeds import seed_admin_user + +from .models import FarmHub, FarmType, Product +from .services import dispatch_farm_zoning + + +ADMIN_FARM_UUID = uuid.UUID("11111111-1111-1111-1111-111111111111") +ADMIN_FARM_DATA = { + "name": "Admin Smart Farm", + "is_active": True, + "customization": { + "irrigation": { + "mode": "smart", + "report_interval_sec": 300, + }, + "alerts": { + "sms": True, + "email": True, + "push": True, + }, + }, + "sensors": [ + { + "name": "Station 1", + "sensor_type": "weather_station", + "is_active": True, + "specifications": { + "model": "CL-SENSE-PRO-X", + "firmware": "2.4.1", + "manufacturer": "CropLogic", + }, + "power_source": { + "type": "hybrid", + "battery": {"capacity_mah": 12000, "voltage": 12}, + "solar": {"panel_watt": 40, "controller": "MPPT"}, + }, + "customization": { + "thresholds": { + "temperature_c": {"min": 10, "max": 36}, + "humidity_percent": {"min": 30, "max": 85}, + } + }, + }, + { + "name": "Soil Probe 1", + "sensor_type": "soil_probe", + "is_active": True, + "specifications": { + "capabilities": ["soil_moisture", "soil_temperature", "ph", "ec"], + }, + "power_source": {"type": "battery", "backup": "solar"}, + "customization": { + "depth_cm": [20, 40], + "thresholds": { + "soil_moisture_percent": {"min": 25, "max": 70}, + "ph": {"min": 5.8, "max": 7.2}, + }, + }, + }, + ], +} + +ADMIN_FARM_AREA_GEOJSON = { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [51.418934, 35.706815], + [51.423054, 35.691062], + [51.384258, 35.689389], + [51.418934, 35.706815], + ] + ], + }, +} + + +def _get_default_catalog(): + farm_type, _ = FarmType.objects.get_or_create(name="زراعی") + wheat, _ = Product.objects.get_or_create(farm_type=farm_type, name="گندم") + corn, _ = Product.objects.get_or_create(farm_type=farm_type, name="ذرت") + return farm_type, [wheat, corn] + + +@transaction.atomic +def seed_admin_farm(): + owner, _ = seed_admin_user() + farm_type, products = _get_default_catalog() + farm, created = FarmHub.objects.update_or_create( + farm_uuid=ADMIN_FARM_UUID, + defaults={ + "owner": owner, + "farm_type": farm_type, + "name": ADMIN_FARM_DATA["name"], + "is_active": ADMIN_FARM_DATA["is_active"], + "customization": ADMIN_FARM_DATA["customization"], + }, + ) + farm.products.set(products) + farm.sensors.all().delete() + farm.sensors.bulk_create([farm.sensors.model(farm=farm, **sensor_data) for sensor_data in ADMIN_FARM_DATA["sensors"]]) + if created: + dispatch_farm_zoning(ADMIN_FARM_AREA_GEOJSON, farm) + return farm, created diff --git a/farm_hub/serializers.py b/farm_hub/serializers.py new file mode 100644 index 0000000..fff348a --- /dev/null +++ b/farm_hub/serializers.py @@ -0,0 +1,180 @@ +from rest_framework import serializers + +from .models import FarmHub, FarmSensor, FarmType, Product + + +class FarmTypeSerializer(serializers.ModelSerializer): + class Meta: + model = FarmType + fields = ["uuid", "name", "description", "metadata"] + + +class ProductSerializer(serializers.ModelSerializer): + class Meta: + model = Product + fields = ["uuid", "name", "description", "metadata"] + + +class FarmSensorSerializer(serializers.ModelSerializer): + last_updated = serializers.DateTimeField(source="updated_at", read_only=True) + + class Meta: + model = FarmSensor + fields = [ + "uuid", + "name", + "sensor_type", + "is_active", + "specifications", + "power_source", + "customization", + "last_updated", + ] + read_only_fields = ["uuid", "last_updated"] + + +class FarmHubSerializer(serializers.ModelSerializer): + last_updated = serializers.DateTimeField(source="updated_at", read_only=True) + farm_type = FarmTypeSerializer(read_only=True) + products = ProductSerializer(many=True, read_only=True) + sensors = FarmSensorSerializer(many=True, read_only=True) + + class Meta: + model = FarmHub + fields = [ + "farm_uuid", + "name", + "is_active", + "customization", + "farm_type", + "products", + "sensors", + "last_updated", + ] + read_only_fields = ["farm_uuid", "last_updated"] + + +class FarmSensorWriteSerializer(serializers.ModelSerializer): + class Meta: + model = FarmSensor + fields = [ + "name", + "sensor_type", + "is_active", + "specifications", + "power_source", + "customization", + ] + + +class FarmHubCreateSerializer(serializers.ModelSerializer): + area_geojson = serializers.JSONField(write_only=True, required=False) + farm_type_uuid = serializers.UUIDField(write_only=True) + product_uuids = serializers.ListField( + child=serializers.UUIDField(), + write_only=True, + allow_empty=False, + ) + sensors = FarmSensorWriteSerializer(many=True, required=False) + + class Meta: + model = FarmHub + fields = [ + "name", + "is_active", + "customization", + "farm_type_uuid", + "product_uuids", + "sensors", + "area_geojson", + ] + + def validate_area_geojson(self, value): + if not isinstance(value, dict): + raise serializers.ValidationError("`area_geojson` must be a GeoJSON object.") + + geometry = value.get("geometry") if value.get("type") == "Feature" else value + if not isinstance(geometry, dict): + raise serializers.ValidationError("`area_geojson.geometry` is required.") + + if geometry.get("type") != "Polygon": + raise serializers.ValidationError("`area_geojson.geometry.type` must be `Polygon`.") + + coordinates = geometry.get("coordinates") + if not isinstance(coordinates, list) or not coordinates or not isinstance(coordinates[0], list): + raise serializers.ValidationError("`area_geojson.geometry.coordinates` must be a polygon ring.") + + return value + + def validate(self, attrs): + farm_type_uuid = attrs.get("farm_type_uuid") + product_uuids = attrs.get("product_uuids") + + if farm_type_uuid is None: + if self.instance is None: + raise serializers.ValidationError({"farm_type_uuid": ["This field is required."]}) + farm_type = self.instance.farm_type + else: + try: + farm_type = FarmType.objects.get(uuid=farm_type_uuid) + except FarmType.DoesNotExist as exc: + raise serializers.ValidationError({"farm_type_uuid": ["Farm type not found."]}) from exc + + if product_uuids is None: + products = list(self.instance.products.all()) if self.instance is not None else [] + else: + products = list(Product.objects.filter(uuid__in=product_uuids)) + if len(products) != len(product_uuids): + raise serializers.ValidationError({"product_uuids": ["One or more products were not found."]}) + + invalid_products = [product.name for product in products if product.farm_type_id != farm_type.id] + if invalid_products: + raise serializers.ValidationError( + {"product_uuids": [f"Products must belong to farm type `{farm_type.name}`."]} + ) + + attrs["farm_type"] = farm_type + attrs["products"] = products + return attrs + + def create(self, validated_data): + validated_data.pop("area_geojson", None) + sensors_data = validated_data.pop("sensors", []) + products = validated_data.pop("products", []) + validated_data["farm_type"] = validated_data.pop("farm_type") + validated_data.pop("farm_type_uuid", None) + validated_data.pop("product_uuids", None) + + farm = super().create(validated_data) + if products: + farm.products.set(products) + if sensors_data: + FarmSensor.objects.bulk_create([FarmSensor(farm=farm, **sensor_data) for sensor_data in sensors_data]) + return farm + + def update(self, instance, validated_data): + validated_data.pop("area_geojson", None) + sensors_data = validated_data.pop("sensors", None) + products = validated_data.pop("products", None) + farm_type = validated_data.pop("farm_type", None) + validated_data.pop("farm_type_uuid", None) + validated_data.pop("product_uuids", None) + + for attr, value in validated_data.items(): + setattr(instance, attr, value) + if farm_type is not None: + instance.farm_type = farm_type + instance.save() + + if products is not None: + instance.products.set(products) + if sensors_data is not None: + instance.sensors.all().delete() + if sensors_data: + FarmSensor.objects.bulk_create([FarmSensor(farm=instance, **sensor_data) for sensor_data in sensors_data]) + + return instance + + +class FarmToggleSerializer(serializers.Serializer): + farm_uuid = serializers.UUIDField() diff --git a/sensor_hub/services.py b/farm_hub/services.py similarity index 59% rename from sensor_hub/services.py rename to farm_hub/services.py index 258297e..89e5d34 100644 --- a/sensor_hub/services.py +++ b/farm_hub/services.py @@ -3,19 +3,19 @@ from django.db import transaction from crop_zoning.services import create_zones_and_dispatch, get_initial_zones_payload, normalize_area_feature -def dispatch_sensor_zoning(area_feature, sensor): - crop_area, _zones = create_zones_and_dispatch(normalize_area_feature(area_feature), sensor=sensor) +def dispatch_farm_zoning(area_feature, farm): + crop_area, _zones = create_zones_and_dispatch(normalize_area_feature(area_feature), farm=farm) return get_initial_zones_payload(crop_area) -def create_sensor_with_zoning(serializer, owner): +def create_farm_with_zoning(serializer, owner): area_feature = serializer.validated_data.pop("area_geojson", None) with transaction.atomic(): - sensor = serializer.save(owner=owner) + farm = serializer.save(owner=owner) zoning_payload = None if area_feature is not None: - zoning_payload = dispatch_sensor_zoning(area_feature, sensor) + zoning_payload = dispatch_farm_zoning(area_feature, farm) - return sensor, zoning_payload + return farm, zoning_payload diff --git a/sensor_hub/tests.py b/farm_hub/tests.py similarity index 51% rename from sensor_hub/tests.py rename to farm_hub/tests.py index 6507f9d..df0e54a 100644 --- a/sensor_hub/tests.py +++ b/farm_hub/tests.py @@ -3,8 +3,9 @@ from django.test import TestCase, override_settings from rest_framework.test import APIRequestFactory, force_authenticate from crop_zoning.models import CropArea -from sensor_hub.seeds import seed_admin_sensor -from sensor_hub.views import SensorListCreateView +from farm_hub.models import FarmType, Product +from farm_hub.seeds import seed_admin_farm +from farm_hub.views import FarmListCreateView AREA_GEOJSON = { @@ -28,7 +29,7 @@ AREA_GEOJSON = { USE_EXTERNAL_API_MOCK=True, CROP_ZONE_CHUNK_AREA_SQM=200000, ) -class SensorListCreateViewTests(TestCase): +class FarmListCreateViewTests(TestCase): def setUp(self): self.factory = APIRequestFactory() self.user = get_user_model().objects.create_user( @@ -37,27 +38,39 @@ class SensorListCreateViewTests(TestCase): email="farmer@example.com", phone_number="09120000000", ) + self.farm_type, _ = FarmType.objects.get_or_create(name="زراعی") + self.wheat, _ = Product.objects.get_or_create(farm_type=self.farm_type, name="گندم") - def test_create_sensor_with_area_geojson_creates_crop_zoning_payload(self): + def test_create_farm_with_area_geojson_creates_crop_zoning_payload(self): request = self.factory.post( - "/api/sensor-hub/", + "/api/farm-hub/", { - "name": "zone-sensor", - "specifications": {"model": "SH-1"}, - "power_source": {"type": "battery"}, - "customized_sensors": {"report_interval_sec": 300}, + "name": "farm-1", + "farm_type_uuid": str(self.farm_type.uuid), + "product_uuids": [str(self.wheat.uuid)], + "customization": {"report_interval_sec": 300}, + "sensors": [ + { + "name": "zone-sensor", + "sensor_type": "weather_station", + "specifications": {"model": "FH-1"}, + "power_source": {"type": "battery"}, + "customization": {"report_interval_sec": 300}, + } + ], "area_geojson": AREA_GEOJSON, }, format="json", ) force_authenticate(request, user=self.user) - response = SensorListCreateView.as_view()(request) + response = FarmListCreateView.as_view()(request) self.assertEqual(response.status_code, 201) self.assertEqual(response.data["code"], 201) - self.assertEqual(response.data["data"]["name"], "zone-sensor") + self.assertEqual(response.data["data"]["name"], "farm-1") self.assertIn("zoning", response.data["data"]) + self.assertEqual(len(response.data["data"]["sensors"]), 1) self.assertGreater(response.data["data"]["zoning"]["zone_count"], 1) self.assertEqual( response.data["data"]["zoning"]["zone_count"], @@ -70,19 +83,20 @@ class SensorListCreateViewTests(TestCase): USE_EXTERNAL_API_MOCK=True, CROP_ZONE_CHUNK_AREA_SQM=200000, ) -class SensorSeedTests(TestCase): - def test_seed_admin_sensor_dispatches_crop_logic_flow_on_create(self): - sensor, created = seed_admin_sensor() +class FarmSeedTests(TestCase): + def test_seed_admin_farm_dispatches_crop_logic_flow_on_create(self): + farm, created = seed_admin_farm() self.assertTrue(created) - self.assertEqual(sensor.uuid_sensor.hex, "11111111111111111111111111111111") + self.assertEqual(farm.farm_uuid.hex, "11111111111111111111111111111111") self.assertEqual(CropArea.objects.count(), 1) + self.assertEqual(farm.sensors.count(), 2) - def test_seed_admin_sensor_does_not_dispatch_twice_for_existing_seed(self): - first_sensor, first_created = seed_admin_sensor() - second_sensor, second_created = seed_admin_sensor() + def test_seed_admin_farm_does_not_dispatch_twice_for_existing_seed(self): + first_farm, first_created = seed_admin_farm() + second_farm, second_created = seed_admin_farm() self.assertTrue(first_created) self.assertFalse(second_created) - self.assertEqual(first_sensor.id, second_sensor.id) + self.assertEqual(first_farm.id, second_farm.id) self.assertEqual(CropArea.objects.count(), 1) diff --git a/farm_hub/urls.py b/farm_hub/urls.py new file mode 100644 index 0000000..0b9732d --- /dev/null +++ b/farm_hub/urls.py @@ -0,0 +1,10 @@ +from django.urls import path + +from .views import FarmActiveView, FarmDeactiveView, FarmDetailView, FarmListCreateView + +urlpatterns = [ + path("active/", FarmActiveView.as_view(), name="farm-hub-active"), + path("deactive/", FarmDeactiveView.as_view(), name="farm-hub-deactive"), + path("/", FarmDetailView.as_view(), name="farm-hub-detail"), + path("", FarmListCreateView.as_view(), name="farm-hub-list"), +] diff --git a/farm_hub/views.py b/farm_hub/views.py new file mode 100644 index 0000000..ff36da8 --- /dev/null +++ b/farm_hub/views.py @@ -0,0 +1,139 @@ +from django.core.exceptions import ImproperlyConfigured +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.utils import extend_schema + +from config.swagger import code_response +from .models import FarmHub +from .serializers import FarmHubCreateSerializer, FarmHubSerializer, FarmToggleSerializer +from .services import create_farm_with_zoning + + +class FarmHubBaseView(APIView): + permission_classes = [IsAuthenticated] + + def _get_farm(self, request, farm_uuid): + try: + return FarmHub.objects.prefetch_related("products", "sensors").select_related("farm_type").get( + farm_uuid=farm_uuid, + owner=request.user, + ) + except FarmHub.DoesNotExist: + return None + + +class FarmListCreateView(FarmHubBaseView): + @extend_schema( + tags=["Farm Hub"], + responses={200: code_response("FarmListResponse", data=FarmHubSerializer(many=True))}, + ) + def get(self, request): + farms = FarmHub.objects.filter(owner=request.user).select_related("farm_type").prefetch_related( + "products", + "sensors", + ) + data = FarmHubSerializer(farms, many=True).data + return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK) + + @extend_schema( + tags=["Farm Hub"], + request=FarmHubCreateSerializer, + responses={201: code_response("FarmCreateResponse", data=FarmHubSerializer())}, + ) + def post(self, request): + serializer = FarmHubCreateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + try: + farm, zoning_payload = create_farm_with_zoning(serializer, owner=request.user) + except ValueError as exc: + raise serializers.ValidationError({"area_geojson": [str(exc)]}) from exc + except ImproperlyConfigured as exc: + return Response({"code": 500, "msg": str(exc)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + data = FarmHubSerializer(farm).data + if zoning_payload is not None: + data["zoning"] = zoning_payload + return Response({"code": 201, "msg": "success", "data": data}, status=status.HTTP_201_CREATED) + + +class FarmDetailView(FarmHubBaseView): + @extend_schema( + tags=["Farm Hub"], + responses={ + 200: code_response("FarmDetailResponse", data=FarmHubSerializer()), + 404: code_response("FarmNotFoundResponse"), + }, + ) + def get(self, request, farm_uuid): + farm = self._get_farm(request, farm_uuid) + if farm is None: + return Response({"code": 404, "msg": "Farm not found."}, status=status.HTTP_404_NOT_FOUND) + data = FarmHubSerializer(farm).data + return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK) + + @extend_schema( + tags=["Farm Hub"], + request=FarmHubCreateSerializer, + responses={ + 200: code_response("FarmUpdateResponse", data=FarmHubSerializer()), + 404: code_response("FarmUpdateNotFoundResponse"), + }, + ) + def patch(self, request, farm_uuid): + farm = self._get_farm(request, farm_uuid) + if farm is None: + return Response({"code": 404, "msg": "Farm not found."}, status=status.HTTP_404_NOT_FOUND) + serializer = FarmHubCreateSerializer(farm, data=request.data, partial=True) + serializer.is_valid(raise_exception=True) + serializer.save() + farm.refresh_from_db() + data = FarmHubSerializer(farm).data + return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK) + + @extend_schema( + tags=["Farm Hub"], + responses={ + 200: code_response("FarmDeleteResponse"), + 404: code_response("FarmDeleteNotFoundResponse"), + }, + ) + def delete(self, request, farm_uuid): + farm = self._get_farm(request, farm_uuid) + if farm is None: + return Response({"code": 404, "msg": "Farm not found."}, status=status.HTTP_404_NOT_FOUND) + farm.delete() + return Response({"code": 200, "msg": "success"}, status=status.HTTP_200_OK) + + +class FarmToggleView(FarmHubBaseView): + action = None + + @extend_schema( + tags=["Farm Hub"], + request=FarmToggleSerializer, + responses={ + 200: code_response("FarmToggleResponse"), + 400: code_response("FarmToggleValidationResponse"), + 404: code_response("FarmToggleNotFoundResponse"), + }, + ) + def post(self, request): + serializer = FarmToggleSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + farm = self._get_farm(request, serializer.validated_data["farm_uuid"]) + if farm is None: + return Response({"code": 404, "msg": "Farm not found."}, status=status.HTTP_404_NOT_FOUND) + + farm.is_active = self.action == "active" + farm.save(update_fields=["is_active", "updated_at"]) + return Response({"code": 200, "msg": "success"}, status=status.HTTP_200_OK) + + +class FarmActiveView(FarmToggleView): + action = "active" + + +class FarmDeactiveView(FarmToggleView): + action = "deactive" diff --git a/fertilization_recommendation/migrations/0001_initial.py b/fertilization_recommendation/migrations/0001_initial.py new file mode 100644 index 0000000..d8408b9 --- /dev/null +++ b/fertilization_recommendation/migrations/0001_initial.py @@ -0,0 +1,41 @@ +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("farm_hub", "0002_seed_default_catalog"), + ] + + operations = [ + migrations.CreateModel( + name="FertilizationRecommendationRequest", + 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)), + ("crop_id", models.CharField(blank=True, default="", max_length=255)), + ("growth_stage", models.CharField(blank=True, default="", max_length=255)), + ("task_id", models.CharField(blank=True, db_index=True, default="", max_length=255)), + ("status", models.CharField(blank=True, default="", max_length=64)), + ("request_payload", models.JSONField(blank=True, default=dict)), + ("response_payload", models.JSONField(blank=True, default=dict)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "farm", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="fertilization_recommendations", + to="farm_hub.farmhub", + ), + ), + ], + options={ + "db_table": "fertilization_recommendation_requests", + "ordering": ["-created_at", "-id"], + }, + ), + ] diff --git a/fertilization_recommendation/migrations/__init__.py b/fertilization_recommendation/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fertilization_recommendation/models.py b/fertilization_recommendation/models.py new file mode 100644 index 0000000..cbbb5f0 --- /dev/null +++ b/fertilization_recommendation/models.py @@ -0,0 +1,29 @@ +import uuid + +from django.db import models + +from farm_hub.models import FarmHub + + +class FertilizationRecommendationRequest(models.Model): + uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True) + farm = models.ForeignKey( + FarmHub, + on_delete=models.CASCADE, + related_name="fertilization_recommendations", + ) + crop_id = models.CharField(max_length=255, blank=True, default="") + growth_stage = models.CharField(max_length=255, blank=True, default="") + task_id = models.CharField(max_length=255, blank=True, default="", db_index=True) + status = models.CharField(max_length=64, blank=True, default="") + request_payload = models.JSONField(default=dict, blank=True) + response_payload = 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 = "fertilization_recommendation_requests" + ordering = ["-created_at", "-id"] + + def __str__(self): + return self.task_id or str(self.uuid) diff --git a/fertilization_recommendation/serializers.py b/fertilization_recommendation/serializers.py index f728f69..8cc9f82 100644 --- a/fertilization_recommendation/serializers.py +++ b/fertilization_recommendation/serializers.py @@ -8,6 +8,7 @@ class FertilizationFarmDataSerializer(serializers.Serializer): class FertilizationRecommendRequestSerializer(serializers.Serializer): + farm_uuid = serializers.UUIDField(required=True) crop_id = serializers.CharField(required=False, allow_blank=True) growth_stage = serializers.CharField(required=False, allow_blank=True) farm_data = FertilizationFarmDataSerializer(required=False) diff --git a/fertilization_recommendation/views.py b/fertilization_recommendation/views.py index ca7c20d..7fbced1 100644 --- a/fertilization_recommendation/views.py +++ b/fertilization_recommendation/views.py @@ -10,7 +10,9 @@ 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 farm_hub.models import FarmHub from .mock_data import CONFIG_RESPONSE_DATA +from .models import FertilizationRecommendationRequest from .serializers import ( FertilizationRecommendRequestSerializer, FertilizationRecommendResponseDataSerializer, @@ -19,45 +21,85 @@ from .serializers import ( ) -class ConfigView(APIView): +class FarmAccessMixin: + @staticmethod + def _get_farm(request, farm_uuid): + if not farm_uuid: + raise serializers.ValidationError({"farm_uuid": ["This field is required."]}) + try: + return FarmHub.objects.get(farm_uuid=farm_uuid, owner=request.user) + except FarmHub.DoesNotExist as exc: + raise serializers.ValidationError({"farm_uuid": ["Farm not found."]}) from exc + + +class ConfigView(FarmAccessMixin, APIView): @extend_schema( tags=["Fertilization Recommendation"], + parameters=[ + OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=True), + ], responses={200: status_response("FertilizationConfigResponse", data=serializers.JSONField())}, ) def get(self, request): - return Response({"status": "success", "data": CONFIG_RESPONSE_DATA}, status=status.HTTP_200_OK) + farm = self._get_farm(request, request.query_params.get("farm_uuid")) + data = dict(CONFIG_RESPONSE_DATA) + data["farm_uuid"] = str(farm.farm_uuid) + return Response({"status": "success", "data": data}, status=status.HTTP_200_OK) -class RecommendView(APIView): +class RecommendView(FarmAccessMixin, APIView): @extend_schema( tags=["Fertilization Recommendation"], request=FertilizationRecommendRequestSerializer, responses={200: status_response("FertilizationRecommendResponse", data=FertilizationRecommendResponseDataSerializer())}, ) def post(self, request): + serializer = FertilizationRecommendRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + payload = serializer.validated_data.copy() + farm = self._get_farm(request, payload.get("farm_uuid")) + payload["farm_uuid"] = str(farm.farm_uuid) + adapter_response = external_api_request( "ai", "/fertilization/recommend", method="POST", - payload=request.data, + payload=payload, + ) + + response_data = adapter_response.data if isinstance(adapter_response.data, dict) else {} + FertilizationRecommendationRequest.objects.create( + farm=farm, + crop_id=payload.get("crop_id", ""), + growth_stage=payload.get("growth_stage", ""), + task_id=str(response_data.get("data", {}).get("task_id") or response_data.get("task_id") or ""), + status=str(response_data.get("data", {}).get("status") or response_data.get("status") or ""), + request_payload=payload, + response_payload=adapter_response.data if isinstance(adapter_response.data, dict) else {"raw": adapter_response.data}, ) return Response(adapter_response.data, status=adapter_response.status_code) - - -class RecommendTaskStatusView(APIView): +class RecommendTaskStatusView(FarmAccessMixin, APIView): @extend_schema( tags=["Fertilization Recommendation"], parameters=[ OpenApiParameter(name="task_id", type=OpenApiTypes.STR, location=OpenApiParameter.PATH), + OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=True), ], responses={200: status_response("FertilizationRecommendTaskStatusResponse", data=FertilizationTaskStatusDataSerializer())}, ) def get(self, request, task_id): + farm = self._get_farm(request, request.query_params.get("farm_uuid")) adapter_response = external_api_request( "ai", f"/fertilization/status/{task_id}", method="GET", + query={"farm_uuid": str(farm.farm_uuid)}, + ) + response_data = adapter_response.data if isinstance(adapter_response.data, dict) else {} + FertilizationRecommendationRequest.objects.filter(farm=farm, task_id=task_id).update( + status=str(response_data.get("data", {}).get("status") or response_data.get("status") or ""), + response_payload=adapter_response.data if isinstance(adapter_response.data, dict) else {"raw": adapter_response.data}, ) return Response(adapter_response.data, status=adapter_response.status_code) diff --git a/irrigation_recommendation/migrations/0001_initial.py b/irrigation_recommendation/migrations/0001_initial.py new file mode 100644 index 0000000..dbcedf7 --- /dev/null +++ b/irrigation_recommendation/migrations/0001_initial.py @@ -0,0 +1,40 @@ +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("farm_hub", "0002_seed_default_catalog"), + ] + + operations = [ + migrations.CreateModel( + name="IrrigationRecommendationRequest", + 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)), + ("crop_id", models.CharField(blank=True, default="", max_length=255)), + ("task_id", models.CharField(blank=True, db_index=True, default="", max_length=255)), + ("status", models.CharField(blank=True, default="", max_length=64)), + ("request_payload", models.JSONField(blank=True, default=dict)), + ("response_payload", models.JSONField(blank=True, default=dict)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "farm", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="irrigation_recommendations", + to="farm_hub.farmhub", + ), + ), + ], + options={ + "db_table": "irrigation_recommendation_requests", + "ordering": ["-created_at", "-id"], + }, + ), + ] diff --git a/irrigation_recommendation/migrations/__init__.py b/irrigation_recommendation/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/irrigation_recommendation/models.py b/irrigation_recommendation/models.py new file mode 100644 index 0000000..59ad3cb --- /dev/null +++ b/irrigation_recommendation/models.py @@ -0,0 +1,28 @@ +import uuid + +from django.db import models + +from farm_hub.models import FarmHub + + +class IrrigationRecommendationRequest(models.Model): + uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True) + farm = models.ForeignKey( + FarmHub, + on_delete=models.CASCADE, + related_name="irrigation_recommendations", + ) + crop_id = models.CharField(max_length=255, blank=True, default="") + task_id = models.CharField(max_length=255, blank=True, default="", db_index=True) + status = models.CharField(max_length=64, blank=True, default="") + request_payload = models.JSONField(default=dict, blank=True) + response_payload = 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 = "irrigation_recommendation_requests" + ordering = ["-created_at", "-id"] + + def __str__(self): + return self.task_id or str(self.uuid) diff --git a/irrigation_recommendation/serializers.py b/irrigation_recommendation/serializers.py index ca593ad..f7ff40e 100644 --- a/irrigation_recommendation/serializers.py +++ b/irrigation_recommendation/serializers.py @@ -8,6 +8,7 @@ class IrrigationFarmDataSerializer(serializers.Serializer): class IrrigationRecommendRequestSerializer(serializers.Serializer): + farm_uuid = serializers.UUIDField(required=True) crop_id = serializers.CharField(required=False, allow_blank=True) farm_data = IrrigationFarmDataSerializer(required=False) soilType = serializers.CharField(required=False, allow_blank=True) diff --git a/irrigation_recommendation/views.py b/irrigation_recommendation/views.py index 1434814..6e40a1d 100644 --- a/irrigation_recommendation/views.py +++ b/irrigation_recommendation/views.py @@ -10,7 +10,9 @@ 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 farm_hub.models import FarmHub from .mock_data import CONFIG_RESPONSE_DATA +from .models import IrrigationRecommendationRequest from .serializers import ( IrrigationRecommendRequestSerializer, IrrigationRecommendResponseDataSerializer, @@ -19,59 +21,116 @@ from .serializers import ( ) -class ConfigView(APIView): +class FarmAccessMixin: + @staticmethod + def _get_farm(request, farm_uuid): + if not farm_uuid: + raise serializers.ValidationError({"farm_uuid": ["This field is required."]}) + try: + return FarmHub.objects.get(farm_uuid=farm_uuid, owner=request.user) + except FarmHub.DoesNotExist as exc: + raise serializers.ValidationError({"farm_uuid": ["Farm not found."]}) from exc + + +class ConfigView(FarmAccessMixin, APIView): @extend_schema( tags=["Irrigation Recommendation"], + parameters=[ + OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=True), + ], responses={200: status_response("IrrigationConfigResponse", data=serializers.JSONField())}, ) def get(self, request): - return Response({"status": "success", "data": CONFIG_RESPONSE_DATA}, status=status.HTTP_200_OK) + farm = self._get_farm(request, request.query_params.get("farm_uuid")) + data = dict(CONFIG_RESPONSE_DATA) + data["farm_uuid"] = str(farm.farm_uuid) + return Response({"status": "success", "data": data}, status=status.HTTP_200_OK) -class RecommendView(APIView): +class RecommendView(FarmAccessMixin, APIView): @extend_schema( tags=["Irrigation Recommendation"], request=IrrigationRecommendRequestSerializer, responses={200: status_response("IrrigationRecommendResponse", data=IrrigationRecommendResponseDataSerializer())}, ) def post(self, request): + serializer = IrrigationRecommendRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + payload = serializer.validated_data.copy() + farm = self._get_farm(request, payload.get("farm_uuid")) + payload["farm_uuid"] = str(farm.farm_uuid) + adapter_response = external_api_request( "ai", "/irrigation/recommend", method="POST", - payload=request.data, + payload=payload, + ) + + response_data = adapter_response.data if isinstance(adapter_response.data, dict) else {} + IrrigationRecommendationRequest.objects.create( + farm=farm, + crop_id=payload.get("crop_id", ""), + task_id=str(response_data.get("data", {}).get("task_id") or response_data.get("task_id") or ""), + status=str(response_data.get("data", {}).get("status") or response_data.get("status") or ""), + request_payload=payload, + response_payload=adapter_response.data if isinstance(adapter_response.data, dict) else {"raw": adapter_response.data}, ) return Response(adapter_response.data, status=adapter_response.status_code) -class RecommendTaskCreateView(APIView): +class RecommendTaskCreateView(FarmAccessMixin, APIView): @extend_schema( tags=["Irrigation Recommendation"], request=IrrigationRecommendRequestSerializer, responses={200: status_response("IrrigationRecommendTaskCreateResponse", data=IrrigationTaskSubmitDataSerializer())}, ) def post(self, request): + serializer = IrrigationRecommendRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + payload = serializer.validated_data.copy() + farm = self._get_farm(request, payload.get("farm_uuid")) + payload["farm_uuid"] = str(farm.farm_uuid) + adapter_response = external_api_request( "ai", "/irrigation/recommend", method="POST", - payload=request.data, + payload=payload, + ) + + response_data = adapter_response.data if isinstance(adapter_response.data, dict) else {} + IrrigationRecommendationRequest.objects.create( + farm=farm, + crop_id=payload.get("crop_id", ""), + task_id=str(response_data.get("data", {}).get("task_id") or response_data.get("task_id") or ""), + status=str(response_data.get("data", {}).get("status") or response_data.get("status") or ""), + request_payload=payload, + response_payload=adapter_response.data if isinstance(adapter_response.data, dict) else {"raw": adapter_response.data}, ) return Response(adapter_response.data, status=adapter_response.status_code) -class RecommendTaskStatusView(APIView): +class RecommendTaskStatusView(FarmAccessMixin, APIView): @extend_schema( tags=["Irrigation Recommendation"], parameters=[ OpenApiParameter(name="task_id", type=OpenApiTypes.STR, location=OpenApiParameter.PATH), + OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=True), ], responses={200: status_response("IrrigationRecommendTaskStatusResponse", data=IrrigationTaskStatusDataSerializer())}, ) def get(self, request, task_id): + farm = self._get_farm(request, request.query_params.get("farm_uuid")) adapter_response = external_api_request( "ai", f"/irrigation/recommend/status/{task_id}", method="GET", + query={"farm_uuid": str(farm.farm_uuid)}, + ) + response_data = adapter_response.data if isinstance(adapter_response.data, dict) else {} + IrrigationRecommendationRequest.objects.filter(farm=farm, task_id=task_id).update( + status=str(response_data.get("data", {}).get("status") or response_data.get("status") or ""), + response_payload=adapter_response.data if isinstance(adapter_response.data, dict) else {"raw": adapter_response.data}, ) return Response(adapter_response.data, status=adapter_response.status_code) diff --git a/json/mock_data/fertilization/recommend/post_400.json b/json/mock_data/fertilization/recommend/post_400.json index 9fdc597..5e4c3f5 100644 --- a/json/mock_data/fertilization/recommend/post_400.json +++ b/json/mock_data/fertilization/recommend/post_400.json @@ -2,7 +2,7 @@ "code": 400, "msg": "داده نامعتبر.", "data": { - "sensor_uuid": [ + "farm_uuid": [ "This field is required." ] } diff --git a/json/mock_data/index.json b/json/mock_data/index.json index d051fb6..91c1a8e 100644 --- a/json/mock_data/index.json +++ b/json/mock_data/index.json @@ -512,42 +512,42 @@ }, { "method": "PUT", - "path": "/api/sensor-data/{uuid_sensor}/", + "path": "/api/sensor-data/{farm_uuid}/", "status_code": 200, "description": "Sensor update put success", "file": "json/mock_data/sensor-data/update-put_200.json" }, { "method": "PUT", - "path": "/api/sensor-data/{uuid_sensor}/", + "path": "/api/sensor-data/{farm_uuid}/", "status_code": 400, "description": "Sensor update put validation error", "file": "json/mock_data/sensor-data/update-put_400.json" }, { "method": "PUT", - "path": "/api/sensor-data/{uuid_sensor}/", + "path": "/api/sensor-data/{farm_uuid}/", "status_code": 404, "description": "Sensor update put location not found", "file": "json/mock_data/sensor-data/update-put_404.json" }, { "method": "PATCH", - "path": "/api/sensor-data/{uuid_sensor}/", + "path": "/api/sensor-data/{farm_uuid}/", "status_code": 200, "description": "Sensor update patch success", "file": "json/mock_data/sensor-data/update-patch_200.json" }, { "method": "PATCH", - "path": "/api/sensor-data/{uuid_sensor}/", + "path": "/api/sensor-data/{farm_uuid}/", "status_code": 400, "description": "Sensor update patch validation error", "file": "json/mock_data/sensor-data/update-patch_400.json" }, { "method": "PATCH", - "path": "/api/sensor-data/{uuid_sensor}/", + "path": "/api/sensor-data/{farm_uuid}/", "status_code": 404, "description": "Sensor update patch location not found", "file": "json/mock_data/sensor-data/update-patch_404.json" diff --git a/json/mock_data/irrigation/recommend/post_400.json b/json/mock_data/irrigation/recommend/post_400.json index 9fdc597..5e4c3f5 100644 --- a/json/mock_data/irrigation/recommend/post_400.json +++ b/json/mock_data/irrigation/recommend/post_400.json @@ -2,7 +2,7 @@ "code": 400, "msg": "داده نامعتبر.", "data": { - "sensor_uuid": [ + "farm_uuid": [ "This field is required." ] } diff --git a/json/mock_data/rag/fertilization/post_400.json b/json/mock_data/rag/fertilization/post_400.json index 6bea070..b82ea08 100644 --- a/json/mock_data/rag/fertilization/post_400.json +++ b/json/mock_data/rag/fertilization/post_400.json @@ -1,5 +1,5 @@ { "code": 400, - "msg": "پارامتر sensor_uuid الزامی است.", + "msg": "پارامتر farm_uuid الزامی است.", "data": null } diff --git a/json/mock_data/rag/irrigation/post_400.json b/json/mock_data/rag/irrigation/post_400.json index 6bea070..b82ea08 100644 --- a/json/mock_data/rag/irrigation/post_400.json +++ b/json/mock_data/rag/irrigation/post_400.json @@ -1,5 +1,5 @@ { "code": 400, - "msg": "پارامتر sensor_uuid الزامی است.", + "msg": "پارامتر farm_uuid الزامی است.", "data": null } diff --git a/json/mock_data/sensor-data/update-patch_200.json b/json/mock_data/sensor-data/update-patch_200.json index 5bc26e3..31fc9cd 100644 --- a/json/mock_data/sensor-data/update-patch_200.json +++ b/json/mock_data/sensor-data/update-patch_200.json @@ -2,7 +2,7 @@ "code": 200, "msg": "success", "data": { - "uuid_sensor": "550e8400-e29b-41d4-a716-446655440000", + "farm_uuid": "550e8400-e29b-41d4-a716-446655440000", "location_id": 12, "soil_moisture": 45.2, "soil_temperature": 22.5, diff --git a/json/mock_data/sensor-data/update-put_200.json b/json/mock_data/sensor-data/update-put_200.json index 5bc26e3..31fc9cd 100644 --- a/json/mock_data/sensor-data/update-put_200.json +++ b/json/mock_data/sensor-data/update-put_200.json @@ -2,7 +2,7 @@ "code": 200, "msg": "success", "data": { - "uuid_sensor": "550e8400-e29b-41d4-a716-446655440000", + "farm_uuid": "550e8400-e29b-41d4-a716-446655440000", "location_id": 12, "soil_moisture": 45.2, "soil_temperature": 22.5, diff --git a/plant/__init__.py b/plant/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plant/apps.py b/plant/apps.py new file mode 100644 index 0000000..b6db011 --- /dev/null +++ b/plant/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class PlantConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "plant" diff --git a/plant/migrations/0001_initial.py b/plant/migrations/0001_initial.py new file mode 100644 index 0000000..36f99a3 --- /dev/null +++ b/plant/migrations/0001_initial.py @@ -0,0 +1,35 @@ +# Generated by Django 5.2.12 on 2026-03-19 15:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Plant', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(db_index=True, help_text='نام گیاه', max_length=255, unique=True)), + ('light', models.CharField(blank=True, help_text='نور مورد نیاز', max_length=255)), + ('watering', models.CharField(blank=True, help_text='آبیاری', max_length=255)), + ('soil', models.CharField(blank=True, help_text='خاک مناسب', max_length=255)), + ('temperature', models.CharField(blank=True, help_text='دمای مناسب', max_length=255)), + ('planting_season', models.CharField(blank=True, help_text='فصل کاشت', max_length=255)), + ('harvest_time', models.CharField(blank=True, help_text='زمان برداشت', max_length=255)), + ('spacing', models.CharField(blank=True, help_text='فاصله کاشت', max_length=255)), + ('fertilizer', models.CharField(blank=True, help_text='کود مناسب', max_length=255)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'گیاه', + 'verbose_name_plural': 'گیاهان', + 'ordering': ['name'], + }, + ), + ] diff --git a/plant/migrations/0002_plant_health_profile.py b/plant/migrations/0002_plant_health_profile.py new file mode 100644 index 0000000..c896745 --- /dev/null +++ b/plant/migrations/0002_plant_health_profile.py @@ -0,0 +1,23 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("plant", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="plant", + name="health_profile", + field=models.JSONField( + blank=True, + default=dict, + help_text=( + "پروفایل سلامت گیاه برای KPIها. ساختار نمونه: " + '{"moisture": {"ideal_value": 65, "min_range": 45, "max_range": 75, "weight": 0.4}}' + ), + ), + ), + ] diff --git a/plant/migrations/0003_plant_irrigation_profile.py b/plant/migrations/0003_plant_irrigation_profile.py new file mode 100644 index 0000000..189c0ac --- /dev/null +++ b/plant/migrations/0003_plant_irrigation_profile.py @@ -0,0 +1,24 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("plant", "0002_plant_health_profile"), + ] + + operations = [ + migrations.AddField( + model_name="plant", + name="irrigation_profile", + field=models.JSONField( + blank=True, + default=dict, + help_text=( + "پروفایل آبیاری گیاه برای محاسبات ETc. " + '{"kc_initial": 0.6, "kc_mid": 1.15, "kc_end": 0.8, ' + '"growth_stage_duration": {"initial": 20, "mid": 30, "late": 25}}' + ), + ), + ), + ] diff --git a/plant/migrations/0004_plant_growth_profile.py b/plant/migrations/0004_plant_growth_profile.py new file mode 100644 index 0000000..048e78e --- /dev/null +++ b/plant/migrations/0004_plant_growth_profile.py @@ -0,0 +1,24 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("plant", "0003_plant_irrigation_profile"), + ] + + operations = [ + migrations.AddField( + model_name="plant", + name="growth_profile", + field=models.JSONField( + blank=True, + default=dict, + help_text=( + "پروفایل رشد گیاه برای مدل GDD. " + '{"base_temperature": 10, "required_gdd_for_maturity": 1200, ' + '"stage_thresholds": {"flowering": 500, "fruiting": 850}, "current_cumulative_gdd": 320}' + ), + ), + ), + ] diff --git a/plant/migrations/__init__.py b/plant/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plant/models.py b/plant/models.py new file mode 100644 index 0000000..8302207 --- /dev/null +++ b/plant/models.py @@ -0,0 +1,49 @@ +from django.db import models + + +class Plant(models.Model): + name = models.CharField(max_length=255, unique=True, db_index=True, help_text="نام گیاه") + light = models.CharField(max_length=255, blank=True, help_text="نور مورد نیاز") + watering = models.CharField(max_length=255, blank=True, help_text="آبیاری") + soil = models.CharField(max_length=255, blank=True, help_text="خاک مناسب") + temperature = models.CharField(max_length=255, blank=True, help_text="دمای مناسب") + planting_season = models.CharField(max_length=255, blank=True, help_text="فصل کاشت") + harvest_time = models.CharField(max_length=255, blank=True, help_text="زمان برداشت") + spacing = models.CharField(max_length=255, blank=True, help_text="فاصله کاشت") + fertilizer = models.CharField(max_length=255, blank=True, help_text="کود مناسب") + health_profile = models.JSONField( + blank=True, + default=dict, + help_text=( + "پروفایل سلامت گیاه برای KPIها. ساختار نمونه: " + '{"moisture": {"ideal_value": 65, "min_range": 45, "max_range": 75, "weight": 0.4}}' + ), + ) + irrigation_profile = models.JSONField( + blank=True, + default=dict, + help_text=( + "پروفایل آبیاری گیاه برای محاسبات ETc. " + '{"kc_initial": 0.6, "kc_mid": 1.15, "kc_end": 0.8, ' + '"growth_stage_duration": {"initial": 20, "mid": 30, "late": 25}}' + ), + ) + growth_profile = models.JSONField( + blank=True, + default=dict, + help_text=( + "پروفایل رشد گیاه برای مدل GDD. " + '{"base_temperature": 10, "required_gdd_for_maturity": 1200, ' + '"stage_thresholds": {"flowering": 500, "fruiting": 850}, "current_cumulative_gdd": 320}' + ), + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = "گیاه" + verbose_name_plural = "گیاهان" + ordering = ["name"] + + def __str__(self): + return self.name diff --git a/sensor_hub/migrations/0001_initial.py b/sensor_hub/migrations/0001_initial.py deleted file mode 100644 index 181a68c..0000000 --- a/sensor_hub/migrations/0001_initial.py +++ /dev/null @@ -1,37 +0,0 @@ -# Generated by Django 5.1.15 on 2026-03-23 18:48 - -import django.db.models.deletion -import uuid -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name='Sensor', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('uuid_sensor', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True)), - ('name', models.CharField(max_length=255)), - ('is_active', models.BooleanField(default=True)), - ('specifications', models.JSONField(blank=True, default=dict)), - ('power_source', models.JSONField(blank=True, default=dict)), - ('customized_sensors', 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='sensors', to=settings.AUTH_USER_MODEL)), - ], - options={ - 'db_table': 'sensors', - 'ordering': ['-created_at'], - }, - ), - ] diff --git a/sensor_hub/models.py b/sensor_hub/models.py deleted file mode 100644 index ded00ad..0000000 --- a/sensor_hub/models.py +++ /dev/null @@ -1,27 +0,0 @@ -import uuid - -from django.conf import settings -from django.db import models - - -class Sensor(models.Model): - uuid_sensor = 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="sensors", - ) - name = models.CharField(max_length=255) - is_active = models.BooleanField(default=True) - specifications = models.JSONField(default=dict, blank=True) - power_source = models.JSONField(default=dict, blank=True) - customized_sensors = 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 = "sensors" - ordering = ["-created_at"] - - def __str__(self): - return f"{self.name} ({self.uuid_sensor})" diff --git a/sensor_hub/postman/sensor_hub.json b/sensor_hub/postman/sensor_hub.json deleted file mode 100644 index f4897f7..0000000 --- a/sensor_hub/postman/sensor_hub.json +++ /dev/null @@ -1,132 +0,0 @@ -{ - "info": { - "name": "Sensor Hub", - "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", - "description": "Sensor Hub API. GET list, GET by uuid (detail), POST add, PATCH update, DELETE delete, POST active/deactive. Authenticated user required. Static responses only." - }, - "item": [ - { - "name": "List sensors", - "request": { - "method": "GET", - "header": [ - {"key": "Content-Type", "value": "application/json"}, - {"key": "Authorization", "value": "Bearer {{token}}", "description": "Required: user must be authenticated"} - ], - "url": "{{baseUrl}}/api/sensor-hub/", - "description": "Get list of sensors. GET on base route." - }, - "response": [ - { - "name": "Success", - "status": "OK", - "code": 200, - "body": "{\n \"status\": \"success\",\n \"data\": {\n \"name\": \"sensor-hub-static\",\n \"uuid_sensor\": \"550e8400-e29b-41d4-a716-446655440000\",\n \"last_updated\": \"2025-02-18T12:00:00Z\",\n \"specifications\": {\n \"model\": \"SH-1\",\n \"firmware\": \"1.0.0\",\n \"capabilities\": [\"temperature\", \"humidity\", \"light\"]\n },\n \"power_source\": {\n \"type\": \"battery\",\n \"voltage\": 3.3,\n \"backup\": \"solar\"\n },\n \"customized_sensors\": {\n \"thresholds\": {\"temperature_min\": 10, \"temperature_max\": 35},\n \"report_interval_sec\": 300\n }\n }\n}" - } - ] - }, - { - "name": "Get sensor details (by uuid)", - "request": { - "method": "GET", - "header": [ - {"key": "Content-Type", "value": "application/json"}, - {"key": "Authorization", "value": "Bearer {{token}}", "description": "Required: user must be authenticated"} - ], - "url": "{{baseUrl}}/api/sensor-hub/{{uuid}}/", - "description": "Get one sensor by uuid in path." - }, - "response": [ - { - "name": "Success", - "status": "OK", - "code": 200, - "body": "{\n \"status\": \"success\",\n \"data\": {\n \"name\": \"sensor-hub-static\",\n \"uuid_sensor\": \"550e8400-e29b-41d4-a716-446655440000\",\n \"last_updated\": \"2025-02-18T12:00:00Z\",\n \"specifications\": {\n \"model\": \"SH-1\",\n \"firmware\": \"1.0.0\",\n \"capabilities\": [\"temperature\", \"humidity\", \"light\"]\n },\n \"power_source\": {\n \"type\": \"battery\",\n \"voltage\": 3.3,\n \"backup\": \"solar\"\n },\n \"customized_sensors\": {\n \"thresholds\": {\"temperature_min\": 10, \"temperature_max\": 35},\n \"report_interval_sec\": 300\n }\n }\n}" - } - ] - }, - { - "name": "Add sensor", - "request": { - "method": "POST", - "header": [ - {"key": "Content-Type", "value": "application/json"}, - {"key": "Authorization", "value": "Bearer {{token}}", "description": "Required: user must be authenticated"} - ], - "body": {"mode": "raw", "raw": "{}"}, - "url": "{{baseUrl}}/api/sensor-hub/", - "description": "Add a new sensor. POST on base route." - }, - "response": [ - {"name": "Success", "status": "OK", "code": 200, "body": "{\n \"status\": \"success\"\n}"} - ] - }, - { - "name": "Update sensor", - "request": { - "method": "PATCH", - "header": [ - {"key": "Content-Type", "value": "application/json"}, - {"key": "Authorization", "value": "Bearer {{token}}", "description": "Required: user must be authenticated"} - ], - "body": {"mode": "raw", "raw": "{}"}, - "url": "{{baseUrl}}/api/sensor-hub/{{uuid}}/", - "description": "Update sensor by uuid in path. PATCH." - }, - "response": [ - {"name": "Success", "status": "OK", "code": 200, "body": "{\n \"status\": \"success\"\n}"} - ] - }, - { - "name": "Delete sensor", - "request": { - "method": "DELETE", - "header": [ - {"key": "Authorization", "value": "Bearer {{token}}", "description": "Required: user must be authenticated"} - ], - "url": "{{baseUrl}}/api/sensor-hub/{{uuid}}/", - "description": "Delete sensor by uuid in path." - }, - "response": [ - {"name": "Success", "status": "OK", "code": 200, "body": "{\n \"status\": \"success\"\n}"} - ] - }, - { - "name": "Activate", - "request": { - "method": "POST", - "header": [ - {"key": "Content-Type", "value": "application/json"}, - {"key": "Authorization", "value": "Bearer {{token}}", "description": "Required: user must be authenticated"} - ], - "body": {"mode": "raw", "raw": "{}"}, - "url": "{{baseUrl}}/api/sensor-hub/active/", - "description": "Activate. POST on active/ route." - }, - "response": [ - {"name": "Success", "status": "OK", "code": 200, "body": "{\n \"status\": \"success\"\n}"} - ] - }, - { - "name": "Deactivate", - "request": { - "method": "POST", - "header": [ - {"key": "Content-Type", "value": "application/json"}, - {"key": "Authorization", "value": "Bearer {{token}}", "description": "Required: user must be authenticated"} - ], - "body": {"mode": "raw", "raw": "{}"}, - "url": "{{baseUrl}}/api/sensor-hub/deactive/", - "description": "Deactivate. POST on deactive/ route." - }, - "response": [ - {"name": "Success", "status": "OK", "code": 200, "body": "{\n \"status\": \"success\"\n}"} - ] - } - ], - "variable": [ - {"key": "baseUrl", "value": "http://localhost:8000"}, - {"key": "token", "value": ""}, - {"key": "uuid", "value": "550e8400-e29b-41d4-a716-446655440000"} - ] -} diff --git a/sensor_hub/seeds.py b/sensor_hub/seeds.py deleted file mode 100644 index 170ee8e..0000000 --- a/sensor_hub/seeds.py +++ /dev/null @@ -1,111 +0,0 @@ -import uuid - -from django.db import transaction - -from account.seeds import seed_admin_user - -from .models import Sensor -from .services import dispatch_sensor_zoning - - -ADMIN_SENSOR_UUID = uuid.UUID("11111111-1111-1111-1111-111111111111") -ADMIN_SENSOR_DATA = { - "name": "Admin Smart Farm Sensor", - "is_active": True, - "specifications": { - "model": "CL-SENSE-PRO-X", - "firmware": "2.4.1", - "manufacturer": "CropLogic", - "serial_number": "CL-ADMIN-0001", - "capabilities": [ - "temperature", - "humidity", - "soil_moisture", - "soil_temperature", - "light_intensity", - "ph", - "ec", - "wind_speed", - ], - "connectivity": { - "protocol": "LoRaWAN", - "sim_enabled": True, - "bluetooth": True, - "wifi_fallback": True, - }, - "location": { - "label": "Admin Demo Field", - "lat": 35.6892, - "lng": 51.389, - "altitude_m": 1190, - }, - }, - "power_source": { - "type": "hybrid", - "battery": { - "capacity_mah": 12000, - "voltage": 12, - "health_percent": 98, - }, - "solar": { - "panel_watt": 40, - "controller": "MPPT", - }, - "backup": "dc_adapter", - }, - "customized_sensors": { - "thresholds": { - "temperature_c": {"min": 10, "max": 36}, - "humidity_percent": {"min": 30, "max": 85}, - "soil_moisture_percent": {"min": 25, "max": 70}, - "ph": {"min": 5.8, "max": 7.2}, - "ec_ds_m": {"min": 1.1, "max": 2.4}, - }, - "report_interval_sec": 300, - "alerts": { - "sms": True, - "email": True, - "push": True, - }, - "calibration": { - "last_calibrated_at": "2025-03-01T08:30:00Z", - "technician": "system", - "status": "passed", - }, - }, -} - -ADMIN_SENSOR_AREA_GEOJSON = { - "type": "Feature", - "properties": {}, - "geometry": { - "type": "Polygon", - "coordinates": [ - [ - [51.418934, 35.706815], - [51.423054, 35.691062], - [51.384258, 35.689389], - [51.418934, 35.706815], - ] - ], - }, -} - - -@transaction.atomic -def seed_admin_sensor(): - owner, _ = seed_admin_user() - sensor, created = Sensor.objects.update_or_create( - uuid_sensor=ADMIN_SENSOR_UUID, - defaults={ - "owner": owner, - "name": ADMIN_SENSOR_DATA["name"], - "is_active": ADMIN_SENSOR_DATA["is_active"], - "specifications": ADMIN_SENSOR_DATA["specifications"], - "power_source": ADMIN_SENSOR_DATA["power_source"], - "customized_sensors": ADMIN_SENSOR_DATA["customized_sensors"], - }, - ) - if created: - dispatch_sensor_zoning(ADMIN_SENSOR_AREA_GEOJSON, sensor) - return sensor, created diff --git a/sensor_hub/serializers.py b/sensor_hub/serializers.py deleted file mode 100644 index 434bad3..0000000 --- a/sensor_hub/serializers.py +++ /dev/null @@ -1,63 +0,0 @@ -from rest_framework import serializers - -from .models import Sensor - - -class SensorSerializer(serializers.ModelSerializer): - last_updated = serializers.DateTimeField(source="updated_at", read_only=True) - - class Meta: - model = Sensor - fields = [ - "uuid_sensor", - "name", - "is_active", - "specifications", - "power_source", - "customized_sensors", - "last_updated", - ] - read_only_fields = ["uuid_sensor", "last_updated"] - - -class SensorCreateSerializer(serializers.ModelSerializer): - area_geojson = serializers.JSONField(write_only=True, required=False) - - class Meta: - model = Sensor - fields = [ - "name", - "specifications", - "power_source", - "customized_sensors", - "area_geojson", - ] - - def validate_area_geojson(self, value): - if not isinstance(value, dict): - raise serializers.ValidationError("`area_geojson` must be a GeoJSON object.") - - geometry = value.get("geometry") if value.get("type") == "Feature" else value - if not isinstance(geometry, dict): - raise serializers.ValidationError("`area_geojson.geometry` is required.") - - if geometry.get("type") != "Polygon": - raise serializers.ValidationError("`area_geojson.geometry.type` must be `Polygon`.") - - coordinates = geometry.get("coordinates") - if not isinstance(coordinates, list) or not coordinates or not isinstance(coordinates[0], list): - raise serializers.ValidationError("`area_geojson.geometry.coordinates` must be a polygon ring.") - - return value - - def create(self, validated_data): - validated_data.pop("area_geojson", None) - return super().create(validated_data) - - def update(self, instance, validated_data): - validated_data.pop("area_geojson", None) - return super().update(instance, validated_data) - - -class SensorToggleSerializer(serializers.Serializer): - uuid_sensor = serializers.UUIDField() diff --git a/sensor_hub/urls.py b/sensor_hub/urls.py deleted file mode 100644 index aba7fad..0000000 --- a/sensor_hub/urls.py +++ /dev/null @@ -1,10 +0,0 @@ -from django.urls import path - -from .views import SensorActiveView, SensorDeactiveView, SensorDetailView, SensorListCreateView - -urlpatterns = [ - path("active/", SensorActiveView.as_view(), name="sensor-hub-active"), - path("deactive/", SensorDeactiveView.as_view(), name="sensor-hub-deactive"), - path("/", SensorDetailView.as_view(), name="sensor-hub-detail"), - path("", SensorListCreateView.as_view(), name="sensor-hub-list"), -] diff --git a/sensor_hub/views.py b/sensor_hub/views.py deleted file mode 100644 index 6087ae1..0000000 --- a/sensor_hub/views.py +++ /dev/null @@ -1,132 +0,0 @@ -from rest_framework import serializers, status -from django.core.exceptions import ImproperlyConfigured -from rest_framework.permissions import IsAuthenticated -from rest_framework.response import Response -from rest_framework.views import APIView -from drf_spectacular.utils import extend_schema - -from config.swagger import code_response -from .models import Sensor -from .serializers import SensorCreateSerializer, SensorSerializer, SensorToggleSerializer -from .services import create_sensor_with_zoning - - -class SensorHubBaseView(APIView): - permission_classes = [IsAuthenticated] - - def _get_sensor(self, request, uuid): - try: - return Sensor.objects.get(uuid_sensor=uuid, owner=request.user) - except Sensor.DoesNotExist: - return None - - -class SensorListCreateView(SensorHubBaseView): - @extend_schema( - tags=["Sensor Hub"], - responses={200: code_response("SensorListResponse", data=SensorSerializer(many=True))}, - ) - def get(self, request): - sensors = Sensor.objects.filter(owner=request.user) - data = SensorSerializer(sensors, many=True).data - return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK) - - @extend_schema( - tags=["Sensor Hub"], - request=SensorCreateSerializer, - responses={201: code_response("SensorCreateResponse", data=SensorSerializer())}, - ) - def post(self, request): - serializer = SensorCreateSerializer(data=request.data) - serializer.is_valid(raise_exception=True) - try: - sensor, zoning_payload = create_sensor_with_zoning(serializer, owner=request.user) - except ValueError as exc: - raise serializers.ValidationError({"area_geojson": [str(exc)]}) from exc - except ImproperlyConfigured as exc: - return Response({"code": 500, "msg": str(exc)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) - data = SensorSerializer(sensor).data - if zoning_payload is not None: - data["zoning"] = zoning_payload - return Response({"code": 201, "msg": "success", "data": data}, status=status.HTTP_201_CREATED) - - -class SensorDetailView(SensorHubBaseView): - @extend_schema( - tags=["Sensor Hub"], - responses={ - 200: code_response("SensorDetailResponse", data=SensorSerializer()), - 404: code_response("SensorNotFoundResponse"), - }, - ) - def get(self, request, uuid): - sensor = self._get_sensor(request, uuid) - if sensor is None: - return Response({"code": 404, "msg": "Sensor not found."}, status=status.HTTP_404_NOT_FOUND) - data = SensorSerializer(sensor).data - return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK) - - @extend_schema( - tags=["Sensor Hub"], - request=SensorCreateSerializer, - responses={ - 200: code_response("SensorUpdateResponse", data=SensorSerializer()), - 404: code_response("SensorUpdateNotFoundResponse"), - }, - ) - def patch(self, request, uuid): - sensor = self._get_sensor(request, uuid) - if sensor is None: - return Response({"code": 404, "msg": "Sensor not found."}, status=status.HTTP_404_NOT_FOUND) - serializer = SensorCreateSerializer(sensor, data=request.data, partial=True) - serializer.is_valid(raise_exception=True) - serializer.save() - data = SensorSerializer(sensor).data - return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK) - - @extend_schema( - tags=["Sensor Hub"], - responses={ - 200: code_response("SensorDeleteResponse"), - 404: code_response("SensorDeleteNotFoundResponse"), - }, - ) - def delete(self, request, uuid): - sensor = self._get_sensor(request, uuid) - if sensor is None: - return Response({"code": 404, "msg": "Sensor not found."}, status=status.HTTP_404_NOT_FOUND) - sensor.delete() - return Response({"code": 200, "msg": "success"}, status=status.HTTP_200_OK) - - -class SensorToggleView(SensorHubBaseView): - action = None - - @extend_schema( - tags=["Sensor Hub"], - request=SensorToggleSerializer, - responses={ - 200: code_response("SensorToggleResponse"), - 400: code_response("SensorToggleValidationResponse"), - 404: code_response("SensorToggleNotFoundResponse"), - }, - ) - def post(self, request): - serializer = SensorToggleSerializer(data=request.data) - serializer.is_valid(raise_exception=True) - - sensor = self._get_sensor(request, serializer.validated_data["uuid_sensor"]) - if sensor is None: - return Response({"code": 404, "msg": "Sensor not found."}, status=status.HTTP_404_NOT_FOUND) - - sensor.is_active = self.action == "active" - sensor.save(update_fields=["is_active", "updated_at"]) - return Response({"code": 200, "msg": "success"}, status=status.HTTP_200_OK) - - -class SensorActiveView(SensorToggleView): - action = "active" - - -class SensorDeactiveView(SensorToggleView): - action = "deactive"