This commit is contained in:
2026-04-03 23:51:00 +03:30
parent e2728871ee
commit ecb42c6895
32 changed files with 2336 additions and 3 deletions
+865
View File
@@ -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",
),
),
]
+7
View File
@@ -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(
+28
View File
@@ -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
View File
@@ -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
View File
@@ -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",