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