This commit is contained in:
2026-04-02 23:25:39 +03:30
parent 881f8efa4d
commit bd0d04256c
84 changed files with 2725 additions and 856 deletions
+2 -2
View File
@@ -24,8 +24,8 @@ EXTERNAL_API_TIMEOUT=30
AI_SERVICE_BASE_URL=https://ai.example.com AI_SERVICE_BASE_URL=https://ai.example.com
AI_SERVICE_API_KEY= AI_SERVICE_API_KEY=
SENSOR_HUB_SERVICE_BASE_URL=https://sensor-hub.example.com FARM_HUB_SERVICE_BASE_URL=https://farm-hub.example.com
SENSOR_HUB_SERVICE_API_KEY= FARM_HUB_SERVICE_API_KEY=
CROP_ZONE_CHUNK_AREA_SQM=10000 CROP_ZONE_CHUNK_AREA_SQM=10000
CROP_ZONE_TASK_STALE_SECONDS=300 CROP_ZONE_TASK_STALE_SECONDS=300
+4 -4
View File
@@ -4,7 +4,7 @@
- Backend Service - Backend Service
- AI 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** مسئول دریافت، ذخیره و مدیریت داده‌های سنسورهای مزرعه است.
به دلیل حساسیت بالای این داده‌ها و اهمیت **عدم از دست رفتن اطلاعات سنسورها**، این سرویس به‌صورت مستقل از سایر بخش‌ها پیاده‌سازی شده است. به دلیل حساسیت بالای این داده‌ها و اهمیت **عدم از دست رفتن اطلاعات سنسورها**، این سرویس به‌صورت مستقل از سایر بخش‌ها پیاده‌سازی شده است.
+4 -4
View File
@@ -4,7 +4,7 @@
- Backend Service - Backend Service
- AI 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** مسئول دریافت، ذخیره و مدیریت داده‌های سنسورهای مزرعه است.
به دلیل حساسیت بالای این داده‌ها و اهمیت **عدم از دست رفتن اطلاعات سنسورها**، این سرویس به‌صورت مستقل از سایر بخش‌ها پیاده‌سازی شده است. به دلیل حساسیت بالای این داده‌ها و اهمیت **عدم از دست رفتن اطلاعات سنسورها**، این سرویس به‌صورت مستقل از سایر بخش‌ها پیاده‌سازی شده است.
+1 -6
View File
@@ -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 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/ 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 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 > /etc/apt/sources.list
# System deps for MySQL client (pkg-config required by mysqlclient to find libs) # System deps for MySQL client (pkg-config required by mysqlclient to find libs)
+890
View File
@@ -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=<uuid>
```
- جدید:
```http
GET /api/crop-zoning/area/?farm_uuid=<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=<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=<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=<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=<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=<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 <access_token>
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 باشد.
+5 -4
View File
@@ -27,7 +27,8 @@ INSTALLED_APPS = [
"django.contrib.staticfiles", "django.contrib.staticfiles",
"auth.apps.AuthConfig", "auth.apps.AuthConfig",
"account.apps.AccountConfig", "account.apps.AccountConfig",
"sensor_hub.apps.SensorHubConfig", "farm_hub.apps.FarmHubConfig",
"plant.apps.PlantConfig",
"dashboard", "dashboard",
"crop_zoning", "crop_zoning",
"plant_simulator", "plant_simulator",
@@ -146,9 +147,9 @@ EXTERNAL_SERVICES = {
"base_url": os.getenv("AI_SERVICE_BASE_URL", ""), "base_url": os.getenv("AI_SERVICE_BASE_URL", ""),
"api_key": os.getenv("AI_SERVICE_API_KEY", ""), "api_key": os.getenv("AI_SERVICE_API_KEY", ""),
}, },
"sensor_hub": { "farm_hub": {
"base_url": os.getenv("SENSOR_HUB_SERVICE_BASE_URL", ""), "base_url": os.getenv("FARM_HUB_SERVICE_BASE_URL", ""),
"api_key": os.getenv("SENSOR_HUB_SERVICE_API_KEY", ""), "api_key": os.getenv("FARM_HUB_SERVICE_API_KEY", ""),
}, },
} }
+1 -1
View File
@@ -9,7 +9,7 @@ urlpatterns = [
path("api/docs/redoc/", SpectacularRedocView.as_view(url_name="schema"), name="redoc"), path("api/docs/redoc/", SpectacularRedocView.as_view(url_name="schema"), name="redoc"),
path("api/auth/", include("auth.urls")), path("api/auth/", include("auth.urls")),
path("api/account/", include("account.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-config/", include("dashboard.urls_config")),
path("api/farm-dashboard/", include("dashboard.urls")), path("api/farm-dashboard/", include("dashboard.urls")),
path("api/crop-zoning/", include("crop_zoning.urls")), path("api/crop-zoning/", include("crop_zoning.urls")),
+13 -13
View File
@@ -42,7 +42,7 @@
### کار این view ### کار این view
- `sensor_uuid` را از query params می‌گیرد. - `farm_uuid` را از query params می‌گیرد.
- `page` و `page_size` را هم از query params می‌گیرد. - `page` و `page_size` را هم از query params می‌گیرد.
- از service می‌خواهد مطمئن شود برای این sensor یک area معتبر و zoneهای آن وجود دارند. - از service می‌خواهد مطمئن شود برای این sensor یک area معتبر و zoneهای آن وجود دارند.
- اگر zoneها وجود نداشته باشند، ساخته می‌شوند. - اگر zoneها وجود نداشته باشند، ساخته می‌شوند.
@@ -51,7 +51,7 @@
### ورودی‌های `AreaView` ### ورودی‌های `AreaView`
- `sensor_uuid`: اجباری - `farm_uuid`: اجباری
- `page`: اختیاری، پیش‌فرض `1` - `page`: اختیاری، پیش‌فرض `1`
- `page_size`: اختیاری، پیش‌فرض `10` - `page_size`: اختیاری، پیش‌فرض `10`
@@ -68,8 +68,8 @@
اگر هر کدام از این موارد رخ بدهد، خطای `400` داده می‌شود: اگر هر کدام از این موارد رخ بدهد، خطای `400` داده می‌شود:
- `sensor_uuid` ارسال نشده باشد - `farm_uuid` ارسال نشده باشد
- `sensor_uuid` معتبر نباشد یا sensor پیدا نشود - `farm_uuid` معتبر نباشد یا farm پیدا نشود
- `page` نامعتبر باشد - `page` نامعتبر باشد
- `page_size` نامعتبر باشد - `page_size` نامعتبر باشد
@@ -107,7 +107,7 @@
### تفاوت با `AreaView` ### تفاوت با `AreaView`
- `AreaView` بر اساس `sensor_uuid` کار می‌کند و وضعیت taskها را هم برمی‌گرداند. - `AreaView` بر اساس `farm_uuid` کار می‌کند و وضعیت taskها را هم برمی‌گرداند.
- `ZonesInitialView` بیشتر برای ساخت اولیه zoneها از روی polygon مناسب است. - `ZonesInitialView` بیشتر برای ساخت اولیه zoneها از روی polygon مناسب است.
--- ---
@@ -532,14 +532,14 @@ metrics را داخل مدل‌های مختلف ذخیره می‌کند:
اگر area در دیتابیس وجود داشته باشد ولی zoneهایش از بین رفته باشند یا ساخته نشده باشند، دوباره از روی geometry آن zoneها را می‌سازد. اگر area در دیتابیس وجود داشته باشد ولی zoneهایش از بین رفته باشند یا ساخته نشده باشند، دوباره از روی geometry آن zoneها را می‌سازد.
### `get_sensor_for_uuid(sensor_uuid)` ### `get_farm_for_uuid(farm_uuid)`
اعتبارسنجی می‌کند که: اعتبارسنجی می‌کند که:
- `sensor_uuid` ارسال شده باشد - `farm_uuid` ارسال شده باشد
- sensor واقعا در دیتابیس وجود داشته باشد - 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 را از ابتدا تا انتها خیلی ساده توضیح بدهیم: اگر بخواهیم کل flow را از ابتدا تا انتها خیلی ساده توضیح بدهیم:
1. فرانت `sensor_uuid` و احتمالا `page` و `page_size` را می‌فرستد. 1. فرانت `farm_uuid` و احتمالا `page` و `page_size` را می‌فرستد.
2. `AreaView` پارامترها را می‌خواند. 2. `AreaView` پارامترها را می‌خواند.
3. `ensure_latest_area_ready_for_processing` اجرا می‌شود. 3. `ensure_latest_area_ready_for_processing` اجرا می‌شود.
4. اگر area وجود نداشته باشد، area و zoneها ساخته می‌شوند. 4. اگر area وجود نداشته باشد، area و zoneها ساخته می‌شوند.
@@ -714,7 +714,7 @@ payload ساده‌تر برای endpoint اولیه zoneها می‌سازد.
### `_request()` ### `_request()`
یک request استاندارد برای `AreaView` با `sensor_uuid` معتبر می‌سازد. یک request استاندارد برای `AreaView` با `farm_uuid` معتبر می‌سازد.
### `_request_with_pagination(page, page_size)` ### `_request_with_pagination(page, page_size)`
@@ -724,9 +724,9 @@ payload ساده‌تر برای endpoint اولیه zoneها می‌سازد.
### تست‌های اصلی `AreaView` ### تست‌های اصلی `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` #### `test_get_returns_pending_task_status_until_all_zones_complete`
+11 -11
View File
@@ -20,7 +20,7 @@ Content-Type: application/json
## Flow پیشنهادی فرانت ## Flow پیشنهادی فرانت
1. ابتدا `GET /area/` را با `sensor_uuid` صدا بزنید. 1. ابتدا `GET /area/` را با `farm_uuid` صدا بزنید.
2. اگر `task.status` برابر `PENDING` یا `PROCESSING` بود، polling انجام دهید. 2. اگر `task.status` برابر `PENDING` یا `PROCESSING` بود، polling انجام دهید.
3. وقتی `task.status` برابر `SUCCESS` شد: 3. وقتی `task.status` برابر `SUCCESS` شد:
- `area` را برای polygon اصلی زمین استفاده کنید. - `area` را برای polygon اصلی زمین استفاده کنید.
@@ -29,7 +29,7 @@ Content-Type: application/json
## وضعیت‌های Task ## وضعیت‌های Task
- `IDLE`: هنوز area/taskی برای سنسور وجود ندارد. - `IDLE`: هنوز area/taskی برای مزرعه وجود ندارد.
- `PENDING`: تسک ساخته شده ولی پردازش هنوز شروع نشده یا در صف است. - `PENDING`: تسک ساخته شده ولی پردازش هنوز شروع نشده یا در صف است.
- `PROCESSING`: بخشی از زون‌ها در حال پردازش هستند یا برخی کامل شده‌اند. - `PROCESSING`: بخشی از زون‌ها در حال پردازش هستند یا برخی کامل شده‌اند.
- `SUCCESS`: همه زون‌ها کامل پردازش شده‌اند. - `SUCCESS`: همه زون‌ها کامل پردازش شده‌اند.
@@ -51,18 +51,18 @@ Content-Type: application/json
## 1) Get Area ## 1) Get Area
```http ```http
GET /api/crop-zoning/area/?sensor_uuid=<sensor_uuid>&page=1&page_size=10 GET /api/crop-zoning/area/?farm_uuid=<farm_uuid>&page=1&page_size=10
``` ```
### Query Params ### Query Params
- `sensor_uuid`: اجباری، UUID سنسور - `farm_uuid`: اجباری، UUID مزرعه
- `page`: اختیاری، شماره صفحه زون‌ها. پیش‌فرض `1` - `page`: اختیاری، شماره صفحه زون‌ها. پیش‌فرض `1`
- `page_size`: اختیاری، تعداد زون در هر صفحه. پیش‌فرض `10` - `page_size`: اختیاری، تعداد زون در هر صفحه. پیش‌فرض `10`
### کاربرد ### کاربرد
- گرفتن آخرین area مربوط به سنسور - گرفتن آخرین area مربوط به مزرعه
- ساخت area و zoneها در صورت نبود داده - ساخت area و zoneها در صورت نبود داده
- دریافت وضعیت task - دریافت وضعیت task
- دریافت لیست `zones` به صورت صفحه‌بندی‌شده برای نمایش روی نقشه - دریافت لیست `zones` به صورت صفحه‌بندی‌شده برای نمایش روی نقشه
@@ -175,13 +175,13 @@ GET /api/crop-zoning/area/?sensor_uuid=<sensor_uuid>&page=1&page_size=10
#### صفحه اول با 10 زون در هر صفحه #### صفحه اول با 10 زون در هر صفحه
```http ```http
GET /api/crop-zoning/area/?sensor_uuid=<sensor_uuid>&page=1&page_size=10 GET /api/crop-zoning/area/?farm_uuid=<farm_uuid>&page=1&page_size=10
``` ```
#### صفحه سوم با 25 زون در هر صفحه #### صفحه سوم با 25 زون در هر صفحه
```http ```http
GET /api/crop-zoning/area/?sensor_uuid=<sensor_uuid>&page=3&page_size=25 GET /api/crop-zoning/area/?farm_uuid=<farm_uuid>&page=3&page_size=25
``` ```
### فیلدهای مهم `zones` ### فیلدهای مهم `zones`
@@ -215,21 +215,21 @@ GET /api/crop-zoning/area/?sensor_uuid=<sensor_uuid>&page=3&page_size=25
### خطاها ### خطاها
#### وقتی `sensor_uuid` ارسال نشود #### وقتی `farm_uuid` ارسال نشود
```json ```json
{ {
"status": "error", "status": "error",
"message": "sensor_uuid is required." "message": "farm_uuid is required."
} }
``` ```
#### وقتی سنسور پیدا نشود #### وقتی مزرعه پیدا نشود
```json ```json
{ {
"status": "error", "status": "error",
"message": "Sensor not found." "message": "Farm not found."
} }
``` ```
@@ -4,20 +4,20 @@ import django.db.models.deletion
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("sensor_hub", "0001_initial"), ("farm_hub", "0002_seed_default_catalog"),
("crop_zoning", "0003_zone_processing_and_analysis"), ("crop_zoning", "0003_zone_processing_and_analysis"),
] ]
operations = [ operations = [
migrations.AddField( migrations.AddField(
model_name="croparea", model_name="croparea",
name="sensor", name="farm",
field=models.ForeignKey( field=models.ForeignKey(
blank=True, blank=True,
null=True, null=True,
on_delete=django.db.models.deletion.CASCADE, on_delete=django.db.models.deletion.CASCADE,
related_name="crop_areas", related_name="crop_areas",
to="sensor_hub.sensor", to="farm_hub.farmhub",
), ),
), ),
] ]
+3 -6
View File
@@ -1,13 +1,13 @@
import uuid import uuid
from django.db import models from django.db import models
from sensor_hub.models import Sensor from farm_hub.models import FarmHub
class CropArea(models.Model): class CropArea(models.Model):
uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True) uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True)
sensor = models.ForeignKey( farm = models.ForeignKey(
Sensor, FarmHub,
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name="crop_areas", related_name="crop_areas",
null=True, null=True,
@@ -74,7 +74,6 @@ class CropZone(models.Model):
return self.zone_id return self.zone_id
class CropProduct(models.Model): class CropProduct(models.Model):
product_id = models.CharField(max_length=64, unique=True) product_id = models.CharField(max_length=64, unique=True)
label = models.CharField(max_length=255) label = models.CharField(max_length=255)
@@ -205,7 +204,6 @@ class CropZoneCultivationRiskLayer(models.Model):
ordering = ["crop_zone_id"] ordering = ["crop_zone_id"]
class CropZoneAnalysis(models.Model): class CropZoneAnalysis(models.Model):
source = models.CharField(max_length=64, blank=True, default="") source = models.CharField(max_length=64, blank=True, default="")
external_record_id = 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: class Meta:
db_table = "crop_zone_analyses" db_table = "crop_zone_analyses"
ordering = ["crop_zone_id"] ordering = ["crop_zone_id"]
File diff suppressed because one or more lines are too long
+18 -13
View File
@@ -9,7 +9,7 @@ from kombu.exceptions import OperationalError
from django.db import transaction from django.db import transaction
from django.db.models import Prefetch from django.db.models import Prefetch
from django.utils import timezone 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 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")) return list(crop_area.zones.order_by("sequence", "id"))
def get_sensor_for_uuid(sensor_uuid): def get_farm_for_uuid(farm_uuid, owner=None):
if not sensor_uuid: if not farm_uuid:
raise ValueError("sensor_uuid is required.") raise ValueError("farm_uuid is required.")
filters = {"farm_uuid": farm_uuid}
if owner is not None:
filters["owner"] = owner
try: try:
return Sensor.objects.get(uuid_sensor=sensor_uuid) return FarmHub.objects.get(**filters)
except Sensor.DoesNotExist as exc: except FarmHub.DoesNotExist as exc:
raise ValueError("Sensor not found.") from exc raise ValueError("Farm not found.") from exc
def ensure_latest_area_ready_for_processing(sensor_uuid, area_feature=None): def ensure_latest_area_ready_for_processing(farm_uuid, area_feature=None, owner=None):
sensor = get_sensor_for_uuid(sensor_uuid) farm = get_farm_for_uuid(farm_uuid, owner=owner)
latest_area = CropArea.objects.filter(sensor=sensor).order_by("-created_at", "-id").first() latest_area = CropArea.objects.filter(farm=farm).order_by("-created_at", "-id").first()
if latest_area is None: 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 return latest_area
zones = create_missing_zones_for_area(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) 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() ensure_products_exist()
area_feature = normalize_area_feature(area_feature) area_feature = normalize_area_feature(area_feature)
zoning_result = split_area_into_zones(area_feature, cell_side_km=cell_side_km) 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(): with transaction.atomic():
crop_area = CropArea.objects.create( crop_area = CropArea.objects.create(
sensor=sensor, farm=farm,
geometry=area_data["geometry"], geometry=area_data["geometry"],
points=area_data["points"], points=area_data["points"],
center=area_data["center"], center=area_data["center"],
+54 -117
View File
@@ -1,16 +1,15 @@
from datetime import timedelta
from unittest.mock import patch from unittest.mock import patch
from kombu.exceptions import OperationalError
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.test import TestCase, override_settings from django.test import TestCase, override_settings
from django.utils import timezone from django.utils import timezone
from rest_framework.test import APIRequestFactory from kombu.exceptions import OperationalError
from datetime import timedelta from rest_framework.test import APIRequestFactory, force_authenticate
from crop_zoning.models import CropArea, CropZone from crop_zoning.models import CropArea, CropZone
from crop_zoning.views import AreaView, ZonesInitialView from crop_zoning.views import AreaView, ZonesInitialView
from sensor_hub.models import Sensor from farm_hub.models import FarmHub, FarmType
AREA_GEOJSON = { AREA_GEOJSON = {
@@ -69,11 +68,19 @@ class AreaViewTests(TestCase):
email="farmer@example.com", email="farmer@example.com",
phone_number="09120000000", 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): def _create_area(self, **kwargs):
defaults = { defaults = {
"sensor": self.sensor, "farm": self.farm,
"geometry": AREA_GEOJSON, "geometry": AREA_GEOJSON,
"points": AREA_GEOJSON["geometry"]["coordinates"][0][:-1], "points": AREA_GEOJSON["geometry"]["coordinates"][0][:-1],
"center": {"longitude": 51.40874867, "latitude": 35.69575533}, "center": {"longitude": 51.40874867, "latitude": 35.69575533},
@@ -86,18 +93,32 @@ class AreaViewTests(TestCase):
return CropArea.objects.create(**defaults) return CropArea.objects.create(**defaults)
def _request(self): 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): def _request_with_pagination(self, page=1, page_size=10):
return self.factory.get( request = self.factory.get(
f"/api/crop-zoning/area/?sensor_uuid={self.sensor.uuid_sensor}&page={page}&page_size={page_size}" 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/") request = self.factory.get("/api/crop-zoning/area/")
force_authenticate(request, user=self.user)
response = AreaView.as_view()(request) response = AreaView.as_view()(request)
self.assertEqual(response.status_code, 400) 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): def test_get_returns_pending_task_status_until_all_zones_complete(self):
crop_area = self._create_area() crop_area = self._create_area()
@@ -219,7 +240,7 @@ class AreaViewTests(TestCase):
mock_dispatch.assert_called_once() mock_dispatch.assert_called_once()
@patch("crop_zoning.services.create_zones_and_dispatch") @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) created_area = self._create_area(zone_count=0)
mock_create.return_value = (created_area, []) mock_create.return_value = (created_area, [])
@@ -227,7 +248,7 @@ class AreaViewTests(TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
mock_create.assert_called_once() 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") @patch("crop_zoning.tasks.process_zone_soil_data.delay")
def test_each_zone_gets_its_own_task(self, mock_delay): def test_each_zone_gets_its_own_task(self, mock_delay):
@@ -238,8 +259,8 @@ class AreaViewTests(TestCase):
geometry=AREA_GEOJSON["geometry"], geometry=AREA_GEOJSON["geometry"],
points=AREA_GEOJSON["geometry"]["coordinates"][0][:-1], points=AREA_GEOJSON["geometry"]["coordinates"][0][:-1],
center={"longitude": 51.4087, "latitude": 35.6957}, center={"longitude": 51.4087, "latitude": 35.6957},
area_sqm=150000, area_sqm=200000,
area_hectares=15, area_hectares=20,
sequence=0, sequence=0,
processing_status=CropZone.STATUS_PENDING, processing_status=CropZone.STATUS_PENDING,
task_id="", task_id="",
@@ -250,129 +271,45 @@ class AreaViewTests(TestCase):
geometry=AREA_GEOJSON["geometry"], geometry=AREA_GEOJSON["geometry"],
points=AREA_GEOJSON["geometry"]["coordinates"][0][:-1], points=AREA_GEOJSON["geometry"]["coordinates"][0][:-1],
center={"longitude": 51.4088, "latitude": 35.6958}, center={"longitude": 51.4088, "latitude": 35.6958},
area_sqm=150000, area_sqm=100000,
area_hectares=15, area_hectares=10,
sequence=1, sequence=1,
processing_status=CropZone.STATUS_PENDING, processing_status=CropZone.STATUS_PENDING,
task_id="", 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()) response = AreaView.as_view()(self._request())
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(mock_delay.call_count, 2) self.assertEqual(mock_delay.call_count, 2)
zone0.refresh_from_db() zone0.refresh_from_db()
zone1.refresh_from_db() zone1.refresh_from_db()
self.assertEqual(zone0.task_id, "task-zone-0") self.assertTrue(zone0.task_id)
self.assertEqual(zone1.task_id, "task-zone-1") self.assertTrue(zone1.task_id)
self.assertNotEqual(zone0.task_id, zone1.task_id)
@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)
@patch("crop_zoning.services.AsyncResult") @patch("crop_zoning.services.AsyncResult")
@patch("crop_zoning.tasks.process_zone_soil_data.delay") def test_stale_tasks_are_redispatched(self, mock_async_result):
def test_get_redispatches_pending_zone_when_shared_task_already_completed(self, mock_delay, mock_async_result):
crop_area = self._create_area() crop_area = self._create_area()
CropZone.objects.create( stale_time = timezone.now() - timedelta(minutes=10)
stale_zone = CropZone.objects.create(
crop_area=crop_area, crop_area=crop_area,
zone_id="zone-0", zone_id="zone-0",
geometry=AREA_GEOJSON["geometry"], geometry=AREA_GEOJSON["geometry"],
points=AREA_GEOJSON["geometry"]["coordinates"][0][:-1], points=AREA_GEOJSON["geometry"]["coordinates"][0][:-1],
center={"longitude": 51.4087, "latitude": 35.6957}, center={"longitude": 51.4087, "latitude": 35.6957},
area_sqm=150000, area_sqm=200000,
area_hectares=15, area_hectares=20,
sequence=0, sequence=0,
processing_status=CropZone.STATUS_COMPLETED, processing_status=CropZone.STATUS_PROCESSING,
task_id="legacy-shared-task-id", task_id="stale-task",
) )
stale_zone = CropZone.objects.create( CropZone.objects.filter(id=stale_zone.id).update(updated_at=stale_time)
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"])
class Result: mock_async_result.side_effect = OperationalError("broker down")
id = "requeued-zone-1"
mock_delay.return_value = Result() with patch("crop_zoning.services.dispatch_zone_processing_tasks") as mock_dispatch:
mock_async_result.return_value.state = "SUCCESS" response = AreaView.as_view()(self._request())
response = AreaView.as_view()(self._request())
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(mock_delay.call_count, 1) mock_dispatch.assert_called_once_with(zone_ids=[stale_zone.id], force=True)
stale_zone.refresh_from_db()
self.assertEqual(stale_zone.task_id, "requeued-zone-1")
+8 -8
View File
@@ -17,8 +17,8 @@ from .services import (
get_products_payload, get_products_payload,
get_soil_quality_payload, get_soil_quality_payload,
get_water_need_payload, get_water_need_payload,
get_zone_page_request_params,
get_zone_details_payload, get_zone_details_payload,
get_zone_page_request_params,
) )
@@ -27,26 +27,26 @@ class AreaView(APIView):
tags=["Crop Zoning"], tags=["Crop Zoning"],
parameters=[ parameters=[
OpenApiParameter( OpenApiParameter(
name="sensor_uuid", name="farm_uuid",
type=OpenApiTypes.UUID, type=OpenApiTypes.UUID,
location=OpenApiParameter.QUERY, location=OpenApiParameter.QUERY,
required=True, required=True,
description="UUID سنسور ارسالی کاربر برای گرفتن یا ساخت task فعال همان سنسور.", description="UUID مزرعه برای گرفتن يا ساخت آخرين پردازش محدوده همان مزرعه.",
), ),
OpenApiParameter( OpenApiParameter(
name="page", name="page",
type=OpenApiTypes.INT, type=OpenApiTypes.INT,
location=OpenApiParameter.QUERY, location=OpenApiParameter.QUERY,
required=False, required=False,
description="شماره صفحه زونها. مقدار پیش‌فرض 1 است.", description="شماره صفحه زون ها. مقدار پيش فرض 1 است.",
), ),
OpenApiParameter( OpenApiParameter(
name="page_size", name="page_size",
type=OpenApiTypes.INT, type=OpenApiTypes.INT,
location=OpenApiParameter.QUERY, location=OpenApiParameter.QUERY,
required=False, required=False,
description="تعداد زون در هر صفحه. مقدار پیش‌فرض 10 است.", description="تعداد زون در هر صفحه. مقدار پيش فرض 10 است.",
) ),
], ],
responses={ responses={
200: status_response("CropZoningAreaResponse", data=serializers.JSONField()), 200: status_response("CropZoningAreaResponse", data=serializers.JSONField()),
@@ -55,10 +55,10 @@ class AreaView(APIView):
}, },
) )
def get(self, request): def get(self, request):
sensor_uuid = request.query_params.get("sensor_uuid") farm_uuid = request.query_params.get("farm_uuid")
try: try:
page, page_size = get_zone_page_request_params(request.query_params) 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: except ValueError as exc:
return Response({"status": "error", "message": str(exc)}, status=status.HTTP_400_BAD_REQUEST) return Response({"status": "error", "message": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
except ImproperlyConfigured as exc: except ImproperlyConfigured as exc:
+36
View File
@@ -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"],
},
),
]
+23
View File
@@ -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}"
File diff suppressed because one or more lines are too long
+3 -1
View File
@@ -4,6 +4,7 @@ from .mock_data import VALID_CARD_IDS, VALID_ROW_IDS
class FarmDashboardConfigSerializer(serializers.Serializer): class FarmDashboardConfigSerializer(serializers.Serializer):
farm_uuid = serializers.UUIDField(read_only=True)
disabled_card_ids = serializers.ListField( disabled_card_ids = serializers.ListField(
child=serializers.CharField(), child=serializers.CharField(),
allow_empty=True, allow_empty=True,
@@ -40,6 +41,7 @@ class FarmDashboardConfigSerializer(serializers.Serializer):
class FarmDashboardConfigPatchSerializer(FarmDashboardConfigSerializer): class FarmDashboardConfigPatchSerializer(FarmDashboardConfigSerializer):
farm_uuid = serializers.UUIDField(required=True)
disabled_card_ids = serializers.ListField( disabled_card_ids = serializers.ListField(
child=serializers.CharField(), child=serializers.CharField(),
allow_empty=True, allow_empty=True,
@@ -54,6 +56,6 @@ class FarmDashboardConfigPatchSerializer(FarmDashboardConfigSerializer):
def validate(self, attrs): def validate(self, attrs):
attrs = super().validate(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.") raise serializers.ValidationError("At least one config field must be provided.")
return attrs return attrs
+100 -14
View File
@@ -1,52 +1,105 @@
from copy import deepcopy from copy import deepcopy
from unittest.mock import patch
from django.test import SimpleTestCase from django.contrib.auth import get_user_model
from rest_framework.test import APIRequestFactory from django.test import TestCase
from rest_framework.test import APIRequestFactory, force_authenticate
from .mock_data import DEFAULT_CONFIG, reset_config from farm_hub.models import FarmHub, FarmType
from .views import FarmDashboardConfigView
from .mock_data import DEFAULT_CONFIG
from .models import FarmDashboardConfig
from .views import FarmDashboardCardsView, FarmDashboardConfigView
class FarmDashboardConfigViewTests(SimpleTestCase): class DashboardBaseTestCase(TestCase):
def setUp(self): def setUp(self):
self.factory = APIRequestFactory() 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): class FarmDashboardConfigViewTests(DashboardBaseTestCase):
request = self.factory.get("/api/farm-dashboard-config/") 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) 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.status_code, 200)
self.assertEqual(response.data["code"], 200) self.assertEqual(response.data["code"], 200)
self.assertEqual(response.data["msg"], "OK") 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): def test_patch_partial_update_returns_full_final_config(self):
request = self.factory.patch( request = self.factory.patch(
"/api/farm-dashboard-config/", "/api/farm-dashboard-config/",
{"disabled_card_ids": ["farmWeatherCard"]}, {
"farm_uuid": str(self.farm.farm_uuid),
"disabled_card_ids": ["farmWeatherCard"],
},
format="json", format="json",
) )
force_authenticate(request, user=self.user)
response = FarmDashboardConfigView.as_view()(request) response = FarmDashboardConfigView.as_view()(request)
expected = deepcopy(DEFAULT_CONFIG) expected = deepcopy(DEFAULT_CONFIG)
expected["farm_uuid"] = str(self.farm.farm_uuid)
expected["disabled_card_ids"] = ["farmWeatherCard"] expected["disabled_card_ids"] = ["farmWeatherCard"]
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["data"], expected) 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): def test_patch_only_drag_flag_still_returns_full_config(self):
request = self.factory.patch( request = self.factory.patch(
"/api/farm-dashboard-config/", "/api/farm-dashboard-config/",
{"enable_drag_reorder": False}, {
"farm_uuid": str(self.farm.farm_uuid),
"enable_drag_reorder": False,
},
format="json", format="json",
) )
force_authenticate(request, user=self.user)
response = FarmDashboardConfigView.as_view()(request) response = FarmDashboardConfigView.as_view()(request)
expected = deepcopy(DEFAULT_CONFIG) expected = deepcopy(DEFAULT_CONFIG)
expected["farm_uuid"] = str(self.farm.farm_uuid)
expected["enable_drag_reorder"] = False expected["enable_drag_reorder"] = False
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
@@ -57,10 +110,43 @@ class FarmDashboardConfigViewTests(SimpleTestCase):
def test_patch_rejects_invalid_row_order(self): def test_patch_rejects_invalid_row_order(self):
request = self.factory.patch( request = self.factory.patch(
"/api/farm-dashboard-config/", "/api/farm-dashboard-config/",
{"row_order": ["overviewKpis"]}, {
"farm_uuid": str(self.farm.farm_uuid),
"row_order": ["overviewKpis"],
},
format="json", format="json",
) )
force_authenticate(request, user=self.user)
response = FarmDashboardConfigView.as_view()(request) response = FarmDashboardConfigView.as_view()(request)
self.assertEqual(response.status_code, 400) self.assertEqual(response.status_code, 400)
self.assertIn("row_order", response.data) 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.")
+85 -20
View File
@@ -2,21 +2,59 @@
Farm Dashboard API views. Farm Dashboard API views.
""" """
from rest_framework import status from rest_framework import serializers, status
from rest_framework import serializers from rest_framework.permissions import IsAuthenticated
from rest_framework.permissions import AllowAny
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.views import APIView 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 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 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( @extend_schema_view(
get=extend_schema( get=extend_schema(
tags=["Farm Dashboard"], tags=["Farm Dashboard"],
parameters=[
OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=True),
],
responses={200: code_response("FarmDashboardConfigGetResponse", data=FarmDashboardConfigSerializer())}, responses={200: code_response("FarmDashboardConfigGetResponse", data=FarmDashboardConfigSerializer())},
), ),
patch=extend_schema( patch=extend_schema(
@@ -25,25 +63,43 @@ from .serializers import FarmDashboardConfigPatchSerializer, FarmDashboardConfig
responses={200: code_response("FarmDashboardConfigPatchResponse", data=FarmDashboardConfigSerializer())}, responses={200: code_response("FarmDashboardConfigPatchResponse", data=FarmDashboardConfigSerializer())},
), ),
) )
class FarmDashboardConfigView(APIView): class FarmDashboardConfigView(FarmAccessMixin, APIView):
""" """
Farm dashboard config endpoints. Farm dashboard config endpoints.
GET returns the current config. GET/PATCH are persisted in DB per farm.
PATCH accepts partial updates and returns the full final config.
""" """
permission_classes = [AllowAny]
permission_classes = [IsAuthenticated]
def get(self, request): def get(self, request):
config = get_config() farm = self._get_farm(request, request.query_params.get("farm_uuid"))
return Response({"code": 200, "msg": "OK", "data": config}, status=status.HTTP_200_OK) 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): def patch(self, request):
serializer = FarmDashboardConfigPatchSerializer(data=request.data) serializer = FarmDashboardConfigPatchSerializer(data=request.data)
serializer.is_valid(raise_exception=True) 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( return Response(
{"code": 200, "msg": "OK", "data": response_serializer.data}, {"code": 200, "msg": "OK", "data": self._serialize_config(config)},
status=status.HTTP_200_OK, status=status.HTTP_200_OK,
) )
@@ -51,17 +107,26 @@ class FarmDashboardConfigView(APIView):
@extend_schema_view( @extend_schema_view(
get=extend_schema( get=extend_schema(
tags=["Farm Dashboard"], tags=["Farm Dashboard"],
parameters=[
OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=True),
],
responses={200: code_response("FarmDashboardCardsResponse", data=serializers.JSONField())}, responses={200: code_response("FarmDashboardCardsResponse", data=serializers.JSONField())},
), ),
) )
class FarmDashboardCardsView(APIView): class FarmDashboardCardsView(FarmAccessMixin, APIView):
""" """
Farm dashboard cards endpoint: GET. Farm dashboard cards endpoint: GET.
Returns unified response with all 15 card payloads. Requires farm_uuid and forwards it to the external AI service.
No database. Static mock data only.
""" """
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) return Response(adapter_response.data, status=adapter_response.status_code)
-11
View File
@@ -43,15 +43,6 @@ services:
volumes: volumes:
- backend_redis_data:/data - 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: web:
build: build:
@@ -81,8 +72,6 @@ services:
condition: service_healthy condition: service_healthy
redis: redis:
condition: service_healthy condition: service_healthy
qdrant:
condition: service_started
restart: unless-stopped restart: unless-stopped
celery: celery:
+3 -3
View File
@@ -10,9 +10,9 @@ EXTERNAL_SERVICES = {
"base_url": os.getenv("AI_SERVICE_BASE_URL", ""), "base_url": os.getenv("AI_SERVICE_BASE_URL", ""),
"api_key": os.getenv("AI_SERVICE_API_KEY", ""), "api_key": os.getenv("AI_SERVICE_API_KEY", ""),
}, },
"sensor_hub": { "farm_hub": {
"base_url": os.getenv("SENSOR_HUB_SERVICE_BASE_URL", ""), "base_url": os.getenv("FARM_HUB_SERVICE_BASE_URL", ""),
"api_key": os.getenv("SENSOR_HUB_SERVICE_API_KEY", ""), "api_key": os.getenv("FARM_HUB_SERVICE_API_KEY", ""),
}, },
} }
``` ```
@@ -2,7 +2,7 @@
"code": 400, "code": 400,
"msg": "داده نامعتبر.", "msg": "داده نامعتبر.",
"data": { "data": {
"sensor_uuid": [ "farm_uuid": [
"This field is required." "This field is required."
] ]
} }
+6 -6
View File
@@ -512,42 +512,42 @@
}, },
{ {
"method": "PUT", "method": "PUT",
"path": "/api/sensor-data/{uuid_sensor}/", "path": "/api/sensor-data/{farm_uuid}/",
"status_code": 200, "status_code": 200,
"description": "Sensor update put success", "description": "Sensor update put success",
"file": "json/mock_data/sensor-data/update-put_200.json" "file": "json/mock_data/sensor-data/update-put_200.json"
}, },
{ {
"method": "PUT", "method": "PUT",
"path": "/api/sensor-data/{uuid_sensor}/", "path": "/api/sensor-data/{farm_uuid}/",
"status_code": 400, "status_code": 400,
"description": "Sensor update put validation error", "description": "Sensor update put validation error",
"file": "json/mock_data/sensor-data/update-put_400.json" "file": "json/mock_data/sensor-data/update-put_400.json"
}, },
{ {
"method": "PUT", "method": "PUT",
"path": "/api/sensor-data/{uuid_sensor}/", "path": "/api/sensor-data/{farm_uuid}/",
"status_code": 404, "status_code": 404,
"description": "Sensor update put location not found", "description": "Sensor update put location not found",
"file": "json/mock_data/sensor-data/update-put_404.json" "file": "json/mock_data/sensor-data/update-put_404.json"
}, },
{ {
"method": "PATCH", "method": "PATCH",
"path": "/api/sensor-data/{uuid_sensor}/", "path": "/api/sensor-data/{farm_uuid}/",
"status_code": 200, "status_code": 200,
"description": "Sensor update patch success", "description": "Sensor update patch success",
"file": "json/mock_data/sensor-data/update-patch_200.json" "file": "json/mock_data/sensor-data/update-patch_200.json"
}, },
{ {
"method": "PATCH", "method": "PATCH",
"path": "/api/sensor-data/{uuid_sensor}/", "path": "/api/sensor-data/{farm_uuid}/",
"status_code": 400, "status_code": 400,
"description": "Sensor update patch validation error", "description": "Sensor update patch validation error",
"file": "json/mock_data/sensor-data/update-patch_400.json" "file": "json/mock_data/sensor-data/update-patch_400.json"
}, },
{ {
"method": "PATCH", "method": "PATCH",
"path": "/api/sensor-data/{uuid_sensor}/", "path": "/api/sensor-data/{farm_uuid}/",
"status_code": 404, "status_code": 404,
"description": "Sensor update patch location not found", "description": "Sensor update patch location not found",
"file": "json/mock_data/sensor-data/update-patch_404.json" "file": "json/mock_data/sensor-data/update-patch_404.json"
@@ -2,7 +2,7 @@
"code": 400, "code": 400,
"msg": "داده نامعتبر.", "msg": "داده نامعتبر.",
"data": { "data": {
"sensor_uuid": [ "farm_uuid": [
"This field is required." "This field is required."
] ]
} }
@@ -1,5 +1,5 @@
{ {
"code": 400, "code": 400,
"msg": "پارامتر sensor_uuid الزامی است.", "msg": "پارامتر farm_uuid الزامی است.",
"data": null "data": null
} }
@@ -1,5 +1,5 @@
{ {
"code": 400, "code": 400,
"msg": "پارامتر sensor_uuid الزامی است.", "msg": "پارامتر farm_uuid الزامی است.",
"data": null "data": null
} }
@@ -2,7 +2,7 @@
"code": 200, "code": 200,
"msg": "success", "msg": "success",
"data": { "data": {
"uuid_sensor": "550e8400-e29b-41d4-a716-446655440000", "farm_uuid": "550e8400-e29b-41d4-a716-446655440000",
"location_id": 12, "location_id": 12,
"soil_moisture": 45.2, "soil_moisture": 45.2,
"soil_temperature": 22.5, "soil_temperature": 22.5,
@@ -2,7 +2,7 @@
"code": 200, "code": 200,
"msg": "success", "msg": "success",
"data": { "data": {
"uuid_sensor": "550e8400-e29b-41d4-a716-446655440000", "farm_uuid": "550e8400-e29b-41d4-a716-446655440000",
"location_id": 12, "location_id": 12,
"soil_moisture": 45.2, "soil_moisture": 45.2,
"soil_temperature": 22.5, "soil_temperature": 22.5,
@@ -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",
),
),
]
+16
View File
@@ -3,6 +3,8 @@ import uuid
from django.conf import settings from django.conf import settings
from django.db import models from django.db import models
from farm_hub.models import FarmHub
class Conversation(models.Model): class Conversation(models.Model):
uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True) 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, on_delete=models.CASCADE,
related_name="farm_ai_conversations", 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="") title = models.CharField(max_length=255, blank=True, default="")
farm_context = models.JSONField(default=dict, blank=True) farm_context = models.JSONField(default=dict, blank=True)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
@@ -38,6 +47,13 @@ class Message(models.Model):
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name="messages", 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) role = models.CharField(max_length=32, choices=ROLE_CHOICES)
content = models.TextField(blank=True, default="") content = models.TextField(blank=True, default="")
images = models.JSONField(default=list, blank=True) images = models.JSONField(default=list, blank=True)
+9
View File
@@ -17,10 +17,12 @@ class ChatSectionSerializer(serializers.Serializer):
class ConversationSummarySerializer(serializers.Serializer): class ConversationSummarySerializer(serializers.Serializer):
id = serializers.UUIDField(source="uuid", read_only=True) 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) message_count = serializers.IntegerField(read_only=True)
class ConversationCreateSerializer(serializers.Serializer): class ConversationCreateSerializer(serializers.Serializer):
farm_uuid = serializers.UUIDField(required=True)
title = serializers.CharField(required=False, allow_blank=True, max_length=255) title = serializers.CharField(required=False, allow_blank=True, max_length=255)
farm_context = serializers.JSONField(required=False) farm_context = serializers.JSONField(required=False)
@@ -28,6 +30,7 @@ class ConversationCreateSerializer(serializers.Serializer):
class ChatHistoryMessageSerializer(serializers.Serializer): class ChatHistoryMessageSerializer(serializers.Serializer):
message_id = serializers.UUIDField(read_only=True) message_id = serializers.UUIDField(read_only=True)
conversation_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) role = serializers.ChoiceField(choices=Message.ROLE_CHOICES, read_only=True)
content = serializers.CharField(read_only=True, allow_blank=True) content = serializers.CharField(read_only=True, allow_blank=True)
sections = ChatSectionSerializer(many=True, read_only=True) sections = ChatSectionSerializer(many=True, read_only=True)
@@ -37,18 +40,21 @@ class ChatHistoryMessageSerializer(serializers.Serializer):
class ConversationMessagesSerializer(serializers.Serializer): class ConversationMessagesSerializer(serializers.Serializer):
conversation_id = serializers.UUIDField(read_only=True) conversation_id = serializers.UUIDField(read_only=True)
farm_uuid = serializers.UUIDField(read_only=True)
messages = ChatHistoryMessageSerializer(many=True, read_only=True) messages = ChatHistoryMessageSerializer(many=True, read_only=True)
class ChatResponseDataSerializer(serializers.Serializer): class ChatResponseDataSerializer(serializers.Serializer):
message_id = serializers.UUIDField(read_only=True) message_id = serializers.UUIDField(read_only=True)
conversation_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) content = serializers.CharField(read_only=True, allow_blank=True)
sections = ChatSectionSerializer(many=True, read_only=True) sections = ChatSectionSerializer(many=True, read_only=True)
class ConversationDeleteSerializer(serializers.Serializer): class ConversationDeleteSerializer(serializers.Serializer):
conversation_id = serializers.UUIDField(read_only=True) conversation_id = serializers.UUIDField(read_only=True)
farm_uuid = serializers.UUIDField(read_only=True)
class ChatTaskSubmitDataSerializer(serializers.Serializer): class ChatTaskSubmitDataSerializer(serializers.Serializer):
@@ -57,18 +63,21 @@ class ChatTaskSubmitDataSerializer(serializers.Serializer):
status_url = serializers.CharField(required=False, allow_blank=True) status_url = serializers.CharField(required=False, allow_blank=True)
conversation_id = serializers.UUIDField(read_only=True) conversation_id = serializers.UUIDField(read_only=True)
message_id = serializers.UUIDField(read_only=True) message_id = serializers.UUIDField(read_only=True)
farm_uuid = serializers.UUIDField(read_only=True)
class ChatTaskStatusDataSerializer(serializers.Serializer): class ChatTaskStatusDataSerializer(serializers.Serializer):
task_id = serializers.CharField(required=False, allow_blank=True) task_id = serializers.CharField(required=False, allow_blank=True)
status = serializers.CharField(required=False, allow_blank=True) status = serializers.CharField(required=False, allow_blank=True)
conversation_id = serializers.UUIDField(read_only=True) conversation_id = serializers.UUIDField(read_only=True)
farm_uuid = serializers.UUIDField(read_only=True)
progress = serializers.JSONField(required=False) progress = serializers.JSONField(required=False)
result = serializers.JSONField(required=False) result = serializers.JSONField(required=False)
error = serializers.CharField(required=False, allow_blank=True) error = serializers.CharField(required=False, allow_blank=True)
class ChatPostSerializer(serializers.Serializer): class ChatPostSerializer(serializers.Serializer):
farm_uuid = serializers.UUIDField(required=True)
content = serializers.CharField(required=False, allow_blank=True, default="") content = serializers.CharField(required=False, allow_blank=True, default="")
images = serializers.ListField( images = serializers.ListField(
child=serializers.CharField(), child=serializers.CharField(),
+17 -1
View File
@@ -2,6 +2,8 @@ from django.contrib.auth import get_user_model
from django.test import TestCase, override_settings from django.test import TestCase, override_settings
from rest_framework.test import APIRequestFactory, force_authenticate from rest_framework.test import APIRequestFactory, force_authenticate
from farm_hub.models import FarmHub, FarmType
from .models import Conversation, Message from .models import Conversation, Message
from .views import ChatTaskStatusView from .views import ChatTaskStatusView
@@ -16,24 +18,35 @@ class ChatTaskStatusViewTests(TestCase):
email="farmer@example.com", email="farmer@example.com",
phone_number="09120000000", 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( self.conversation = Conversation.objects.create(
owner=self.user, owner=self.user,
farm=self.farm,
title="Irrigation chat", title="Irrigation chat",
farm_context={}, farm_context={},
) )
self.user_message = Message.objects.create( self.user_message = Message.objects.create(
conversation=self.conversation, conversation=self.conversation,
farm=self.farm,
role=Message.ROLE_USER, role=Message.ROLE_USER,
content="What is the best irrigation plan?", content="What is the best irrigation plan?",
raw_response={ raw_response={
"task_id": "farm-ai-chat-task-123", "task_id": "farm-ai-chat-task-123",
"status": "PENDING", "status": "PENDING",
"status_url": "/api/tasks/farm-ai-chat-task-123/status/", "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): 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) force_authenticate(request, user=self.user)
response = ChatTaskStatusView.as_view()(request, task_id="farm-ai-chat-task-123") 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"]["task_id"], "farm-ai-chat-task-123")
self.assertEqual(response.data["data"]["status"], "SUCCESS") self.assertEqual(response.data["data"]["status"], "SUCCESS")
self.assertEqual(response.data["data"]["conversation_id"], str(self.conversation.uuid)) self.assertEqual(response.data["data"]["conversation_id"], str(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(response.data["data"]["result"]["content"], "Here is the recommended plan.")
self.assertEqual(len(response.data["data"]["result"]["sections"]), 3) self.assertEqual(len(response.data["data"]["result"]["sections"]), 3)
self.assertEqual(response.data["data"]["result"]["task_id"], "farm-ai-chat-task-123") self.assertEqual(response.data["data"]["result"]["task_id"], "farm-ai-chat-task-123")
@@ -53,6 +67,8 @@ class ChatTaskStatusViewTests(TestCase):
.first() .first()
) )
self.assertIsNotNone(assistant_message) 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.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["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) self.assertEqual(len(assistant_message.raw_response["sections"]), 3)
+86 -30
View File
@@ -14,6 +14,7 @@ from drf_spectacular.utils import OpenApiParameter, extend_schema
from config.swagger import status_response from config.swagger import status_response
from external_api_adapter import request as external_api_request from external_api_adapter import request as external_api_request
from external_api_adapter.exceptions import ExternalAPIRequestError from external_api_adapter.exceptions import ExternalAPIRequestError
from farm_hub.models import FarmHub
from .mock_data import CHAT_RESPONSE_DATA, CONTEXT_RESPONSE_DATA from .mock_data import CHAT_RESPONSE_DATA, CONTEXT_RESPONSE_DATA
from .models import Conversation, Message from .models import Conversation, Message
from .serializers import ( 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( @extend_schema(
tags=["Farm AI Assistant"], 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())}, responses={200: status_response("FarmAiAssistantContextResponse", data=serializers.JSONField())},
) )
def get(self, request): 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( return Response(
{"status": "success", "data": CONTEXT_RESPONSE_DATA}, {"status": "success", "data": data},
status=status.HTTP_200_OK, status=status.HTTP_200_OK,
) )
class ConversationAccessMixin: class ConversationAccessMixin(FarmAccessMixin):
@staticmethod @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: try:
return Conversation.objects.get(uuid=conversation_id, owner=request.user) return Conversation.objects.select_related("farm").get(**filters)
except Conversation.DoesNotExist as exc: except Conversation.DoesNotExist as exc:
raise Http404("Conversation not found") from exc raise Http404("Conversation not found") from exc
@@ -84,18 +107,20 @@ class ConversationAccessMixin:
normalized_sections.append(normalized_section) normalized_sections.append(normalized_section)
return normalized_sections return normalized_sections
def _build_mock_assistant_payload(self, conversation_id): def _build_mock_assistant_payload(self, conversation):
payload = deepcopy(CHAT_RESPONSE_DATA) 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 return payload
def _get_or_create_conversation(self, request, validated): def _get_or_create_conversation(self, request, validated):
conversation_id = validated.get("conversation_id") conversation_id = validated.get("conversation_id")
farm_context = validated.get("farm_context") farm_context = validated.get("farm_context")
title = validated.get("title", "").strip() title = validated.get("title", "").strip()
farm = self._get_farm(request, validated.get("farm_uuid"))
if conversation_id: if conversation_id:
conversation = self._get_conversation(request, conversation_id) conversation = self._get_conversation(request, conversation_id, farm.farm_uuid)
updated_fields = [] updated_fields = []
if farm_context is not None: if farm_context is not None:
conversation.farm_context = farm_context conversation.farm_context = farm_context
@@ -110,6 +135,7 @@ class ConversationAccessMixin:
return Conversation.objects.create( return Conversation.objects.create(
owner=request.user, owner=request.user,
farm=farm,
title=title or (validated.get("content", "")[:255]) or "New chat", title=title or (validated.get("content", "")[:255]) or "New chat",
farm_context=farm_context or {}, farm_context=farm_context or {},
) )
@@ -117,6 +143,7 @@ class ConversationAccessMixin:
@staticmethod @staticmethod
def _build_adapter_payload(request, validated, conversation): def _build_adapter_payload(request, validated, conversation):
payload = { payload = {
"farm_uuid": str(conversation.farm.farm_uuid),
"content": validated.get("content", ""), "content": validated.get("content", ""),
"query": validated.get("content", ""), "query": validated.get("content", ""),
"images": validated.get("images", []), "images": validated.get("images", []),
@@ -129,7 +156,7 @@ class ConversationAccessMixin:
payload["title"] = validated.get("title", "") payload["title"] = validated.get("title", "")
return payload return payload
def _extract_assistant_payload(self, adapter_data, conversation_id): def _extract_assistant_payload(self, adapter_data, conversation):
payload_source = adapter_data payload_source = adapter_data
if isinstance(adapter_data, dict) and isinstance(adapter_data.get("data"), dict): if isinstance(adapter_data, dict) and isinstance(adapter_data.get("data"), dict):
payload_source = adapter_data["data"] payload_source = adapter_data["data"]
@@ -149,13 +176,14 @@ class ConversationAccessMixin:
return { return {
"message_id": "", "message_id": "",
"conversation_id": str(conversation_id), "conversation_id": str(conversation.uuid),
"farm_uuid": str(conversation.farm.farm_uuid),
"content": content, "content": content,
"sections": sections, "sections": sections,
} }
@staticmethod @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 payload_source = adapter_data
if isinstance(adapter_data, dict) and isinstance(adapter_data.get("data"), dict): if isinstance(adapter_data, dict) and isinstance(adapter_data.get("data"), dict):
payload_source = adapter_data["data"] payload_source = adapter_data["data"]
@@ -167,11 +195,12 @@ class ConversationAccessMixin:
"task_id": str(payload_source.get("task_id") or ""), "task_id": str(payload_source.get("task_id") or ""),
"status": str(payload_source.get("status") or ""), "status": str(payload_source.get("status") or ""),
"status_url": str(payload_source.get("status_url") 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), "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 payload_source = adapter_data
if isinstance(adapter_data, dict) and isinstance(adapter_data.get("data"), dict): if isinstance(adapter_data, dict) and isinstance(adapter_data.get("data"), dict):
payload_source = adapter_data["data"] payload_source = adapter_data["data"]
@@ -183,8 +212,11 @@ class ConversationAccessMixin:
"task_id": str(payload_source.get("task_id") or task_id), "task_id": str(payload_source.get("task_id") or task_id),
"status": str(payload_source.get("status") or ""), "status": str(payload_source.get("status") or ""),
} }
if conversation_id: if conversation:
task_status_payload["conversation_id"] = str(conversation_id) 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") progress = payload_source.get("progress")
if progress is not None: if progress is not None:
@@ -231,6 +263,7 @@ class ConversationAccessMixin:
return { return {
"message_id": str(message.uuid), "message_id": str(message.uuid),
"conversation_id": str(message.conversation.uuid), "conversation_id": str(message.conversation.uuid),
"farm_uuid": str(message.farm.farm_uuid),
"role": message.role, "role": message.role,
"content": message.content, "content": message.content,
"sections": ConversationAccessMixin._normalize_sections(sections), "sections": ConversationAccessMixin._normalize_sections(sections),
@@ -239,11 +272,12 @@ class ConversationAccessMixin:
} }
@staticmethod @staticmethod
def _find_user_message_for_task(request, task_id): def _find_user_message_for_task(request, task_id, farm_uuid):
return ( return (
Message.objects.select_related("conversation") Message.objects.select_related("conversation", "farm")
.filter( .filter(
conversation__owner=request.user, conversation__owner=request.user,
farm__farm_uuid=farm_uuid,
role=Message.ROLE_USER, role=Message.ROLE_USER,
raw_response__task_id=task_id, raw_response__task_id=task_id,
) )
@@ -252,7 +286,7 @@ class ConversationAccessMixin:
) )
def _persist_task_result(self, user_message, task_id, result): 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 = ( assistant_message = (
user_message.conversation.messages.filter( user_message.conversation.messages.filter(
role=Message.ROLE_ASSISTANT, role=Message.ROLE_ASSISTANT,
@@ -265,6 +299,7 @@ class ConversationAccessMixin:
if assistant_message is None: if assistant_message is None:
assistant_message = Message.objects.create( assistant_message = Message.objects.create(
conversation=user_message.conversation, conversation=user_message.conversation,
farm=user_message.farm,
role=Message.ROLE_ASSISTANT, role=Message.ROLE_ASSISTANT,
content=assistant_payload.get("content", ""), content=assistant_payload.get("content", ""),
raw_response={}, raw_response={},
@@ -293,11 +328,15 @@ class ChatListCreateView(ConversationAccessMixin, APIView):
@extend_schema( @extend_schema(
tags=["Farm AI Assistant"], 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))}, responses={200: status_response("FarmAiAssistantConversationListResponse", data=ConversationSummarySerializer(many=True))},
) )
def get(self, request): def get(self, request):
farm = self._get_farm(request, request.query_params.get("farm_uuid"))
conversations = ( conversations = (
Conversation.objects.filter(owner=request.user) Conversation.objects.filter(owner=request.user, farm=farm)
.annotate(message_count=Count("messages")) .annotate(message_count=Count("messages"))
.order_by("-updated_at", "-created_at") .order_by("-updated_at", "-created_at")
) )
@@ -314,8 +353,10 @@ class ChatListCreateView(ConversationAccessMixin, APIView):
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
validated = serializer.validated_data validated = serializer.validated_data
farm = self._get_farm(request, validated.get("farm_uuid"))
conversation = Conversation.objects.create( conversation = Conversation.objects.create(
owner=request.user, owner=request.user,
farm=farm,
title=validated.get("title", "").strip() or "New chat", title=validated.get("title", "").strip() or "New chat",
farm_context=validated.get("farm_context") or {}, farm_context=validated.get("farm_context") or {},
) )
@@ -323,6 +364,7 @@ class ChatListCreateView(ConversationAccessMixin, APIView):
response_serializer = ConversationSummarySerializer( response_serializer = ConversationSummarySerializer(
{ {
"uuid": conversation.uuid, "uuid": conversation.uuid,
"farm": farm,
"message_count": 0, "message_count": 0,
} }
) )
@@ -336,18 +378,21 @@ class ChatMessagesView(ConversationAccessMixin, APIView):
tags=["Farm AI Assistant"], tags=["Farm AI Assistant"],
parameters=[ parameters=[
OpenApiParameter(name="conversation_id", type=OpenApiTypes.UUID, location=OpenApiParameter.PATH), 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())}, responses={200: status_response("FarmAiAssistantMessageListResponse", data=ConversationMessagesSerializer())},
) )
def get(self, request, conversation_id): def get(self, request, conversation_id):
conversation = self._get_conversation(request, conversation_id) farm = self._get_farm(request, request.query_params.get("farm_uuid"))
messages = conversation.messages.all() 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] serialized_messages = [self._serialize_chat_message(message) for message in messages]
return Response( return Response(
{ {
"status": "success", "status": "success",
"data": { "data": {
"conversation_id": str(conversation.uuid), "conversation_id": str(conversation.uuid),
"farm_uuid": str(farm.farm_uuid),
"messages": serialized_messages, "messages": serialized_messages,
}, },
}, },
@@ -362,18 +407,22 @@ class ChatDetailView(ConversationAccessMixin, APIView):
tags=["Farm AI Assistant"], tags=["Farm AI Assistant"],
parameters=[ parameters=[
OpenApiParameter(name="conversation_id", type=OpenApiTypes.UUID, location=OpenApiParameter.PATH), 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())}, responses={200: status_response("FarmAiAssistantConversationDeleteResponse", data=ConversationDeleteSerializer())},
) )
def delete(self, request, conversation_id): 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_conversation_id = str(conversation.uuid)
deleted_farm_uuid = str(conversation.farm.farm_uuid)
conversation.delete() conversation.delete()
return Response( return Response(
{ {
"status": "success", "status": "success",
"data": { "data": {
"conversation_id": deleted_conversation_id, "conversation_id": deleted_conversation_id,
"farm_uuid": deleted_farm_uuid,
}, },
}, },
status=status.HTTP_200_OK, status=status.HTTP_200_OK,
@@ -397,10 +446,11 @@ class ChatView(ConversationAccessMixin, APIView):
user_message = Message.objects.create( user_message = Message.objects.create(
conversation=conversation, conversation=conversation,
farm=conversation.farm,
role=Message.ROLE_USER, role=Message.ROLE_USER,
content=validated.get("content", ""), content=validated.get("content", ""),
images=validated.get("images", []), images=validated.get("images", []),
raw_response={}, raw_response={"farm_uuid": str(conversation.farm.farm_uuid)},
) )
adapter_payload = self._build_adapter_payload(request, validated, conversation) adapter_payload = self._build_adapter_payload(request, validated, conversation)
@@ -420,14 +470,15 @@ class ChatView(ConversationAccessMixin, APIView):
}, },
status=adapter_response.status_code, 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 response_status_code = adapter_response.status_code
except ExternalAPIRequestError: 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 response_status_code = status.HTTP_200_OK
assistant_message = Message.objects.create( assistant_message = Message.objects.create(
conversation=conversation, conversation=conversation,
farm=conversation.farm,
role=Message.ROLE_ASSISTANT, role=Message.ROLE_ASSISTANT,
content=assistant_payload.get("content", ""), content=assistant_payload.get("content", ""),
raw_response={}, raw_response={},
@@ -467,10 +518,11 @@ class ChatTaskCreateView(ConversationAccessMixin, APIView):
conversation = self._get_or_create_conversation(request, validated) conversation = self._get_or_create_conversation(request, validated)
user_message = Message.objects.create( user_message = Message.objects.create(
conversation=conversation, conversation=conversation,
farm=conversation.farm,
role=Message.ROLE_USER, role=Message.ROLE_USER,
content=validated.get("content", ""), content=validated.get("content", ""),
images=validated.get("images", []), images=validated.get("images", []),
raw_response={}, raw_response={"farm_uuid": str(conversation.farm.farm_uuid)},
) )
adapter_payload = self._build_adapter_payload(request, validated, conversation) adapter_payload = self._build_adapter_payload(request, validated, conversation)
@@ -503,7 +555,7 @@ class ChatTaskCreateView(ConversationAccessMixin, APIView):
task_payload = self._extract_task_submit_payload( task_payload = self._extract_task_submit_payload(
adapter_response.data, adapter_response.data,
conversation.uuid, conversation,
user_message.uuid, user_message.uuid,
) )
user_message.raw_response = task_payload user_message.raw_response = task_payload
@@ -526,15 +578,18 @@ class ChatTaskStatusView(ConversationAccessMixin, APIView):
tags=["Farm AI Assistant"], tags=["Farm AI Assistant"],
parameters=[ parameters=[
OpenApiParameter(name="task_id", type=OpenApiTypes.STR, location=OpenApiParameter.PATH), 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())}, responses={200: status_response("FarmAiAssistantChatTaskStatusResponse", data=ChatTaskStatusDataSerializer())},
) )
def get(self, request, task_id): def get(self, request, task_id):
farm = self._get_farm(request, request.query_params.get("farm_uuid"))
try: try:
adapter_response = external_api_request( adapter_response = external_api_request(
"ai", "ai",
f"/tasks/{task_id}/status", f"/tasks/{task_id}/status",
method="GET", method="GET",
query={"farm_uuid": str(farm.farm_uuid)},
) )
except ExternalAPIRequestError: except ExternalAPIRequestError:
return Response( return Response(
@@ -556,12 +611,13 @@ class ChatTaskStatusView(ConversationAccessMixin, APIView):
status=adapter_response.status_code, status=adapter_response.status_code,
) )
user_message = self._find_user_message_for_task(request, task_id) user_message = self._find_user_message_for_task(request, task_id, farm.farm_uuid)
conversation_id = user_message.conversation.uuid if user_message else None conversation = user_message.conversation if user_message else None
task_status_payload = self._extract_task_status_payload( task_status_payload = self._extract_task_status_payload(
adapter_response.data, adapter_response.data,
task_id, task_id,
conversation_id=conversation_id, conversation=conversation,
farm_uuid=farm.farm_uuid,
) )
result = self._extract_structured_task_result(adapter_response.data) result = self._extract_structured_task_result(adapter_response.data)
+2 -2
View File
@@ -1,6 +1,6 @@
from django.apps import AppConfig from django.apps import AppConfig
class SensorHubConfig(AppConfig): class FarmHubConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField" default_auto_field = "django.db.models.BigAutoField"
name = "sensor_hub" name = "farm_hub"
@@ -1,21 +1,20 @@
from django.core.management.base import BaseCommand, CommandError 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): 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): def handle(self, *args, **options):
try: try:
sensor, created = seed_admin_sensor() farm, created = seed_admin_farm()
except ValueError as exc: except ValueError as exc:
raise CommandError(str(exc)) from exc raise CommandError(str(exc)) from exc
action = "created" if created else "updated" action = "created" if created else "updated"
self.stdout.write( self.stdout.write(
self.style.SUCCESS( self.style.SUCCESS(
f"Admin sensor {action}: uuid_sensor={sensor.uuid_sensor}, " f"Admin farm {action}: farm_uuid={farm.farm_uuid}, name={farm.name}, owner={farm.owner.username}"
f"name={sensor.name}, owner={sensor.owner.username}"
) )
) )
+125
View File
@@ -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"),
),
]
@@ -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),
]
View File
+95
View File
@@ -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})"
+117
View File
@@ -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"}
]
}
+110
View File
@@ -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
+180
View File
@@ -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()
@@ -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 from crop_zoning.services import create_zones_and_dispatch, get_initial_zones_payload, normalize_area_feature
def dispatch_sensor_zoning(area_feature, sensor): def dispatch_farm_zoning(area_feature, farm):
crop_area, _zones = create_zones_and_dispatch(normalize_area_feature(area_feature), sensor=sensor) crop_area, _zones = create_zones_and_dispatch(normalize_area_feature(area_feature), farm=farm)
return get_initial_zones_payload(crop_area) 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) area_feature = serializer.validated_data.pop("area_geojson", None)
with transaction.atomic(): with transaction.atomic():
sensor = serializer.save(owner=owner) farm = serializer.save(owner=owner)
zoning_payload = None zoning_payload = None
if area_feature is not 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
+33 -19
View File
@@ -3,8 +3,9 @@ from django.test import TestCase, override_settings
from rest_framework.test import APIRequestFactory, force_authenticate from rest_framework.test import APIRequestFactory, force_authenticate
from crop_zoning.models import CropArea from crop_zoning.models import CropArea
from sensor_hub.seeds import seed_admin_sensor from farm_hub.models import FarmType, Product
from sensor_hub.views import SensorListCreateView from farm_hub.seeds import seed_admin_farm
from farm_hub.views import FarmListCreateView
AREA_GEOJSON = { AREA_GEOJSON = {
@@ -28,7 +29,7 @@ AREA_GEOJSON = {
USE_EXTERNAL_API_MOCK=True, USE_EXTERNAL_API_MOCK=True,
CROP_ZONE_CHUNK_AREA_SQM=200000, CROP_ZONE_CHUNK_AREA_SQM=200000,
) )
class SensorListCreateViewTests(TestCase): class FarmListCreateViewTests(TestCase):
def setUp(self): def setUp(self):
self.factory = APIRequestFactory() self.factory = APIRequestFactory()
self.user = get_user_model().objects.create_user( self.user = get_user_model().objects.create_user(
@@ -37,27 +38,39 @@ class SensorListCreateViewTests(TestCase):
email="farmer@example.com", email="farmer@example.com",
phone_number="09120000000", 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( request = self.factory.post(
"/api/sensor-hub/", "/api/farm-hub/",
{ {
"name": "zone-sensor", "name": "farm-1",
"specifications": {"model": "SH-1"}, "farm_type_uuid": str(self.farm_type.uuid),
"power_source": {"type": "battery"}, "product_uuids": [str(self.wheat.uuid)],
"customized_sensors": {"report_interval_sec": 300}, "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, "area_geojson": AREA_GEOJSON,
}, },
format="json", format="json",
) )
force_authenticate(request, user=self.user) 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.status_code, 201)
self.assertEqual(response.data["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.assertIn("zoning", response.data["data"])
self.assertEqual(len(response.data["data"]["sensors"]), 1)
self.assertGreater(response.data["data"]["zoning"]["zone_count"], 1) self.assertGreater(response.data["data"]["zoning"]["zone_count"], 1)
self.assertEqual( self.assertEqual(
response.data["data"]["zoning"]["zone_count"], response.data["data"]["zoning"]["zone_count"],
@@ -70,19 +83,20 @@ class SensorListCreateViewTests(TestCase):
USE_EXTERNAL_API_MOCK=True, USE_EXTERNAL_API_MOCK=True,
CROP_ZONE_CHUNK_AREA_SQM=200000, CROP_ZONE_CHUNK_AREA_SQM=200000,
) )
class SensorSeedTests(TestCase): class FarmSeedTests(TestCase):
def test_seed_admin_sensor_dispatches_crop_logic_flow_on_create(self): def test_seed_admin_farm_dispatches_crop_logic_flow_on_create(self):
sensor, created = seed_admin_sensor() farm, created = seed_admin_farm()
self.assertTrue(created) 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(CropArea.objects.count(), 1)
self.assertEqual(farm.sensors.count(), 2)
def test_seed_admin_sensor_does_not_dispatch_twice_for_existing_seed(self): def test_seed_admin_farm_does_not_dispatch_twice_for_existing_seed(self):
first_sensor, first_created = seed_admin_sensor() first_farm, first_created = seed_admin_farm()
second_sensor, second_created = seed_admin_sensor() second_farm, second_created = seed_admin_farm()
self.assertTrue(first_created) self.assertTrue(first_created)
self.assertFalse(second_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) self.assertEqual(CropArea.objects.count(), 1)
+10
View File
@@ -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("<uuid:farm_uuid>/", FarmDetailView.as_view(), name="farm-hub-detail"),
path("", FarmListCreateView.as_view(), name="farm-hub-list"),
]
+139
View File
@@ -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"
@@ -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"],
},
),
]
+29
View File
@@ -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)
@@ -8,6 +8,7 @@ class FertilizationFarmDataSerializer(serializers.Serializer):
class FertilizationRecommendRequestSerializer(serializers.Serializer): class FertilizationRecommendRequestSerializer(serializers.Serializer):
farm_uuid = serializers.UUIDField(required=True)
crop_id = serializers.CharField(required=False, allow_blank=True) crop_id = serializers.CharField(required=False, allow_blank=True)
growth_stage = serializers.CharField(required=False, allow_blank=True) growth_stage = serializers.CharField(required=False, allow_blank=True)
farm_data = FertilizationFarmDataSerializer(required=False) farm_data = FertilizationFarmDataSerializer(required=False)
+49 -7
View File
@@ -10,7 +10,9 @@ from drf_spectacular.utils import OpenApiParameter, extend_schema
from config.swagger import status_response from config.swagger import status_response
from external_api_adapter import request as external_api_request from external_api_adapter import request as external_api_request
from farm_hub.models import FarmHub
from .mock_data import CONFIG_RESPONSE_DATA from .mock_data import CONFIG_RESPONSE_DATA
from .models import FertilizationRecommendationRequest
from .serializers import ( from .serializers import (
FertilizationRecommendRequestSerializer, FertilizationRecommendRequestSerializer,
FertilizationRecommendResponseDataSerializer, 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( @extend_schema(
tags=["Fertilization Recommendation"], tags=["Fertilization Recommendation"],
parameters=[
OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=True),
],
responses={200: status_response("FertilizationConfigResponse", data=serializers.JSONField())}, responses={200: status_response("FertilizationConfigResponse", data=serializers.JSONField())},
) )
def get(self, request): 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( @extend_schema(
tags=["Fertilization Recommendation"], tags=["Fertilization Recommendation"],
request=FertilizationRecommendRequestSerializer, request=FertilizationRecommendRequestSerializer,
responses={200: status_response("FertilizationRecommendResponse", data=FertilizationRecommendResponseDataSerializer())}, responses={200: status_response("FertilizationRecommendResponse", data=FertilizationRecommendResponseDataSerializer())},
) )
def post(self, request): 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( adapter_response = external_api_request(
"ai", "ai",
"/fertilization/recommend", "/fertilization/recommend",
method="POST", 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) return Response(adapter_response.data, status=adapter_response.status_code)
class RecommendTaskStatusView(FarmAccessMixin, APIView):
class RecommendTaskStatusView(APIView):
@extend_schema( @extend_schema(
tags=["Fertilization Recommendation"], tags=["Fertilization Recommendation"],
parameters=[ parameters=[
OpenApiParameter(name="task_id", type=OpenApiTypes.STR, location=OpenApiParameter.PATH), 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())}, responses={200: status_response("FertilizationRecommendTaskStatusResponse", data=FertilizationTaskStatusDataSerializer())},
) )
def get(self, request, task_id): def get(self, request, task_id):
farm = self._get_farm(request, request.query_params.get("farm_uuid"))
adapter_response = external_api_request( adapter_response = external_api_request(
"ai", "ai",
f"/fertilization/status/{task_id}", f"/fertilization/status/{task_id}",
method="GET", 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) return Response(adapter_response.data, status=adapter_response.status_code)
@@ -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"],
},
),
]
+28
View File
@@ -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)
+1
View File
@@ -8,6 +8,7 @@ class IrrigationFarmDataSerializer(serializers.Serializer):
class IrrigationRecommendRequestSerializer(serializers.Serializer): class IrrigationRecommendRequestSerializer(serializers.Serializer):
farm_uuid = serializers.UUIDField(required=True)
crop_id = serializers.CharField(required=False, allow_blank=True) crop_id = serializers.CharField(required=False, allow_blank=True)
farm_data = IrrigationFarmDataSerializer(required=False) farm_data = IrrigationFarmDataSerializer(required=False)
soilType = serializers.CharField(required=False, allow_blank=True) soilType = serializers.CharField(required=False, allow_blank=True)
+66 -7
View File
@@ -10,7 +10,9 @@ from drf_spectacular.utils import OpenApiParameter, extend_schema
from config.swagger import status_response from config.swagger import status_response
from external_api_adapter import request as external_api_request from external_api_adapter import request as external_api_request
from farm_hub.models import FarmHub
from .mock_data import CONFIG_RESPONSE_DATA from .mock_data import CONFIG_RESPONSE_DATA
from .models import IrrigationRecommendationRequest
from .serializers import ( from .serializers import (
IrrigationRecommendRequestSerializer, IrrigationRecommendRequestSerializer,
IrrigationRecommendResponseDataSerializer, 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( @extend_schema(
tags=["Irrigation Recommendation"], tags=["Irrigation Recommendation"],
parameters=[
OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=True),
],
responses={200: status_response("IrrigationConfigResponse", data=serializers.JSONField())}, responses={200: status_response("IrrigationConfigResponse", data=serializers.JSONField())},
) )
def get(self, request): 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( @extend_schema(
tags=["Irrigation Recommendation"], tags=["Irrigation Recommendation"],
request=IrrigationRecommendRequestSerializer, request=IrrigationRecommendRequestSerializer,
responses={200: status_response("IrrigationRecommendResponse", data=IrrigationRecommendResponseDataSerializer())}, responses={200: status_response("IrrigationRecommendResponse", data=IrrigationRecommendResponseDataSerializer())},
) )
def post(self, request): 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( adapter_response = external_api_request(
"ai", "ai",
"/irrigation/recommend", "/irrigation/recommend",
method="POST", 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) return Response(adapter_response.data, status=adapter_response.status_code)
class RecommendTaskCreateView(APIView): class RecommendTaskCreateView(FarmAccessMixin, APIView):
@extend_schema( @extend_schema(
tags=["Irrigation Recommendation"], tags=["Irrigation Recommendation"],
request=IrrigationRecommendRequestSerializer, request=IrrigationRecommendRequestSerializer,
responses={200: status_response("IrrigationRecommendTaskCreateResponse", data=IrrigationTaskSubmitDataSerializer())}, responses={200: status_response("IrrigationRecommendTaskCreateResponse", data=IrrigationTaskSubmitDataSerializer())},
) )
def post(self, request): 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( adapter_response = external_api_request(
"ai", "ai",
"/irrigation/recommend", "/irrigation/recommend",
method="POST", 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) return Response(adapter_response.data, status=adapter_response.status_code)
class RecommendTaskStatusView(APIView): class RecommendTaskStatusView(FarmAccessMixin, APIView):
@extend_schema( @extend_schema(
tags=["Irrigation Recommendation"], tags=["Irrigation Recommendation"],
parameters=[ parameters=[
OpenApiParameter(name="task_id", type=OpenApiTypes.STR, location=OpenApiParameter.PATH), 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())}, responses={200: status_response("IrrigationRecommendTaskStatusResponse", data=IrrigationTaskStatusDataSerializer())},
) )
def get(self, request, task_id): def get(self, request, task_id):
farm = self._get_farm(request, request.query_params.get("farm_uuid"))
adapter_response = external_api_request( adapter_response = external_api_request(
"ai", "ai",
f"/irrigation/recommend/status/{task_id}", f"/irrigation/recommend/status/{task_id}",
method="GET", 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) return Response(adapter_response.data, status=adapter_response.status_code)
@@ -2,7 +2,7 @@
"code": 400, "code": 400,
"msg": "داده نامعتبر.", "msg": "داده نامعتبر.",
"data": { "data": {
"sensor_uuid": [ "farm_uuid": [
"This field is required." "This field is required."
] ]
} }
+6 -6
View File
@@ -512,42 +512,42 @@
}, },
{ {
"method": "PUT", "method": "PUT",
"path": "/api/sensor-data/{uuid_sensor}/", "path": "/api/sensor-data/{farm_uuid}/",
"status_code": 200, "status_code": 200,
"description": "Sensor update put success", "description": "Sensor update put success",
"file": "json/mock_data/sensor-data/update-put_200.json" "file": "json/mock_data/sensor-data/update-put_200.json"
}, },
{ {
"method": "PUT", "method": "PUT",
"path": "/api/sensor-data/{uuid_sensor}/", "path": "/api/sensor-data/{farm_uuid}/",
"status_code": 400, "status_code": 400,
"description": "Sensor update put validation error", "description": "Sensor update put validation error",
"file": "json/mock_data/sensor-data/update-put_400.json" "file": "json/mock_data/sensor-data/update-put_400.json"
}, },
{ {
"method": "PUT", "method": "PUT",
"path": "/api/sensor-data/{uuid_sensor}/", "path": "/api/sensor-data/{farm_uuid}/",
"status_code": 404, "status_code": 404,
"description": "Sensor update put location not found", "description": "Sensor update put location not found",
"file": "json/mock_data/sensor-data/update-put_404.json" "file": "json/mock_data/sensor-data/update-put_404.json"
}, },
{ {
"method": "PATCH", "method": "PATCH",
"path": "/api/sensor-data/{uuid_sensor}/", "path": "/api/sensor-data/{farm_uuid}/",
"status_code": 200, "status_code": 200,
"description": "Sensor update patch success", "description": "Sensor update patch success",
"file": "json/mock_data/sensor-data/update-patch_200.json" "file": "json/mock_data/sensor-data/update-patch_200.json"
}, },
{ {
"method": "PATCH", "method": "PATCH",
"path": "/api/sensor-data/{uuid_sensor}/", "path": "/api/sensor-data/{farm_uuid}/",
"status_code": 400, "status_code": 400,
"description": "Sensor update patch validation error", "description": "Sensor update patch validation error",
"file": "json/mock_data/sensor-data/update-patch_400.json" "file": "json/mock_data/sensor-data/update-patch_400.json"
}, },
{ {
"method": "PATCH", "method": "PATCH",
"path": "/api/sensor-data/{uuid_sensor}/", "path": "/api/sensor-data/{farm_uuid}/",
"status_code": 404, "status_code": 404,
"description": "Sensor update patch location not found", "description": "Sensor update patch location not found",
"file": "json/mock_data/sensor-data/update-patch_404.json" "file": "json/mock_data/sensor-data/update-patch_404.json"
@@ -2,7 +2,7 @@
"code": 400, "code": 400,
"msg": "داده نامعتبر.", "msg": "داده نامعتبر.",
"data": { "data": {
"sensor_uuid": [ "farm_uuid": [
"This field is required." "This field is required."
] ]
} }
@@ -1,5 +1,5 @@
{ {
"code": 400, "code": 400,
"msg": "پارامتر sensor_uuid الزامی است.", "msg": "پارامتر farm_uuid الزامی است.",
"data": null "data": null
} }
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"code": 400, "code": 400,
"msg": "پارامتر sensor_uuid الزامی است.", "msg": "پارامتر farm_uuid الزامی است.",
"data": null "data": null
} }
@@ -2,7 +2,7 @@
"code": 200, "code": 200,
"msg": "success", "msg": "success",
"data": { "data": {
"uuid_sensor": "550e8400-e29b-41d4-a716-446655440000", "farm_uuid": "550e8400-e29b-41d4-a716-446655440000",
"location_id": 12, "location_id": 12,
"soil_moisture": 45.2, "soil_moisture": 45.2,
"soil_temperature": 22.5, "soil_temperature": 22.5,
@@ -2,7 +2,7 @@
"code": 200, "code": 200,
"msg": "success", "msg": "success",
"data": { "data": {
"uuid_sensor": "550e8400-e29b-41d4-a716-446655440000", "farm_uuid": "550e8400-e29b-41d4-a716-446655440000",
"location_id": 12, "location_id": 12,
"soil_moisture": 45.2, "soil_moisture": 45.2,
"soil_temperature": 22.5, "soil_temperature": 22.5,
View File
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class PlantConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "plant"
+35
View File
@@ -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'],
},
),
]
@@ -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}}'
),
),
),
]
@@ -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}}'
),
),
),
]
@@ -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}'
),
),
),
]
View File
+49
View File
@@ -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
-37
View File
@@ -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'],
},
),
]
-27
View File
@@ -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})"
-132
View File
@@ -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"}
]
}
-111
View File
@@ -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
-63
View File
@@ -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()
-10
View File
@@ -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("<uuid:uuid>/", SensorDetailView.as_view(), name="sensor-hub-detail"),
path("", SensorListCreateView.as_view(), name="sensor-hub-list"),
]
-132
View File
@@ -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"