UPDATE
This commit is contained in:
@@ -29,6 +29,11 @@ CELERY_BROKER_URL=redis://redis:6379/0
|
|||||||
CELERY_RESULT_BACKEND=redis://redis:6379/0
|
CELERY_RESULT_BACKEND=redis://redis:6379/0
|
||||||
CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP=true
|
CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP=true
|
||||||
|
|
||||||
|
PEST_DISEASE_RISK_SUMMARY_CACHE_TTL=14400
|
||||||
|
WATER_NEED_PREDICTION_CACHE_TTL=14400
|
||||||
|
SOIL_SUMMARY_CACHE_TTL=14400
|
||||||
|
SOIL_ANOMALIES_CACHE_TTL=14400
|
||||||
|
|
||||||
FARM_ALERTS_AI_SYNC_CRON_MINUTE=0
|
FARM_ALERTS_AI_SYNC_CRON_MINUTE=0
|
||||||
FARM_ALERTS_AI_SYNC_CRON_HOUR=*
|
FARM_ALERTS_AI_SYNC_CRON_HOUR=*
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@@ -135,6 +135,11 @@ CACHES = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PEST_DISEASE_RISK_SUMMARY_CACHE_TTL = int(os.getenv("PEST_DISEASE_RISK_SUMMARY_CACHE_TTL", "14400"))
|
||||||
|
WATER_NEED_PREDICTION_CACHE_TTL = int(os.getenv("WATER_NEED_PREDICTION_CACHE_TTL", "14400"))
|
||||||
|
SOIL_SUMMARY_CACHE_TTL = int(os.getenv("SOIL_SUMMARY_CACHE_TTL", "14400"))
|
||||||
|
SOIL_ANOMALIES_CACHE_TTL = int(os.getenv("SOIL_ANOMALIES_CACHE_TTL", "14400"))
|
||||||
|
|
||||||
REST_FRAMEWORK = {
|
REST_FRAMEWORK = {
|
||||||
"DEFAULT_PERMISSION_CLASSES": [
|
"DEFAULT_PERMISSION_CLASSES": [
|
||||||
"rest_framework.permissions.IsAuthenticated",
|
"rest_framework.permissions.IsAuthenticated",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ urlpatterns = [
|
|||||||
|
|
||||||
path("api/crop-zoning/", include("crop_zoning.urls")),
|
path("api/crop-zoning/", include("crop_zoning.urls")),
|
||||||
path("api/yield-harvest/", include("yield_harvest.urls")),
|
path("api/yield-harvest/", include("yield_harvest.urls")),
|
||||||
|
path("api/crop-simulation/", include("yield_harvest.crop_simulation_urls")),
|
||||||
|
|
||||||
path("api/pest-detection/", include("pest_detection.urls")),
|
path("api/pest-detection/", include("pest_detection.urls")),
|
||||||
path("api/pest-disease/", include("pest_detection.pest_disease_urls")),
|
path("api/pest-disease/", include("pest_detection.pest_disease_urls")),
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
# Pest Disease Risk Summary API Reference
|
||||||
|
|
||||||
|
این فایل برای فرانت آماده شده تا ساختار خروجی endpoint زیر مشخص و قابل استفاده باشد:
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/pest-disease/risk-summary/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
این endpoint فقط `farm_uuid` میگیرد و در backend:
|
||||||
|
|
||||||
|
- مزرعه را پیدا میکند
|
||||||
|
- اولین محصول ثبتشده روی همان مزرعه را برمیدارد
|
||||||
|
- `plant_name` را از همان محصول پر میکند
|
||||||
|
- `growth_stage` را فعلاً به صورت ثابت `گلدهی` به سرویس AI میفرستد
|
||||||
|
- خروجی خلاصه ریسک آفت و بیماری را به فرانت برمیگرداند
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Request
|
||||||
|
|
||||||
|
### Body
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"farm_uuid": "11111111-1111-1111-1111-111111111111"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Request Fields
|
||||||
|
|
||||||
|
| field | type | required | description |
|
||||||
|
|---|---|---:|---|
|
||||||
|
| `farm_uuid` | `string (uuid)` | yes | UUID مزرعه |
|
||||||
|
|
||||||
|
### Important Notes
|
||||||
|
|
||||||
|
- این endpoint فقط `farm_uuid` را از کلاینت قبول میکند.
|
||||||
|
- `plant_name` نباید از فرانت ارسال شود.
|
||||||
|
- `growth_stage` نباید از فرانت ارسال شود.
|
||||||
|
- `plant_name` در backend از اولین محصول مزرعه استخراج میشود.
|
||||||
|
- اگر مزرعه هیچ محصولی نداشته باشد، `plant_name` به صورت رشته خالی به AI ارسال میشود.
|
||||||
|
- `growth_stage` فعلاً همیشه `گلدهی` است.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Success Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"farm_uuid": "11111111-1111-1111-1111-111111111111",
|
||||||
|
"diseaseRisk": {
|
||||||
|
"id": "disease-risk",
|
||||||
|
"title": "ریسک بیماری",
|
||||||
|
"subtitle": "فشار بیماری در حال افزایش است",
|
||||||
|
"stats": "68%",
|
||||||
|
"avatarColor": "warning",
|
||||||
|
"avatarIcon": "tabler-biohazard",
|
||||||
|
"chipText": "متوسط",
|
||||||
|
"chipColor": "warning",
|
||||||
|
"details": {
|
||||||
|
"reason": "رطوبت بالا و تهویه ضعیف"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pestRisk": {
|
||||||
|
"id": "pest-risk",
|
||||||
|
"title": "ریسک آفت",
|
||||||
|
"subtitle": "فعالیت آفات قابل توجه است",
|
||||||
|
"stats": "41%",
|
||||||
|
"avatarColor": "info",
|
||||||
|
"avatarIcon": "tabler-bug",
|
||||||
|
"chipText": "کم",
|
||||||
|
"chipColor": "success",
|
||||||
|
"details": {
|
||||||
|
"reason": "شرایط محیطی نسبتاً پایدار"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"drivers": {
|
||||||
|
"humidity": "high",
|
||||||
|
"temperature": "moderate"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Success Response Fields
|
||||||
|
|
||||||
|
### Top Level
|
||||||
|
|
||||||
|
| field | type | description |
|
||||||
|
|---|---|---|
|
||||||
|
| `code` | `number` | در حالت موفق مقدار `200` |
|
||||||
|
| `msg` | `string` | در حالت موفق مقدار `success` |
|
||||||
|
| `data` | `object` | محتوای اصلی پاسخ |
|
||||||
|
|
||||||
|
### `data`
|
||||||
|
|
||||||
|
| field | type | description |
|
||||||
|
|---|---|---|
|
||||||
|
| `farm_uuid` | `string` | UUID مزرعه |
|
||||||
|
| `diseaseRisk` | `object` | کارت ریسک بیماری |
|
||||||
|
| `pestRisk` | `object` | کارت ریسک آفت |
|
||||||
|
| `drivers` | `object` | عوامل موثر روی ریسک |
|
||||||
|
|
||||||
|
### `diseaseRisk` / `pestRisk`
|
||||||
|
|
||||||
|
هر دو فیلد `diseaseRisk` و `pestRisk` یک ساختار مشابه دارند:
|
||||||
|
|
||||||
|
| field | type | description |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | `string` | شناسه کارت |
|
||||||
|
| `title` | `string` | عنوان کارت |
|
||||||
|
| `subtitle` | `string` | توضیح کوتاه |
|
||||||
|
| `stats` | `string` | عدد یا درصد اصلی برای نمایش |
|
||||||
|
| `avatarColor` | `string` | رنگ آیکن یا کارت |
|
||||||
|
| `avatarIcon` | `string` | نام آیکن |
|
||||||
|
| `chipText` | `string` | وضعیت متنی مثل `کم`، `متوسط`، `زیاد` |
|
||||||
|
| `chipColor` | `string` | رنگ وضعیت مثل `success`، `warning`، `error` |
|
||||||
|
| `details` | `object` | اطلاعات تکمیلی برای UI جزئیتر |
|
||||||
|
|
||||||
|
### `drivers`
|
||||||
|
|
||||||
|
| field | type | description |
|
||||||
|
|---|---|---|
|
||||||
|
| `drivers` | `object` | object آزاد از عوامل مؤثر مثل رطوبت، دما، باد، بارندگی و غیره |
|
||||||
|
|
||||||
|
نکته:
|
||||||
|
- ساختار داخلی `drivers` ثابت و محدود نیست و بهتر است در فرانت به صورت dynamic render شود.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error Response - Missing `farm_uuid`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 400,
|
||||||
|
"msg": "error",
|
||||||
|
"data": {
|
||||||
|
"farm_uuid": ["This field is required."]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### When Happens
|
||||||
|
|
||||||
|
- وقتی `farm_uuid` در body ارسال نشده باشد
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error Response - Farm Not Found
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 404,
|
||||||
|
"msg": "error",
|
||||||
|
"data": {
|
||||||
|
"farm_uuid": ["Farm not found."]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### When Happens
|
||||||
|
|
||||||
|
- وقتی `farm_uuid` معتبر باشد ولی مزرعهای با آن پیدا نشود
|
||||||
|
- یا مزرعه متعلق به کاربر فعلی نباشد
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error Response - Upstream / AI Error
|
||||||
|
|
||||||
|
اگر سرویس AI خطا برگرداند، backend همان status code را با این ساختار پاس میدهد:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 500,
|
||||||
|
"msg": "error",
|
||||||
|
"data": {
|
||||||
|
"message": "Upstream service error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
نکته:
|
||||||
|
- محتویات `data` در این حالت بسته به پاسخ upstream ممکن است متفاوت باشد.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frontend Notes
|
||||||
|
|
||||||
|
- فرم درخواست فقط باید `farm_uuid` بفرستد.
|
||||||
|
- `diseaseRisk` و `pestRisk` را مثل card model در UI مصرف کنید.
|
||||||
|
- `drivers` را optional و dynamic در نظر بگیرید.
|
||||||
|
- اگر یکی از `diseaseRisk` یا `pestRisk` خالی بود، UI باید بدون crash کار کند.
|
||||||
|
- متن خطا برای `400` و `404` را میتوانید از `data.farm_uuid[0]` بخوانید.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Suggested TypeScript Interfaces
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export interface RiskCard {
|
||||||
|
id?: string;
|
||||||
|
title?: string;
|
||||||
|
subtitle?: string;
|
||||||
|
stats?: string;
|
||||||
|
avatarColor?: string;
|
||||||
|
avatarIcon?: string;
|
||||||
|
chipText?: string;
|
||||||
|
chipColor?: string;
|
||||||
|
details?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PestDiseaseRiskSummaryResponse {
|
||||||
|
code: number;
|
||||||
|
msg: string;
|
||||||
|
data: {
|
||||||
|
farm_uuid: string;
|
||||||
|
diseaseRisk?: RiskCard;
|
||||||
|
pestRisk?: RiskCard;
|
||||||
|
drivers?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Example Frontend Handling
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const response = await api.post('/api/pest-disease/risk-summary/', {
|
||||||
|
farm_uuid,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = response.data;
|
||||||
|
|
||||||
|
if (result.code === 200) {
|
||||||
|
const diseaseRisk = result.data.diseaseRisk;
|
||||||
|
const pestRisk = result.data.pestRisk;
|
||||||
|
const drivers = result.data.drivers;
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,381 @@
|
|||||||
|
# Soil API Reference for Frontend
|
||||||
|
|
||||||
|
این فایل برای فرانت آماده شده تا ساختار پاسخ APIهای خاک را سریع و شفاف داشته باشید.
|
||||||
|
|
||||||
|
## Base Notes
|
||||||
|
|
||||||
|
- سه endpoint زیر `farm_uuid` را به صورت query param لازم دارند:
|
||||||
|
- `GET /api/soil/anomalies/`
|
||||||
|
- `GET /api/soil/moisture-heatmap/`
|
||||||
|
- `GET /api/soil/summary/`
|
||||||
|
- endpoint `GET /api/soil/avg-moisture/` بدون `farm_uuid` هم جواب میدهد، ولی اگر `farm_uuid` ارسال شود داده بر اساس همان مزرعه محاسبه میشود.
|
||||||
|
- در سه endpoint اول و سوم، اگر `farm_uuid` ارسال نشود یا مزرعه پیدا نشود، پاسخ با ساختار `code/msg/data` برمیگردد.
|
||||||
|
- پاسخ موفق `avg-moisture` با کلید `status` برمیگردد، ولی سه endpoint دیگر با کلیدهای `code`, `msg`, `data` برمیگردند.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1) Average Soil Moisture
|
||||||
|
|
||||||
|
### Endpoint
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/soil/avg-moisture/?farm_uuid=<farm_uuid>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Query Params
|
||||||
|
|
||||||
|
| name | type | required | description |
|
||||||
|
|---|---|---:|---|
|
||||||
|
| `farm_uuid` | `string (uuid)` | no | UUID مزرعه |
|
||||||
|
|
||||||
|
### Success Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"data": {
|
||||||
|
"id": "avg_soil_moisture",
|
||||||
|
"title": "میانگین رطوبت خاک",
|
||||||
|
"subtitle": "کل مزرعه",
|
||||||
|
"stats": "65%",
|
||||||
|
"avatarColor": "primary",
|
||||||
|
"avatarIcon": "tabler-plant-2",
|
||||||
|
"chipText": "بهینه",
|
||||||
|
"chipColor": "success"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response Fields
|
||||||
|
|
||||||
|
| field | type | description |
|
||||||
|
|---|---|---|
|
||||||
|
| `status` | `string` | در حالت موفق مقدار `success` |
|
||||||
|
| `data.id` | `string` | شناسه کارت |
|
||||||
|
| `data.title` | `string` | عنوان کارت |
|
||||||
|
| `data.subtitle` | `string` | زیرعنوان کارت |
|
||||||
|
| `data.stats` | `string` | مقدار اصلی به صورت درصد، مثل `48%` |
|
||||||
|
| `data.avatarColor` | `string` | رنگ آیکن/کارت |
|
||||||
|
| `data.avatarIcon` | `string` | نام آیکن |
|
||||||
|
| `data.chipText` | `string` | وضعیت متنی، مثل `بهینه`، `متوسط`، `کم` |
|
||||||
|
| `data.chipColor` | `string` | رنگ وضعیت، مثل `success`، `warning`، `error` |
|
||||||
|
|
||||||
|
### Frontend Notes
|
||||||
|
|
||||||
|
- این endpoint برای ساخت یک KPI card مناسب است.
|
||||||
|
- `stats` همیشه string است و بهتر است مستقیم render شود.
|
||||||
|
- `chipText` و `chipColor` برای badge یا status pill استفاده شوند.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2) Soil Anomalies
|
||||||
|
|
||||||
|
### Endpoint
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/soil/anomalies/?farm_uuid=<farm_uuid>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Query Params
|
||||||
|
|
||||||
|
| name | type | required | description |
|
||||||
|
|---|---|---:|---|
|
||||||
|
| `farm_uuid` | `string (uuid)` | yes | UUID مزرعه |
|
||||||
|
|
||||||
|
### Success Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"farm_uuid": "11111111-1111-1111-1111-111111111111",
|
||||||
|
"summary": "Risk of localized soil imbalance detected.",
|
||||||
|
"explanation": "One or more soil indicators are outside the expected range.",
|
||||||
|
"likely_cause": "Uneven irrigation or nutrient distribution.",
|
||||||
|
"recommended_action": "Inspect the affected zone and verify irrigation schedule.",
|
||||||
|
"monitoring_priority": "high",
|
||||||
|
"confidence": 0.89,
|
||||||
|
"generated_at": "2025-01-01T10:30:00Z",
|
||||||
|
"anomalies": [
|
||||||
|
{
|
||||||
|
"sensor": "رطوبت خاک زون 3",
|
||||||
|
"value": "38%",
|
||||||
|
"expected": "45-65%",
|
||||||
|
"deviation": "-12%",
|
||||||
|
"severity": "warning"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"interpretation": {
|
||||||
|
"risk_level": "medium"
|
||||||
|
},
|
||||||
|
"knowledge_base": null,
|
||||||
|
"raw_response": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response Fields
|
||||||
|
|
||||||
|
| field | type | description |
|
||||||
|
|---|---|---|
|
||||||
|
| `code` | `number` | در حالت موفق مقدار `200` |
|
||||||
|
| `msg` | `string` | در حالت موفق مقدار `success` |
|
||||||
|
| `data.farm_uuid` | `string` | UUID مزرعه |
|
||||||
|
| `data.summary` | `string` | خلاصه کوتاه نتیجه anomaly detection |
|
||||||
|
| `data.explanation` | `string` | توضیح readable برای فرانت |
|
||||||
|
| `data.likely_cause` | `string` | علت احتمالی |
|
||||||
|
| `data.recommended_action` | `string` | اقدام پیشنهادی |
|
||||||
|
| `data.monitoring_priority` | `string` | سطح اهمیت پایش؛ مثل `low`, `medium`, `high`, `urgent` |
|
||||||
|
| `data.confidence` | `number` | میزان اطمینان مدل |
|
||||||
|
| `data.generated_at` | `string` | زمان تولید تحلیل |
|
||||||
|
| `data.anomalies` | `array` | لیست anomalyها |
|
||||||
|
| `data.anomalies[].sensor` | `string` | نام سنسور یا ناحیه |
|
||||||
|
| `data.anomalies[].value` | `string` | مقدار فعلی |
|
||||||
|
| `data.anomalies[].expected` | `string` | بازه یا مقدار مورد انتظار |
|
||||||
|
| `data.anomalies[].deviation` | `string` | اختلاف با مقدار نرمال |
|
||||||
|
| `data.anomalies[].severity` | `string` | شدت anomaly، مثل `warning` یا `error` |
|
||||||
|
| `data.interpretation` | `object` | تفسیر ساختاریافته برای UI پیشرفته |
|
||||||
|
| `data.knowledge_base` | `string \| null` | مرجع دانشی در صورت وجود |
|
||||||
|
| `data.raw_response` | `string \| null` | متن خام upstream در صورت وجود |
|
||||||
|
|
||||||
|
### Error Response - Missing `farm_uuid`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 400,
|
||||||
|
"msg": "error",
|
||||||
|
"data": {
|
||||||
|
"farm_uuid": ["This field is required."]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Response - Farm Not Found
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 404,
|
||||||
|
"msg": "error",
|
||||||
|
"data": {
|
||||||
|
"farm_uuid": ["Farm not found."]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend Notes
|
||||||
|
|
||||||
|
- `anomalies` میتواند برای table، list یا alert cards استفاده شود.
|
||||||
|
- اگر `anomalies` خالی بود، UI بهتر است empty state نمایش دهد.
|
||||||
|
- `severity` را میتوانید به color map وصل کنید.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3) Soil Moisture Heatmap
|
||||||
|
|
||||||
|
### Endpoint
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/soil/moisture-heatmap/?farm_uuid=<farm_uuid>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Query Params
|
||||||
|
|
||||||
|
| name | type | required | description |
|
||||||
|
|---|---|---:|---|
|
||||||
|
| `farm_uuid` | `string (uuid)` | yes | UUID مزرعه |
|
||||||
|
|
||||||
|
### Success Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"farm_uuid": "11111111-1111-1111-1111-111111111111",
|
||||||
|
"location": {
|
||||||
|
"name": "Zone A"
|
||||||
|
},
|
||||||
|
"current_sensor": {
|
||||||
|
"name": "Sensor 7-in-1"
|
||||||
|
},
|
||||||
|
"soil_profile": [],
|
||||||
|
"timestamp": "2025-01-01T10:30:00Z",
|
||||||
|
"grid_resolution": {
|
||||||
|
"rows": 10,
|
||||||
|
"cols": 10
|
||||||
|
},
|
||||||
|
"grid_cells": [],
|
||||||
|
"sensor_points": [],
|
||||||
|
"quality_legend": {
|
||||||
|
"low": "0-30",
|
||||||
|
"medium": "31-60",
|
||||||
|
"high": "61-100"
|
||||||
|
},
|
||||||
|
"depth_layers": [],
|
||||||
|
"model_metadata": {},
|
||||||
|
"summary": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Supported Response Shape in Current Backend
|
||||||
|
|
||||||
|
در serializer فعلی این فیلدها پشتیبانی میشوند:
|
||||||
|
|
||||||
|
| field | type | description |
|
||||||
|
|---|---|---|
|
||||||
|
| `data.farm_uuid` | `string` | UUID مزرعه |
|
||||||
|
| `data.location` | `object` | اطلاعات مکانی |
|
||||||
|
| `data.current_sensor` | `object` | اطلاعات سنسور فعال |
|
||||||
|
| `data.soil_profile` | `array<object>` | داده لایههای خاک |
|
||||||
|
| `data.timestamp` | `string \| null` | زمان تولید heatmap |
|
||||||
|
| `data.grid_resolution` | `object` | رزولوشن grid |
|
||||||
|
| `data.grid_cells` | `array<object>` | سلولهای grid |
|
||||||
|
| `data.sensor_points` | `array<object>` | نقاط سنسور |
|
||||||
|
| `data.quality_legend` | `object` | legend مقادیر |
|
||||||
|
| `data.depth_layers` | `array<object>` | لایههای عمقی |
|
||||||
|
| `data.model_metadata` | `object` | متادیتای مدل |
|
||||||
|
| `data.summary` | `object` | خلاصه تفسیری |
|
||||||
|
|
||||||
|
### Legacy / Mock Shape You May Also See
|
||||||
|
|
||||||
|
در داده mock داخلی پروژه یک ساختار سادهتر هم وجود دارد:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"data": {
|
||||||
|
"zones": ["زون 1", "زون 2"],
|
||||||
|
"hours": ["6 ص", "8 ص"],
|
||||||
|
"series": [
|
||||||
|
{
|
||||||
|
"name": "زون 1",
|
||||||
|
"data": [
|
||||||
|
{ "x": "6 ص", "y": 52 },
|
||||||
|
{ "x": "8 ص", "y": 48 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Response - Missing `farm_uuid`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 400,
|
||||||
|
"msg": "error",
|
||||||
|
"data": {
|
||||||
|
"farm_uuid": ["This field is required."]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Response - Farm Not Found
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 404,
|
||||||
|
"msg": "error",
|
||||||
|
"data": {
|
||||||
|
"farm_uuid": ["Farm not found."]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend Notes
|
||||||
|
|
||||||
|
- چون upstream shape ممکن است object-based یا series-based باشد، فرانت بهتر است defensive parsing داشته باشد.
|
||||||
|
- اگر `grid_cells` وجود داشت، heatmap را از grid render کنید.
|
||||||
|
- اگر `series` وجود داشت، میتوانید آن را به chart heatmap یا matrix chart بدهید.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4) Soil Summary
|
||||||
|
|
||||||
|
### Endpoint
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/soil/summary/?farm_uuid=<farm_uuid>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Query Params
|
||||||
|
|
||||||
|
| name | type | required | description |
|
||||||
|
|---|---|---:|---|
|
||||||
|
| `farm_uuid` | `string (uuid)` | yes | UUID مزرعه |
|
||||||
|
|
||||||
|
### Success Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"farm_uuid": "11111111-1111-1111-1111-111111111111",
|
||||||
|
"healthScore": 82,
|
||||||
|
"profileSource": "Tomato",
|
||||||
|
"healthScoreDetails": {},
|
||||||
|
"healthLanguage": {},
|
||||||
|
"avgSoilMoisture": 46,
|
||||||
|
"avgSoilMoistureRaw": 46.0,
|
||||||
|
"avgSoilMoistureStatus": "بهینه"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response Fields
|
||||||
|
|
||||||
|
| field | type | description |
|
||||||
|
|---|---|---|
|
||||||
|
| `code` | `number` | در حالت موفق مقدار `200` |
|
||||||
|
| `msg` | `string` | در حالت موفق مقدار `success` |
|
||||||
|
| `data.farm_uuid` | `string` | UUID مزرعه |
|
||||||
|
| `data.healthScore` | `number` | امتیاز سلامت کلی خاک |
|
||||||
|
| `data.profileSource` | `string` | منبع پروفایل یا محصول مرجع |
|
||||||
|
| `data.healthScoreDetails` | `object` | جزئیات محاسبه health score |
|
||||||
|
| `data.healthLanguage` | `object` | متنها و labelهای قابل نمایش |
|
||||||
|
| `data.avgSoilMoisture` | `number` | میانگین گرد شده رطوبت خاک |
|
||||||
|
| `data.avgSoilMoistureRaw` | `number` | میانگین خام |
|
||||||
|
| `data.avgSoilMoistureStatus` | `string` | وضعیت متنی رطوبت خاک |
|
||||||
|
|
||||||
|
### Error Response - Missing `farm_uuid`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 400,
|
||||||
|
"msg": "error",
|
||||||
|
"data": {
|
||||||
|
"farm_uuid": ["This field is required."]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Response - Farm Not Found
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 404,
|
||||||
|
"msg": "error",
|
||||||
|
"data": {
|
||||||
|
"farm_uuid": ["Farm not found."]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend Notes
|
||||||
|
|
||||||
|
- این endpoint برای summary card یا hero panel خیلی مناسب است.
|
||||||
|
- `healthScoreDetails` و `healthLanguage` را optional در نظر بگیرید.
|
||||||
|
- برای UI بهتر، `healthScore` را هم به صورت عدد و هم به صورت progress/gauge نمایش دهید.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Suggested Frontend Handling
|
||||||
|
|
||||||
|
- برای `avg-moisture` انتظار `status/data` داشته باشید.
|
||||||
|
- برای `anomalies`, `moisture-heatmap`, `summary` انتظار `code/msg/data` داشته باشید.
|
||||||
|
- برای خطاهای 400 و 404، متن خطا را از `data.farm_uuid[0]` بخوانید.
|
||||||
|
- در heatmap، parsing را flexible بنویسید چون shape داده ممکن است بسته به upstream تغییر کند.
|
||||||
@@ -0,0 +1,381 @@
|
|||||||
|
# Water & Weather API Reference for Frontend
|
||||||
|
|
||||||
|
این فایل برای فرانت آماده شده تا ساختار پاسخ APIهای زیر مشخص باشد:
|
||||||
|
|
||||||
|
- `GET /api/water/card/`
|
||||||
|
- `GET /api/water/need-prediction/`
|
||||||
|
- `GET /api/water/summary/`
|
||||||
|
- `POST /api/weather/farm-card/`
|
||||||
|
|
||||||
|
## Base Notes
|
||||||
|
|
||||||
|
- `GET /api/water/card/` و `GET /api/water/summary/` بدون `farm_uuid` هم جواب میدهند.
|
||||||
|
- `GET /api/water/need-prediction/` هم بدون `farm_uuid` جواب میدهد، ولی اگر `farm_uuid` وجود داشته باشد ممکن است داده مزرعهمحور برگردد.
|
||||||
|
- `POST /api/weather/farm-card/` نیاز به `farm_uuid` در body دارد.
|
||||||
|
- response shapeها بین این endpointها یکدست نیستند:
|
||||||
|
- بعضی endpointها با `status/data`
|
||||||
|
- بعضی endpointها با `code/msg/data`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1) Water Card
|
||||||
|
|
||||||
|
### Endpoint
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/water/card/?farm_uuid=<farm_uuid>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Query Params
|
||||||
|
|
||||||
|
| field | type | required | description |
|
||||||
|
|---|---|---:|---|
|
||||||
|
| `farm_uuid` | `string (uuid)` | no | UUID مزرعه |
|
||||||
|
|
||||||
|
### Success Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"data": {
|
||||||
|
"condition": "صاف",
|
||||||
|
"temperature": 24,
|
||||||
|
"unit": "°C",
|
||||||
|
"humidity": 45,
|
||||||
|
"windSpeed": 12,
|
||||||
|
"windUnit": "km/h",
|
||||||
|
"chartData": {
|
||||||
|
"labels": ["۶ صبح", "۹ صبح", "۱۲ ظهر", "۳ بعدازظهر"],
|
||||||
|
"series": [[18, 22, 26, 28]]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response Fields
|
||||||
|
|
||||||
|
| field | type | description |
|
||||||
|
|---|---|---|
|
||||||
|
| `status` | `string` | در حالت موفق مقدار `success` |
|
||||||
|
| `data.condition` | `string` | وضعیت فعلی آبوهوا |
|
||||||
|
| `data.temperature` | `number` | دمای فعلی |
|
||||||
|
| `data.unit` | `string` | واحد دما |
|
||||||
|
| `data.humidity` | `number` | رطوبت نسبی |
|
||||||
|
| `data.windSpeed` | `number` | سرعت باد |
|
||||||
|
| `data.windUnit` | `string` | واحد سرعت باد |
|
||||||
|
| `data.chartData.labels` | `string[]` | برچسبهای زمانی |
|
||||||
|
| `data.chartData.series` | `number[][]` | سریهای نمودار |
|
||||||
|
|
||||||
|
### Frontend Notes
|
||||||
|
|
||||||
|
- این endpoint برای weather widget یا weather card مناسب است.
|
||||||
|
- `chartData.series` به شکل آرایه دوبعدی است.
|
||||||
|
- اگر `farm_uuid` ارسال شود، backend داده را از AI گرفته و log هم میکند.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2) Water Need Prediction
|
||||||
|
|
||||||
|
### Endpoint
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/water/need-prediction/?farm_uuid=<farm_uuid>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Query Params
|
||||||
|
|
||||||
|
| field | type | required | description |
|
||||||
|
|---|---|---:|---|
|
||||||
|
| `farm_uuid` | `string (uuid)` | no | UUID مزرعه |
|
||||||
|
|
||||||
|
### Success Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"data": {
|
||||||
|
"farm_uuid": "11111111-1111-1111-1111-111111111111",
|
||||||
|
"totalNext7Days": 24.6,
|
||||||
|
"unit": "mm",
|
||||||
|
"categories": ["2025-01-01", "2025-01-02", "2025-01-03"],
|
||||||
|
"series": [
|
||||||
|
{
|
||||||
|
"name": "نیاز آبی",
|
||||||
|
"data": [3.2, 4.1, 2.8]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dailyBreakdown": [],
|
||||||
|
"insight": {},
|
||||||
|
"knowledge_base": "",
|
||||||
|
"raw_response": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response Fields
|
||||||
|
|
||||||
|
| field | type | description |
|
||||||
|
|---|---|---|
|
||||||
|
| `status` | `string` | در حالت موفق مقدار `success` |
|
||||||
|
| `data.farm_uuid` | `string` | UUID مزرعه در حالت farm-based |
|
||||||
|
| `data.totalNext7Days` | `number` | مجموع نیاز آبی 7 روز آینده |
|
||||||
|
| `data.unit` | `string` | واحد نیاز آبی، مثل `mm` یا `m3` |
|
||||||
|
| `data.categories` | `string[]` | روزها یا تاریخها |
|
||||||
|
| `data.series` | `Array<{name: string, data: number[]}>` | دادههای نمودار |
|
||||||
|
| `data.dailyBreakdown` | `object[]` | breakdown روزانه در صورت وجود |
|
||||||
|
| `data.insight` | `object` | insight یا خلاصه تحلیلی |
|
||||||
|
| `data.knowledge_base` | `string` | منبع دانشی در صورت وجود |
|
||||||
|
| `data.raw_response` | `string` | پاسخ خام upstream در صورت وجود |
|
||||||
|
|
||||||
|
### Frontend Notes
|
||||||
|
|
||||||
|
- اگر `farm_uuid` معتبر باشد، backend داده را از AI میگیرد.
|
||||||
|
- اگر `farm_uuid` ارسال نشود، backend از داده داخلی/mock استفاده میکند.
|
||||||
|
- اگر `farm_uuid` ارسال شود ولی مزرعه پیدا نشود، فعلاً به fallback داخلی برمیگردد و خطا نمیدهد.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3) Water Summary
|
||||||
|
|
||||||
|
### Endpoint
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/water/summary/?farm_uuid=<farm_uuid>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Query Params
|
||||||
|
|
||||||
|
| field | type | required | description |
|
||||||
|
|---|---|---:|---|
|
||||||
|
| `farm_uuid` | `string (uuid)` | no | UUID مزرعه |
|
||||||
|
|
||||||
|
### Success Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"data": {
|
||||||
|
"farmWeatherCard": {
|
||||||
|
"condition": "صاف",
|
||||||
|
"temperature": 24,
|
||||||
|
"unit": "°C",
|
||||||
|
"humidity": 45,
|
||||||
|
"windSpeed": 12,
|
||||||
|
"windUnit": "km/h",
|
||||||
|
"chartData": {
|
||||||
|
"labels": ["۶ صبح", "۹ صبح", "۱۲ ظهر"],
|
||||||
|
"series": [[18, 22, 26]]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"waterNeedPrediction": {
|
||||||
|
"totalNext7Days": 3290,
|
||||||
|
"unit": "m3",
|
||||||
|
"categories": ["روز 1", "روز 2", "روز 3"],
|
||||||
|
"series": [
|
||||||
|
{
|
||||||
|
"name": "نیاز آبی",
|
||||||
|
"data": [420, 450, 480]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"waterStressIndex": {
|
||||||
|
"id": "water_stress_index",
|
||||||
|
"title": "شاخص تنش آبی",
|
||||||
|
"subtitle": "فعلی",
|
||||||
|
"stats": "12%",
|
||||||
|
"avatarColor": "info",
|
||||||
|
"avatarIcon": "tabler-droplet",
|
||||||
|
"chipText": "پایین",
|
||||||
|
"chipColor": "success"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response Fields
|
||||||
|
|
||||||
|
| field | type | description |
|
||||||
|
|---|---|---|
|
||||||
|
| `status` | `string` | در حالت موفق مقدار `success` |
|
||||||
|
| `data.farmWeatherCard` | `object` | اطلاعات کارت وضعیت آبوهوا |
|
||||||
|
| `data.waterNeedPrediction` | `object` | پیشبینی نیاز آبی |
|
||||||
|
| `data.waterStressIndex` | `object` | کارت شاخص تنش آبی |
|
||||||
|
|
||||||
|
### `waterStressIndex` Fields
|
||||||
|
|
||||||
|
| field | type | description |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | `string` | شناسه کارت |
|
||||||
|
| `title` | `string` | عنوان کارت |
|
||||||
|
| `subtitle` | `string` | زیرعنوان |
|
||||||
|
| `stats` | `string` | مقدار اصلی برای نمایش |
|
||||||
|
| `avatarColor` | `string` | رنگ کارت/آیکن |
|
||||||
|
| `avatarIcon` | `string` | نام آیکن |
|
||||||
|
| `chipText` | `string` | وضعیت متنی |
|
||||||
|
| `chipColor` | `string` | رنگ وضعیت |
|
||||||
|
|
||||||
|
### Frontend Notes
|
||||||
|
|
||||||
|
- این endpoint برای dashboard overview مناسب است.
|
||||||
|
- سه بخش اصلی summary را میتوانید مستقیم به سه widget مختلف map کنید.
|
||||||
|
- `waterSummary` یک response ترکیبی است و برای یک صفحه dashboard خیلی کاربردی است.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4) Weather Farm Card
|
||||||
|
|
||||||
|
### Endpoint
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/weather/farm-card/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Request Body
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"farm_uuid": "11111111-1111-1111-1111-111111111111"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Request Fields
|
||||||
|
|
||||||
|
| field | type | required | description |
|
||||||
|
|---|---|---:|---|
|
||||||
|
| `farm_uuid` | `string (uuid)` | yes | UUID مزرعه |
|
||||||
|
|
||||||
|
### Success Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"condition": "صاف",
|
||||||
|
"temperature": 28.0,
|
||||||
|
"unit": "°C",
|
||||||
|
"humidity": 45,
|
||||||
|
"windSpeed": 12,
|
||||||
|
"windUnit": "km/h",
|
||||||
|
"chartData": {
|
||||||
|
"labels": ["۶ صبح", "۹ صبح", "۱۲ ظهر", "۳ بعدازظهر"],
|
||||||
|
"series": [[18, 22, 26, 28]]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response Fields
|
||||||
|
|
||||||
|
| field | type | description |
|
||||||
|
|---|---|---|
|
||||||
|
| `code` | `number` | در حالت موفق مقدار `200` |
|
||||||
|
| `msg` | `string` | در حالت موفق مقدار `success` |
|
||||||
|
| `data.condition` | `string` | وضعیت فعلی آبوهوا |
|
||||||
|
| `data.temperature` | `number` | دمای فعلی |
|
||||||
|
| `data.unit` | `string` | واحد دما |
|
||||||
|
| `data.humidity` | `number` | رطوبت نسبی |
|
||||||
|
| `data.windSpeed` | `number` | سرعت باد |
|
||||||
|
| `data.windUnit` | `string` | واحد سرعت باد |
|
||||||
|
| `data.chartData.labels` | `string[]` | برچسبهای زمانی |
|
||||||
|
| `data.chartData.series` | `number[][]` | دادههای نمودار |
|
||||||
|
|
||||||
|
### Error Response - Missing `farm_uuid`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 400,
|
||||||
|
"msg": "error",
|
||||||
|
"data": {
|
||||||
|
"farm_uuid": ["This field is required."]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Response - Farm Not Found
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 404,
|
||||||
|
"msg": "error",
|
||||||
|
"data": {
|
||||||
|
"farm_uuid": ["Farm not found."]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Response - Upstream Error
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 500,
|
||||||
|
"msg": "error",
|
||||||
|
"data": {
|
||||||
|
"message": "Upstream service error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend Notes
|
||||||
|
|
||||||
|
- این endpoint نسخه authenticated و farm-specific برای weather card است.
|
||||||
|
- اگر farm متعلق به کاربر فعلی نباشد، `404` برمیگردد.
|
||||||
|
- response این endpoint با `code/msg/data` است، نه `status/data`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Suggested TypeScript Interfaces
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export interface WeatherChartData {
|
||||||
|
labels?: string[];
|
||||||
|
series?: number[][];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FarmWeatherCard {
|
||||||
|
condition?: string;
|
||||||
|
temperature?: number;
|
||||||
|
unit?: string;
|
||||||
|
humidity?: number;
|
||||||
|
windSpeed?: number;
|
||||||
|
windUnit?: string;
|
||||||
|
chartData?: WeatherChartData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WaterNeedSeries {
|
||||||
|
name?: string;
|
||||||
|
data?: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WaterNeedPrediction {
|
||||||
|
farm_uuid?: string;
|
||||||
|
totalNext7Days?: number;
|
||||||
|
unit?: string;
|
||||||
|
categories?: string[];
|
||||||
|
series?: WaterNeedSeries[];
|
||||||
|
dailyBreakdown?: Record<string, unknown>[];
|
||||||
|
insight?: Record<string, unknown>;
|
||||||
|
knowledge_base?: string;
|
||||||
|
raw_response?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WaterStressCard {
|
||||||
|
id?: string;
|
||||||
|
title?: string;
|
||||||
|
subtitle?: string;
|
||||||
|
stats?: string;
|
||||||
|
avatarColor?: string;
|
||||||
|
avatarIcon?: string;
|
||||||
|
chipText?: string;
|
||||||
|
chipColor?: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Suggested Frontend Handling
|
||||||
|
|
||||||
|
- برای `GET /api/water/card/`, `GET /api/water/need-prediction/`, `GET /api/water/summary/` انتظار `status/data` داشته باشید.
|
||||||
|
- برای `POST /api/weather/farm-card/` انتظار `code/msg/data` داشته باشید.
|
||||||
|
- برای `POST /api/weather/farm-card/` خطاها را از `data.farm_uuid[0]` بخوانید.
|
||||||
|
- برای chartها بهتر است `labels` و `series` را optional render کنید.
|
||||||
@@ -37,11 +37,13 @@ class PestDetectionAnalyzeResponseSerializer(serializers.Serializer):
|
|||||||
|
|
||||||
|
|
||||||
class PestDetectionRiskRequestSerializer(serializers.Serializer):
|
class PestDetectionRiskRequestSerializer(serializers.Serializer):
|
||||||
farm_uuid = serializers.UUIDField(required=True, help_text="UUID مزرعه برای تحلیل ریسک آفت/بیماری.")
|
farm_uuid = serializers.UUIDField(default="11111111-1111-1111-1111-111111111111", help_text="UUID مزرعه برای تحلیل ریسک آفت/بیماری.")
|
||||||
sensor_uuid = serializers.UUIDField(required=False, help_text="UUID سنسور مرتبط در صورت وجود.")
|
plant_name = serializers.CharField(required=False, allow_blank=True, default="پیاز", help_text="نام محصول یا گیاه.")
|
||||||
plant_name = serializers.CharField(required=False, allow_blank=True, default="", help_text="نام محصول یا گیاه.")
|
growth_stage = serializers.CharField(required=False, allow_blank=True, default="گلدهی", help_text="مرحله رشد گیاه.")
|
||||||
growth_stage = serializers.CharField(required=False, allow_blank=True, default="", help_text="مرحله رشد گیاه.")
|
|
||||||
query = serializers.CharField(required=False, allow_blank=True, default="", help_text="پرسش تکمیلی کاربر.")
|
|
||||||
|
class PestDetectionRiskSummaryRequestSerializer(serializers.Serializer):
|
||||||
|
farm_uuid = serializers.UUIDField(required=True, help_text="UUID مزرعه برای خلاصه ریسک آفت/بیماری.")
|
||||||
|
|
||||||
|
|
||||||
class RiskBreakdownSerializer(serializers.Serializer):
|
class RiskBreakdownSerializer(serializers.Serializer):
|
||||||
|
|||||||
+183
-7
@@ -1,18 +1,34 @@
|
|||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from django.core.cache import cache
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.test import TestCase
|
from django.test import TestCase, override_settings
|
||||||
from django.urls import resolve
|
from django.urls import resolve
|
||||||
from rest_framework.test import APIRequestFactory, force_authenticate
|
from rest_framework.test import APIRequestFactory, force_authenticate
|
||||||
|
|
||||||
from external_api_adapter.adapter import AdapterResponse
|
from external_api_adapter.adapter import AdapterResponse
|
||||||
from farm_hub.models import FarmHub, FarmType
|
from farm_hub.models import FarmHub, FarmType, Product
|
||||||
|
|
||||||
from .views import AnalyzeView, RiskSummaryView, RiskView
|
from .views import AnalyzeView, RiskSummaryView, RiskView
|
||||||
|
|
||||||
|
|
||||||
|
TEST_CACHES = {
|
||||||
|
"default": {
|
||||||
|
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
|
||||||
|
"LOCATION": "pest-detection-tests",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_RISK_SUMMARY_CACHE_TTL = 14400
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
CACHES=TEST_CACHES,
|
||||||
|
PEST_DISEASE_RISK_SUMMARY_CACHE_TTL=TEST_RISK_SUMMARY_CACHE_TTL,
|
||||||
|
)
|
||||||
class PestDetectionViewTests(TestCase):
|
class PestDetectionViewTests(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
cache.clear()
|
||||||
self.factory = APIRequestFactory()
|
self.factory = APIRequestFactory()
|
||||||
self.user = get_user_model().objects.create_user(
|
self.user = get_user_model().objects.create_user(
|
||||||
username="farmer",
|
username="farmer",
|
||||||
@@ -28,6 +44,8 @@ class PestDetectionViewTests(TestCase):
|
|||||||
)
|
)
|
||||||
self.farm_type = FarmType.objects.create(name="زراعی")
|
self.farm_type = FarmType.objects.create(name="زراعی")
|
||||||
self.farm = FarmHub.objects.create(owner=self.user, farm_type=self.farm_type, name="Farm 1")
|
self.farm = FarmHub.objects.create(owner=self.user, farm_type=self.farm_type, name="Farm 1")
|
||||||
|
self.product = Product.objects.create(farm_type=self.farm_type, name="پیاز")
|
||||||
|
self.farm.products.add(self.product)
|
||||||
self.other_farm = FarmHub.objects.create(owner=self.other_user, farm_type=self.farm_type, name="Farm 2")
|
self.other_farm = FarmHub.objects.create(owner=self.other_user, farm_type=self.farm_type, name="Farm 2")
|
||||||
|
|
||||||
@patch("pest_detection.views.external_api_request")
|
@patch("pest_detection.views.external_api_request")
|
||||||
@@ -127,7 +145,6 @@ class PestDetectionViewTests(TestCase):
|
|||||||
"farm_uuid": str(self.farm.farm_uuid),
|
"farm_uuid": str(self.farm.farm_uuid),
|
||||||
"plant_name": "wheat",
|
"plant_name": "wheat",
|
||||||
"growth_stage": "",
|
"growth_stage": "",
|
||||||
"query": "",
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -163,9 +180,13 @@ class PestDetectionViewTests(TestCase):
|
|||||||
self.assertEqual(response.data["data"]["drivers"], {"humidity": "high"})
|
self.assertEqual(response.data["data"]["drivers"], {"humidity": "high"})
|
||||||
mock_external_api_request.assert_called_once_with(
|
mock_external_api_request.assert_called_once_with(
|
||||||
"ai",
|
"ai",
|
||||||
"/api/pest-disease/risk-summary/",
|
"/api/pest-disease/risk/",
|
||||||
method="POST",
|
method="POST",
|
||||||
payload={"farm_uuid": str(self.farm.farm_uuid)},
|
payload={
|
||||||
|
"farm_uuid": str(self.farm.farm_uuid),
|
||||||
|
"plant_name": "پیاز",
|
||||||
|
"growth_stage": "گلدهی",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@patch("pest_detection.views.external_api_request")
|
@patch("pest_detection.views.external_api_request")
|
||||||
@@ -196,11 +217,166 @@ class PestDetectionViewTests(TestCase):
|
|||||||
self.assertEqual(response.data["data"]["farm_uuid"], str(self.farm.farm_uuid))
|
self.assertEqual(response.data["data"]["farm_uuid"], str(self.farm.farm_uuid))
|
||||||
mock_external_api_request.assert_called_once_with(
|
mock_external_api_request.assert_called_once_with(
|
||||||
"ai",
|
"ai",
|
||||||
"/api/pest-disease/risk-summary/",
|
"/api/pest-disease/risk/",
|
||||||
method="POST",
|
method="POST",
|
||||||
payload={"farm_uuid": str(self.farm.farm_uuid)},
|
payload={
|
||||||
|
"farm_uuid": str(self.farm.farm_uuid),
|
||||||
|
"plant_name": "پیاز",
|
||||||
|
"growth_stage": "گلدهی",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@patch("pest_detection.views.external_api_request")
|
||||||
|
def test_risk_summary_uses_blank_plant_name_when_farm_has_no_products(self, mock_external_api_request):
|
||||||
|
mock_external_api_request.return_value = AdapterResponse(
|
||||||
|
status_code=200,
|
||||||
|
data={
|
||||||
|
"data": {
|
||||||
|
"result": {
|
||||||
|
"disease_risk": {"title": "Disease"},
|
||||||
|
"pest_risk": {"title": "Pest"},
|
||||||
|
"drivers": {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
farm_without_products = FarmHub.objects.create(owner=self.user, farm_type=self.farm_type, name="Farm 3")
|
||||||
|
|
||||||
|
request = self.factory.post(
|
||||||
|
"/api/pest-disease/risk-summary/",
|
||||||
|
{"farm_uuid": str(farm_without_products.farm_uuid)},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
force_authenticate(request, user=self.user)
|
||||||
|
|
||||||
|
response = RiskSummaryView.as_view()(request)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
mock_external_api_request.assert_called_once_with(
|
||||||
|
"ai",
|
||||||
|
"/api/pest-disease/risk/",
|
||||||
|
method="POST",
|
||||||
|
payload={
|
||||||
|
"farm_uuid": str(farm_without_products.farm_uuid),
|
||||||
|
"plant_name": "",
|
||||||
|
"growth_stage": "گلدهی",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("pest_detection.views.external_api_request")
|
||||||
|
def test_risk_summary_caches_last_four_responses(self, mock_external_api_request):
|
||||||
|
for index in range(5):
|
||||||
|
product = Product.objects.create(farm_type=self.farm_type, name=f"Product {index}")
|
||||||
|
farm = FarmHub.objects.create(owner=self.user, farm_type=self.farm_type, name=f"Farm {index + 10}")
|
||||||
|
farm.products.add(product)
|
||||||
|
mock_external_api_request.return_value = AdapterResponse(
|
||||||
|
status_code=200,
|
||||||
|
data={
|
||||||
|
"data": {
|
||||||
|
"result": {
|
||||||
|
"disease_risk": {"title": f"Disease {index}"},
|
||||||
|
"pest_risk": {"title": f"Pest {index}"},
|
||||||
|
"drivers": {"index": index},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
request = self.factory.post(
|
||||||
|
"/api/pest-disease/risk-summary/",
|
||||||
|
{"farm_uuid": str(farm.farm_uuid)},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
force_authenticate(request, user=self.user)
|
||||||
|
response = RiskSummaryView.as_view()(request)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
cached_items = cache.get(RiskSummaryView.RISK_SUMMARY_CACHE_KEY)
|
||||||
|
|
||||||
|
self.assertEqual(len(cached_items), 4)
|
||||||
|
self.assertEqual(cached_items[0]["drivers"], {"index": 4})
|
||||||
|
self.assertEqual(cached_items[-1]["drivers"], {"index": 1})
|
||||||
|
|
||||||
|
@patch("pest_detection.views.external_api_request")
|
||||||
|
def test_risk_summary_returns_cached_response_for_same_farm(self, mock_external_api_request):
|
||||||
|
mock_external_api_request.return_value = AdapterResponse(
|
||||||
|
status_code=200,
|
||||||
|
data={
|
||||||
|
"data": {
|
||||||
|
"result": {
|
||||||
|
"disease_risk": {"title": "Disease"},
|
||||||
|
"pest_risk": {"title": "Pest"},
|
||||||
|
"drivers": {"humidity": "high"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
for _ in range(2):
|
||||||
|
request = self.factory.post(
|
||||||
|
"/api/pest-disease/risk-summary/",
|
||||||
|
{"farm_uuid": str(self.farm.farm_uuid)},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
force_authenticate(request, user=self.user)
|
||||||
|
response = RiskSummaryView.as_view()(request)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.data["data"]["drivers"], {"humidity": "high"})
|
||||||
|
|
||||||
|
cache_key = RiskSummaryView._build_risk_summary_cache_key(self.user.id, self.farm.farm_uuid)
|
||||||
|
self.assertEqual(cache.get(cache_key)["farm_uuid"], str(self.farm.farm_uuid))
|
||||||
|
mock_external_api_request.assert_called_once()
|
||||||
|
|
||||||
|
@patch("pest_detection.views.cache.set")
|
||||||
|
@patch("pest_detection.views.external_api_request")
|
||||||
|
def test_risk_summary_uses_env_ttl_for_cache(self, mock_external_api_request, mock_cache_set):
|
||||||
|
mock_external_api_request.return_value = AdapterResponse(
|
||||||
|
status_code=200,
|
||||||
|
data={
|
||||||
|
"data": {
|
||||||
|
"result": {
|
||||||
|
"disease_risk": {"title": "Disease"},
|
||||||
|
"pest_risk": {"title": "Pest"},
|
||||||
|
"drivers": {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
request = self.factory.post(
|
||||||
|
"/api/pest-disease/risk-summary/",
|
||||||
|
{"farm_uuid": str(self.farm.farm_uuid)},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
force_authenticate(request, user=self.user)
|
||||||
|
|
||||||
|
response = RiskSummaryView.as_view()(request)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertTrue(
|
||||||
|
any(call.kwargs.get("timeout") == TEST_RISK_SUMMARY_CACHE_TTL for call in mock_cache_set.call_args_list)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_risk_summary_rejects_extra_fields(self):
|
||||||
|
request = self.factory.post(
|
||||||
|
"/api/pest-disease/risk-summary/",
|
||||||
|
{
|
||||||
|
"farm_uuid": str(self.farm.farm_uuid),
|
||||||
|
"plant_name": "گندم",
|
||||||
|
"growth_stage": "رشد رویشی",
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
force_authenticate(request, user=self.user)
|
||||||
|
|
||||||
|
response = RiskSummaryView.as_view()(request)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
self.assertEqual(response.data["code"], 400)
|
||||||
|
self.assertIn("non_field_errors", response.data["data"])
|
||||||
|
|
||||||
def test_risk_summary_rejects_foreign_farm_uuid(self):
|
def test_risk_summary_rejects_foreign_farm_uuid(self):
|
||||||
request = self.factory.post(
|
request = self.factory.post(
|
||||||
"/api/pest-disease/risk-summary/",
|
"/api/pest-disease/risk-summary/",
|
||||||
|
|||||||
+53
-14
@@ -4,6 +4,8 @@ Pest detection API views.
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.cache import cache
|
||||||
from drf_spectacular.utils import extend_schema
|
from drf_spectacular.utils import extend_schema
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
@@ -18,10 +20,27 @@ from .serializers import (
|
|||||||
PestDetectionRiskRequestSerializer,
|
PestDetectionRiskRequestSerializer,
|
||||||
PestDetectionRiskResponseSerializer,
|
PestDetectionRiskResponseSerializer,
|
||||||
PestDetectionRiskSummaryResponseSerializer,
|
PestDetectionRiskSummaryResponseSerializer,
|
||||||
|
PestDetectionRiskSummaryRequestSerializer,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class PestDetectionFarmMixin:
|
class PestDetectionFarmMixin:
|
||||||
|
RISK_SUMMARY_CACHE_KEY = "pest-disease:risk-summary:recent"
|
||||||
|
RISK_SUMMARY_CACHE_LIMIT = 4
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _store_recent_risk_summary(cls, payload):
|
||||||
|
cached_items = cache.get(cls.RISK_SUMMARY_CACHE_KEY, [])
|
||||||
|
if not isinstance(cached_items, list):
|
||||||
|
cached_items = []
|
||||||
|
|
||||||
|
cached_items.insert(0, payload)
|
||||||
|
cache.set(cls.RISK_SUMMARY_CACHE_KEY, cached_items[:cls.RISK_SUMMARY_CACHE_LIMIT], timeout=None)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_risk_summary_cache_key(user_id, farm_uuid):
|
||||||
|
return f"pest-disease:risk-summary:{user_id}:{farm_uuid}"
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _get_farm(request, farm_uuid):
|
def _get_farm(request, farm_uuid):
|
||||||
if not farm_uuid:
|
if not farm_uuid:
|
||||||
@@ -62,6 +81,13 @@ class PestDetectionFarmMixin:
|
|||||||
image_urls = parsed if parsed is not None else [image_urls]
|
image_urls = parsed if parsed is not None else [image_urls]
|
||||||
return [str(item) for item in image_urls if str(item).strip()]
|
return [str(item) for item in image_urls if str(item).strip()]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_first_farm_product_name(farm):
|
||||||
|
first_product = farm.products.order_by("id").first()
|
||||||
|
if first_product is None:
|
||||||
|
return ""
|
||||||
|
return (first_product.name or "").strip()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _attach_uploaded_files(payload, uploaded_images):
|
def _attach_uploaded_files(payload, uploaded_images):
|
||||||
if not uploaded_images:
|
if not uploaded_images:
|
||||||
@@ -187,15 +213,12 @@ class RiskView(PestDetectionFarmMixin, APIView):
|
|||||||
if error_response is not None:
|
if error_response is not None:
|
||||||
return error_response
|
return error_response
|
||||||
|
|
||||||
|
plant_name = self._get_first_farm_product_name(farm)
|
||||||
ai_payload = {
|
ai_payload = {
|
||||||
"farm_uuid": str(farm.farm_uuid),
|
"farm_uuid": str(farm.farm_uuid),
|
||||||
"plant_name": payload.get("plant_name", ""),
|
"plant_name": plant_name,
|
||||||
"growth_stage": payload.get("growth_stage", ""),
|
"growth_stage": "گلدهی",
|
||||||
"query": payload.get("query", ""),
|
|
||||||
}
|
}
|
||||||
sensor_uuid = payload.get("sensor_uuid")
|
|
||||||
if sensor_uuid:
|
|
||||||
ai_payload["sensor_uuid"] = str(sensor_uuid)
|
|
||||||
|
|
||||||
adapter_response = external_api_request(
|
adapter_response = external_api_request(
|
||||||
"ai",
|
"ai",
|
||||||
@@ -216,26 +239,40 @@ class RiskView(PestDetectionFarmMixin, APIView):
|
|||||||
class RiskSummaryView(PestDetectionFarmMixin, APIView):
|
class RiskSummaryView(PestDetectionFarmMixin, APIView):
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
tags=["Pest Detection"],
|
tags=["Pest Detection"],
|
||||||
request=PestDetectionRiskRequestSerializer,
|
request=PestDetectionRiskSummaryRequestSerializer,
|
||||||
responses={200: status_response("PestDetectionRiskSummaryResponse", data=PestDetectionRiskSummaryResponseSerializer())},
|
responses={200: status_response("PestDetectionRiskSummaryResponse", data=PestDetectionRiskSummaryResponseSerializer())},
|
||||||
)
|
)
|
||||||
def post(self, request):
|
def post(self, request):
|
||||||
farm_uuid = request.data.get("farm_uuid")
|
serializer = PestDetectionRiskSummaryRequestSerializer(data=request.data)
|
||||||
sensor_uuid = request.data.get("sensor_uuid")
|
serializer.is_valid(raise_exception=True)
|
||||||
|
payload = serializer.validated_data
|
||||||
|
|
||||||
|
farm_uuid = payload.get("farm_uuid")
|
||||||
|
|
||||||
farm, error_response = self._get_farm(request, farm_uuid)
|
farm, error_response = self._get_farm(request, farm_uuid)
|
||||||
if error_response is not None:
|
if error_response is not None:
|
||||||
return error_response
|
return error_response
|
||||||
|
|
||||||
payload = {"farm_uuid": str(farm.farm_uuid)}
|
cache_key = self._build_risk_summary_cache_key(request.user.id, farm.farm_uuid)
|
||||||
if sensor_uuid:
|
cached_response = cache.get(cache_key)
|
||||||
payload["sensor_uuid"] = str(sensor_uuid)
|
if isinstance(cached_response, dict):
|
||||||
|
return Response(
|
||||||
|
{"code": 200, "msg": "success", "data": cached_response},
|
||||||
|
status=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
|
||||||
|
plant_name = self._get_first_farm_product_name(farm)
|
||||||
|
ai_payload = {
|
||||||
|
"farm_uuid": str(farm.farm_uuid),
|
||||||
|
"plant_name": plant_name,
|
||||||
|
"growth_stage": "گلدهی",
|
||||||
|
}
|
||||||
|
|
||||||
adapter_response = external_api_request(
|
adapter_response = external_api_request(
|
||||||
"ai",
|
"ai",
|
||||||
"/api/pest-disease/risk-summary/",
|
"/api/pest-disease/risk/",
|
||||||
method="POST",
|
method="POST",
|
||||||
payload=payload,
|
payload=ai_payload,
|
||||||
)
|
)
|
||||||
|
|
||||||
if adapter_response.status_code >= 400:
|
if adapter_response.status_code >= 400:
|
||||||
@@ -248,6 +285,8 @@ class RiskSummaryView(PestDetectionFarmMixin, APIView):
|
|||||||
"pestRisk": result.get("pestRisk") or result.get("pest_risk") or {},
|
"pestRisk": result.get("pestRisk") or result.get("pest_risk") or {},
|
||||||
"drivers": result.get("drivers") if isinstance(result.get("drivers"), dict) else {},
|
"drivers": result.get("drivers") if isinstance(result.get("drivers"), dict) else {},
|
||||||
}
|
}
|
||||||
|
cache.set(cache_key, response_payload, timeout=settings.PEST_DISEASE_RISK_SUMMARY_CACHE_TTL)
|
||||||
|
self._store_recent_risk_summary(response_payload)
|
||||||
return Response(
|
return Response(
|
||||||
{"code": 200, "msg": "success", "data": response_payload},
|
{"code": 200, "msg": "success", "data": response_payload},
|
||||||
status=status.HTTP_200_OK,
|
status=status.HTTP_200_OK,
|
||||||
|
|||||||
+158
-1
@@ -1,6 +1,7 @@
|
|||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from django.test import TestCase
|
from django.core.cache import cache
|
||||||
|
from django.test import TestCase, override_settings
|
||||||
from rest_framework.test import APIRequestFactory
|
from rest_framework.test import APIRequestFactory
|
||||||
|
|
||||||
from external_api_adapter.adapter import AdapterResponse
|
from external_api_adapter.adapter import AdapterResponse
|
||||||
@@ -10,8 +11,24 @@ from account.models import User
|
|||||||
from .views import SoilAnomalyDetectionView, SoilMoistureHeatmapView, SoilSummaryView
|
from .views import SoilAnomalyDetectionView, SoilMoistureHeatmapView, SoilSummaryView
|
||||||
|
|
||||||
|
|
||||||
|
TEST_CACHES = {
|
||||||
|
"default": {
|
||||||
|
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
|
||||||
|
"LOCATION": "soil-tests",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_SOIL_SUMMARY_CACHE_TTL = 14400
|
||||||
|
TEST_SOIL_ANOMALIES_CACHE_TTL = 14400
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
CACHES=TEST_CACHES,
|
||||||
|
SOIL_ANOMALIES_CACHE_TTL=TEST_SOIL_ANOMALIES_CACHE_TTL,
|
||||||
|
)
|
||||||
class SoilAnomalyDetectionViewTests(TestCase):
|
class SoilAnomalyDetectionViewTests(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
cache.clear()
|
||||||
self.factory = APIRequestFactory()
|
self.factory = APIRequestFactory()
|
||||||
self.user = User.objects.create_user(
|
self.user = User.objects.create_user(
|
||||||
username="soil-user",
|
username="soil-user",
|
||||||
@@ -61,6 +78,75 @@ class SoilAnomalyDetectionViewTests(TestCase):
|
|||||||
payload={"farm_uuid": str(self.farm.farm_uuid)},
|
payload={"farm_uuid": str(self.farm.farm_uuid)},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@patch("soil.views.external_api_request")
|
||||||
|
def test_anomalies_cache_last_four_responses(self, mock_external_api_request):
|
||||||
|
for index in range(5):
|
||||||
|
farm = FarmHub.objects.create(owner=self.user, farm_type=self.farm_type, name=f"Soil Farm Cache {index}")
|
||||||
|
mock_external_api_request.return_value = AdapterResponse(
|
||||||
|
status_code=200,
|
||||||
|
data={
|
||||||
|
"data": {
|
||||||
|
"farm_uuid": str(farm.farm_uuid),
|
||||||
|
"summary": f"summary {index}",
|
||||||
|
"anomalies": [],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
request = self.factory.get(f"/api/soil/anomalies/?farm_uuid={farm.farm_uuid}")
|
||||||
|
response = SoilAnomalyDetectionView.as_view()(request)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
cached_items = cache.get("soil:anomalies:recent")
|
||||||
|
|
||||||
|
self.assertEqual(len(cached_items), 4)
|
||||||
|
self.assertEqual(cached_items[0]["summary"], "summary 4")
|
||||||
|
self.assertEqual(cached_items[-1]["summary"], "summary 1")
|
||||||
|
|
||||||
|
@patch("soil.views.external_api_request")
|
||||||
|
def test_anomalies_return_cached_response_for_same_farm(self, mock_external_api_request):
|
||||||
|
mock_external_api_request.return_value = AdapterResponse(
|
||||||
|
status_code=200,
|
||||||
|
data={
|
||||||
|
"data": {
|
||||||
|
"farm_uuid": str(self.farm.farm_uuid),
|
||||||
|
"summary": "cached summary",
|
||||||
|
"anomalies": [],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
for _ in range(2):
|
||||||
|
request = self.factory.get(f"/api/soil/anomalies/?farm_uuid={self.farm.farm_uuid}")
|
||||||
|
response = SoilAnomalyDetectionView.as_view()(request)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.data["data"]["summary"], "cached summary")
|
||||||
|
|
||||||
|
self.assertEqual(cache.get(f"soil:anomalies:{self.farm.farm_uuid}")["summary"], "cached summary")
|
||||||
|
mock_external_api_request.assert_called_once()
|
||||||
|
|
||||||
|
@patch("soil.views.cache.set")
|
||||||
|
@patch("soil.views.external_api_request")
|
||||||
|
def test_anomalies_use_env_ttl_for_cache(self, mock_external_api_request, mock_cache_set):
|
||||||
|
mock_external_api_request.return_value = AdapterResponse(
|
||||||
|
status_code=200,
|
||||||
|
data={
|
||||||
|
"data": {
|
||||||
|
"farm_uuid": str(self.farm.farm_uuid),
|
||||||
|
"summary": "summary",
|
||||||
|
"anomalies": [],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
request = self.factory.get(f"/api/soil/anomalies/?farm_uuid={self.farm.farm_uuid}")
|
||||||
|
response = SoilAnomalyDetectionView.as_view()(request)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertTrue(any(call.kwargs.get("timeout") == TEST_SOIL_ANOMALIES_CACHE_TTL for call in mock_cache_set.call_args_list))
|
||||||
|
|
||||||
def test_anomalies_require_farm_uuid(self):
|
def test_anomalies_require_farm_uuid(self):
|
||||||
request = self.factory.get("/api/soil/anomalies/")
|
request = self.factory.get("/api/soil/anomalies/")
|
||||||
response = SoilAnomalyDetectionView.as_view()(request)
|
response = SoilAnomalyDetectionView.as_view()(request)
|
||||||
@@ -146,8 +232,13 @@ class SoilMoistureHeatmapViewTests(TestCase):
|
|||||||
self.assertEqual(response.data["data"]["farm_uuid"][0], "Farm not found.")
|
self.assertEqual(response.data["data"]["farm_uuid"][0], "Farm not found.")
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
CACHES=TEST_CACHES,
|
||||||
|
SOIL_SUMMARY_CACHE_TTL=TEST_SOIL_SUMMARY_CACHE_TTL,
|
||||||
|
)
|
||||||
class SoilSummaryViewTests(TestCase):
|
class SoilSummaryViewTests(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
cache.clear()
|
||||||
self.factory = APIRequestFactory()
|
self.factory = APIRequestFactory()
|
||||||
self.user = User.objects.create_user(
|
self.user = User.objects.create_user(
|
||||||
username="soil-summary-user",
|
username="soil-summary-user",
|
||||||
@@ -193,6 +284,72 @@ class SoilSummaryViewTests(TestCase):
|
|||||||
payload={"farm_uuid": str(self.farm.farm_uuid)},
|
payload={"farm_uuid": str(self.farm.farm_uuid)},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@patch("soil.views.external_api_request")
|
||||||
|
def test_summary_returns_cached_response_for_same_farm(self, mock_external_api_request):
|
||||||
|
mock_external_api_request.return_value = AdapterResponse(
|
||||||
|
status_code=200,
|
||||||
|
data={
|
||||||
|
"data": {
|
||||||
|
"farm_uuid": str(self.farm.farm_uuid),
|
||||||
|
"healthScore": 82,
|
||||||
|
"profileSource": "Tomato",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
for _ in range(2):
|
||||||
|
request = self.factory.get(f"/api/soil/summary/?farm_uuid={self.farm.farm_uuid}")
|
||||||
|
response = SoilSummaryView.as_view()(request)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.data["data"]["healthScore"], 82)
|
||||||
|
|
||||||
|
self.assertEqual(cache.get(f"soil:summary:{self.farm.farm_uuid}")["healthScore"], 82)
|
||||||
|
mock_external_api_request.assert_called_once()
|
||||||
|
|
||||||
|
@patch("soil.views.cache.set")
|
||||||
|
@patch("soil.views.external_api_request")
|
||||||
|
def test_summary_uses_env_ttl_for_cache(self, mock_external_api_request, mock_cache_set):
|
||||||
|
mock_external_api_request.return_value = AdapterResponse(
|
||||||
|
status_code=200,
|
||||||
|
data={
|
||||||
|
"data": {
|
||||||
|
"farm_uuid": str(self.farm.farm_uuid),
|
||||||
|
"healthScore": 82,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
request = self.factory.get(f"/api/soil/summary/?farm_uuid={self.farm.farm_uuid}")
|
||||||
|
response = SoilSummaryView.as_view()(request)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertTrue(any(call.kwargs.get("timeout") == TEST_SOIL_SUMMARY_CACHE_TTL for call in mock_cache_set.call_args_list))
|
||||||
|
|
||||||
|
@patch("soil.views.external_api_request")
|
||||||
|
def test_summary_caches_last_four_responses(self, mock_external_api_request):
|
||||||
|
for index in range(5):
|
||||||
|
farm = FarmHub.objects.create(owner=self.user, farm_type=self.farm_type, name=f"Soil Summary Cache {index}")
|
||||||
|
mock_external_api_request.return_value = AdapterResponse(
|
||||||
|
status_code=200,
|
||||||
|
data={
|
||||||
|
"data": {
|
||||||
|
"farm_uuid": str(farm.farm_uuid),
|
||||||
|
"healthScore": 80 + index,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
request = self.factory.get(f"/api/soil/summary/?farm_uuid={farm.farm_uuid}")
|
||||||
|
response = SoilSummaryView.as_view()(request)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
cached_items = cache.get("soil:summary:recent")
|
||||||
|
self.assertEqual(len(cached_items), 4)
|
||||||
|
self.assertEqual(cached_items[0]["healthScore"], 84)
|
||||||
|
self.assertEqual(cached_items[-1]["healthScore"], 81)
|
||||||
|
|
||||||
def test_summary_requires_farm_uuid(self):
|
def test_summary_requires_farm_uuid(self):
|
||||||
request = self.factory.get("/api/soil/summary/")
|
request = self.factory.get("/api/soil/summary/")
|
||||||
response = SoilSummaryView.as_view()(request)
|
response = SoilSummaryView.as_view()(request)
|
||||||
|
|||||||
+59
-2
@@ -1,3 +1,5 @@
|
|||||||
|
from django.conf import settings
|
||||||
|
from django.core.cache import cache
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
@@ -20,6 +22,39 @@ from .services import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
SOIL_ANOMALIES_CACHE_KEY = "soil:anomalies:recent"
|
||||||
|
SOIL_ANOMALIES_CACHE_LIMIT = 4
|
||||||
|
SOIL_SUMMARY_CACHE_KEY = "soil:summary:recent"
|
||||||
|
SOIL_SUMMARY_CACHE_LIMIT = 4
|
||||||
|
|
||||||
|
|
||||||
|
def _store_recent_soil_anomalies(payload):
|
||||||
|
cached_items = cache.get(SOIL_ANOMALIES_CACHE_KEY, [])
|
||||||
|
if not isinstance(cached_items, list):
|
||||||
|
cached_items = []
|
||||||
|
|
||||||
|
cached_items.insert(0, payload)
|
||||||
|
cache.set(SOIL_ANOMALIES_CACHE_KEY, cached_items[:SOIL_ANOMALIES_CACHE_LIMIT], timeout=None)
|
||||||
|
|
||||||
|
|
||||||
|
def _store_recent_soil_summary(payload):
|
||||||
|
cached_items = cache.get(SOIL_SUMMARY_CACHE_KEY, [])
|
||||||
|
if not isinstance(cached_items, list):
|
||||||
|
cached_items = []
|
||||||
|
|
||||||
|
cached_items.insert(0, payload)
|
||||||
|
cache.set(SOIL_SUMMARY_CACHE_KEY, cached_items[:SOIL_SUMMARY_CACHE_LIMIT], timeout=None)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_soil_summary_cache_key(farm_uuid):
|
||||||
|
return f"soil:summary:{farm_uuid}"
|
||||||
|
|
||||||
|
|
||||||
|
def _build_soil_anomalies_cache_key(farm_uuid):
|
||||||
|
return f"soil:anomalies:{farm_uuid}"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _get_farm_from_request(request):
|
def _get_farm_from_request(request):
|
||||||
farm_uuid = request.query_params.get("farm_uuid")
|
farm_uuid = request.query_params.get("farm_uuid")
|
||||||
if not farm_uuid:
|
if not farm_uuid:
|
||||||
@@ -85,6 +120,14 @@ class SoilAnomalyDetectionView(APIView):
|
|||||||
status=status.HTTP_404_NOT_FOUND,
|
status=status.HTTP_404_NOT_FOUND,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
cache_key = _build_soil_anomalies_cache_key(farm.farm_uuid)
|
||||||
|
cached_anomalies = cache.get(cache_key)
|
||||||
|
if isinstance(cached_anomalies, dict):
|
||||||
|
return Response(
|
||||||
|
{"code": 200, "msg": "success", "data": cached_anomalies},
|
||||||
|
status=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
|
||||||
adapter_response = external_api_request(
|
adapter_response = external_api_request(
|
||||||
"ai",
|
"ai",
|
||||||
"/api/soile/anomaly-detection/",
|
"/api/soile/anomaly-detection/",
|
||||||
@@ -102,8 +145,11 @@ class SoilAnomalyDetectionView(APIView):
|
|||||||
status=adapter_response.status_code,
|
status=adapter_response.status_code,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
response_payload = _extract_adapter_result(adapter_response.data)
|
||||||
|
cache.set(cache_key, response_payload, timeout=settings.SOIL_ANOMALIES_CACHE_TTL)
|
||||||
|
_store_recent_soil_anomalies(response_payload)
|
||||||
return Response(
|
return Response(
|
||||||
{"code": 200, "msg": "success", "data": _extract_adapter_result(adapter_response.data)},
|
{"code": 200, "msg": "success", "data": response_payload},
|
||||||
status=status.HTTP_200_OK,
|
status=status.HTTP_200_OK,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -177,6 +223,14 @@ class SoilSummaryView(APIView):
|
|||||||
status=status.HTTP_404_NOT_FOUND,
|
status=status.HTTP_404_NOT_FOUND,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
cache_key = _build_soil_summary_cache_key(farm.farm_uuid)
|
||||||
|
cached_summary = cache.get(cache_key)
|
||||||
|
if isinstance(cached_summary, dict):
|
||||||
|
return Response(
|
||||||
|
{"code": 200, "msg": "success", "data": cached_summary},
|
||||||
|
status=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
|
||||||
adapter_response = external_api_request(
|
adapter_response = external_api_request(
|
||||||
"ai",
|
"ai",
|
||||||
"/api/soile/health-summary/",
|
"/api/soile/health-summary/",
|
||||||
@@ -194,7 +248,10 @@ class SoilSummaryView(APIView):
|
|||||||
status=adapter_response.status_code,
|
status=adapter_response.status_code,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
response_payload = _extract_adapter_result(adapter_response.data)
|
||||||
|
cache.set(cache_key, response_payload, timeout=settings.SOIL_SUMMARY_CACHE_TTL)
|
||||||
|
_store_recent_soil_summary(response_payload)
|
||||||
return Response(
|
return Response(
|
||||||
{"code": 200, "msg": "success", "data": _extract_adapter_result(adapter_response.data)},
|
{"code": 200, "msg": "success", "data": response_payload},
|
||||||
status=status.HTTP_200_OK,
|
status=status.HTTP_200_OK,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
|
||||||
|
class WeatherFarmCardRequestSerializer(serializers.Serializer):
|
||||||
|
farm_uuid = serializers.UUIDField(
|
||||||
|
required=True,
|
||||||
|
initial="11111111-1111-1111-1111-111111111111",
|
||||||
|
help_text="UUID مزرعه.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class WeatherChartDataSerializer(serializers.Serializer):
|
class WeatherChartDataSerializer(serializers.Serializer):
|
||||||
labels = serializers.ListField(child=serializers.CharField(), required=False)
|
labels = serializers.ListField(child=serializers.CharField(), required=False)
|
||||||
series = serializers.ListField(
|
series = serializers.ListField(
|
||||||
|
|||||||
+94
-2
@@ -1,18 +1,34 @@
|
|||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from django.core.cache import cache
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.test import TestCase
|
from django.test import TestCase, override_settings
|
||||||
from django.urls import Resolver404, resolve
|
from django.urls import Resolver404, resolve
|
||||||
from rest_framework.test import APIRequestFactory, force_authenticate
|
from rest_framework.test import APIRequestFactory, force_authenticate
|
||||||
|
|
||||||
from external_api_adapter.adapter import AdapterResponse
|
from external_api_adapter.adapter import AdapterResponse
|
||||||
from farm_hub.models import FarmHub, FarmType
|
from farm_hub.models import FarmHub, FarmType
|
||||||
|
|
||||||
from .views import WaterNeedPredictionView, WeatherFarmCardView
|
from .views import WaterNeedPredictionView, WaterSummaryView, WeatherFarmCardView
|
||||||
|
|
||||||
|
|
||||||
|
TEST_CACHES = {
|
||||||
|
"default": {
|
||||||
|
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
|
||||||
|
"LOCATION": "water-tests",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_WATER_NEED_PREDICTION_CACHE_TTL = 14400
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
CACHES=TEST_CACHES,
|
||||||
|
WATER_NEED_PREDICTION_CACHE_TTL=TEST_WATER_NEED_PREDICTION_CACHE_TTL,
|
||||||
|
)
|
||||||
class WeatherViewTests(TestCase):
|
class WeatherViewTests(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
cache.clear()
|
||||||
self.factory = APIRequestFactory()
|
self.factory = APIRequestFactory()
|
||||||
self.user = get_user_model().objects.create_user(
|
self.user = get_user_model().objects.create_user(
|
||||||
username="farmer",
|
username="farmer",
|
||||||
@@ -73,6 +89,82 @@ class WeatherViewTests(TestCase):
|
|||||||
payload={"farm_uuid": str(self.farm.farm_uuid)},
|
payload={"farm_uuid": str(self.farm.farm_uuid)},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@patch("water.views.external_api_request")
|
||||||
|
def test_water_need_prediction_caches_last_four_ai_responses(self, mock_external_api_request):
|
||||||
|
farms = []
|
||||||
|
for index in range(5):
|
||||||
|
farm = FarmHub.objects.create(owner=self.user, farm_type=self.farm_type, name=f"Farm Cache {index}")
|
||||||
|
farms.append(farm)
|
||||||
|
|
||||||
|
mock_external_api_request.return_value = AdapterResponse(
|
||||||
|
status_code=200,
|
||||||
|
data={"data": {"result": {"totalNext7Days": float(index), "unit": "mm"}}},
|
||||||
|
)
|
||||||
|
|
||||||
|
request = self.factory.get(f"/api/water/need-prediction/?farm_uuid={farm.farm_uuid}")
|
||||||
|
response = WaterNeedPredictionView.as_view()(request)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
cached_items = cache.get(WeatherFarmCardView.WATER_NEED_PREDICTION_CACHE_KEY)
|
||||||
|
|
||||||
|
self.assertEqual(len(cached_items), 4)
|
||||||
|
self.assertEqual(cached_items[0]["totalNext7Days"], 4.0)
|
||||||
|
self.assertEqual(cached_items[-1]["totalNext7Days"], 1.0)
|
||||||
|
|
||||||
|
@patch("water.views.external_api_request")
|
||||||
|
def test_water_need_prediction_returns_cached_response_for_same_farm(self, mock_external_api_request):
|
||||||
|
mock_external_api_request.return_value = AdapterResponse(
|
||||||
|
status_code=200,
|
||||||
|
data={"data": {"result": {"totalNext7Days": 24.6, "unit": "mm"}}},
|
||||||
|
)
|
||||||
|
|
||||||
|
for _ in range(2):
|
||||||
|
request = self.factory.get(f"/api/water/need-prediction/?farm_uuid={self.farm.farm_uuid}")
|
||||||
|
force_authenticate(request, user=self.user)
|
||||||
|
response = WaterNeedPredictionView.as_view()(request)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.data["data"]["farm_uuid"], str(self.farm.farm_uuid))
|
||||||
|
|
||||||
|
cache_key = WeatherFarmCardView._build_water_need_prediction_cache_key(self.user.id, self.farm.farm_uuid)
|
||||||
|
self.assertEqual(cache.get(cache_key)["totalNext7Days"], 24.6)
|
||||||
|
mock_external_api_request.assert_called_once()
|
||||||
|
|
||||||
|
@patch("water.views.cache.set")
|
||||||
|
@patch("water.views.external_api_request")
|
||||||
|
def test_water_need_prediction_uses_env_ttl_for_cache(self, mock_external_api_request, mock_cache_set):
|
||||||
|
mock_external_api_request.return_value = AdapterResponse(
|
||||||
|
status_code=200,
|
||||||
|
data={"data": {"result": {"totalNext7Days": 24.6, "unit": "mm"}}},
|
||||||
|
)
|
||||||
|
|
||||||
|
request = self.factory.get(f"/api/water/need-prediction/?farm_uuid={self.farm.farm_uuid}")
|
||||||
|
force_authenticate(request, user=self.user)
|
||||||
|
response = WaterNeedPredictionView.as_view()(request)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertTrue(
|
||||||
|
any(call.kwargs.get("timeout") == TEST_WATER_NEED_PREDICTION_CACHE_TTL for call in mock_cache_set.call_args_list)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_water_summary_caches_last_four_responses(self):
|
||||||
|
for index in range(5):
|
||||||
|
farm = FarmHub.objects.create(owner=self.user, farm_type=self.farm_type, name=f"Summary Farm {index}")
|
||||||
|
request = self.factory.get(f"/api/water/summary/?farm_uuid={farm.farm_uuid}")
|
||||||
|
response = WaterSummaryView.as_view()(request)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
cached_items = cache.get(WeatherFarmCardView.WATER_SUMMARY_CACHE_KEY)
|
||||||
|
cached_items[0]["farmWeatherCard"]["condition"] = f"Condition {index}"
|
||||||
|
cache.set(WeatherFarmCardView.WATER_SUMMARY_CACHE_KEY, cached_items, timeout=None)
|
||||||
|
|
||||||
|
cached_items = cache.get(WeatherFarmCardView.WATER_SUMMARY_CACHE_KEY)
|
||||||
|
|
||||||
|
self.assertEqual(len(cached_items), 4)
|
||||||
|
self.assertEqual(cached_items[0]["farmWeatherCard"]["condition"], "Condition 4")
|
||||||
|
self.assertEqual(cached_items[-1]["farmWeatherCard"]["condition"], "Condition 1")
|
||||||
|
|
||||||
def test_weather_view_rejects_foreign_farm_uuid(self):
|
def test_weather_view_rejects_foreign_farm_uuid(self):
|
||||||
request = self.factory.post("/api/weather/farm-card/", {"farm_uuid": str(self.other_farm.farm_uuid)}, format="json")
|
request = self.factory.post("/api/weather/farm-card/", {"farm_uuid": str(self.other_farm.farm_uuid)}, format="json")
|
||||||
force_authenticate(request, user=self.user)
|
force_authenticate(request, user=self.user)
|
||||||
|
|||||||
+52
-3
@@ -2,6 +2,8 @@
|
|||||||
WATER API views.
|
WATER API views.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.cache import cache
|
||||||
from rest_framework import serializers, status
|
from rest_framework import serializers, status
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
@@ -12,7 +14,13 @@ from config.swagger import farm_uuid_query_param, sensor_uuid_query_param, statu
|
|||||||
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 farm_hub.models import FarmHub
|
||||||
from .models import WeatherForecastLog
|
from .models import WeatherForecastLog
|
||||||
from .serializers import FarmWeatherCardSerializer, WaterNeedPredictionSerializer, WaterStressIndexSerializer, WaterSummarySerializer
|
from .serializers import (
|
||||||
|
FarmWeatherCardSerializer,
|
||||||
|
WaterNeedPredictionSerializer,
|
||||||
|
WaterStressIndexSerializer,
|
||||||
|
WaterSummarySerializer,
|
||||||
|
WeatherFarmCardRequestSerializer,
|
||||||
|
)
|
||||||
from .services import get_water_need_prediction_data, get_water_stress_index_data, get_water_summary_data
|
from .services import get_water_need_prediction_data, get_water_stress_index_data, get_water_summary_data
|
||||||
|
|
||||||
|
|
||||||
@@ -85,6 +93,32 @@ class FarmWeatherCardView(APIView):
|
|||||||
|
|
||||||
|
|
||||||
class WeatherFarmBaseView(APIView):
|
class WeatherFarmBaseView(APIView):
|
||||||
|
WATER_NEED_PREDICTION_CACHE_KEY = "water:need-prediction:recent"
|
||||||
|
WATER_NEED_PREDICTION_CACHE_LIMIT = 4
|
||||||
|
WATER_SUMMARY_CACHE_KEY = "water:summary:recent"
|
||||||
|
WATER_SUMMARY_CACHE_LIMIT = 4
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _store_recent_entries(cls, cache_key, cache_limit, payload):
|
||||||
|
cached_items = cache.get(cache_key, [])
|
||||||
|
if not isinstance(cached_items, list):
|
||||||
|
cached_items = []
|
||||||
|
|
||||||
|
cached_items.insert(0, payload)
|
||||||
|
cache.set(cache_key, cached_items[:cache_limit], timeout=None)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _store_recent_water_need_prediction(cls, payload):
|
||||||
|
cls._store_recent_entries(cls.WATER_NEED_PREDICTION_CACHE_KEY, cls.WATER_NEED_PREDICTION_CACHE_LIMIT, payload)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _store_recent_water_summary(cls, payload):
|
||||||
|
cls._store_recent_entries(cls.WATER_SUMMARY_CACHE_KEY, cls.WATER_SUMMARY_CACHE_LIMIT, payload)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_water_need_prediction_cache_key(user_id, farm_uuid):
|
||||||
|
return f"water:need-prediction:{user_id}:{farm_uuid}"
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _get_farm(request, farm_uuid):
|
def _get_farm(request, farm_uuid):
|
||||||
if not farm_uuid:
|
if not farm_uuid:
|
||||||
@@ -149,7 +183,7 @@ class WeatherFarmBaseView(APIView):
|
|||||||
class WeatherFarmCardView(WeatherFarmBaseView):
|
class WeatherFarmCardView(WeatherFarmBaseView):
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
tags=["WEATHER"],
|
tags=["WEATHER"],
|
||||||
request=serializers.Serializer,
|
request=WeatherFarmCardRequestSerializer,
|
||||||
responses={200: status_response("WeatherFarmCardResponse", data=FarmWeatherCardSerializer())},
|
responses={200: status_response("WeatherFarmCardResponse", data=FarmWeatherCardSerializer())},
|
||||||
)
|
)
|
||||||
def post(self, request):
|
def post(self, request):
|
||||||
@@ -187,9 +221,22 @@ class WaterNeedPredictionView(APIView):
|
|||||||
except (FarmHub.DoesNotExist, Exception):
|
except (FarmHub.DoesNotExist, Exception):
|
||||||
farm = None
|
farm = None
|
||||||
else:
|
else:
|
||||||
|
cache_key = WeatherFarmBaseView._build_water_need_prediction_cache_key(
|
||||||
|
getattr(request.user, "id", "anonymous"),
|
||||||
|
farm.farm_uuid,
|
||||||
|
)
|
||||||
|
cached_prediction = cache.get(cache_key)
|
||||||
|
if isinstance(cached_prediction, dict):
|
||||||
|
return Response(
|
||||||
|
{"status": "success", "data": cached_prediction},
|
||||||
|
status=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
|
||||||
prediction_data, error_response = WeatherFarmBaseView._fetch_water_need_prediction_data(farm.farm_uuid)
|
prediction_data, error_response = WeatherFarmBaseView._fetch_water_need_prediction_data(farm.farm_uuid)
|
||||||
if error_response is not None:
|
if error_response is not None:
|
||||||
return error_response
|
return error_response
|
||||||
|
cache.set(cache_key, prediction_data, timeout=settings.WATER_NEED_PREDICTION_CACHE_TTL)
|
||||||
|
WeatherFarmBaseView._store_recent_water_need_prediction(prediction_data)
|
||||||
return Response(
|
return Response(
|
||||||
{"status": "success", "data": prediction_data},
|
{"status": "success", "data": prediction_data},
|
||||||
status=status.HTTP_200_OK,
|
status=status.HTTP_200_OK,
|
||||||
@@ -292,7 +339,9 @@ class WaterSummaryView(APIView):
|
|||||||
except (FarmHub.DoesNotExist, Exception):
|
except (FarmHub.DoesNotExist, Exception):
|
||||||
farm = None
|
farm = None
|
||||||
|
|
||||||
|
summary_data = get_water_summary_data(farm)
|
||||||
|
WeatherFarmBaseView._store_recent_water_summary(summary_data)
|
||||||
return Response(
|
return Response(
|
||||||
{"status": "success", "data": get_water_summary_data(farm)},
|
{"status": "success", "data": summary_data},
|
||||||
status=status.HTTP_200_OK,
|
status=status.HTTP_200_OK,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,4 +4,4 @@ from django.apps import AppConfig
|
|||||||
class YieldHarvestConfig(AppConfig):
|
class YieldHarvestConfig(AppConfig):
|
||||||
default_auto_field = "django.db.models.BigAutoField"
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
name = "yield_harvest"
|
name = "yield_harvest"
|
||||||
verbose_name = "Yield & Harvest Prediction"
|
verbose_name = "Yield, Harvest & Crop Simulation"
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from .views import (
|
||||||
|
CurrentFarmChartView,
|
||||||
|
GrowthSimulationStatusView,
|
||||||
|
GrowthSimulationView,
|
||||||
|
HarvestPredictionView,
|
||||||
|
YieldHarvestSummaryView,
|
||||||
|
YieldPredictionView,
|
||||||
|
)
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("current-farm-chart/", CurrentFarmChartView.as_view(), name="crop-simulation-current-farm-chart"),
|
||||||
|
path("growth/", GrowthSimulationView.as_view(), name="crop-simulation-growth"),
|
||||||
|
path("growth/<str:task_id>/status/", GrowthSimulationStatusView.as_view(), name="crop-simulation-growth-status"),
|
||||||
|
path("harvest-prediction/", HarvestPredictionView.as_view(), name="crop-simulation-harvest-prediction"),
|
||||||
|
path("yield-harvest-summary/", YieldHarvestSummaryView.as_view(), name="crop-simulation-yield-harvest-summary"),
|
||||||
|
path("yield-prediction/", YieldPredictionView.as_view(), name="crop-simulation-yield-prediction"),
|
||||||
|
]
|
||||||
@@ -48,9 +48,14 @@ class HarvestPredictionCardSerializer(serializers.Serializer):
|
|||||||
|
|
||||||
|
|
||||||
class YieldHarvestSummarySerializer(serializers.Serializer):
|
class YieldHarvestSummarySerializer(serializers.Serializer):
|
||||||
yield_prediction_card = YieldPredictionCardSerializer(required=False)
|
farm_uuid = serializers.CharField(required=False, allow_blank=True)
|
||||||
yield_prediction_chart = YieldPredictionChartSerializer(required=False)
|
season_highlights_card = serializers.DictField(required=False)
|
||||||
harvest_prediction_card = HarvestPredictionCardSerializer(required=False)
|
yield_prediction = serializers.DictField(required=False)
|
||||||
|
harvest_prediction_card = serializers.DictField(required=False)
|
||||||
|
harvest_readiness_zones = serializers.DictField(required=False)
|
||||||
|
yield_quality_bands = serializers.DictField(required=False)
|
||||||
|
harvest_operations_card = serializers.DictField(required=False)
|
||||||
|
yield_prediction_chart = serializers.DictField(required=False)
|
||||||
|
|
||||||
|
|
||||||
class CropSimulationRequestSerializer(serializers.Serializer):
|
class CropSimulationRequestSerializer(serializers.Serializer):
|
||||||
@@ -133,11 +138,11 @@ class CurrentFarmChartSerializer(serializers.Serializer):
|
|||||||
scenario_id = serializers.IntegerField(required=False)
|
scenario_id = serializers.IntegerField(required=False)
|
||||||
simulation_warning = serializers.CharField(required=False, allow_blank=True)
|
simulation_warning = serializers.CharField(required=False, allow_blank=True)
|
||||||
categories = serializers.ListField(child=serializers.CharField(), required=False)
|
categories = serializers.ListField(child=serializers.CharField(), required=False)
|
||||||
series = serializers.DictField(required=False)
|
series = serializers.ListField(child=serializers.DictField(), required=False)
|
||||||
summary = serializers.DictField(required=False)
|
summary = serializers.ListField(child=serializers.DictField(), required=False)
|
||||||
current_state = serializers.DictField(required=False)
|
current_state = serializers.DictField(required=False)
|
||||||
metrics = serializers.DictField(required=False)
|
metrics = serializers.DictField(required=False)
|
||||||
daily_output = serializers.DictField(required=False)
|
daily_output = serializers.ListField(child=serializers.DictField(), required=False)
|
||||||
|
|
||||||
|
|
||||||
class HarvestPredictionSerializer(serializers.Serializer):
|
class HarvestPredictionSerializer(serializers.Serializer):
|
||||||
|
|||||||
+97
-8
@@ -1,9 +1,8 @@
|
|||||||
import json
|
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from rest_framework.test import APIRequestFactory, force_authenticate
|
from rest_framework.test import APIClient, APIRequestFactory, force_authenticate
|
||||||
|
|
||||||
from external_api_adapter.adapter import AdapterResponse
|
from external_api_adapter.adapter import AdapterResponse
|
||||||
from farm_hub.models import FarmHub, FarmType
|
from farm_hub.models import FarmHub, FarmType
|
||||||
@@ -13,12 +12,14 @@ from .views import (
|
|||||||
GrowthSimulationStatusView,
|
GrowthSimulationStatusView,
|
||||||
GrowthSimulationView,
|
GrowthSimulationView,
|
||||||
HarvestPredictionView,
|
HarvestPredictionView,
|
||||||
|
YieldHarvestSummaryView,
|
||||||
YieldPredictionView,
|
YieldPredictionView,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class CropSimulationViewTests(TestCase):
|
class CropSimulationViewTests(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
self.api_client = APIClient()
|
||||||
self.factory = APIRequestFactory()
|
self.factory = APIRequestFactory()
|
||||||
self.user = get_user_model().objects.create_user(
|
self.user = get_user_model().objects.create_user(
|
||||||
username="farmer",
|
username="farmer",
|
||||||
@@ -35,6 +36,7 @@ class CropSimulationViewTests(TestCase):
|
|||||||
self.farm_type = FarmType.objects.create(name="زراعی")
|
self.farm_type = FarmType.objects.create(name="زراعی")
|
||||||
self.farm = FarmHub.objects.create(owner=self.user, farm_type=self.farm_type, name="Farm 1")
|
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")
|
self.other_farm = FarmHub.objects.create(owner=self.other_user, farm_type=self.farm_type, name="Farm 2")
|
||||||
|
self.api_client.force_authenticate(user=self.user)
|
||||||
|
|
||||||
@patch("yield_harvest.views.external_api_request")
|
@patch("yield_harvest.views.external_api_request")
|
||||||
def test_growth_queues_simulation_task(self, mock_external_api_request):
|
def test_growth_queues_simulation_task(self, mock_external_api_request):
|
||||||
@@ -54,6 +56,7 @@ class CropSimulationViewTests(TestCase):
|
|||||||
{"plant_name": "گوجهفرنگی", "dynamic_parameters": ["DVS", "LAI"], "farm_uuid": str(self.farm.farm_uuid)},
|
{"plant_name": "گوجهفرنگی", "dynamic_parameters": ["DVS", "LAI"], "farm_uuid": str(self.farm.farm_uuid)},
|
||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
|
force_authenticate(request, user=self.user)
|
||||||
|
|
||||||
response = GrowthSimulationView.as_view()(request)
|
response = GrowthSimulationView.as_view()(request)
|
||||||
|
|
||||||
@@ -84,16 +87,14 @@ class CropSimulationViewTests(TestCase):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
response = self.client.post(
|
response = self.api_client.post(
|
||||||
"/api/crop-simulation/growth/",
|
"/api/crop-simulation/growth/",
|
||||||
data=json.dumps(
|
|
||||||
{
|
{
|
||||||
"plant_name": "گوجهفرنگی",
|
"plant_name": "گوجهفرنگی",
|
||||||
"dynamic_parameters": ["DVS", "LAI"],
|
"dynamic_parameters": ["DVS", "LAI"],
|
||||||
"farm_uuid": str(self.farm.farm_uuid),
|
"farm_uuid": str(self.farm.farm_uuid),
|
||||||
}
|
},
|
||||||
),
|
format="json",
|
||||||
content_type="application/json",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 202)
|
self.assertEqual(response.status_code, 202)
|
||||||
@@ -105,6 +106,7 @@ class CropSimulationViewTests(TestCase):
|
|||||||
{"plant_name": "گوجهفرنگی", "dynamic_parameters": ["DVS"]},
|
{"plant_name": "گوجهفرنگی", "dynamic_parameters": ["DVS"]},
|
||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
|
force_authenticate(request, user=self.user)
|
||||||
|
|
||||||
response = GrowthSimulationView.as_view()(request)
|
response = GrowthSimulationView.as_view()(request)
|
||||||
|
|
||||||
@@ -131,6 +133,7 @@ class CropSimulationViewTests(TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
request = self.factory.get("/api/yield-harvest/crop-simulation/growth/growth-task-123/status/?page=1&page_size=10")
|
request = self.factory.get("/api/yield-harvest/crop-simulation/growth/growth-task-123/status/?page=1&page_size=10")
|
||||||
|
force_authenticate(request, user=self.user)
|
||||||
response = GrowthSimulationStatusView.as_view()(request, task_id="growth-task-123")
|
response = GrowthSimulationStatusView.as_view()(request, task_id="growth-task-123")
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
@@ -163,7 +166,7 @@ class CropSimulationViewTests(TestCase):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
response = self.client.get("/api/crop-simulation/growth/growth-task-123/status/?page=1&page_size=10")
|
response = self.api_client.get("/api/crop-simulation/growth/growth-task-123/status/?page=1&page_size=10")
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertEqual(response.json()["data"]["status"], "SUCCESS")
|
self.assertEqual(response.json()["data"]["status"], "SUCCESS")
|
||||||
@@ -218,6 +221,22 @@ class CropSimulationViewTests(TestCase):
|
|||||||
payload={"farm_uuid": str(self.farm.farm_uuid), "plant_name": "wheat"},
|
payload={"farm_uuid": str(self.farm.farm_uuid), "plant_name": "wheat"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@patch("yield_harvest.views.external_api_request")
|
||||||
|
def test_current_farm_chart_top_level_route_proxies_to_ai_service(self, mock_external_api_request):
|
||||||
|
mock_external_api_request.return_value = AdapterResponse(
|
||||||
|
status_code=200,
|
||||||
|
data={"data": {"result": {"farm_uuid": str(self.farm.farm_uuid), "plant_name": "wheat"}}},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.api_client.post(
|
||||||
|
"/api/crop-simulation/current-farm-chart/",
|
||||||
|
{"farm_uuid": str(self.farm.farm_uuid), "plant_name": "wheat"},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.json()["data"]["farm_uuid"], str(self.farm.farm_uuid))
|
||||||
|
|
||||||
@patch("yield_harvest.views.external_api_request")
|
@patch("yield_harvest.views.external_api_request")
|
||||||
def test_harvest_prediction_proxies_to_ai_service(self, mock_external_api_request):
|
def test_harvest_prediction_proxies_to_ai_service(self, mock_external_api_request):
|
||||||
mock_external_api_request.return_value = AdapterResponse(
|
mock_external_api_request.return_value = AdapterResponse(
|
||||||
@@ -252,6 +271,22 @@ class CropSimulationViewTests(TestCase):
|
|||||||
payload={"farm_uuid": str(self.farm.farm_uuid), "plant_name": ""},
|
payload={"farm_uuid": str(self.farm.farm_uuid), "plant_name": ""},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@patch("yield_harvest.views.external_api_request")
|
||||||
|
def test_harvest_prediction_top_level_route_proxies_to_ai_service(self, mock_external_api_request):
|
||||||
|
mock_external_api_request.return_value = AdapterResponse(
|
||||||
|
status_code=200,
|
||||||
|
data={"data": {"result": {"date": "2026-07-15", "daysUntil": 96}}},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.api_client.post(
|
||||||
|
"/api/crop-simulation/harvest-prediction/",
|
||||||
|
{"farm_uuid": str(self.farm.farm_uuid)},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.json()["data"]["daysUntil"], 96)
|
||||||
|
|
||||||
@patch("yield_harvest.views.external_api_request")
|
@patch("yield_harvest.views.external_api_request")
|
||||||
def test_yield_prediction_proxies_to_ai_service(self, mock_external_api_request):
|
def test_yield_prediction_proxies_to_ai_service(self, mock_external_api_request):
|
||||||
mock_external_api_request.return_value = AdapterResponse(
|
mock_external_api_request.return_value = AdapterResponse(
|
||||||
@@ -279,6 +314,60 @@ class CropSimulationViewTests(TestCase):
|
|||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertEqual(response.data["data"]["predictedYieldTons"], 8.4)
|
self.assertEqual(response.data["data"]["predictedYieldTons"], 8.4)
|
||||||
|
|
||||||
|
@patch("yield_harvest.views.external_api_request")
|
||||||
|
def test_yield_prediction_top_level_route_proxies_to_ai_service(self, mock_external_api_request):
|
||||||
|
mock_external_api_request.return_value = AdapterResponse(
|
||||||
|
status_code=200,
|
||||||
|
data={"data": {"result": {"farm_uuid": str(self.farm.farm_uuid), "predictedYieldTons": 8.4}}},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.api_client.post(
|
||||||
|
"/api/crop-simulation/yield-prediction/",
|
||||||
|
{"farm_uuid": str(self.farm.farm_uuid)},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.json()["data"]["predictedYieldTons"], 8.4)
|
||||||
|
|
||||||
|
@patch("yield_harvest.views.external_api_request")
|
||||||
|
def test_yield_harvest_summary_top_level_route_proxies_to_ai_service(self, mock_external_api_request):
|
||||||
|
mock_external_api_request.return_value = AdapterResponse(
|
||||||
|
status_code=200,
|
||||||
|
data={
|
||||||
|
"data": {
|
||||||
|
"result": {
|
||||||
|
"farm_uuid": str(self.farm.farm_uuid),
|
||||||
|
"season_highlights_card": {"title": "Season highlights"},
|
||||||
|
"yield_prediction": {"predicted_yield_tons": 5.1},
|
||||||
|
"harvest_prediction_card": {"harvest_date": "2026-09-28", "days_until": 152},
|
||||||
|
"harvest_readiness_zones": {"zones": []},
|
||||||
|
"yield_quality_bands": {"primary_quality_grade": "B"},
|
||||||
|
"harvest_operations_card": {"steps": []},
|
||||||
|
"yield_prediction_chart": {"series": []},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.api_client.get(
|
||||||
|
f"/api/crop-simulation/yield-harvest-summary/?farm_uuid={self.farm.farm_uuid}&season_year=1404&crop_name=wheat&include_narrative=true"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.json()["data"]["farm_uuid"], str(self.farm.farm_uuid))
|
||||||
|
mock_external_api_request.assert_called_once_with(
|
||||||
|
"ai",
|
||||||
|
"/api/crop-simulation/yield-harvest-summary/",
|
||||||
|
method="GET",
|
||||||
|
query={
|
||||||
|
"farm_uuid": str(self.farm.farm_uuid),
|
||||||
|
"season_year": "1404",
|
||||||
|
"crop_name": "wheat",
|
||||||
|
"include_narrative": "true",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
def test_crop_simulation_rejects_foreign_farm_uuid(self):
|
def test_crop_simulation_rejects_foreign_farm_uuid(self):
|
||||||
request = self.factory.post(
|
request = self.factory.post(
|
||||||
"/api/yield-harvest/crop-simulation/yield-prediction/",
|
"/api/yield-harvest/crop-simulation/yield-prediction/",
|
||||||
|
|||||||
+14
-1
@@ -1,7 +1,20 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from .views import YieldHarvestSummaryView
|
from .views import (
|
||||||
|
CurrentFarmChartView,
|
||||||
|
GrowthSimulationStatusView,
|
||||||
|
GrowthSimulationView,
|
||||||
|
HarvestPredictionView,
|
||||||
|
YieldHarvestSummaryView,
|
||||||
|
YieldPredictionView,
|
||||||
|
)
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("summary/", YieldHarvestSummaryView.as_view(), name="yield-harvest-summary"),
|
path("summary/", YieldHarvestSummaryView.as_view(), name="yield-harvest-summary"),
|
||||||
|
path("crop-simulation/current-farm-chart/", CurrentFarmChartView.as_view(), name="yield-harvest-current-farm-chart"),
|
||||||
|
path("crop-simulation/growth/", GrowthSimulationView.as_view(), name="yield-harvest-growth"),
|
||||||
|
path("crop-simulation/growth/<str:task_id>/status/", GrowthSimulationStatusView.as_view(), name="yield-harvest-growth-status"),
|
||||||
|
path("crop-simulation/harvest-prediction/", HarvestPredictionView.as_view(), name="yield-harvest-harvest-prediction"),
|
||||||
|
path("crop-simulation/yield-prediction/", YieldPredictionView.as_view(), name="yield-harvest-yield-prediction"),
|
||||||
|
path("crop-simulation/yield-harvest-summary/", YieldHarvestSummaryView.as_view(), name="yield-harvest-crop-simulation-summary"),
|
||||||
]
|
]
|
||||||
|
|||||||
+61
-22
@@ -6,7 +6,7 @@ from rest_framework.views import APIView
|
|||||||
from drf_spectacular.types import OpenApiTypes
|
from drf_spectacular.types import OpenApiTypes
|
||||||
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
||||||
|
|
||||||
from config.swagger import farm_uuid_query_param, status_response
|
from config.swagger import code_response, farm_uuid_query_param
|
||||||
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 farm_hub.models import FarmHub
|
||||||
from .models import YieldHarvestPredictionLog
|
from .models import YieldHarvestPredictionLog
|
||||||
@@ -46,28 +46,60 @@ class YieldHarvestSummaryView(APIView):
|
|||||||
@extend_schema(
|
@extend_schema(
|
||||||
tags=["Yield & Harvest Prediction"],
|
tags=["Yield & Harvest Prediction"],
|
||||||
parameters=[
|
parameters=[
|
||||||
farm_uuid_query_param(required=False, description="UUID of the farm for yield and harvest prediction."),
|
farm_uuid_query_param(required=True, description="UUID of the farm for yield and harvest prediction."),
|
||||||
|
OpenApiParameter(
|
||||||
|
name="season_year",
|
||||||
|
type=OpenApiTypes.INT,
|
||||||
|
location=OpenApiParameter.QUERY,
|
||||||
|
required=False,
|
||||||
|
description="سال زراعی.",
|
||||||
|
),
|
||||||
|
OpenApiParameter(
|
||||||
|
name="crop_name",
|
||||||
|
type=OpenApiTypes.STR,
|
||||||
|
location=OpenApiParameter.QUERY,
|
||||||
|
required=False,
|
||||||
|
description="نام محصول.",
|
||||||
|
),
|
||||||
|
OpenApiParameter(
|
||||||
|
name="include_narrative",
|
||||||
|
type=OpenApiTypes.BOOL,
|
||||||
|
location=OpenApiParameter.QUERY,
|
||||||
|
required=False,
|
||||||
|
description="در صورت true بودن متن های narrative نیز اضافه می شوند.",
|
||||||
|
),
|
||||||
],
|
],
|
||||||
responses={200: status_response("YieldHarvestSummaryResponse", data=YieldHarvestSummarySerializer())},
|
responses={200: code_response("YieldHarvestSummaryResponse", data=YieldHarvestSummarySerializer())},
|
||||||
)
|
)
|
||||||
def get(self, request):
|
def get(self, request):
|
||||||
farm_uuid = request.query_params.get("farm_uuid")
|
farm_uuid = request.query_params.get("farm_uuid")
|
||||||
query = {"farm_uuid": str(farm_uuid)} if farm_uuid else {}
|
farm, error_response = CropSimulationBaseView._get_farm(request, farm_uuid)
|
||||||
|
if error_response is not None:
|
||||||
|
return error_response
|
||||||
|
|
||||||
|
query = {"farm_uuid": str(farm.farm_uuid)}
|
||||||
|
if request.query_params.get("season_year"):
|
||||||
|
query["season_year"] = request.query_params.get("season_year")
|
||||||
|
if request.query_params.get("crop_name"):
|
||||||
|
query["crop_name"] = request.query_params.get("crop_name")
|
||||||
|
if request.query_params.get("include_narrative") is not None:
|
||||||
|
query["include_narrative"] = request.query_params.get("include_narrative")
|
||||||
|
|
||||||
adapter_response = external_api_request(
|
adapter_response = external_api_request(
|
||||||
"ai",
|
"ai",
|
||||||
"/yield-harvest/summary",
|
"/api/crop-simulation/yield-harvest-summary/",
|
||||||
method="GET",
|
method="GET",
|
||||||
query=query,
|
query=query,
|
||||||
)
|
)
|
||||||
|
if adapter_response.status_code >= 400:
|
||||||
|
return CropSimulationBaseView._error_response(adapter_response)
|
||||||
|
|
||||||
response_data = adapter_response.data if isinstance(adapter_response.data, dict) else {}
|
summary = CropSimulationBaseView._extract_result(adapter_response.data)
|
||||||
summary = response_data.get("result", response_data.get("data", response_data))
|
|
||||||
|
|
||||||
self._persist_log(farm_uuid, summary)
|
self._persist_log(farm.farm_uuid, summary)
|
||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
{"status": "success", "data": summary},
|
{"code": 200, "msg": "success", "data": summary},
|
||||||
status=status.HTTP_200_OK,
|
status=status.HTTP_200_OK,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -80,18 +112,25 @@ class YieldHarvestSummaryView(APIView):
|
|||||||
except (FarmHub.DoesNotExist, Exception):
|
except (FarmHub.DoesNotExist, Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
yield_card = summary.get("yield_prediction_card", {})
|
yield_card = summary.get("yield_prediction") or summary.get("yield_prediction_card") or {}
|
||||||
harvest_card = summary.get("harvest_prediction_card", {})
|
harvest_card = summary.get("harvest_prediction_card", {})
|
||||||
|
yield_chart = summary.get("yield_prediction_chart", {})
|
||||||
|
if not isinstance(yield_card, dict):
|
||||||
|
yield_card = {}
|
||||||
|
if not isinstance(harvest_card, dict):
|
||||||
|
harvest_card = {}
|
||||||
|
if not isinstance(yield_chart, dict):
|
||||||
|
yield_chart = {}
|
||||||
|
|
||||||
YieldHarvestPredictionLog.objects.create(
|
YieldHarvestPredictionLog.objects.create(
|
||||||
farm=farm,
|
farm=farm,
|
||||||
yield_stats=yield_card.get("stats", ""),
|
yield_stats=str(yield_card.get("predicted_yield_tons") or yield_card.get("stats") or ""),
|
||||||
yield_chip_text=yield_card.get("chipText", ""),
|
yield_chip_text=str(yield_card.get("unit") or yield_card.get("chipText") or ""),
|
||||||
harvest_date=harvest_card.get("date") or None,
|
harvest_date=harvest_card.get("harvest_date") or harvest_card.get("date") or None,
|
||||||
days_until_harvest=harvest_card.get("daysUntil"),
|
days_until_harvest=harvest_card.get("days_until") or harvest_card.get("daysUntil"),
|
||||||
optimal_window_start=harvest_card.get("optimalWindowStart") or None,
|
optimal_window_start=harvest_card.get("optimal_window_start") or harvest_card.get("optimalWindowStart") or None,
|
||||||
optimal_window_end=harvest_card.get("optimalWindowEnd") or None,
|
optimal_window_end=harvest_card.get("optimal_window_end") or harvest_card.get("optimalWindowEnd") or None,
|
||||||
chart_data=summary.get("yield_prediction_chart", {}),
|
chart_data=yield_chart,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -147,7 +186,7 @@ class CurrentFarmChartView(CropSimulationBaseView):
|
|||||||
@extend_schema(
|
@extend_schema(
|
||||||
tags=["Crop Simulation"],
|
tags=["Crop Simulation"],
|
||||||
request=CropSimulationRequestSerializer,
|
request=CropSimulationRequestSerializer,
|
||||||
responses={200: status_response("CurrentFarmChartResponse", data=CurrentFarmChartSerializer())},
|
responses={200: code_response("CurrentFarmChartResponse", data=CurrentFarmChartSerializer())},
|
||||||
)
|
)
|
||||||
def post(self, request):
|
def post(self, request):
|
||||||
serializer = CropSimulationRequestSerializer(data=request.data)
|
serializer = CropSimulationRequestSerializer(data=request.data)
|
||||||
@@ -176,7 +215,7 @@ class HarvestPredictionView(CropSimulationBaseView):
|
|||||||
@extend_schema(
|
@extend_schema(
|
||||||
tags=["Crop Simulation"],
|
tags=["Crop Simulation"],
|
||||||
request=CropSimulationRequestSerializer,
|
request=CropSimulationRequestSerializer,
|
||||||
responses={200: status_response("CropSimulationHarvestPredictionResponse", data=HarvestPredictionSerializer())},
|
responses={200: code_response("CropSimulationHarvestPredictionResponse", data=HarvestPredictionSerializer())},
|
||||||
)
|
)
|
||||||
def post(self, request):
|
def post(self, request):
|
||||||
serializer = CropSimulationRequestSerializer(data=request.data)
|
serializer = CropSimulationRequestSerializer(data=request.data)
|
||||||
@@ -205,7 +244,7 @@ class YieldPredictionView(CropSimulationBaseView):
|
|||||||
@extend_schema(
|
@extend_schema(
|
||||||
tags=["Crop Simulation"],
|
tags=["Crop Simulation"],
|
||||||
request=CropSimulationRequestSerializer,
|
request=CropSimulationRequestSerializer,
|
||||||
responses={200: status_response("CropSimulationYieldPredictionResponse", data=YieldPredictionSerializer())},
|
responses={200: code_response("CropSimulationYieldPredictionResponse", data=YieldPredictionSerializer())},
|
||||||
)
|
)
|
||||||
def post(self, request):
|
def post(self, request):
|
||||||
serializer = CropSimulationRequestSerializer(data=request.data)
|
serializer = CropSimulationRequestSerializer(data=request.data)
|
||||||
@@ -232,7 +271,7 @@ class GrowthSimulationView(APIView):
|
|||||||
@extend_schema(
|
@extend_schema(
|
||||||
tags=["Crop Simulation"],
|
tags=["Crop Simulation"],
|
||||||
request=GrowthSimulationRequestSerializer,
|
request=GrowthSimulationRequestSerializer,
|
||||||
responses={202: status_response("GrowthSimulationQueuedResponse", data=GrowthSimulationQueuedDataSerializer())},
|
responses={202: code_response("GrowthSimulationQueuedResponse", data=GrowthSimulationQueuedDataSerializer())},
|
||||||
)
|
)
|
||||||
def post(self, request):
|
def post(self, request):
|
||||||
serializer = GrowthSimulationRequestSerializer(data=request.data)
|
serializer = GrowthSimulationRequestSerializer(data=request.data)
|
||||||
@@ -279,7 +318,7 @@ class GrowthSimulationStatusView(APIView):
|
|||||||
description="اندازه صفحه بین 1 تا 50.",
|
description="اندازه صفحه بین 1 تا 50.",
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
responses={200: status_response("GrowthSimulationStatusResponse", data=GrowthSimulationStatusDataSerializer())},
|
responses={200: code_response("GrowthSimulationStatusResponse", data=GrowthSimulationStatusDataSerializer())},
|
||||||
)
|
)
|
||||||
def get(self, request, task_id):
|
def get(self, request, task_id):
|
||||||
query = {}
|
query = {}
|
||||||
|
|||||||
Reference in New Issue
Block a user