diff --git a/.env.example b/.env.example index 25e0292..ad39f04 100644 --- a/.env.example +++ b/.env.example @@ -29,6 +29,11 @@ CELERY_BROKER_URL=redis://redis:6379/0 CELERY_RESULT_BACKEND=redis://redis:6379/0 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_HOUR=* diff --git a/celerybeat-schedule b/celerybeat-schedule index 086bbb4..fdd0c09 100644 Binary files a/celerybeat-schedule and b/celerybeat-schedule differ diff --git a/config/settings.py b/config/settings.py index 133c327..18191ee 100644 --- a/config/settings.py +++ b/config/settings.py @@ -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 = { "DEFAULT_PERMISSION_CLASSES": [ "rest_framework.permissions.IsAuthenticated", diff --git a/config/urls.py b/config/urls.py index c47d14e..2ea3f7e 100644 --- a/config/urls.py +++ b/config/urls.py @@ -19,6 +19,7 @@ urlpatterns = [ path("api/crop-zoning/", include("crop_zoning.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-disease/", include("pest_detection.pest_disease_urls")), diff --git a/docs/pest_disease_risk_summary_frontend_api_reference.md b/docs/pest_disease_risk_summary_frontend_api_reference.md new file mode 100644 index 0000000..e96c430 --- /dev/null +++ b/docs/pest_disease_risk_summary_frontend_api_reference.md @@ -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; +} + +export interface PestDiseaseRiskSummaryResponse { + code: number; + msg: string; + data: { + farm_uuid: string; + diseaseRisk?: RiskCard; + pestRisk?: RiskCard; + drivers?: Record; + }; +} +``` + +--- + +## 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; +} +``` diff --git a/docs/soil_frontend_api_reference.md b/docs/soil_frontend_api_reference.md new file mode 100644 index 0000000..acbe0a7 --- /dev/null +++ b/docs/soil_frontend_api_reference.md @@ -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= +``` + +### 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= +``` + +### 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= +``` + +### 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` | داده لایه‌های خاک | +| `data.timestamp` | `string \| null` | زمان تولید heatmap | +| `data.grid_resolution` | `object` | رزولوشن grid | +| `data.grid_cells` | `array` | سلول‌های grid | +| `data.sensor_points` | `array` | نقاط سنسور | +| `data.quality_legend` | `object` | legend مقادیر | +| `data.depth_layers` | `array` | لایه‌های عمقی | +| `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= +``` + +### 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 تغییر کند. diff --git a/docs/water_weather_frontend_api_reference.md b/docs/water_weather_frontend_api_reference.md new file mode 100644 index 0000000..90d9e62 --- /dev/null +++ b/docs/water_weather_frontend_api_reference.md @@ -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= +``` + +### 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= +``` + +### 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= +``` + +### 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[]; + insight?: Record; + 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 کنید. diff --git a/pest_detection/serializers.py b/pest_detection/serializers.py index 21cb82a..8883229 100644 --- a/pest_detection/serializers.py +++ b/pest_detection/serializers.py @@ -37,11 +37,13 @@ class PestDetectionAnalyzeResponseSerializer(serializers.Serializer): class PestDetectionRiskRequestSerializer(serializers.Serializer): - farm_uuid = serializers.UUIDField(required=True, help_text="UUID مزرعه برای تحلیل ریسک آفت/بیماری.") - sensor_uuid = serializers.UUIDField(required=False, help_text="UUID سنسور مرتبط در صورت وجود.") - plant_name = 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="پرسش تکمیلی کاربر.") + farm_uuid = serializers.UUIDField(default="11111111-1111-1111-1111-111111111111", help_text="UUID مزرعه برای تحلیل ریسک آفت/بیماری.") + plant_name = serializers.CharField(required=False, allow_blank=True, default="پیاز", help_text="نام محصول یا گیاه.") + growth_stage = 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): diff --git a/pest_detection/tests.py b/pest_detection/tests.py index 84949a8..10c8637 100644 --- a/pest_detection/tests.py +++ b/pest_detection/tests.py @@ -1,18 +1,34 @@ from unittest.mock import patch +from django.core.cache import cache 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 rest_framework.test import APIRequestFactory, force_authenticate 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 +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): def setUp(self): + cache.clear() self.factory = APIRequestFactory() self.user = get_user_model().objects.create_user( username="farmer", @@ -28,6 +44,8 @@ class PestDetectionViewTests(TestCase): ) self.farm_type = FarmType.objects.create(name="زراعی") 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") @patch("pest_detection.views.external_api_request") @@ -127,7 +145,6 @@ class PestDetectionViewTests(TestCase): "farm_uuid": str(self.farm.farm_uuid), "plant_name": "wheat", "growth_stage": "", - "query": "", }, ) @@ -163,9 +180,13 @@ class PestDetectionViewTests(TestCase): self.assertEqual(response.data["data"]["drivers"], {"humidity": "high"}) mock_external_api_request.assert_called_once_with( "ai", - "/api/pest-disease/risk-summary/", + "/api/pest-disease/risk/", 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") @@ -196,11 +217,166 @@ class PestDetectionViewTests(TestCase): self.assertEqual(response.data["data"]["farm_uuid"], str(self.farm.farm_uuid)) mock_external_api_request.assert_called_once_with( "ai", - "/api/pest-disease/risk-summary/", + "/api/pest-disease/risk/", 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): request = self.factory.post( "/api/pest-disease/risk-summary/", diff --git a/pest_detection/views.py b/pest_detection/views.py index bb122e7..cc30d76 100644 --- a/pest_detection/views.py +++ b/pest_detection/views.py @@ -4,6 +4,8 @@ Pest detection API views. import json +from django.conf import settings +from django.core.cache import cache from drf_spectacular.utils import extend_schema from rest_framework import status from rest_framework.response import Response @@ -18,10 +20,27 @@ from .serializers import ( PestDetectionRiskRequestSerializer, PestDetectionRiskResponseSerializer, PestDetectionRiskSummaryResponseSerializer, + PestDetectionRiskSummaryRequestSerializer, ) 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 def _get_farm(request, farm_uuid): if not farm_uuid: @@ -62,6 +81,13 @@ class PestDetectionFarmMixin: image_urls = parsed if parsed is not None else [image_urls] 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 def _attach_uploaded_files(payload, uploaded_images): if not uploaded_images: @@ -187,15 +213,12 @@ class RiskView(PestDetectionFarmMixin, APIView): if error_response is not None: return error_response + plant_name = self._get_first_farm_product_name(farm) ai_payload = { "farm_uuid": str(farm.farm_uuid), - "plant_name": payload.get("plant_name", ""), - "growth_stage": payload.get("growth_stage", ""), - "query": payload.get("query", ""), + "plant_name": plant_name, + "growth_stage": "گلدهی", } - sensor_uuid = payload.get("sensor_uuid") - if sensor_uuid: - ai_payload["sensor_uuid"] = str(sensor_uuid) adapter_response = external_api_request( "ai", @@ -216,26 +239,40 @@ class RiskView(PestDetectionFarmMixin, APIView): class RiskSummaryView(PestDetectionFarmMixin, APIView): @extend_schema( tags=["Pest Detection"], - request=PestDetectionRiskRequestSerializer, + request=PestDetectionRiskSummaryRequestSerializer, responses={200: status_response("PestDetectionRiskSummaryResponse", data=PestDetectionRiskSummaryResponseSerializer())}, ) def post(self, request): - farm_uuid = request.data.get("farm_uuid") - sensor_uuid = request.data.get("sensor_uuid") + serializer = PestDetectionRiskSummaryRequestSerializer(data=request.data) + 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) if error_response is not None: return error_response - payload = {"farm_uuid": str(farm.farm_uuid)} - if sensor_uuid: - payload["sensor_uuid"] = str(sensor_uuid) + cache_key = self._build_risk_summary_cache_key(request.user.id, farm.farm_uuid) + cached_response = cache.get(cache_key) + 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( "ai", - "/api/pest-disease/risk-summary/", + "/api/pest-disease/risk/", method="POST", - payload=payload, + payload=ai_payload, ) if adapter_response.status_code >= 400: @@ -248,6 +285,8 @@ class RiskSummaryView(PestDetectionFarmMixin, APIView): "pestRisk": result.get("pestRisk") or result.get("pest_risk") or {}, "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( {"code": 200, "msg": "success", "data": response_payload}, status=status.HTTP_200_OK, diff --git a/soil/tests.py b/soil/tests.py index e72f1b4..14744be 100644 --- a/soil/tests.py +++ b/soil/tests.py @@ -1,6 +1,7 @@ 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 external_api_adapter.adapter import AdapterResponse @@ -10,8 +11,24 @@ from account.models import User 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): def setUp(self): + cache.clear() self.factory = APIRequestFactory() self.user = User.objects.create_user( username="soil-user", @@ -61,6 +78,75 @@ class SoilAnomalyDetectionViewTests(TestCase): 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): request = self.factory.get("/api/soil/anomalies/") response = SoilAnomalyDetectionView.as_view()(request) @@ -146,8 +232,13 @@ class SoilMoistureHeatmapViewTests(TestCase): 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): def setUp(self): + cache.clear() self.factory = APIRequestFactory() self.user = User.objects.create_user( username="soil-summary-user", @@ -193,6 +284,72 @@ class SoilSummaryViewTests(TestCase): 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): request = self.factory.get("/api/soil/summary/") response = SoilSummaryView.as_view()(request) diff --git a/soil/views.py b/soil/views.py index f710ed5..3ceb7cd 100644 --- a/soil/views.py +++ b/soil/views.py @@ -1,3 +1,5 @@ +from django.conf import settings +from django.core.cache import cache from rest_framework import status from rest_framework.response import Response 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): farm_uuid = request.query_params.get("farm_uuid") if not farm_uuid: @@ -85,6 +120,14 @@ class SoilAnomalyDetectionView(APIView): 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( "ai", "/api/soile/anomaly-detection/", @@ -102,8 +145,11 @@ class SoilAnomalyDetectionView(APIView): 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( - {"code": 200, "msg": "success", "data": _extract_adapter_result(adapter_response.data)}, + {"code": 200, "msg": "success", "data": response_payload}, status=status.HTTP_200_OK, ) @@ -177,6 +223,14 @@ class SoilSummaryView(APIView): 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( "ai", "/api/soile/health-summary/", @@ -194,7 +248,10 @@ class SoilSummaryView(APIView): 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( - {"code": 200, "msg": "success", "data": _extract_adapter_result(adapter_response.data)}, + {"code": 200, "msg": "success", "data": response_payload}, status=status.HTTP_200_OK, ) diff --git a/water/serializers.py b/water/serializers.py index ccbee5f..91835cd 100644 --- a/water/serializers.py +++ b/water/serializers.py @@ -1,6 +1,14 @@ 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): labels = serializers.ListField(child=serializers.CharField(), required=False) series = serializers.ListField( diff --git a/water/tests.py b/water/tests.py index fb078f2..51f9ca9 100644 --- a/water/tests.py +++ b/water/tests.py @@ -1,18 +1,34 @@ from unittest.mock import patch +from django.core.cache import cache 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 rest_framework.test import APIRequestFactory, force_authenticate from external_api_adapter.adapter import AdapterResponse 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): def setUp(self): + cache.clear() self.factory = APIRequestFactory() self.user = get_user_model().objects.create_user( username="farmer", @@ -73,6 +89,82 @@ class WeatherViewTests(TestCase): 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): request = self.factory.post("/api/weather/farm-card/", {"farm_uuid": str(self.other_farm.farm_uuid)}, format="json") force_authenticate(request, user=self.user) diff --git a/water/views.py b/water/views.py index 9c113cc..b802729 100644 --- a/water/views.py +++ b/water/views.py @@ -2,6 +2,8 @@ WATER API views. """ +from django.conf import settings +from django.core.cache import cache from rest_framework import serializers, status from rest_framework.response import Response 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 farm_hub.models import FarmHub 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 @@ -85,6 +93,32 @@ class FarmWeatherCardView(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 def _get_farm(request, farm_uuid): if not farm_uuid: @@ -149,7 +183,7 @@ class WeatherFarmBaseView(APIView): class WeatherFarmCardView(WeatherFarmBaseView): @extend_schema( tags=["WEATHER"], - request=serializers.Serializer, + request=WeatherFarmCardRequestSerializer, responses={200: status_response("WeatherFarmCardResponse", data=FarmWeatherCardSerializer())}, ) def post(self, request): @@ -187,9 +221,22 @@ class WaterNeedPredictionView(APIView): except (FarmHub.DoesNotExist, Exception): farm = None 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) if error_response is not None: 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( {"status": "success", "data": prediction_data}, status=status.HTTP_200_OK, @@ -292,7 +339,9 @@ class WaterSummaryView(APIView): except (FarmHub.DoesNotExist, Exception): farm = None + summary_data = get_water_summary_data(farm) + WeatherFarmBaseView._store_recent_water_summary(summary_data) return Response( - {"status": "success", "data": get_water_summary_data(farm)}, + {"status": "success", "data": summary_data}, status=status.HTTP_200_OK, ) diff --git a/yield_harvest/apps.py b/yield_harvest/apps.py index 5bb08ac..f94d02c 100644 --- a/yield_harvest/apps.py +++ b/yield_harvest/apps.py @@ -4,4 +4,4 @@ from django.apps import AppConfig class YieldHarvestConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "yield_harvest" - verbose_name = "Yield & Harvest Prediction" + verbose_name = "Yield, Harvest & Crop Simulation" diff --git a/yield_harvest/crop_simulation_urls.py b/yield_harvest/crop_simulation_urls.py new file mode 100644 index 0000000..099b20d --- /dev/null +++ b/yield_harvest/crop_simulation_urls.py @@ -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//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"), +] diff --git a/yield_harvest/serializers.py b/yield_harvest/serializers.py index 138e66c..a021315 100644 --- a/yield_harvest/serializers.py +++ b/yield_harvest/serializers.py @@ -48,9 +48,14 @@ class HarvestPredictionCardSerializer(serializers.Serializer): class YieldHarvestSummarySerializer(serializers.Serializer): - yield_prediction_card = YieldPredictionCardSerializer(required=False) - yield_prediction_chart = YieldPredictionChartSerializer(required=False) - harvest_prediction_card = HarvestPredictionCardSerializer(required=False) + farm_uuid = serializers.CharField(required=False, allow_blank=True) + season_highlights_card = serializers.DictField(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): @@ -133,11 +138,11 @@ class CurrentFarmChartSerializer(serializers.Serializer): scenario_id = serializers.IntegerField(required=False) simulation_warning = serializers.CharField(required=False, allow_blank=True) categories = serializers.ListField(child=serializers.CharField(), required=False) - series = serializers.DictField(required=False) - summary = serializers.DictField(required=False) + series = serializers.ListField(child=serializers.DictField(), required=False) + summary = serializers.ListField(child=serializers.DictField(), required=False) current_state = 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): diff --git a/yield_harvest/tests.py b/yield_harvest/tests.py index 1843430..8690354 100644 --- a/yield_harvest/tests.py +++ b/yield_harvest/tests.py @@ -1,9 +1,8 @@ -import json from unittest.mock import patch from django.contrib.auth import get_user_model 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 farm_hub.models import FarmHub, FarmType @@ -13,12 +12,14 @@ from .views import ( GrowthSimulationStatusView, GrowthSimulationView, HarvestPredictionView, + YieldHarvestSummaryView, YieldPredictionView, ) class CropSimulationViewTests(TestCase): def setUp(self): + self.api_client = APIClient() self.factory = APIRequestFactory() self.user = get_user_model().objects.create_user( username="farmer", @@ -35,6 +36,7 @@ class CropSimulationViewTests(TestCase): self.farm_type = FarmType.objects.create(name="زراعی") self.farm = FarmHub.objects.create(owner=self.user, farm_type=self.farm_type, name="Farm 1") self.other_farm = FarmHub.objects.create(owner=self.other_user, farm_type=self.farm_type, name="Farm 2") + self.api_client.force_authenticate(user=self.user) @patch("yield_harvest.views.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)}, format="json", ) + force_authenticate(request, user=self.user) 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/", - data=json.dumps( - { - "plant_name": "گوجه‌فرنگی", - "dynamic_parameters": ["DVS", "LAI"], - "farm_uuid": str(self.farm.farm_uuid), - } - ), - content_type="application/json", + { + "plant_name": "گوجه‌فرنگی", + "dynamic_parameters": ["DVS", "LAI"], + "farm_uuid": str(self.farm.farm_uuid), + }, + format="json", ) self.assertEqual(response.status_code, 202) @@ -105,6 +106,7 @@ class CropSimulationViewTests(TestCase): {"plant_name": "گوجه‌فرنگی", "dynamic_parameters": ["DVS"]}, format="json", ) + force_authenticate(request, user=self.user) 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") + force_authenticate(request, user=self.user) response = GrowthSimulationStatusView.as_view()(request, task_id="growth-task-123") 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.json()["data"]["status"], "SUCCESS") @@ -218,6 +221,22 @@ class CropSimulationViewTests(TestCase): 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") def test_harvest_prediction_proxies_to_ai_service(self, mock_external_api_request): mock_external_api_request.return_value = AdapterResponse( @@ -252,6 +271,22 @@ class CropSimulationViewTests(TestCase): 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") def test_yield_prediction_proxies_to_ai_service(self, mock_external_api_request): mock_external_api_request.return_value = AdapterResponse( @@ -279,6 +314,60 @@ class CropSimulationViewTests(TestCase): self.assertEqual(response.status_code, 200) 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): request = self.factory.post( "/api/yield-harvest/crop-simulation/yield-prediction/", diff --git a/yield_harvest/urls.py b/yield_harvest/urls.py index 99f8486..42fc3ac 100644 --- a/yield_harvest/urls.py +++ b/yield_harvest/urls.py @@ -1,7 +1,20 @@ from django.urls import path -from .views import YieldHarvestSummaryView +from .views import ( + CurrentFarmChartView, + GrowthSimulationStatusView, + GrowthSimulationView, + HarvestPredictionView, + YieldHarvestSummaryView, + YieldPredictionView, +) urlpatterns = [ 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//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"), ] diff --git a/yield_harvest/views.py b/yield_harvest/views.py index 281eaa6..f96b963 100644 --- a/yield_harvest/views.py +++ b/yield_harvest/views.py @@ -6,7 +6,7 @@ from rest_framework.views import APIView from drf_spectacular.types import OpenApiTypes 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 farm_hub.models import FarmHub from .models import YieldHarvestPredictionLog @@ -46,28 +46,60 @@ class YieldHarvestSummaryView(APIView): @extend_schema( tags=["Yield & Harvest Prediction"], 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): 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( "ai", - "/yield-harvest/summary", + "/api/crop-simulation/yield-harvest-summary/", method="GET", 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 = response_data.get("result", response_data.get("data", response_data)) + summary = CropSimulationBaseView._extract_result(adapter_response.data) - self._persist_log(farm_uuid, summary) + self._persist_log(farm.farm_uuid, summary) return Response( - {"status": "success", "data": summary}, + {"code": 200, "msg": "success", "data": summary}, status=status.HTTP_200_OK, ) @@ -80,18 +112,25 @@ class YieldHarvestSummaryView(APIView): except (FarmHub.DoesNotExist, Exception): 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", {}) + 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( farm=farm, - yield_stats=yield_card.get("stats", ""), - yield_chip_text=yield_card.get("chipText", ""), - harvest_date=harvest_card.get("date") or None, - days_until_harvest=harvest_card.get("daysUntil"), - optimal_window_start=harvest_card.get("optimalWindowStart") or None, - optimal_window_end=harvest_card.get("optimalWindowEnd") or None, - chart_data=summary.get("yield_prediction_chart", {}), + yield_stats=str(yield_card.get("predicted_yield_tons") or yield_card.get("stats") or ""), + yield_chip_text=str(yield_card.get("unit") or yield_card.get("chipText") or ""), + harvest_date=harvest_card.get("harvest_date") or harvest_card.get("date") or None, + days_until_harvest=harvest_card.get("days_until") or harvest_card.get("daysUntil"), + optimal_window_start=harvest_card.get("optimal_window_start") or harvest_card.get("optimalWindowStart") or None, + optimal_window_end=harvest_card.get("optimal_window_end") or harvest_card.get("optimalWindowEnd") or None, + chart_data=yield_chart, ) @@ -147,7 +186,7 @@ class CurrentFarmChartView(CropSimulationBaseView): @extend_schema( tags=["Crop Simulation"], request=CropSimulationRequestSerializer, - responses={200: status_response("CurrentFarmChartResponse", data=CurrentFarmChartSerializer())}, + responses={200: code_response("CurrentFarmChartResponse", data=CurrentFarmChartSerializer())}, ) def post(self, request): serializer = CropSimulationRequestSerializer(data=request.data) @@ -176,7 +215,7 @@ class HarvestPredictionView(CropSimulationBaseView): @extend_schema( tags=["Crop Simulation"], request=CropSimulationRequestSerializer, - responses={200: status_response("CropSimulationHarvestPredictionResponse", data=HarvestPredictionSerializer())}, + responses={200: code_response("CropSimulationHarvestPredictionResponse", data=HarvestPredictionSerializer())}, ) def post(self, request): serializer = CropSimulationRequestSerializer(data=request.data) @@ -205,7 +244,7 @@ class YieldPredictionView(CropSimulationBaseView): @extend_schema( tags=["Crop Simulation"], request=CropSimulationRequestSerializer, - responses={200: status_response("CropSimulationYieldPredictionResponse", data=YieldPredictionSerializer())}, + responses={200: code_response("CropSimulationYieldPredictionResponse", data=YieldPredictionSerializer())}, ) def post(self, request): serializer = CropSimulationRequestSerializer(data=request.data) @@ -232,7 +271,7 @@ class GrowthSimulationView(APIView): @extend_schema( tags=["Crop Simulation"], request=GrowthSimulationRequestSerializer, - responses={202: status_response("GrowthSimulationQueuedResponse", data=GrowthSimulationQueuedDataSerializer())}, + responses={202: code_response("GrowthSimulationQueuedResponse", data=GrowthSimulationQueuedDataSerializer())}, ) def post(self, request): serializer = GrowthSimulationRequestSerializer(data=request.data) @@ -279,7 +318,7 @@ class GrowthSimulationStatusView(APIView): description="اندازه صفحه بین 1 تا 50.", ), ], - responses={200: status_response("GrowthSimulationStatusResponse", data=GrowthSimulationStatusDataSerializer())}, + responses={200: code_response("GrowthSimulationStatusResponse", data=GrowthSimulationStatusDataSerializer())}, ) def get(self, request, task_id): query = {}