UPDATE
This commit is contained in:
@@ -0,0 +1,865 @@
|
||||
# مستندات کامل API های `farm_hub`
|
||||
|
||||
این فایل بر اساس پیادهسازی واقعی اپ `farm_hub` در فایلهای `farm_hub/urls.py`, `farm_hub/views.py`, `farm_hub/serializers.py`, `farm_hub/models.py` و `farm_hub/services.py` تهیه شده است.
|
||||
|
||||
نکته مهم: فایل `farm_hub/apps.py` فقط برای ثبت Django app استفاده میشود و خودِ APIها داخل آن تعریف نشدهاند. APIهای این ماژول در `farm_hub/urls.py` و `farm_hub/views.py` قرار دارند.
|
||||
|
||||
## مشخصات کلی
|
||||
|
||||
- Base path:
|
||||
|
||||
```text
|
||||
/api/farm-hub/
|
||||
```
|
||||
|
||||
- احراز هویت:
|
||||
|
||||
تمام endpointهای این ماژول نیاز به کاربر لاگینشده دارند.
|
||||
|
||||
```http
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
- فرمت کلی پاسخ موفق:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
- فرمت کلی پاسخ خطا:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 404,
|
||||
"msg": "Farm not found."
|
||||
}
|
||||
```
|
||||
|
||||
یا در خطاهای validation:
|
||||
|
||||
```json
|
||||
{
|
||||
"field_name": [
|
||||
"error message"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## لیست endpointها
|
||||
|
||||
| Method | URL | توضیح |
|
||||
|---|---|---|
|
||||
| GET | `/api/farm-hub/` | دریافت لیست مزارع کاربر جاری |
|
||||
| POST | `/api/farm-hub/` | ساخت مزرعه جدید |
|
||||
| GET | `/api/farm-hub/farm-types/` | دریافت لیست نوع مزرعهها |
|
||||
| GET | `/api/farm-hub/farm-types/{farm_type_uuid}/products/` | دریافت محصولات مربوط به یک نوع مزرعه |
|
||||
| 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/` | غیرفعالکردن مزرعه |
|
||||
|
||||
---
|
||||
|
||||
## 1) دریافت لیست مزارع
|
||||
|
||||
### Request
|
||||
|
||||
```http
|
||||
GET /api/farm-hub/
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
### رفتار
|
||||
|
||||
- فقط مزارع متعلق به کاربر جاری برگردانده میشوند.
|
||||
- برای هر مزرعه، اطلاعات `farm_type`، لیست `products`، لیست `sensors` و `area_uuid` برگردانده میشود.
|
||||
|
||||
### Response 200
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": [
|
||||
{
|
||||
"farm_uuid": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"area_uuid": "0c7dfd7f-94bf-46f3-b2f9-30a89f0a1111",
|
||||
"name": "مزرعه شماره 1",
|
||||
"is_active": true,
|
||||
"farm_type": {
|
||||
"uuid": "11111111-1111-1111-1111-111111111111",
|
||||
"name": "زراعی",
|
||||
"description": "",
|
||||
"metadata": {}
|
||||
},
|
||||
"products": [
|
||||
{
|
||||
"uuid": "22222222-2222-2222-2222-222222222222",
|
||||
"name": "گندم",
|
||||
"description": "",
|
||||
"metadata": {},
|
||||
"light": "",
|
||||
"watering": "",
|
||||
"soil": "",
|
||||
"temperature": "",
|
||||
"planting_season": "پاییز",
|
||||
"harvest_time": "بهار",
|
||||
"spacing": "",
|
||||
"fertilizer": "",
|
||||
"health_profile": {
|
||||
"moisture": {
|
||||
"ideal_value": 65
|
||||
}
|
||||
},
|
||||
"irrigation_profile": {},
|
||||
"growth_profile": {}
|
||||
}
|
||||
],
|
||||
"sensors": [
|
||||
{
|
||||
"uuid": "33333333-3333-3333-3333-333333333333",
|
||||
"sensor_catalog_uuid": "44444444-4444-4444-4444-444444444444",
|
||||
"physical_device_uuid": "55555555-5555-5555-5555-555555555555",
|
||||
"name": "Station 1",
|
||||
"sensor_type": "weather_station",
|
||||
"is_active": true,
|
||||
"specifications": {
|
||||
"model": "FH-1"
|
||||
},
|
||||
"power_source": {
|
||||
"type": "battery"
|
||||
},
|
||||
"last_updated": "2025-02-18T12:00:00Z"
|
||||
}
|
||||
],
|
||||
"last_updated": "2025-02-18T12:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2) ساخت مزرعه جدید
|
||||
|
||||
### Request
|
||||
|
||||
```http
|
||||
POST /api/farm-hub/
|
||||
Authorization: Bearer <token>
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
### Body
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "مزرعه شماره 1",
|
||||
"is_active": true,
|
||||
"farm_type_uuid": "11111111-1111-1111-1111-111111111111",
|
||||
"product_uuids": [
|
||||
"22222222-2222-2222-2222-222222222222"
|
||||
],
|
||||
"sensors": [
|
||||
{
|
||||
"sensor_catalog_uuid": "44444444-4444-4444-4444-444444444444",
|
||||
"physical_device_uuid": "55555555-5555-5555-5555-555555555555",
|
||||
"name": "Station 1",
|
||||
"sensor_type": "weather_station",
|
||||
"is_active": true,
|
||||
"specifications": {
|
||||
"model": "FH-1"
|
||||
},
|
||||
"power_source": {
|
||||
"type": "battery"
|
||||
}
|
||||
}
|
||||
],
|
||||
"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]
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### فیلدهای ورودی
|
||||
|
||||
| فیلد | نوع | اجباری | توضیح |
|
||||
|---|---|---|---|
|
||||
| `name` | string | بله | نام مزرعه |
|
||||
| `is_active` | boolean | خیر | وضعیت فعال بودن مزرعه؛ پیشفرض مدل `true` است |
|
||||
| `farm_type_uuid` | uuid | بله | UUID نوع مزرعه |
|
||||
| `product_uuids` | array[uuid] | بله | لیست UUID محصولات؛ خالی بودن مجاز نیست |
|
||||
| `sensors` | array | خیر | لیست سنسورهای مزرعه |
|
||||
| `area_geojson` | object | خیر | محدوده زمین به صورت GeoJSON از نوع `Polygon` |
|
||||
|
||||
### فیلدهای هر سنسور در `sensors`
|
||||
|
||||
| فیلد | نوع | اجباری | توضیح |
|
||||
|---|---|---|---|
|
||||
| `sensor_catalog_uuid` | uuid | خیر | اگر ارسال شود باید در `SensorCatalog` وجود داشته باشد |
|
||||
| `physical_device_uuid` | uuid | خیر | شناسه دستگاه فیزیکی؛ اگر داده نشود مدل خودش مقدار تولید میکند |
|
||||
| `name` | string | وابسته به ورودی | نام سنسور؛ اگر `sensor_catalog_uuid` معتبر باشد و `name` نفرستید، از نام catalog استفاده میشود، ولی اگر `sensor_catalog_uuid` هم نداشته باشید عملا باید `name` را بفرستید |
|
||||
| `sensor_type` | string | خیر | نوع سنسور |
|
||||
| `is_active` | boolean | خیر | وضعیت فعال بودن سنسور |
|
||||
| `specifications` | object | خیر | مشخصات فنی |
|
||||
| `power_source` | object | خیر | نوع یا جزئیات منبع تغذیه |
|
||||
|
||||
### اعتبارسنجیها
|
||||
|
||||
- `farm_uuid` اگر از سمت کلاینت ارسال شود نادیده گرفته میشود.
|
||||
- `farm_type_uuid` باید معتبر باشد، وگرنه:
|
||||
|
||||
```json
|
||||
{
|
||||
"farm_type_uuid": [
|
||||
"Farm type not found."
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `product_uuids` باید همگی وجود داشته باشند:
|
||||
|
||||
```json
|
||||
{
|
||||
"product_uuids": [
|
||||
"One or more products were not found."
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- همه محصولات باید متعلق به همان `farm_type` باشند:
|
||||
|
||||
```json
|
||||
{
|
||||
"product_uuids": [
|
||||
"Products must belong to farm type `زراعی`."
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `sensor_catalog_uuid` اگر ارسال شود باید معتبر باشد:
|
||||
|
||||
```json
|
||||
{
|
||||
"sensors": [
|
||||
{
|
||||
"sensor_catalog_uuid": [
|
||||
"Sensor catalog not found."
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `area_geojson` باید object معتبر باشد.
|
||||
- اگر `area_geojson.type == "Feature"` باشد، مقدار `geometry` بررسی میشود.
|
||||
- `geometry.type` فقط باید `Polygon` باشد.
|
||||
- `coordinates` باید ساختار polygon ring داشته باشد.
|
||||
|
||||
نمونه خطاهای `area_geojson`:
|
||||
|
||||
```json
|
||||
{
|
||||
"area_geojson": [
|
||||
"`area_geojson` must be a GeoJSON object."
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"area_geojson": [
|
||||
"`area_geojson.geometry.type` must be `Polygon`."
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### رفتار داخلی مهم
|
||||
|
||||
- اگر `area_geojson` ارسال نشود، سیستم از `get_default_area_feature()` استفاده میکند.
|
||||
- بعد از ساخت مزرعه، فرآیند zoning اجرا میشود.
|
||||
- خروجی zoning به `current_crop_area` وصل میشود.
|
||||
- اگر zoning با موفقیت ساخته شود، در response فیلد `zoning` هم برگردانده میشود.
|
||||
|
||||
### Response 201
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 201,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"farm_uuid": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"area_uuid": "0c7dfd7f-94bf-46f3-b2f9-30a89f0a1111",
|
||||
"name": "مزرعه شماره 1",
|
||||
"is_active": true,
|
||||
"farm_type": {
|
||||
"uuid": "11111111-1111-1111-1111-111111111111",
|
||||
"name": "زراعی",
|
||||
"description": "",
|
||||
"metadata": {}
|
||||
},
|
||||
"products": [
|
||||
{
|
||||
"uuid": "22222222-2222-2222-2222-222222222222",
|
||||
"name": "گندم",
|
||||
"description": "",
|
||||
"metadata": {},
|
||||
"light": "",
|
||||
"watering": "",
|
||||
"soil": "",
|
||||
"temperature": "",
|
||||
"planting_season": "پاییز",
|
||||
"harvest_time": "بهار",
|
||||
"spacing": "",
|
||||
"fertilizer": "",
|
||||
"health_profile": {},
|
||||
"irrigation_profile": {},
|
||||
"growth_profile": {}
|
||||
}
|
||||
],
|
||||
"sensors": [
|
||||
{
|
||||
"uuid": "33333333-3333-3333-3333-333333333333",
|
||||
"sensor_catalog_uuid": "44444444-4444-4444-4444-444444444444",
|
||||
"physical_device_uuid": "55555555-5555-5555-5555-555555555555",
|
||||
"name": "Station 1",
|
||||
"sensor_type": "weather_station",
|
||||
"is_active": true,
|
||||
"specifications": {
|
||||
"model": "FH-1"
|
||||
},
|
||||
"power_source": {
|
||||
"type": "battery"
|
||||
},
|
||||
"last_updated": "2025-02-18T12:00:00Z"
|
||||
}
|
||||
],
|
||||
"last_updated": "2025-02-18T12:00:00Z",
|
||||
"zoning": {
|
||||
"zone_count": 4
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Response 500
|
||||
|
||||
در صورتی که سرویس لازم برای zoning/config بهدرستی تنظیم نشده باشد:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 500,
|
||||
"msg": "..."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3) دریافت لیست نوع مزرعهها
|
||||
|
||||
### Request
|
||||
|
||||
```http
|
||||
GET /api/farm-hub/farm-types/
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
### Response 200
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": [
|
||||
{
|
||||
"uuid": "11111111-1111-1111-1111-111111111111",
|
||||
"name": "زراعی",
|
||||
"description": "",
|
||||
"metadata": {}
|
||||
},
|
||||
{
|
||||
"uuid": "22222222-2222-2222-2222-222222222222",
|
||||
"name": "درختی",
|
||||
"description": "",
|
||||
"metadata": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### نکته
|
||||
|
||||
- خروجی بر اساس `name` مرتب میشود.
|
||||
|
||||
---
|
||||
|
||||
## 4) دریافت محصولات یک نوع مزرعه
|
||||
|
||||
### Request
|
||||
|
||||
```http
|
||||
GET /api/farm-hub/farm-types/{farm_type_uuid}/products/
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
### Path Params
|
||||
|
||||
| پارامتر | نوع | توضیح |
|
||||
|---|---|---|
|
||||
| `farm_type_uuid` | uuid | شناسه نوع مزرعه |
|
||||
|
||||
### Response 200
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": [
|
||||
{
|
||||
"uuid": "22222222-2222-2222-2222-222222222222",
|
||||
"name": "گندم",
|
||||
"description": "",
|
||||
"metadata": {},
|
||||
"light": "",
|
||||
"watering": "",
|
||||
"soil": "",
|
||||
"temperature": "",
|
||||
"planting_season": "پاییز",
|
||||
"harvest_time": "بهار",
|
||||
"spacing": "",
|
||||
"fertilizer": "",
|
||||
"health_profile": {
|
||||
"moisture": {
|
||||
"ideal_value": 65
|
||||
}
|
||||
},
|
||||
"irrigation_profile": {},
|
||||
"growth_profile": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Response 404
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 404,
|
||||
"msg": "Farm type not found."
|
||||
}
|
||||
```
|
||||
|
||||
### نکته
|
||||
|
||||
- محصولات با ترتیب `name` برگردانده میشوند.
|
||||
|
||||
---
|
||||
|
||||
## 5) دریافت جزئیات یک مزرعه
|
||||
|
||||
### Request
|
||||
|
||||
```http
|
||||
GET /api/farm-hub/{farm_uuid}/
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
### Path Params
|
||||
|
||||
| پارامتر | نوع | توضیح |
|
||||
|---|---|---|
|
||||
| `farm_uuid` | uuid | شناسه مزرعه |
|
||||
|
||||
### رفتار
|
||||
|
||||
- فقط اگر مزرعه متعلق به کاربر جاری باشد برگردانده میشود.
|
||||
- اگر UUID وجود داشته باشد ولی متعلق به کاربر دیگری باشد، عملا مثل not found رفتار میشود.
|
||||
|
||||
### Response 200
|
||||
|
||||
ساختار `data` دقیقا مثل آیتمهای خروجی لیست مزرعهها است.
|
||||
|
||||
### Response 404
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 404,
|
||||
"msg": "Farm not found."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6) ویرایش مزرعه
|
||||
|
||||
### Request
|
||||
|
||||
```http
|
||||
PATCH /api/farm-hub/{farm_uuid}/
|
||||
Authorization: Bearer <token>
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
### Path Params
|
||||
|
||||
| پارامتر | نوع | توضیح |
|
||||
|---|---|---|
|
||||
| `farm_uuid` | uuid | شناسه مزرعه |
|
||||
|
||||
### Body
|
||||
|
||||
این endpoint از `partial update` استفاده میکند؛ یعنی میتوانید فقط بخشی از فیلدها را بفرستید.
|
||||
|
||||
نمونه:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "مزرعه اصلاح شده",
|
||||
"is_active": false,
|
||||
"farm_type_uuid": "11111111-1111-1111-1111-111111111111",
|
||||
"product_uuids": [
|
||||
"22222222-2222-2222-2222-222222222222"
|
||||
],
|
||||
"sensors": [
|
||||
{
|
||||
"sensor_catalog_uuid": "44444444-4444-4444-4444-444444444444",
|
||||
"physical_device_uuid": "55555555-5555-5555-5555-555555555555",
|
||||
"name": "Station Updated",
|
||||
"sensor_type": "weather_station",
|
||||
"is_active": true,
|
||||
"specifications": {
|
||||
"model": "FH-2"
|
||||
},
|
||||
"power_source": {
|
||||
"type": "solar"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### رفتار update
|
||||
|
||||
- `name` و `is_active` در صورت ارسال تغییر میکنند.
|
||||
- اگر `farm_type_uuid` ارسال شود، نوع مزرعه بهروزرسانی میشود.
|
||||
- اگر `product_uuids` ارسال شود، همه محصولات مزرعه با لیست جدید جایگزین میشوند.
|
||||
- اگر `sensors` ارسال شود، همه سنسورهای قبلی حذف و سپس سنسورهای جدید از نو ساخته میشوند.
|
||||
- `area_geojson` در متد `update` دریافت میشود ولی در حال حاضر برای update نادیده گرفته میشود و zoning مجدد انجام نمیشود.
|
||||
|
||||
### اعتبارسنجی
|
||||
|
||||
همان قوانین create اینجا هم برقرار است، با این تفاوت:
|
||||
|
||||
- در update، اگر `farm_type_uuid` ارسال نشود، از `farm_type` فعلی استفاده میشود.
|
||||
- در update، اگر `product_uuids` ارسال نشود، محصولات فعلی حفظ میشوند.
|
||||
- در update، اگر `sensors` ارسال نشود، سنسورهای فعلی حفظ میشوند.
|
||||
|
||||
### Response 200
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"farm_uuid": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"area_uuid": "0c7dfd7f-94bf-46f3-b2f9-30a89f0a1111",
|
||||
"name": "مزرعه اصلاح شده",
|
||||
"is_active": false,
|
||||
"farm_type": {
|
||||
"uuid": "11111111-1111-1111-1111-111111111111",
|
||||
"name": "زراعی",
|
||||
"description": "",
|
||||
"metadata": {}
|
||||
},
|
||||
"products": [],
|
||||
"sensors": [],
|
||||
"last_updated": "2025-02-18T13:00:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Response 404
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 404,
|
||||
"msg": "Farm not found."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7) حذف مزرعه
|
||||
|
||||
### Request
|
||||
|
||||
```http
|
||||
DELETE /api/farm-hub/{farm_uuid}/
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
### Response 200
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "success"
|
||||
}
|
||||
```
|
||||
|
||||
### Response 404
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 404,
|
||||
"msg": "Farm not found."
|
||||
}
|
||||
```
|
||||
|
||||
### نکته
|
||||
|
||||
- فقط مزرعه متعلق به کاربر جاری حذف میشود.
|
||||
|
||||
---
|
||||
|
||||
## 8) فعالکردن مزرعه
|
||||
|
||||
### Request
|
||||
|
||||
```http
|
||||
POST /api/farm-hub/active/
|
||||
Authorization: Bearer <token>
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
### Body
|
||||
|
||||
```json
|
||||
{
|
||||
"farm_uuid": "550e8400-e29b-41d4-a716-446655440000"
|
||||
}
|
||||
```
|
||||
|
||||
### Response 200
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "success"
|
||||
}
|
||||
```
|
||||
|
||||
### Response 404
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 404,
|
||||
"msg": "Farm not found."
|
||||
}
|
||||
```
|
||||
|
||||
### خطای validation
|
||||
|
||||
اگر `farm_uuid` ارسال نشود یا فرمت آن درست نباشد، خطای serializer برگردانده میشود. نمونه:
|
||||
|
||||
```json
|
||||
{
|
||||
"farm_uuid": [
|
||||
"This field is required."
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9) غیرفعالکردن مزرعه
|
||||
|
||||
### Request
|
||||
|
||||
```http
|
||||
POST /api/farm-hub/deactive/
|
||||
Authorization: Bearer <token>
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
### Body
|
||||
|
||||
```json
|
||||
{
|
||||
"farm_uuid": "550e8400-e29b-41d4-a716-446655440000"
|
||||
}
|
||||
```
|
||||
|
||||
### Response 200
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "success"
|
||||
}
|
||||
```
|
||||
|
||||
### Response 404
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 404,
|
||||
"msg": "Farm not found."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ساختار آبجکتها
|
||||
|
||||
## آبجکت `FarmType`
|
||||
|
||||
```json
|
||||
{
|
||||
"uuid": "11111111-1111-1111-1111-111111111111",
|
||||
"name": "زراعی",
|
||||
"description": "",
|
||||
"metadata": {}
|
||||
}
|
||||
```
|
||||
|
||||
## آبجکت `Product`
|
||||
|
||||
```json
|
||||
{
|
||||
"uuid": "22222222-2222-2222-2222-222222222222",
|
||||
"name": "گندم",
|
||||
"description": "",
|
||||
"metadata": {},
|
||||
"light": "",
|
||||
"watering": "",
|
||||
"soil": "",
|
||||
"temperature": "",
|
||||
"planting_season": "",
|
||||
"harvest_time": "",
|
||||
"spacing": "",
|
||||
"fertilizer": "",
|
||||
"health_profile": {},
|
||||
"irrigation_profile": {},
|
||||
"growth_profile": {}
|
||||
}
|
||||
```
|
||||
|
||||
### توضیح فیلدهای پروفایل محصول
|
||||
|
||||
- `health_profile`: برای KPIها و سلامت محصول
|
||||
- `irrigation_profile`: برای محاسبات آبیاری و ETc
|
||||
- `growth_profile`: برای مدل رشد مانند GDD
|
||||
|
||||
نمونه ساختار `health_profile`:
|
||||
|
||||
```json
|
||||
{
|
||||
"moisture": {
|
||||
"ideal_value": 65,
|
||||
"min_range": 45,
|
||||
"max_range": 75,
|
||||
"weight": 0.4
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
نمونه ساختار `irrigation_profile`:
|
||||
|
||||
```json
|
||||
{
|
||||
"kc_initial": 0.6,
|
||||
"kc_mid": 1.15,
|
||||
"kc_end": 0.8,
|
||||
"growth_stage_duration": {
|
||||
"initial": 20,
|
||||
"mid": 30,
|
||||
"late": 25
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
نمونه ساختار `growth_profile`:
|
||||
|
||||
```json
|
||||
{
|
||||
"base_temperature": 10,
|
||||
"required_gdd_for_maturity": 1200,
|
||||
"stage_thresholds": {
|
||||
"flowering": 500,
|
||||
"fruiting": 850
|
||||
},
|
||||
"current_cumulative_gdd": 320
|
||||
}
|
||||
```
|
||||
|
||||
## آبجکت `FarmSensor`
|
||||
|
||||
```json
|
||||
{
|
||||
"uuid": "33333333-3333-3333-3333-333333333333",
|
||||
"sensor_catalog_uuid": "44444444-4444-4444-4444-444444444444",
|
||||
"physical_device_uuid": "55555555-5555-5555-5555-555555555555",
|
||||
"name": "Station 1",
|
||||
"sensor_type": "weather_station",
|
||||
"is_active": true,
|
||||
"specifications": {},
|
||||
"power_source": {},
|
||||
"last_updated": "2025-02-18T12:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
## آبجکت `FarmHub`
|
||||
|
||||
```json
|
||||
{
|
||||
"farm_uuid": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"area_uuid": "0c7dfd7f-94bf-46f3-b2f9-30a89f0a1111",
|
||||
"name": "مزرعه شماره 1",
|
||||
"is_active": true,
|
||||
"farm_type": {},
|
||||
"products": [],
|
||||
"sensors": [],
|
||||
"last_updated": "2025-02-18T12:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## نکات مهم برای فرانتاند
|
||||
|
||||
- برای ساخت مزرعه، ابتدا `GET /api/farm-hub/farm-types/` را صدا بزنید.
|
||||
- سپس برای نوع انتخابشده، `GET /api/farm-hub/farm-types/{farm_type_uuid}/products/` را بگیرید.
|
||||
- برای ساخت یا ویرایش مزرعه، `product_uuids` باید با `farm_type_uuid` همخوانی داشته باشند.
|
||||
- اگر در update فیلد `sensors` را بفرستید، لیست قبلی کامل جایگزین میشود.
|
||||
- اگر در create، `area_geojson` نفرستید، سیستم خودش یک محدوده پیشفرض میسازد.
|
||||
- endpointهای detail/update/delete/active/deactive فقط روی مزرعههای خود کاربر عمل میکنند.
|
||||
|
||||
---
|
||||
|
||||
## منبع پیادهسازی
|
||||
|
||||
- رجیستر اپ: `farm_hub/apps.py`
|
||||
- تعریف routeها: `farm_hub/urls.py`
|
||||
- منطق APIها: `farm_hub/views.py`
|
||||
- serializerها و validation: `farm_hub/serializers.py`
|
||||
- مدلها: `farm_hub/models.py`
|
||||
- منطق ساخت zoning: `farm_hub/services.py`
|
||||
- نمونه requestها: `farm_hub/postman/farm_hub.json`
|
||||
@@ -0,0 +1,25 @@
|
||||
# Generated by Django 5.2.12 on 2026-04-03
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("farm_hub", "0006_seed_expanded_product_catalog"),
|
||||
("access_control", "0002_link_subscription_plan_to_farm"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="farmhub",
|
||||
name="subscription_plan",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="farms",
|
||||
to="access_control.subscriptionplan",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -91,6 +91,13 @@ class FarmHub(models.Model):
|
||||
on_delete=models.PROTECT,
|
||||
related_name="farms",
|
||||
)
|
||||
subscription_plan = models.ForeignKey(
|
||||
"access_control.SubscriptionPlan",
|
||||
on_delete=models.PROTECT,
|
||||
related_name="farms",
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
name = models.CharField(max_length=255)
|
||||
is_active = models.BooleanField(default=True)
|
||||
current_crop_area = models.ForeignKey(
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
from rest_framework import serializers
|
||||
from access_control.models import SubscriptionPlan
|
||||
from access_control.serializers import SubscriptionPlanSerializer
|
||||
from access_control.catalog import GOLD_PLAN_CODE
|
||||
|
||||
from .models import FarmHub, FarmSensor, FarmType, Product
|
||||
from sensor_catalog.models import SensorCatalog
|
||||
@@ -55,6 +58,7 @@ class FarmSensorSerializer(serializers.ModelSerializer):
|
||||
class FarmHubSerializer(serializers.ModelSerializer):
|
||||
last_updated = serializers.DateTimeField(source="updated_at", read_only=True)
|
||||
farm_type = FarmTypeSerializer(read_only=True)
|
||||
subscription_plan = SubscriptionPlanSerializer(read_only=True)
|
||||
products = ProductSerializer(many=True, read_only=True)
|
||||
sensors = FarmSensorSerializer(many=True, read_only=True)
|
||||
area_uuid = serializers.UUIDField(source="current_crop_area.uuid", read_only=True)
|
||||
@@ -67,6 +71,7 @@ class FarmHubSerializer(serializers.ModelSerializer):
|
||||
"name",
|
||||
"is_active",
|
||||
"farm_type",
|
||||
"subscription_plan",
|
||||
"products",
|
||||
"sensors",
|
||||
"last_updated",
|
||||
@@ -105,6 +110,7 @@ class FarmSensorWriteSerializer(serializers.ModelSerializer):
|
||||
class FarmHubCreateSerializer(serializers.ModelSerializer):
|
||||
area_geojson = serializers.JSONField(write_only=True, required=False)
|
||||
farm_type_uuid = serializers.UUIDField(write_only=True)
|
||||
subscription_plan_uuid = serializers.UUIDField(write_only=True, required=False, allow_null=True)
|
||||
product_uuids = serializers.ListField(
|
||||
child=serializers.UUIDField(),
|
||||
write_only=True,
|
||||
@@ -118,6 +124,7 @@ class FarmHubCreateSerializer(serializers.ModelSerializer):
|
||||
"name",
|
||||
"is_active",
|
||||
"farm_type_uuid",
|
||||
"subscription_plan_uuid",
|
||||
"product_uuids",
|
||||
"sensors",
|
||||
"area_geojson",
|
||||
@@ -148,6 +155,7 @@ class FarmHubCreateSerializer(serializers.ModelSerializer):
|
||||
|
||||
def validate(self, attrs):
|
||||
farm_type_uuid = attrs.get("farm_type_uuid")
|
||||
subscription_plan_uuid = attrs.get("subscription_plan_uuid", serializers.empty)
|
||||
product_uuids = attrs.get("product_uuids")
|
||||
|
||||
if farm_type_uuid is None:
|
||||
@@ -173,7 +181,21 @@ class FarmHubCreateSerializer(serializers.ModelSerializer):
|
||||
{"product_uuids": [f"Products must belong to farm type `{farm_type.name}`."]}
|
||||
)
|
||||
|
||||
if subscription_plan_uuid is serializers.empty:
|
||||
if self.instance is not None:
|
||||
subscription_plan = self.instance.subscription_plan
|
||||
else:
|
||||
subscription_plan = SubscriptionPlan.objects.filter(code=GOLD_PLAN_CODE, is_active=True).first()
|
||||
elif subscription_plan_uuid is None:
|
||||
subscription_plan = None
|
||||
else:
|
||||
try:
|
||||
subscription_plan = SubscriptionPlan.objects.get(uuid=subscription_plan_uuid, is_active=True)
|
||||
except SubscriptionPlan.DoesNotExist as exc:
|
||||
raise serializers.ValidationError({"subscription_plan_uuid": ["Subscription plan not found."]}) from exc
|
||||
|
||||
attrs["farm_type"] = farm_type
|
||||
attrs["subscription_plan"] = subscription_plan
|
||||
attrs["products"] = products
|
||||
return attrs
|
||||
|
||||
@@ -182,7 +204,9 @@ class FarmHubCreateSerializer(serializers.ModelSerializer):
|
||||
sensors_data = validated_data.pop("sensors", [])
|
||||
products = validated_data.pop("products", [])
|
||||
validated_data["farm_type"] = validated_data.pop("farm_type")
|
||||
validated_data["subscription_plan"] = validated_data.pop("subscription_plan", None)
|
||||
validated_data.pop("farm_type_uuid", None)
|
||||
validated_data.pop("subscription_plan_uuid", None)
|
||||
validated_data.pop("product_uuids", None)
|
||||
|
||||
farm = super().create(validated_data)
|
||||
@@ -197,13 +221,17 @@ class FarmHubCreateSerializer(serializers.ModelSerializer):
|
||||
sensors_data = validated_data.pop("sensors", None)
|
||||
products = validated_data.pop("products", None)
|
||||
farm_type = validated_data.pop("farm_type", None)
|
||||
subscription_plan = validated_data.pop("subscription_plan", serializers.empty)
|
||||
validated_data.pop("farm_type_uuid", None)
|
||||
validated_data.pop("subscription_plan_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
|
||||
if subscription_plan is not serializers.empty:
|
||||
instance.subscription_plan = subscription_plan
|
||||
instance.save()
|
||||
|
||||
if products is not None:
|
||||
|
||||
+121
-1
@@ -2,8 +2,11 @@ from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase, override_settings
|
||||
from rest_framework.test import APIRequestFactory, force_authenticate
|
||||
|
||||
from access_control.models import AccessFeature, AccessRule, FarmAccessProfile, SubscriptionPlan
|
||||
from access_control.services import build_farm_access_profile
|
||||
from access_control.views import FarmAccessProfileView
|
||||
from crop_zoning.models import CropArea
|
||||
from farm_hub.models import FarmType, Product
|
||||
from farm_hub.models import FarmHub, FarmType, Product
|
||||
from farm_hub.seeds import seed_admin_farm
|
||||
from farm_hub.views import FarmListCreateView, FarmTypeListView, FarmTypeProductsView
|
||||
from sensor_catalog.models import SensorCatalog
|
||||
@@ -41,6 +44,7 @@ class FarmListCreateViewTests(TestCase):
|
||||
)
|
||||
self.farm_type, _ = FarmType.objects.get_or_create(name="زراعی")
|
||||
self.wheat, _ = Product.objects.get_or_create(farm_type=self.farm_type, name="گندم")
|
||||
self.plan = SubscriptionPlan.objects.create(code="gold", name="Gold")
|
||||
self.weather_station, _ = SensorCatalog.objects.get_or_create(
|
||||
name="Sensor 7 - Soil Moisture Sensor v1.2",
|
||||
defaults={"supported_power_sources": ["solar", "direct_power"]},
|
||||
@@ -53,6 +57,7 @@ class FarmListCreateViewTests(TestCase):
|
||||
{
|
||||
"name": "farm-1",
|
||||
"farm_type_uuid": str(self.farm_type.uuid),
|
||||
"subscription_plan_uuid": str(self.plan.uuid),
|
||||
"product_uuids": [str(self.wheat.uuid)],
|
||||
"sensors": [
|
||||
{
|
||||
@@ -75,6 +80,7 @@ class FarmListCreateViewTests(TestCase):
|
||||
self.assertEqual(response.status_code, 201)
|
||||
self.assertEqual(response.data["code"], 201)
|
||||
self.assertEqual(response.data["data"]["name"], "farm-1")
|
||||
self.assertEqual(response.data["data"]["subscription_plan"]["code"], self.plan.code)
|
||||
self.assertIn("zoning", response.data["data"])
|
||||
self.assertIsNotNone(response.data["data"]["area_uuid"])
|
||||
self.assertEqual(len(response.data["data"]["sensors"]), 1)
|
||||
@@ -129,6 +135,23 @@ class FarmListCreateViewTests(TestCase):
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertIn("sensor_catalog_uuid", response.data["sensors"][0])
|
||||
|
||||
def test_create_farm_defaults_to_gold_plan_when_not_provided(self):
|
||||
request = self.factory.post(
|
||||
"/api/farm-hub/",
|
||||
{
|
||||
"name": "farm-default-plan",
|
||||
"farm_type_uuid": str(self.farm_type.uuid),
|
||||
"product_uuids": [str(self.wheat.uuid)],
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
force_authenticate(request, user=self.user)
|
||||
|
||||
response = FarmListCreateView.as_view()(request)
|
||||
|
||||
self.assertEqual(response.status_code, 201)
|
||||
self.assertEqual(response.data["data"]["subscription_plan"]["code"], "gold")
|
||||
|
||||
|
||||
@override_settings(
|
||||
USE_EXTERNAL_API_MOCK=True,
|
||||
@@ -215,3 +238,100 @@ class FarmCatalogViewsTests(TestCase):
|
||||
|
||||
self.assertEqual(response.status_code, 404)
|
||||
self.assertEqual(response.data["msg"], "Farm type not found.")
|
||||
|
||||
|
||||
class FarmAccessProfileTests(TestCase):
|
||||
def setUp(self):
|
||||
self.factory = APIRequestFactory()
|
||||
self.user = get_user_model().objects.create_user(
|
||||
username="feature-user",
|
||||
password="secret123",
|
||||
email="feature@example.com",
|
||||
phone_number="09120000002",
|
||||
)
|
||||
self.plan = SubscriptionPlan.objects.create(code="starter", name="Starter")
|
||||
self.farm_type = FarmType.objects.create(name="گلخانه ای")
|
||||
self.product = Product.objects.create(farm_type=self.farm_type, name="خیار")
|
||||
self.sensor_catalog = SensorCatalog.objects.create(name="Climate Sensor")
|
||||
self.farm = FarmHub.objects.create(
|
||||
owner=self.user,
|
||||
farm_type=self.farm_type,
|
||||
subscription_plan=self.plan,
|
||||
name="Feature Farm",
|
||||
)
|
||||
self.farm.products.add(self.product)
|
||||
self.farm.sensors.create(name="Climate Node", sensor_catalog=self.sensor_catalog, sensor_type="climate")
|
||||
|
||||
self.greenhouse_dashboard = AccessFeature.objects.create(
|
||||
code="greenhouse-dashboard",
|
||||
name="Greenhouse Dashboard",
|
||||
feature_type=AccessFeature.PAGE,
|
||||
)
|
||||
self.sensor_analytics = AccessFeature.objects.create(
|
||||
code="sensor-analytics",
|
||||
name="Sensor Analytics",
|
||||
feature_type=AccessFeature.WIDGET,
|
||||
)
|
||||
self.legacy_reports = AccessFeature.objects.create(
|
||||
code="legacy-reports",
|
||||
name="Legacy Reports",
|
||||
feature_type=AccessFeature.PAGE,
|
||||
default_enabled=True,
|
||||
)
|
||||
|
||||
plan_rule = AccessRule.objects.create(code="starter-greenhouse", name="Starter Greenhouse", priority=10)
|
||||
plan_rule.features.add(self.greenhouse_dashboard)
|
||||
plan_rule.subscription_plans.add(self.plan)
|
||||
plan_rule.farm_types.add(self.farm_type)
|
||||
|
||||
sensor_rule = AccessRule.objects.create(code="sensor-analytics-rule", name="Sensor Analytics", priority=20)
|
||||
sensor_rule.features.add(self.sensor_analytics)
|
||||
sensor_rule.sensor_catalogs.add(self.sensor_catalog)
|
||||
|
||||
deny_rule = AccessRule.objects.create(
|
||||
code="hide-legacy-reports",
|
||||
name="Hide Legacy Reports",
|
||||
priority=30,
|
||||
effect=AccessRule.DENY,
|
||||
)
|
||||
deny_rule.features.add(self.legacy_reports)
|
||||
deny_rule.products.add(self.product)
|
||||
|
||||
def test_build_farm_access_profile_resolves_combined_rules(self):
|
||||
profile = build_farm_access_profile(self.farm)
|
||||
|
||||
self.assertEqual(profile["subscription_plan"]["code"], self.plan.code)
|
||||
self.assertTrue(profile["features"]["greenhouse-dashboard"]["enabled"])
|
||||
self.assertTrue(profile["features"]["sensor-analytics"]["enabled"])
|
||||
self.assertFalse(profile["features"]["legacy-reports"]["enabled"])
|
||||
self.assertEqual(profile["features"]["legacy-reports"]["source"], "hide-legacy-reports")
|
||||
self.assertEqual(len(profile["matched_rules"]), 3)
|
||||
|
||||
def test_access_profile_view_returns_grouped_features(self):
|
||||
request = self.factory.get(f"/api/access-control/farms/{self.farm.farm_uuid}/profile/")
|
||||
force_authenticate(request, user=self.user)
|
||||
|
||||
response = FarmAccessProfileView.as_view()(request, farm_uuid=self.farm.farm_uuid)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data["data"]["groups"]["pages"]["greenhouse-dashboard"]["enabled"], True)
|
||||
self.assertEqual(response.data["data"]["groups"]["widgets"]["sensor-analytics"]["enabled"], True)
|
||||
self.assertTrue(FarmAccessProfile.objects.filter(farm=self.farm).exists())
|
||||
|
||||
def test_sensor_rule_can_match_by_metadata_sensor_name(self):
|
||||
sensor_page = AccessFeature.objects.create(
|
||||
code="sensor-page",
|
||||
name="Sensor Page",
|
||||
feature_type=AccessFeature.PAGE,
|
||||
)
|
||||
sensor_rule = AccessRule.objects.create(
|
||||
code="sensor-page-by-name",
|
||||
name="Sensor Page By Name",
|
||||
priority=40,
|
||||
metadata={"sensor_catalog_names": [self.sensor_catalog.name]},
|
||||
)
|
||||
sensor_rule.features.add(sensor_page)
|
||||
|
||||
profile = build_farm_access_profile(self.farm)
|
||||
|
||||
self.assertTrue(profile["features"]["sensor-page"]["enabled"])
|
||||
|
||||
+6
-1
@@ -24,6 +24,7 @@ class FarmHubBaseView(APIView):
|
||||
try:
|
||||
return FarmHub.objects.prefetch_related("products", "sensors", "sensors__sensor_catalog").select_related(
|
||||
"farm_type",
|
||||
"subscription_plan",
|
||||
"current_crop_area",
|
||||
).get(
|
||||
farm_uuid=farm_uuid,
|
||||
@@ -39,7 +40,11 @@ class FarmListCreateView(FarmHubBaseView):
|
||||
responses={200: code_response("FarmListResponse", data=FarmHubSerializer(many=True))},
|
||||
)
|
||||
def get(self, request):
|
||||
farms = FarmHub.objects.filter(owner=request.user).select_related("farm_type", "current_crop_area").prefetch_related(
|
||||
farms = FarmHub.objects.filter(owner=request.user).select_related(
|
||||
"farm_type",
|
||||
"subscription_plan",
|
||||
"current_crop_area",
|
||||
).prefetch_related(
|
||||
"products",
|
||||
"sensors",
|
||||
"sensors__sensor_catalog",
|
||||
|
||||
Reference in New Issue
Block a user