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