This commit is contained in:
2026-05-05 21:01:58 +03:30
parent 39efd537bf
commit 4e28bacad6
54 changed files with 2729 additions and 1115 deletions
+26
View File
@@ -0,0 +1,26 @@
# Backend ↔ AI Integration Flow Contract
## Ownership
- `Backend/plants` owns the canonical plant catalog stored in Backend DB.
- `Ai/farm_data` stores plant catalog snapshots and derived farm read-model data for AI workflows.
- `Backend/farm_alerts` returns persisted tracker snapshots; it does not expose live AI inference on the tracker endpoint.
- `Ai/crop_simulation` owns simulation-derived outputs and live inference tasks.
## Flow Types
- `direct_proxy`: Backend forwards request/response to AI without changing ownership.
- `backend_owned_data_with_ai_enrichment`: Backend owns the base record and augments it with AI output or AI sync.
- `cached_snapshot`: Response is served from persisted snapshot state.
- `live_ai_inference`: Response or task is generated from live AI execution.
- `ai_owned_derived_output`: AI returns computed or derived outputs from its own services/read-models.
## Response Metadata
Touched endpoints now expose a top-level `meta` object with:
- `flow_type`
- `source_type`
- `source_service`
- `ownership`
- `live`
- `cached`
- optional `generated_at`
- optional `snapshot_at`
- optional sync fields for Backend plant endpoints
+87 -65
View File
@@ -1,78 +1,100 @@
# بررسی اتصال routeهای درخواستی به سرویس AI
# Backend ↔ AI Route Connection Audit
این گزارش فقط بر اساس کد backend تهیه شده و معیار آن این است:
Last reconciled against current route registrations and view implementations in:
- آیا در کد، `external_api_request(...)` یا `external_request(...)` با **همین route** به سرویس `ai` زده می‌شود یا نه
- اگر با route دیگری به AI متصل شده باشد، route واقعی ذکر شده
- اگر اصلا اتصال AI برای آن route پیدا نشود، به عنوان `متصل نیست` علامت خورده
- `Backend/config/urls.py`
- `Backend/*/urls.py`
- `Backend/*/views.py`
- `Ai/config/urls.py`
- `Ai/*/urls.py`
- `Backend/external_api_adapter/json/ai/index.json`
## متصل به AI با همین route
## Status Vocabulary
| API | اتصال | شواهد |
|---|---|---|
| `POST /api/rag/chat/` | بله | `farm_ai_assistant/views.py:511` |
| `POST /api/soile/moisture-heatmap/` | بله | `soil/views.py:136` |
| `POST /api/soile/health-summary/` | بله | `soil/views.py:182` |
| `POST /api/soile/anomaly-detection/` | بله | `soil/views.py:90` |
| `POST /api/farm-data/` | بله | `farm_hub/services.py:166`, `farm_hub/services.py:89`, `sensor_external_api/services.py:165`, `sensor_external_api/services.py:125` |
| `POST /api/weather/water-need-prediction/` | بله | `water/views.py:136` |
| `POST /api/economy/overview/` | بله | `economic_overview/views.py:73` |
| `GET /api/irrigation/` | بله | `irrigation/views.py:78` |
| `POST /api/irrigation/recommend/` | بله | `irrigation/views.py:165` |
| `POST /api/fertilization/recommend/` | بله | `fertilization/views.py:122` |
| `POST /api/crop-simulation/growth/` | بله | `yield_harvest/views.py:247` |
| `GET /api/crop-simulation/growth/<task_id>/status/` | بله | `yield_harvest/views.py:293` |
| `POST /api/crop-simulation/current-farm-chart/` | بله | `yield_harvest/views.py:145`, `yield_harvest/views.py:162` |
| `POST /api/crop-simulation/harvest-prediction/` | بله | `yield_harvest/views.py:174`, `yield_harvest/views.py:191` |
| `POST /api/crop-simulation/yield-prediction/` | بله | `yield_harvest/views.py:203`, `yield_harvest/views.py:220` |
- `implemented`: route exists and the corresponding backend ↔ AI integration is implemented now
- `partially_implemented`: route exists, but behavior/readiness is limited or alias-based
- `contract_only`: mock/spec exists, but no real client-facing implementation is registered
- `deprecated`: kept for compatibility or aliasing, but not the preferred canonical route
- `missing`: documented previously, but no route/implementation exists now
- `disabled`: intentionally not exposed for current developer/public use
- `transitional`: works now, but still reflects temporary architecture boundaries or compatibility layers
## به AI وصل هستند، اما نه با همین route
## Runtime vs Seed Rule
| API درخواستی | وضعیت | route واقعی AI در کد | شواهد |
|---|---|---|---|
| `POST /api/weather/farm-card/` | با همین route به AI وصل نیست | `GET /weather-forecast/card` | `water/views.py:49` |
| `POST /api/irrigation/water-stress/` | با همین route به AI وصل نیست | `GET /api/water/stress-index/` | `irrigation/views.py:246` |
| `POST /api/pest-disease/detect/` | با همین route به AI وصل نیست | `POST /api/pest-detection/analyze/` | `pest_detection/views.py:161` |
| `POST /api/pest-disease/risk/` | با همین route به AI وصل نیست | `POST /api/pest-detection/risk/` | `pest_detection/views.py:202` |
| `POST /api/pest-disease/risk-summary/` | با همین route به AI وصل نیست | `GET /api/pest-detection/risk-summary/` | `pest_detection/views.py:235` |
| `POST /api/soil-data/ndvi-health/` | با همین route به AI وصل نیست | برای این path اتصال AI پیدا نشد؛ endpoint محلی پروژه با path دیگری ارائه شده | `crop_health/urls.py:6`, `crop_health/tests.py:82` |
| `POST /api/irrigation/` | route به AI با همین method پیدا نشد | فقط `GET /api/irrigation/` در کد استفاده می‌شود | `irrigation/views.py:78` |
- seed/bootstrap data stays allowed for local/dev/test/bootstrap flows
- runtime application code must not silently return mock/sample/demo data
- if real data is missing, the contract must surface an explicit empty state or structured failure
## متصل نیستند
## Ownership Boundaries
برای این routeها هیچ اتصال معناداری به سرویس AI با همین path در کد پیدا نشد.
- Backend owns canonical plant catalog records exposed in `Backend/plants`
- AI `farm_data` owns the derived farm read-model and canonical AI-side farm ↔ plant assignment path
- Backend farm-alert tracker route is cached snapshot delivery, not live AI on request
- AI crop-simulation routes own live or derived simulation outputs
| API | وضعیت | توضیح |
|---|---|---|
| `POST /api/farm-alerts/tracker/` | متصل نیست | view محلی mock دارد و اصلا به AI call نمی‌زند |
| `POST /api/farm-alerts/timeline/` | متصل نیست | view محلی mock دارد و اصلا به AI call نمی‌زند |
| `GET /api/soil-data/` | متصل نیست | فقط spec/mock در `external_api_adapter/json/ai/index.json` دیده شد |
| `POST /api/soil-data/` | عملا با همین route متصل نیست | در `crop_zoning/services.py` call به `/soil-data` بدون پیشوند `/api` وجود دارد |
| `GET /api/soil-data/tasks/<task_id>/status/` | متصل نیست | فقط spec/mock در `external_api_adapter/json/ai/index.json` دیده شد |
| `GET /api/farm-data/<farm_uuid>/detail/` | متصل نیست | هیچ call یا route معناداری پیدا نشد |
| `POST /api/farm-data/parameters/` | متصل نیست | هیچ call یا route معناداری پیدا نشد |
| `GET /api/plants/` | متصل نیست | فقط spec/mock در `external_api_adapter/json/ai/index.json` دیده شد |
| `POST /api/plants/` | متصل نیست | فقط spec/mock در `external_api_adapter/json/ai/index.json` دیده شد |
| `GET /api/plants/<pk>/` | متصل نیست | فقط spec/mock در `external_api_adapter/json/ai/index.json` دیده شد |
| `PUT /api/plants/<pk>/` | متصل نیست | فقط spec/mock در `external_api_adapter/json/ai/index.json` دیده شد |
| `PATCH /api/plants/<pk>/` | متصل نیست | فقط spec/mock در `external_api_adapter/json/ai/index.json` دیده شد |
| `DELETE /api/plants/<pk>/` | متصل نیست | فقط spec/mock در `external_api_adapter/json/ai/index.json` دیده شد |
| `POST /api/plants/fetch-info/` | متصل نیست | فقط spec/mock در `external_api_adapter/json/ai/index.json` دیده شد |
| `GET /api/irrigation/<pk>/` | متصل نیست | route detail/call به AI پیدا نشد |
| `PUT /api/irrigation/<pk>/` | متصل نیست | route detail/call به AI پیدا نشد |
| `PATCH /api/irrigation/<pk>/` | متصل نیست | route detail/call به AI پیدا نشد |
| `DELETE /api/irrigation/<pk>/` | متصل نیست | route detail/call به AI پیدا نشد |
## Source-Of-Truth Matrix
## نکات مهم
| Backend/API contract | Actual route or AI path | Status | Notes |
|---|---|---:|---|
| `POST /api/rag/chat/` | AI only: `Ai/rag/urls.py` | `implemented` | Real AI route; not a backend client route |
| `POST /api/farm-alerts/tracker/` | `Backend/farm_alerts/views.py` → cached snapshot response | `transitional` | Backend route is production-valid, but semantics are `cached_snapshot`, not live AI inference |
| `POST /api/farm-alerts/timeline/` | no backend route | `missing` | Previously documented incorrectly |
| `GET /api/soil-data/` | AI only: `Ai/location_data/urls.py` | `implemented` | Exists on AI service, not on backend public routes |
| `POST /api/soil-data/` | AI only: `Ai/location_data/urls.py` | `implemented` | Exists on AI service, not on backend public routes |
| `GET /api/soil-data/tasks/{task_id}/status/` | AI only: `Ai/location_data/urls.py` | `implemented` | Exists on AI service, not on backend public routes |
| `POST /api/soil-data/ndvi-health/` | real backend route is `POST /api/crop-health/ndvi-health/` | `deprecated` | Old path should not be presented as current |
| `POST /api/soile/moisture-heatmap/` | AI route; backend canonical alias is `POST /api/soil/moisture-heatmap/` | `implemented` | `soile/*` is AI-facing, `soil/*` is backend-facing |
| `POST /api/soile/health-summary/` | AI route; backend canonical alias is `POST /api/soil/summary/` | `implemented` | Same as above |
| `POST /api/soile/anomaly-detection/` | AI route; backend canonical alias is `POST /api/soil/anomalies/` | `implemented` | Same as above |
| `POST /api/farm-data/` | AI route exists; backend uses it for sync | `implemented` | Internal AI contract; not a backend public endpoint |
| `GET /api/farm-data/{farm_uuid}/detail/` | AI route exists: `Ai/farm_data/urls.py` | `implemented` | Internal AI service contract |
| `POST /api/farm-data/parameters/` | AI route exists: `Ai/farm_data/urls.py` | `implemented` | Internal AI service contract |
| `POST /api/weather/farm-card/` | backend route exists; AI canonical route also exists | `implemented` | Backend proxies to weather functionality |
| `POST /api/weather/water-need-prediction/` | AI route exists; backend public contract differs | `partially_implemented` | AI path is real; backend public path is different |
| `POST /api/economy/overview/` | backend + AI route exist | `implemented` | End-to-end connected |
| `GET /api/plants/` | AI route exists as `Ai/plant/urls.py` and backend route exists as `GET /api/plants/` | `implemented` | Different services, both real |
| `POST /api/plants/` | AI + backend real | `implemented` | Different services, both real |
| `GET /api/plants/{pk}/` | AI + backend real | `implemented` | Backend is canonical catalog; AI is its own service/snapshot consumer |
| `PUT /api/plants/{pk}/` | AI route real; backend route not exposed with PUT | `partially_implemented` | Real on AI, not mirrored on backend public app |
| `PATCH /api/plants/{pk}/` | AI route real; backend route not exposed with PATCH | `partially_implemented` | Same limitation |
| `DELETE /api/plants/{pk}/` | AI route real; backend route not exposed with DELETE | `partially_implemented` | Same limitation |
| `POST /api/plants/fetch-info/` | AI route real | `implemented` | AI route exists; backend public equivalent is absent |
| `POST /api/pest-disease/detect/` | backend alias + AI route real | `implemented` | Canonical current path |
| `POST /api/pest-disease/risk/` | backend alias + AI route real | `implemented` | Canonical current path |
| `POST /api/pest-disease/risk-summary/` | backend alias route exists | `implemented` | Implemented in backend alias layer |
| `GET /api/irrigation/` | backend + AI real | `implemented` | Canonical list route |
| `POST /api/irrigation/` | AI route real; backend route currently list/create mismatch | `partially_implemented` | Backend public create contract is not yet cleanly reconciled |
| `GET /api/irrigation/{pk}/` | AI route real; backend route missing | `partially_implemented` | Real in AI only |
| `PUT /api/irrigation/{pk}/` | AI route real; backend route missing | `contract_only` | Present in mock/spec and AI service, not a backend public route |
| `PATCH /api/irrigation/{pk}/` | AI route real; backend route missing | `contract_only` | Same |
| `DELETE /api/irrigation/{pk}/` | AI route real; backend route missing | `contract_only` | Same |
| `POST /api/irrigation/recommend/` | backend + AI real | `implemented` | Canonical route |
| `GET /api/irrigation/recommend/{task_id}/status/` | mock/spec only | `contract_only` | No current backend or AI route registration found |
| `POST /api/fertilization/recommend/` | backend + AI real | `implemented` | Canonical route |
| `GET /api/fertilization/recommend/{task_id}/status/` | mock/spec only | `contract_only` | No current route registration found |
| `POST /api/crop-simulation/growth/` | AI route real; backend canonical client route is `/api/yield-harvest/growth/` | `deprecated` | Real AI route, but backend public source-of-truth remains under `yield-harvest/*` |
| `GET /api/crop-simulation/growth/{task_id}/status/` | AI route real; backend canonical client route is `/api/yield-harvest/growth/{task_id}/status/` | `deprecated` | Same |
| `POST /api/crop-simulation/current-farm-chart/` | AI route real; backend canonical client route is `/api/yield-harvest/current-farm-chart/` | `deprecated` | Same |
| `POST /api/crop-simulation/harvest-prediction/` | AI route real; backend canonical client route is `/api/yield-harvest/harvest-prediction/` | `deprecated` | Same |
| `POST /api/crop-simulation/yield-prediction/` | AI route real; backend canonical client route is `/api/yield-harvest/yield-prediction/` | `deprecated` | Same |
- `crop-simulation` ها هنوز در `yield_harvest/views.py` به AI وصل هستند، ولی route عمومی backend آن‌ها حذف شده است.
- `farm-alerts/tracker` و `farm-alerts/timeline` در backend وجود دارند، اما داده‌شان mock است و به AI وصل نیستند.
- `weather/farm-card` برای AI از route دیگری استفاده می‌کند: `/weather-forecast/card`.
- `irrigation/water-stress` هم به جای route درخواستی، به `/api/water/stress-index/` روی AI وصل شده است.
- `soil-data` وضعیت یکدستی ندارد: specهای mock برای `/api/soil-data/...` موجود است، ولی call واقعی کد به `/soil-data` بدون پیشوند `/api` دیده می‌شود.
## Response Semantics
## جمع‌بندی
- `farm-alerts/tracker` backend route → `cached snapshot`
- `irrigation/*` backend routes → mostly `proxy` or `backend-owned data with AI enrichment`
- `yield-harvest/*` backend routes → `proxy` to AI plus persisted backend logs for some summaries
- `farm-data/*` AI routes → `AI-owned derived read/write model`
- متصل به AI با همین route: `15` مورد
- متصل به AI ولی با route متفاوت: `7` مورد
- متصل نیست: `18` مورد
## Reconciliation Notes
- `pest-disease/*` is now the real backend alias and AI contract. Older references to `pest-detection/analyze` as the “real” path are stale.
- `farm-alerts/timeline` is not a registered backend route and must not be documented as implemented.
- `soil-data/*`, `farm-data/*`, and several `plants/*` routes are real on the AI service, but not backend public routes; docs must distinguish internal AI contracts from backend client APIs.
- `crop-simulation/*` remains real on AI, while backend public endpoints are exposed under `yield-harvest/*`.
- task status endpoints for fertilization and irrigation recommendation remain mock/spec-only in `Backend/external_api_adapter/json/ai/index.json`.
- schema UI endpoints are intentionally disabled in AI; developers should rely on version-controlled audit docs until schema publishing is intentionally re-enabled.
## Known Gaps / Follow-up
- Some backend docs still use historical “AI route” wording where “internal AI contract” would be more precise.
- Some dashboard-era docs still need cleanup where old mock fallback language remains.
+57 -73
View File
@@ -1,84 +1,68 @@
# گزارش وضعیت استفاده APIهای درخواستی
# Requested Endpoint Usage Audit
این گزارش فقط بر اساس کد همین repository تهیه شده است و برای هر API سه چیز بررسی شده:
This file is the backend-facing API status matrix reconciled against current code.
- آیا به عنوان route واقعی در Django backend اکسپوز شده است یا نه
- آیا در کد، تست‌ها، داکیومنت‌های پروژه یا adapterها به آن ارجاع داده شده است یا نه
- اگر path/method اشتباه باشد، نزدیک‌ترین endpoint واقعی پروژه چیست
Status vocabulary:
## 1) استفاده‌شده و فعال در backend
- `implemented`
- `partially_implemented`
- `stub/contract-only`
- `deprecated`
- `missing`
این APIها هم در routeهای backend وجود دارند و هم در کد/تست/داک پروژه استفاده شده‌اند.
## Endpoint Matrix
| API | وضعیت | شواهد |
|---|---|---|
| `POST /api/weather/farm-card/` | فعال و استفاده‌شده | `water/weather_urls.py`, `water/views.py`, `water/tests.py` |
| `POST /api/economy/overview/` | فعال و استفاده‌شده | `economic_overview/urls.py`, `economic_overview/views.py`, `FRONTEND_PAGES_APIS_GUIDE.md` |
| `GET /api/irrigation/` | فعال و استفاده‌شده | `irrigation/urls.py`, `irrigation/views.py`, `API_DATA_SOURCE_AUDIT_FA.md` |
| `POST /api/irrigation/recommend/` | فعال و استفاده‌شده | `irrigation/urls.py`, `irrigation/views.py`, `irrigation/tests.py` |
| `POST /api/irrigation/water-stress/` | فعال و استفاده‌شده | `irrigation/urls.py`, `irrigation/tests.py` |
| `POST /api/fertilization/recommend/` | فعال و استفاده‌شده | `fertilization/urls.py`, `fertilization/views.py`, `API_DATA_SOURCE_AUDIT_FA.md` |
| Endpoint | Backend route | AI route | Status | Notes |
|---|---|---|---:|---|
| `POST /api/weather/farm-card/` | yes | yes | `implemented` | Current backend public weather card route. |
| `POST /api/economy/overview/` | yes | yes | `implemented` | End-to-end route is live. |
| `GET /api/irrigation/` | yes | yes | `implemented` | Method list route is live. |
| `POST /api/irrigation/recommend/` | yes | yes | `implemented` | Recommendation route is live. |
| `POST /api/irrigation/water-stress/` | yes | yes | `implemented` | Backend route proxies to AI-backed water stress flow. |
| `POST /api/fertilization/recommend/` | yes | yes | `implemented` | Live route. |
| `POST /api/pest-disease/detect/` | yes | yes | `implemented` | Canonical current public alias. |
| `POST /api/pest-disease/risk/` | yes | yes | `implemented` | Canonical current public alias. |
| `POST /api/pest-disease/risk-summary/` | yes | no separate AI route | `implemented` | Backend route derives risk summary from the same AI risk integration. |
| `POST /api/farm-alerts/tracker/` | yes | yes | `partially_implemented` | Backend serves snapshot-backed tracker response; not a direct request-time AI invocation. |
| `POST /api/farm-alerts/timeline/` | no | no | `missing` | Was documented, but no route exists. |
| `POST /api/soil/summary/` | yes | n/a | `implemented` | Backend public summary route. |
| `POST /api/soil/anomalies/` | yes | via `POST /api/soile/anomaly-detection/` | `implemented` | Backend canonical route. |
| `POST /api/soil/moisture-heatmap/` | yes | via `POST /api/soile/moisture-heatmap/` | `implemented` | Backend canonical route. |
| `POST /api/crop-health/ndvi-health/` | yes | via `POST /api/soil-data/ndvi-health/` | `implemented` | Backend canonical route. |
| `POST /api/yield-harvest/current-farm-chart/` | yes | via `/api/crop-simulation/current-farm-chart/` | `implemented` | Backend canonical route. |
| `POST /api/yield-harvest/harvest-prediction/` | yes | via `/api/crop-simulation/harvest-prediction/` | `implemented` | Backend canonical route. |
| `POST /api/yield-harvest/yield-prediction/` | yes | via `/api/crop-simulation/yield-prediction/` | `implemented` | Backend canonical route. |
| `POST /api/yield-harvest/growth/` | yes | via `/api/crop-simulation/growth/` | `implemented` | Backend canonical route. |
| `GET /api/yield-harvest/growth/{task_id}/status/` | yes | via `/api/crop-simulation/growth/{task_id}/status/` | `implemented` | Backend canonical route. |
| `GET /api/yield-harvest/summary/` | yes | no | `implemented` | Summary route exists. |
| `GET /api/yield-harvest/yield-harvest-summary/` | yes | via AI summary service | `implemented` | Compatibility alias remains live. |
## 2) در پروژه استفاده شده‌اند، اما به عنوان endpoint مستقیم backend اکسپوز نیستند
## Internal AI Contracts Not To Present As Backend Public APIs
این‌ها یا فقط به عنوان path سرویس خارجی AI استفاده می‌شوند، یا route داخلی‌شان با path دیگری در backend ارائه شده است.
| Endpoint | Status | Notes |
|---|---:|---|
| `POST /api/rag/chat/` | `implemented` | AI service route only. |
| `GET|POST /api/soil-data/` | `implemented` | AI service route only. |
| `GET /api/soil-data/tasks/{task_id}/status/` | `implemented` | AI service route only. |
| `POST /api/soile/*` | `implemented` | AI service routes; backend public aliases are under `soil/*`. |
| `POST /api/farm-data/` | `implemented` | AI service route used for integration and sync. |
| `GET /api/farm-data/{farm_uuid}/detail/` | `implemented` | AI service route. |
| `POST /api/farm-data/parameters/` | `implemented` | AI service route. |
| `POST /api/weather/water-need-prediction/` | `implemented` | AI service route; backend public contract is under `water/*`. |
| `POST /api/crop-simulation/*` | `implemented` | AI service routes; backend public contract is under `yield-harvest/*`. |
| API | وضعیت | توضیح | شواهد |
|---|---|---|---|
| `POST /api/rag/chat/` | استفاده داخلی | route محلی نیست؛ به عنوان درخواست خروجی به سرویس AI استفاده می‌شود | `farm_ai_assistant/views.py`, `external_api_adapter/json/ai/index.json` |
| `GET /api/soil-data/` | فقط contract/mock | route محلی ندارد؛ فقط در adapter mock/spec آمده | `external_api_adapter/json/ai/index.json` |
| `POST /api/soil-data/` | استفاده داخلی/contract | route محلی ندارد؛ mock/spec دارد و integration نزدیک آن در `crop_zoning` به `/soil-data` صدا زده می‌شود | `external_api_adapter/json/ai/index.json`, `crop_zoning/services.py` |
| `GET /api/soil-data/tasks/<task_id>/status/` | فقط contract/mock | route محلی ندارد؛ فقط در adapter mock/spec آمده | `external_api_adapter/json/ai/index.json` |
| `POST /api/soile/moisture-heatmap/` | استفاده داخلی | backend به جای آن `POST /api/soil/moisture-heatmap/` را اکسپوز کرده و این path را به AI صدا می‌زند | `soil/views.py`, `soil/tests.py`, `soil/urls.py` |
| `POST /api/soile/health-summary/` | استفاده داخلی | backend به جای آن `POST /api/soil/summary/` را اکسپوز کرده و این path را به AI صدا می‌زند | `soil/views.py`, `soil/tests.py`, `soil/urls.py` |
| `POST /api/soile/anomaly-detection/` | استفاده داخلی | backend به جای آن `POST /api/soil/anomalies/` را اکسپوز کرده و این path را به AI صدا می‌زند | `soil/views.py`, `soil/tests.py`, `soil/urls.py` |
| `POST /api/farm-data/` | استفاده داخلی | route محلی ندارد؛ برای sync داده مزرعه به سرویس بیرونی استفاده می‌شود | `farm_hub/services.py`, `sensor_external_api/services.py`, `farm_hub/tests.py` |
| `POST /api/weather/water-need-prediction/` | استفاده داخلی | route محلی ندارد؛ backend endpoint معادل را با `GET /api/water/need-prediction/` ارائه می‌کند و خودش این path را به AI صدا می‌زند | `water/views.py`, `water/urls.py`, `water/tests.py` |
| `POST /api/crop-simulation/growth/` | استفاده باقیمانده در کد | route آن حذف شده، ولی هنوز در view/testها ارجاع مانده | `yield_harvest/views.py`, `yield_harvest/tests.py` |
| `GET /api/crop-simulation/growth/<task_id>/status/` | استفاده باقیمانده در کد | route آن حذف شده، ولی هنوز در view/testها ارجاع مانده | `yield_harvest/views.py`, `yield_harvest/tests.py` |
| `POST /api/crop-simulation/current-farm-chart/` | استفاده باقیمانده در کد | route آن حذف شده، ولی هنوز به عنوان AI path در کد وجود دارد | `yield_harvest/views.py`, `yield_harvest/tests.py` |
| `POST /api/crop-simulation/harvest-prediction/` | استفاده باقیمانده در کد | route آن حذف شده، ولی هنوز به عنوان AI path در کد وجود دارد | `yield_harvest/views.py`, `yield_harvest/tests.py` |
| `POST /api/crop-simulation/yield-prediction/` | استفاده باقیمانده در کد | route آن حذف شده، ولی هنوز به عنوان AI path در کد وجود دارد | `yield_harvest/views.py` |
## Contract-Only / Stale Spec Entries
## 3) در لیست شما آمده‌اند، اما با method/path فعلی استفاده نمی‌شوند
| Endpoint | Status | Notes |
|---|---:|---|
| `GET /api/irrigation/recommend/{task_id}/status/` | `stub/contract-only` | Present in mock spec, no real route registration found. |
| `GET /api/fertilization/recommend/{task_id}/status/` | `stub/contract-only` | Present in mock spec, no real route registration found. |
| `PUT|PATCH|DELETE /api/irrigation/{pk}/` | `stub/contract-only` | Spec exists, but no backend public route is registered. |
این‌ها یا method اشتباه دارند، یا path صحیح پروژه چیز دیگری است، یا اصلا implementation محلی برایشان پیدا نشد.
## Deprecated Path Decisions
| API | وضعیت | توضیح | شواهد |
|---|---|---|---|
| `POST /api/farm-alerts/tracker/` | استفاده نمی‌شود | path وجود دارد ولی فقط `GET` پیاده‌سازی شده | `farm_alerts/urls.py`, `farm_alerts/views.py`, `FRONTEND_PAGES_APIS_GUIDE.md` |
| `POST /api/farm-alerts/timeline/` | استفاده نمی‌شود | path وجود دارد ولی فقط `GET` پیاده‌سازی شده | `farm_alerts/urls.py`, `farm_alerts/views.py`, `FRONTEND_PAGES_APIS_GUIDE.md` |
| `POST /api/soil-data/ndvi-health/` | استفاده نمی‌شود | endpoint واقعی پروژه `POST /api/soil/health/ndvi-health/` است | `crop_health/tests.py`, `crop_health/urls.py` |
| `GET /api/farm-data/<farm_uuid>/detail/` | استفاده نمی‌شود | route یا reference معناداری پیدا نشد | جستجو در کل repo |
| `POST /api/farm-data/parameters/` | استفاده نمی‌شود | route یا reference معناداری پیدا نشد | جستجو در کل repo |
| `GET /api/plants/` | استفاده نمی‌شود | فقط در mock/spec های adapter دیده شد؛ route محلی ندارد | `external_api_adapter/json/ai/index.json` |
| `POST /api/plants/` | استفاده نمی‌شود | فقط در mock/spec های adapter دیده شد؛ route محلی ندارد | `external_api_adapter/json/ai/index.json` |
| `GET /api/plants/<pk>/` | استفاده نمی‌شود | فقط در mock/spec های adapter دیده شد؛ route محلی ندارد | `external_api_adapter/json/ai/index.json` |
| `PUT /api/plants/<pk>/` | استفاده نمی‌شود | فقط در mock/spec های adapter دیده شد؛ route محلی ندارد | `external_api_adapter/json/ai/index.json` |
| `PATCH /api/plants/<pk>/` | استفاده نمی‌شود | فقط در mock/spec های adapter دیده شد؛ route محلی ندارد | `external_api_adapter/json/ai/index.json` |
| `DELETE /api/plants/<pk>/` | استفاده نمی‌شود | فقط در mock/spec های adapter دیده شد؛ route محلی ندارد | `external_api_adapter/json/ai/index.json` |
| `POST /api/plants/fetch-info/` | استفاده نمی‌شود | فقط در mock/spec های adapter دیده شد؛ route محلی ندارد | `external_api_adapter/json/ai/index.json` |
| `POST /api/pest-disease/detect/` | استفاده نمی‌شود | endpoint واقعی پروژه `POST /api/pest-detection/analyze/` است | `pest_detection/urls.py`, `pest_detection/views.py` |
| `POST /api/pest-disease/risk/` | استفاده نمی‌شود | endpoint واقعی پروژه `POST /api/pest-detection/risk/` است | `pest_detection/urls.py`, `pest_detection/views.py` |
| `POST /api/pest-disease/risk-summary/` | استفاده نمی‌شود | path و method هر دو متفاوت‌اند؛ endpoint واقعی `GET /api/pest-detection/risk-summary/` است | `pest_detection/urls.py`, `pest_detection/views.py`, `pest_detection/tests.py` |
| `POST /api/irrigation/` | استفاده نمی‌شود | path وجود دارد ولی فقط `GET` list پیاده‌سازی شده | `irrigation/urls.py`, `irrigation/views.py` |
| `GET /api/irrigation/<pk>/` | استفاده نمی‌شود | route detail پیدا نشد | `irrigation/urls.py` |
| `PUT /api/irrigation/<pk>/` | استفاده نمی‌شود | route detail/update پیدا نشد | `irrigation/urls.py` |
| `PATCH /api/irrigation/<pk>/` | استفاده نمی‌شود | route detail/update پیدا نشد | `irrigation/urls.py` |
| `DELETE /api/irrigation/<pk>/` | استفاده نمی‌شود | route detail/delete پیدا نشد | `irrigation/urls.py` |
## 4) جمع‌بندی سریع
- فعال و قابل استفاده در backend: `6` مورد
- استفاده داخلی یا باقیمانده در کد ولی بدون route مستقیم: `14` مورد
- استفاده‌نشده / path یا method اشتباه / بدون implementation: `20` مورد
## 5) نکات مهم برای پاک‌سازی
- `crop-simulation` routeها حذف شده‌اند، ولی referenceهای آن هنوز در `yield_harvest/views.py` و `yield_harvest/tests.py` باقی مانده‌اند.
- `rag/chat` و `farm-data` بیشتر contract داخلی با سرویس AI هستند، نه endpoint قابل استفاده برای کلاینت frontend.
- چند API در لیست شما نام قدیمی یا اشتباه دارند و در backend با path جدیدتری پیاده‌سازی شده‌اند:
- `soil-data/ndvi-health` -> `soil/health/ndvi-health`
- `pest-disease/*` -> `pest-detection/*`
- `weather/water-need-prediction` -> `water/need-prediction`
- `soile/*` -> به صورت داخلی برای AI استفاده می‌شود، ولی route عمومی backend با `soil/*` است
| Old path | Replacement |
|---|---|
| `/api/soil-data/ndvi-health/` | `/api/crop-health/ndvi-health/` |
| `/api/crop-simulation/*` as backend public routes | `/api/yield-harvest/*` |
| `/api/soile/*` as backend public routes | `/api/soil/*` |
+77 -16
View File
@@ -1,5 +1,7 @@
import hashlib
import json
import logging
import time
from functools import lru_cache
from pathlib import Path
from urllib.parse import urljoin
@@ -10,11 +12,15 @@ from django.core.cache import cache
from django.core.exceptions import ImproperlyConfigured
from django.http import QueryDict
from farm_hub.models import FarmHub
from config.observability import classify_exception, log_event, observe_operation, record_metric
from .catalog import GOLD_PLAN_CODE
from .models import AccessFeature, AccessRule, FarmAccessProfile, SubscriptionPlan
logger = logging.getLogger(__name__)
class AccessControlError(Exception):
pass
@@ -268,20 +274,54 @@ def request_opa_batch_authorization(farm, user, features, action, route=None):
payload = {"input": build_authorization_input(farm, user, features, action, route=route)}
try:
response = requests.post(
_opa_url(settings.ACCESS_CONTROL_AUTHZ_BATCH_PATH),
json=payload,
timeout=settings.ACCESS_CONTROL_AUTHZ_TIMEOUT,
)
response.raise_for_status()
except requests.RequestException as exc:
raise AccessControlServiceUnavailable("OPA authorization service is unavailable.") from exc
with observe_operation(source="backend.access_control", provider="opa", operation="batch_authorization"):
started_at = time.monotonic()
try:
response = requests.post(
_opa_url(settings.ACCESS_CONTROL_AUTHZ_BATCH_PATH),
json=payload,
timeout=settings.ACCESS_CONTROL_AUTHZ_TIMEOUT,
)
response.raise_for_status()
except requests.RequestException as exc:
failure = classify_exception(exc)
log_event(
level=logging.ERROR,
message="opa batch authorization request failed",
source="backend.access_control",
provider="opa",
operation="batch_authorization",
result_status="error",
duration_ms=(time.monotonic() - started_at) * 1000,
error_code=failure.error_code,
route=route,
feature_count=len(features),
)
record_metric("access_control.opa.failure", error_code=failure.error_code)
raise AccessControlServiceUnavailable("OPA authorization service is unavailable.") from exc
try:
return response.json().get("result", {})
except ValueError as exc:
raise AccessControlServiceUnavailable("OPA authorization service returned invalid JSON.") from exc
try:
result = response.json().get("result", {})
except ValueError as exc:
log_event(
level=logging.ERROR,
message="opa batch authorization returned invalid json",
source="backend.access_control",
provider="opa",
operation="batch_authorization",
result_status="error",
duration_ms=(time.monotonic() - started_at) * 1000,
error_code="parse_error",
route=route,
feature_count=len(features),
status_code=response.status_code,
)
record_metric("access_control.opa.invalid_json")
raise AccessControlServiceUnavailable("OPA authorization service returned invalid JSON.") from exc
if not result:
record_metric("access_control.opa.empty_result")
logger.warning("OPA returned empty authorization result for route=%s", route)
return result
def normalize_opa_batch_result(data, features):
@@ -319,7 +359,18 @@ def batch_authorize_features(farm, user, features, action, route=None):
try:
cached_result = cache.get(cache_key)
except Exception:
except Exception as exc:
failure = classify_exception(exc)
log_event(
level=logging.WARNING,
message="authorization cache read failed",
source="backend.access_control",
provider="cache",
operation="batch_authorize_features",
result_status="error",
error_code=failure.error_code,
route=route,
)
cached_result = None
if isinstance(cached_result, dict):
@@ -330,8 +381,18 @@ def batch_authorize_features(farm, user, features, action, route=None):
try:
cache.set(cache_key, decisions, timeout=_get_authz_cache_timeout())
except Exception:
pass
except Exception as exc:
failure = classify_exception(exc)
log_event(
level=logging.WARNING,
message="authorization cache write failed",
source="backend.access_control",
provider="cache",
operation="batch_authorize_features",
result_status="error",
error_code=failure.error_code,
route=route,
)
return decisions
+24
View File
@@ -4,6 +4,7 @@ from unittest.mock import patch
from django.test import RequestFactory, SimpleTestCase, override_settings
from account.views import ProfileView
from config.observability import METRICS
from .middleware import RouteFeatureAccessMiddleware
from .services import batch_authorize_features, build_authorization_input
@@ -19,6 +20,9 @@ TEST_CACHES = {
@override_settings(CACHES=TEST_CACHES, ACCESS_CONTROL_AUTHZ_CACHE_TIMEOUT=300)
class AccessControlServiceTests(SimpleTestCase):
def tearDown(self):
METRICS.clear()
def test_batch_authorize_features_uses_cache_for_same_route(self):
farm = SimpleNamespace(farm_uuid="farm-uuid")
user = SimpleNamespace(id=7)
@@ -95,6 +99,26 @@ class AccessControlServiceTests(SimpleTestCase):
},
)
@patch("access_control.services.requests.post")
@override_settings(ACCESS_CONTROL_AUTHZ_ENABLED=True, ACCESS_CONTROL_AUTHZ_BASE_URL="https://opa.example", ACCESS_CONTROL_AUTHZ_BATCH_PATH="/v1/data/authz", ACCESS_CONTROL_AUTHZ_TIMEOUT=1)
def test_request_opa_batch_authorization_records_invalid_json_metric(self, mock_post):
response = mock_post.return_value
response.raise_for_status.return_value = None
response.json.side_effect = ValueError("bad json")
farm = SimpleNamespace(farm_uuid="farm-uuid")
user = SimpleNamespace(id=7, username="u", email="", phone_number="", is_staff=False, is_superuser=False)
with self.assertRaises(Exception):
batch_authorize_features(
farm=farm,
user=user,
features=["farm_dashboard"],
action="view",
route="/api/farm-dashboard/",
)
self.assertEqual(METRICS["access_control.opa.invalid_json"], 1)
class RouteFeatureAccessMiddlewareTests(SimpleTestCase):
def test_middleware_passes_route_feature_and_method_to_service(self):
Binary file not shown.
+53
View File
@@ -0,0 +1,53 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
@dataclass
class FailureContract:
status: str = "error"
error_code: str = "internal_error"
message: str = ""
source: str = "application"
warnings: list[str] = field(default_factory=list)
retriable: bool = False
details: dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
payload = {
"status": self.status,
"error_code": self.error_code,
"message": self.message,
"source": self.source,
"warnings": list(self.warnings),
"retriable": self.retriable,
}
if self.details:
payload["details"] = self.details
return payload
class StructuredServiceError(Exception):
def __init__(
self,
*,
error_code: str,
message: str,
source: str,
warnings: list[str] | None = None,
retriable: bool = False,
details: dict[str, Any] | None = None,
) -> None:
super().__init__(message)
self.contract = FailureContract(
error_code=error_code,
message=message,
source=source,
warnings=warnings or [],
retriable=retriable,
details=details or {},
)
def to_dict(self) -> dict[str, Any]:
return self.contract.to_dict()
+45
View File
@@ -0,0 +1,45 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
def _isoformat(value: Any) -> Any:
if isinstance(value, datetime):
return value.isoformat()
return value
def build_integration_meta(
*,
flow_type: str,
source_type: str,
source_service: str,
ownership: str,
live: bool,
cached: bool,
generated_at: Any = None,
snapshot_at: Any = None,
sync_attempted: bool | None = None,
sync_status: str | None = None,
notes: list[str] | None = None,
) -> dict[str, Any]:
meta = {
"flow_type": flow_type,
"source_type": source_type,
"source_service": source_service,
"ownership": ownership,
"live": live,
"cached": cached,
}
if generated_at is not None:
meta["generated_at"] = _isoformat(generated_at)
if snapshot_at is not None:
meta["snapshot_at"] = _isoformat(snapshot_at)
if sync_attempted is not None:
meta["sync_attempted"] = sync_attempted
if sync_status is not None:
meta["sync_status"] = sync_status
if notes:
meta["notes"] = notes
return meta
+129
View File
@@ -0,0 +1,129 @@
from __future__ import annotations
import logging
import time
from collections import Counter
from contextvars import ContextVar
from dataclasses import dataclass
from typing import Any
logger = logging.getLogger(__name__)
_request_id_ctx: ContextVar[str | None] = ContextVar("backend_request_id", default=None)
METRICS: Counter[str] = Counter()
def set_request_id(request_id: str | None) -> None:
_request_id_ctx.set(request_id)
def get_request_id() -> str | None:
return _request_id_ctx.get()
def record_metric(name: str, value: int = 1, **tags: Any) -> None:
suffix = ",".join(f"{key}={tags[key]}" for key in sorted(tags) if tags[key] is not None)
metric_key = f"{name}|{suffix}" if suffix else name
METRICS[metric_key] += value
@dataclass
class ClassifiedFailure:
error_code: str
failure_type: str
retriable: bool
def classify_exception(exc: Exception) -> ClassifiedFailure:
exc_name = exc.__class__.__name__.lower()
message = str(exc).lower()
if "timeout" in exc_name or "timeout" in message:
return ClassifiedFailure("timeout", "timeout", True)
if "json" in exc_name or "json" in message:
return ClassifiedFailure("parse_error", "parse_error", False)
if "validation" in exc_name or "invalid" in message:
return ClassifiedFailure("validation_failure", "validation_failure", False)
if "connection" in exc_name or "unavailable" in message:
return ClassifiedFailure("dependency_unavailable", "dependency_unavailable", True)
return ClassifiedFailure("provider_error", "provider_error", True)
def log_event(
*,
level: int,
message: str,
source: str,
provider: str | None,
operation: str,
result_status: str,
duration_ms: float | None = None,
error_code: str | None = None,
**extra: Any,
) -> None:
payload = {
"source": source,
"provider": provider,
"operation": operation,
"result_status": result_status,
"duration_ms": round(duration_ms, 2) if duration_ms is not None else None,
"error_code": error_code,
"request_id": get_request_id(),
}
payload.update({key: value for key, value in extra.items() if value is not None})
logger.log(level, message, extra={"event": payload})
class observe_operation:
def __init__(self, *, source: str, provider: str | None, operation: str):
self.source = source
self.provider = provider
self.operation = operation
self.started_at = 0.0
def __enter__(self):
self.started_at = time.monotonic()
log_event(
level=logging.INFO,
message="backend operation started",
source=self.source,
provider=self.provider,
operation=self.operation,
result_status="started",
)
return self
def __exit__(self, exc_type, exc, _tb):
duration_ms = (time.monotonic() - self.started_at) * 1000
if exc is None:
log_event(
level=logging.INFO,
message="backend operation completed",
source=self.source,
provider=self.provider,
operation=self.operation,
result_status="success",
duration_ms=duration_ms,
)
record_metric("backend.operation.success", source=self.source, provider=self.provider, operation=self.operation)
return False
failure = classify_exception(exc)
log_event(
level=logging.ERROR,
message="backend operation failed",
source=self.source,
provider=self.provider,
operation=self.operation,
result_status="error",
duration_ms=duration_ms,
error_code=failure.error_code,
failure_type=failure.failure_type,
)
record_metric(
"backend.operation.failure",
source=self.source,
provider=self.provider,
operation=self.operation,
error_code=failure.error_code,
)
return False
+27
View File
@@ -0,0 +1,27 @@
DEFAULT_AREA_FEATURE = {
"area": {
"type": "Feature",
"properties": {},
"geometry": {
"type": "Polygon",
"coordinates": [
[
[51.38, 35.68],
[51.405, 35.672],
[51.41, 35.695],
[51.385, 35.71],
[51.365, 35.688],
[51.38, 35.68],
]
],
},
}
}
DEFAULT_PRODUCTS_PAYLOAD = {
"products": [
{"id": "wheat", "label": "گندم", "color": "#6bcb77"},
{"id": "canola", "label": "کلزا", "color": "#ffd93d"},
{"id": "saffron", "label": "زعفران", "color": "#9b59b6"},
]
}
+3 -3
View File
@@ -13,7 +13,7 @@ from farm_hub.models import FarmHub
from external_api_adapter.adapter import request as external_request
from .mock_data import AREA_RESPONSE_DATA, PRODUCTS_RESPONSE_DATA
from .defaults import DEFAULT_AREA_FEATURE, DEFAULT_PRODUCTS_PAYLOAD
from .models import (
CropArea,
CropProduct,
@@ -27,7 +27,7 @@ from .models import (
)
EARTH_RADIUS_METERS = 6378137.0
PRODUCT_DEFAULTS = PRODUCTS_RESPONSE_DATA["products"]
PRODUCT_DEFAULTS = DEFAULT_PRODUCTS_PAYLOAD["products"]
DEFAULT_CELL_SIDE_KM = 0.15
DEFAULT_ZONE_PAGE_SIZE = 10
RULE_BASED_ALGORITHM = "rule_based_v1"
@@ -132,7 +132,7 @@ def get_zone_page_request_params(query_params):
def get_default_area_feature():
return deepcopy(AREA_RESPONSE_DATA["area"])
return deepcopy(DEFAULT_AREA_FEATURE["area"])
def normalize_area_feature(area_feature):
+42
View File
@@ -0,0 +1,42 @@
from copy import deepcopy
VALID_ROW_IDS = [
"overviewKpis",
"weatherAlerts",
"sensorMonitoring",
"sensorCharts",
"alertsWater",
"predictions",
"soilHeatmap",
"ndviRecommendations",
"economic",
]
VALID_CARD_IDS = [
"farmOverviewKpis",
"farmWeatherCard",
"farmAlertsTracker",
"sensorValuesList",
"sensorRadarChart",
"sensorComparisonChart",
"anomalyDetectionCard",
"farmAlertsTimeline",
"waterNeedPrediction",
"harvestPredictionCard",
"yieldPredictionChart",
"soilMoistureHeatmap",
"ndviHealthCard",
"recommendationsList",
"economicOverview",
]
DEFAULT_CONFIG = {
"disabled_card_ids": [],
"row_order": VALID_ROW_IDS.copy(),
"enable_drag_reorder": True,
}
def get_default_dashboard_config():
return deepcopy(DEFAULT_CONFIG)
+18 -360
View File
@@ -1,363 +1,21 @@
"""
Static mock data for Farm Dashboard API.
Backward-compatible mock exports for dashboard fake content.
Use `dashboard.defaults` for runtime configuration defaults and
`dashboard.templates` for fallback card payload templates.
"""
from copy import deepcopy
from threading import Lock
VALID_ROW_IDS = [
"overviewKpis",
"weatherAlerts",
"sensorMonitoring",
"sensorCharts",
"alertsWater",
"predictions",
"soilHeatmap",
"ndviRecommendations",
"economic",
]
VALID_CARD_IDS = [
"farmOverviewKpis",
"farmWeatherCard",
"farmAlertsTracker",
"sensorValuesList",
"sensorRadarChart",
"sensorComparisonChart",
"anomalyDetectionCard",
"farmAlertsTimeline",
"waterNeedPrediction",
"harvestPredictionCard",
"yieldPredictionChart",
"soilMoistureHeatmap",
"ndviHealthCard",
"recommendationsList",
"economicOverview",
]
DEFAULT_CONFIG = {
"disabled_card_ids": [],
"row_order": VALID_ROW_IDS.copy(),
"enable_drag_reorder": True,
}
_config_lock = Lock()
_config_state = deepcopy(DEFAULT_CONFIG)
def get_config():
with _config_lock:
return deepcopy(_config_state)
def update_config(changes):
with _config_lock:
_config_state.update(deepcopy(changes))
return deepcopy(_config_state)
def reset_config():
with _config_lock:
_config_state.clear()
_config_state.update(deepcopy(DEFAULT_CONFIG))
return deepcopy(_config_state)
# 4.1 farmOverviewKpis
FARM_OVERVIEW_KPIS = {
"kpis": [
{
"id": "disease_risk",
"title": "ریسک بیماری",
"subtitle": "۷ روز اخیر",
"stats": "پایین",
"avatarColor": "success",
"avatarIcon": "tabler-bug",
"chipText": "5%",
"chipColor": "success",
},
{
"id": "yield_prediction",
"title": "پیش‌بینی عملکرد",
"subtitle": "این فصل",
"stats": "42 تن",
"avatarColor": "secondary",
"avatarIcon": "tabler-chart-bar",
"chipText": "+8%",
"chipColor": "success",
},
{
"id": "pest_risk",
"title": "ریسک آفات",
"subtitle": "پیش‌بینی هوشمند",
"stats": "15%",
"avatarColor": "warning",
"avatarIcon": "tabler-bug-off",
"chipText": "تحت نظر",
"chipColor": "warning",
},
]
}
# 4.2 farmWeatherCard
FARM_WEATHER_CARD = {
"condition": "صاف",
"temperature": 24,
"unit": "°C",
"humidity": 45,
"windSpeed": 12,
"windUnit": "km/h",
"chartData": {
"labels": ["۶ صبح", "۹ صبح", "۱۲ ظهر", "۳ بعدازظهر", "۶ عصر", "۹ شب", "۱۲ شب"],
"series": [[18, 22, 26, 28, 25, 20, 18]],
},
}
# 4.3 farmAlertsTracker
FARM_ALERTS_TRACKER = {
"totalAlerts": 3,
"radialBarValue": 30,
"alertStats": [
{
"title": "کمبود آب",
"count": "2",
"avatarColor": "error",
"avatarIcon": "tabler-droplet-half-2",
},
{
"title": "ریسک قارچی",
"count": "1",
"avatarColor": "warning",
"avatarIcon": "tabler-mushroom",
},
{
"title": "هشدار یخبندان",
"count": "0",
"avatarColor": "info",
"avatarIcon": "tabler-snowflake",
},
],
}
# 4.4 sensorValuesList
SENSOR_VALUES_LIST = {
"sensors": [
{
"title": "28°C",
"subtitle": "دمای هوا",
"trendNumber": 2.1,
"trend": "positive",
"unit": "°C",
},
{
"title": "24°C",
"subtitle": "دمای خاک",
"trendNumber": -0.5,
"trend": "negative",
"unit": "°C",
},
{
"title": "65%",
"subtitle": "رطوبت هوا",
"trendNumber": 3.2,
"trend": "positive",
"unit": "%",
},
{
"title": "42%",
"subtitle": "رطوبت خاک (۱۰ سانتی‌متر)",
"trendNumber": -1.8,
"trend": "negative",
"unit": "%",
},
{
"title": "6.8",
"subtitle": "pH خاک",
"trendNumber": 0.2,
"trend": "positive",
"unit": "pH",
},
{
"title": "1.2",
"subtitle": "هدایت الکتریکی (dS/m)",
"trendNumber": 0.1,
"trend": "positive",
"unit": "dS/m",
},
{
"title": "850",
"subtitle": "شدت نور (لوکس)",
"trendNumber": 15.3,
"trend": "positive",
"unit": "lux",
},
{
"title": "12",
"subtitle": "سرعت باد (کیلومتر/ساعت)",
"trendNumber": -2.4,
"trend": "negative",
"unit": "km/h",
},
]
}
# 4.8 farmAlertsTimeline
FARM_ALERTS_TIMELINE = {
"alerts": [
{
"title": "ریسک کمبود آب",
"description": "رطوبت خاک در عمق ۱۰ سانتی‌متر (۴۲٪) کمتر از حد بهینه است. پیش‌بینی: در صورت عدم آبیاری، تنش طی ۲ تا ۳ روز. توصیه: آبیاری ظرف ۲۴ ساعت.",
"time": "۱۵ دقیقه پیش",
"color": "warning",
},
{
"title": "ریسک بیماری قارچی",
"description": "رطوبت بالا (۶۵٪) و دمای ۲۴ درجه شرایط مساعد برای رشد قارچ. استفاده از قارچ‌کش پیشگیرانه یا کاهش آبیاری را در نظر بگیرید.",
"time": "۱ ساعت پیش",
"color": "error",
},
{
"title": "پیشنهاد آبیاری",
"description": "بازه بهینه آبیاری: ۶:۰۰ تا ۸:۰۰ صبح. حجم پیشنهادی: ۴۵۰ مترمکعب برای زون آ. بهبود راندمان مورد انتظار: ۱۲٪.",
"time": "۲ ساعت پیش",
"color": "info",
},
{
"title": "بررسی شوری خاک",
"description": "مقدار هدایت الکتریکی ۱/۲ dS/m در محدوده مجاز است. نیازی به اقدام نیست. بررسی بعدی توصیه می‌شود ظرف ۵ روز.",
"time": "۴ ساعت پیش",
"color": "success",
},
]
}
# 4.9 waterNeedPrediction
WATER_NEED_PREDICTION = {
"totalNext7Days": 3290,
"unit": "",
"categories": ["روز ۱", "روز ۲", "روز ۳", "روز ۴", "روز ۵", "روز ۶", "روز ۷"],
"series": [{"name": "نیاز آبی", "data": [420, 450, 480, 460, 490, 510, 480]}],
}
# 4.10 harvestPredictionCard
HARVEST_PREDICTION_CARD = {
"date": "2025-10-15",
"dateFormatted": "۱۵ اکتبر ۲۰۲۵",
"daysUntil": 58,
"description": "بر اساس تجمع GDD فعلی و پیش‌بینی آب و هوا. بازه بهینه برداشت: ۱۲ تا ۱۸ اکتبر.",
"optimalWindowStart": "2025-10-12",
"optimalWindowEnd": "2025-10-18",
}
# 4.11 yieldPredictionChart
YIELD_PREDICTION_CHART = {
"categories": ["ژانویه", "فوریه", "مارس", "آوریل", "می", "ژوئن", "ژوئیه", "آگوست", "سپتامبر", "اکتبر", "نوامبر", "دسامبر"],
"series": [
{"name": "امسال", "data": [35, 38, 40, 42, 45, 48, 50, 48, 46, 44, 42, 42]},
{"name": "سال گذشته", "data": [32, 34, 36, 38, 40, 42, 44, 42, 40, 38, 36, 38]},
],
"summary": [
{
"title": "عملکرد پیش‌بینی‌شده",
"subtitle": "این فصل",
"amount": "42 تن",
"avatarColor": "primary",
"avatarIcon": "tabler-chart-bar",
},
{
"title": "تاریخ برداشت",
"subtitle": "حدود ۱۵ اکتبر",
"amount": "+8%",
"avatarColor": "success",
"avatarIcon": "tabler-calendar",
},
],
}
# 4.14 recommendationsList
RECOMMENDATIONS_LIST = {
"recommendations": [
{
"title": "آبیاری: ۶:۰۰ تا ۸:۰۰ صبح",
"subtitle": "۴۵۰ مترمکعب برای زون آ. بدون آبیاری، عملکرد ممکن است حدود ۸٪ کاهش یابد.",
"avatarIcon": "tabler-droplet",
"avatarColor": "primary",
},
{
"title": "کود: NPK 20-20-20",
"subtitle": "اعمال ۲۵ کیلوگرم در هکتار ظرف ۷ روز. کمبود نیتروژن فعلی در بخش ۲.",
"avatarIcon": "tabler-leaf",
"avatarColor": "success",
},
{
"title": "قارچ‌کش: پیشگیرانه",
"subtitle": "رطوبت و دما مساعد قارچ. سمپاشی بر پایه مس را در نظر بگیرید.",
"avatarIcon": "tabler-mushroom",
"avatarColor": "warning",
},
{
"title": "بازه برداشت: ۱۲ تا ۱۸ اکتبر",
"subtitle": "اوج رسیدگی حدود ۱۵ اکتبر. نیروی کار را متناسب برنامه‌ریزی کنید.",
"avatarIcon": "tabler-calendar-event",
"avatarColor": "info",
},
]
}
# 4.15 economicOverview
ECONOMIC_OVERVIEW = {
"economicData": [
{
"title": "هزینه آب",
"value": "€720",
"subtitle": "این ماه",
"avatarIcon": "tabler-droplet",
"avatarColor": "primary",
},
{
"title": "صرفه‌جویی آب هوشمند",
"value": "€156",
"subtitle": "۱۸٪ صرفه‌جویی شده",
"avatarIcon": "tabler-bulb",
"avatarColor": "success",
},
{
"title": "بازده سرمایه پلتفرم",
"value": "127%",
"subtitle": "نسبت به سال گذشته",
"avatarIcon": "tabler-chart-line",
"avatarColor": "info",
},
{
"title": "پیش‌بینی درآمد",
"value": "€42k",
"subtitle": "این فصل",
"avatarIcon": "tabler-currency-euro",
"avatarColor": "success",
},
],
"chartSeries": [
{"name": "هزینه آب", "data": [120, 115, 110, 125, 118, 122]},
{"name": "کود", "data": [80, 85, 90, 75, 82, 78]},
],
"chartCategories": ["ژانویه", "فوریه", "مارس", "آوریل", "می", "ژوئن"],
}
# Unified response for GET /api/farm-dashboard (section 5)
ALL_CARDS = {
"farmOverviewKpis": FARM_OVERVIEW_KPIS , # این باید سه روز یکبار محتواش محاسبه بشه
"farmWeatherCard": FARM_WEATHER_CARD, # هروز
"farmAlertsTracker": FARM_ALERTS_TRACKER, #هروز
"sensorValuesList": SENSOR_VALUES_LIST,#هروز
"sensorRadarChart": {},
"sensorComparisonChart": {},
"anomalyDetectionCard": {},
"farmAlertsTimeline": FARM_ALERTS_TIMELINE,
"waterNeedPrediction": WATER_NEED_PREDICTION,
"harvestPredictionCard": HARVEST_PREDICTION_CARD,
"yieldPredictionChart": YIELD_PREDICTION_CHART,
"soilMoistureHeatmap": {},
"ndviHealthCard": {},
"recommendationsList": RECOMMENDATIONS_LIST, # این باید حتما از recommendetion ها گرفته بشه
"economicOverview": ECONOMIC_OVERVIEW,
}
from .defaults import DEFAULT_CONFIG, VALID_CARD_IDS, VALID_ROW_IDS
from .templates import (
ALL_CARD_TEMPLATES as ALL_CARDS,
ECONOMIC_OVERVIEW,
FARM_ALERTS_TIMELINE,
FARM_ALERTS_TRACKER,
FARM_OVERVIEW_KPIS,
FARM_WEATHER_CARD,
HARVEST_PREDICTION_CARD,
RECOMMENDATIONS_LIST,
SENSOR_VALUES_LIST,
WATER_NEED_PREDICTION,
YIELD_PREDICTION_CHART,
)
+1 -1
View File
@@ -1,6 +1,6 @@
from rest_framework import serializers
from .mock_data import VALID_CARD_IDS, VALID_ROW_IDS
from .defaults import VALID_CARD_IDS, VALID_ROW_IDS
class FarmDashboardConfigSerializer(serializers.Serializer):
+18 -10
View File
@@ -20,7 +20,7 @@ from device_hub.services import (
)
from yield_harvest.services import get_yield_harvest_summary_data
from .mock_data import ALL_CARDS
from .templates import get_all_card_templates
def _update_kpi(card_lookup, card_data):
@@ -83,33 +83,41 @@ def _build_recommendations_list(farm, fallback_data, harvest_card):
def get_farm_dashboard_cards(farm):
cards = deepcopy(ALL_CARDS)
cards = get_all_card_templates()
weather_card = get_farm_weather_card_data(farm)
water_cards = {
"farmWeatherCard": get_farm_weather_card_data(farm),
"waterNeedPrediction": get_water_need_prediction_data(farm),
"waterStressIndex": get_water_stress_index_data(farm),
}
crop_health_summary = get_crop_health_summary_data(farm)
risk_summary = get_risk_summary_data(farm)
yield_summary = get_yield_harvest_summary_data(farm)
water_stress_index = get_water_stress_index_data(farm)
sensor_summary = get_sensor_7_in_1_summary_data(farm)
alert_cards = {
"farmAlertsTracker": get_alert_tracker_data(farm),
"farmAlertsTimeline": get_alert_timeline_data(farm),
}
economic_overview = get_economic_overview_data(farm)
avg_soil_moisture = sensor_summary["avgSoilMoisture"]
cards["farmWeatherCard"] = weather_card
cards["farmAlertsTracker"] = get_alert_tracker_data(farm)
cards["farmAlertsTimeline"] = get_alert_timeline_data(farm)
cards["farmWeatherCard"] = water_cards["farmWeatherCard"]
cards["farmAlertsTracker"] = alert_cards["farmAlertsTracker"]
cards["farmAlertsTimeline"] = alert_cards["farmAlertsTimeline"]
cards["sensorValuesList"] = sensor_summary["sensorValuesList"]
cards["anomalyDetectionCard"] = sensor_summary["anomalyDetectionCard"]
cards["waterNeedPrediction"] = get_water_need_prediction_data(farm)
cards["waterNeedPrediction"] = water_cards["waterNeedPrediction"]
cards["harvestPredictionCard"] = yield_summary["harvest_prediction_card"]
cards["yieldPredictionChart"] = yield_summary["yield_prediction_chart"]
cards["sensorRadarChart"] = sensor_summary["sensorRadarChart"]
cards["sensorComparisonChart"] = sensor_summary["sensorComparisonChart"]
cards["soilMoistureHeatmap"] = sensor_summary["soilMoistureHeatmap"]
cards["ndviHealthCard"] = crop_health_summary["ndviHealthCard"]
cards["economicOverview"] = get_economic_overview_data(farm)
cards["economicOverview"] = economic_overview
cards["farmOverviewKpis"] = _build_overview_kpis(
cards["farmOverviewKpis"],
crop_health_summary,
water_stress_index,
water_cards["waterStressIndex"],
avg_soil_moisture,
risk_summary,
yield_summary,
+298
View File
@@ -0,0 +1,298 @@
"""
Static dashboard payload templates used only as fallback content.
"""
from copy import deepcopy
FARM_OVERVIEW_KPIS = {
"kpis": [
{
"id": "disease_risk",
"title": "ریسک بیماری",
"subtitle": "۷ روز اخیر",
"stats": "پایین",
"avatarColor": "success",
"avatarIcon": "tabler-bug",
"chipText": "5%",
"chipColor": "success",
},
{
"id": "yield_prediction",
"title": "پیش‌بینی عملکرد",
"subtitle": "این فصل",
"stats": "42 تن",
"avatarColor": "secondary",
"avatarIcon": "tabler-chart-bar",
"chipText": "+8%",
"chipColor": "success",
},
{
"id": "pest_risk",
"title": "ریسک آفات",
"subtitle": "پیش‌بینی هوشمند",
"stats": "15%",
"avatarColor": "warning",
"avatarIcon": "tabler-bug-off",
"chipText": "تحت نظر",
"chipColor": "warning",
},
]
}
FARM_WEATHER_CARD = {
"condition": "صاف",
"temperature": 24,
"unit": "°C",
"humidity": 45,
"windSpeed": 12,
"windUnit": "km/h",
"chartData": {
"labels": ["۶ صبح", "۹ صبح", "۱۲ ظهر", "۳ بعدازظهر", "۶ عصر", "۹ شب", "۱۲ شب"],
"series": [[18, 22, 26, 28, 25, 20, 18]],
},
}
FARM_ALERTS_TRACKER = {
"totalAlerts": 3,
"radialBarValue": 30,
"alertStats": [
{
"title": "کمبود آب",
"count": "2",
"avatarColor": "error",
"avatarIcon": "tabler-droplet-half-2",
},
{
"title": "ریسک قارچی",
"count": "1",
"avatarColor": "warning",
"avatarIcon": "tabler-mushroom",
},
{
"title": "هشدار یخبندان",
"count": "0",
"avatarColor": "info",
"avatarIcon": "tabler-snowflake",
},
],
}
SENSOR_VALUES_LIST = {
"sensors": [
{
"title": "28°C",
"subtitle": "دمای هوا",
"trendNumber": 2.1,
"trend": "positive",
"unit": "°C",
},
{
"title": "24°C",
"subtitle": "دمای خاک",
"trendNumber": -0.5,
"trend": "negative",
"unit": "°C",
},
{
"title": "65%",
"subtitle": "رطوبت هوا",
"trendNumber": 3.2,
"trend": "positive",
"unit": "%",
},
{
"title": "42%",
"subtitle": "رطوبت خاک (۱۰ سانتی‌متر)",
"trendNumber": -1.8,
"trend": "negative",
"unit": "%",
},
{
"title": "6.8",
"subtitle": "pH خاک",
"trendNumber": 0.2,
"trend": "positive",
"unit": "pH",
},
{
"title": "1.2",
"subtitle": "هدایت الکتریکی (dS/m)",
"trendNumber": 0.1,
"trend": "positive",
"unit": "dS/m",
},
{
"title": "850",
"subtitle": "شدت نور (لوکس)",
"trendNumber": 15.3,
"trend": "positive",
"unit": "lux",
},
{
"title": "12",
"subtitle": "سرعت باد (کیلومتر/ساعت)",
"trendNumber": -2.4,
"trend": "negative",
"unit": "km/h",
},
]
}
FARM_ALERTS_TIMELINE = {
"alerts": [
{
"title": "ریسک کمبود آب",
"description": "رطوبت خاک در عمق ۱۰ سانتی‌متر (۴۲٪) کمتر از حد بهینه است. پیش‌بینی: در صورت عدم آبیاری، تنش طی ۲ تا ۳ روز. توصیه: آبیاری ظرف ۲۴ ساعت.",
"time": "۱۵ دقیقه پیش",
"color": "warning",
},
{
"title": "ریسک بیماری قارچی",
"description": "رطوبت بالا (۶۵٪) و دمای ۲۴ درجه شرایط مساعد برای رشد قارچ. استفاده از قارچ‌کش پیشگیرانه یا کاهش آبیاری را در نظر بگیرید.",
"time": "۱ ساعت پیش",
"color": "error",
},
{
"title": "پیشنهاد آبیاری",
"description": "بازه بهینه آبیاری: ۶:۰۰ تا ۸:۰۰ صبح. حجم پیشنهادی: ۴۵۰ مترمکعب برای زون آ. بهبود راندمان مورد انتظار: ۱۲٪.",
"time": "۲ ساعت پیش",
"color": "info",
},
{
"title": "بررسی شوری خاک",
"description": "مقدار هدایت الکتریکی ۱/۲ dS/m در محدوده مجاز است. نیازی به اقدام نیست. بررسی بعدی توصیه می‌شود ظرف ۵ روز.",
"time": "۴ ساعت پیش",
"color": "success",
},
]
}
WATER_NEED_PREDICTION = {
"totalNext7Days": 3290,
"unit": "",
"categories": ["روز ۱", "روز ۲", "روز ۳", "روز ۴", "روز ۵", "روز ۶", "روز ۷"],
"series": [{"name": "نیاز آبی", "data": [420, 450, 480, 460, 490, 510, 480]}],
}
HARVEST_PREDICTION_CARD = {
"date": "2025-10-15",
"dateFormatted": "۱۵ اکتبر ۲۰۲۵",
"daysUntil": 58,
"description": "بر اساس تجمع GDD فعلی و پیش‌بینی آب و هوا. بازه بهینه برداشت: ۱۲ تا ۱۸ اکتبر.",
"optimalWindowStart": "2025-10-12",
"optimalWindowEnd": "2025-10-18",
}
YIELD_PREDICTION_CHART = {
"categories": ["ژانویه", "فوریه", "مارس", "آوریل", "می", "ژوئن", "ژوئیه", "آگوست", "سپتامبر", "اکتبر", "نوامبر", "دسامبر"],
"series": [
{"name": "امسال", "data": [35, 38, 40, 42, 45, 48, 50, 48, 46, 44, 42, 42]},
{"name": "سال گذشته", "data": [32, 34, 36, 38, 40, 42, 44, 42, 40, 38, 36, 38]},
],
"summary": [
{
"title": "عملکرد پیش‌بینی‌شده",
"subtitle": "این فصل",
"amount": "42 تن",
"avatarColor": "primary",
"avatarIcon": "tabler-chart-bar",
},
{
"title": "تاریخ برداشت",
"subtitle": "حدود ۱۵ اکتبر",
"amount": "+8%",
"avatarColor": "success",
"avatarIcon": "tabler-calendar",
},
],
}
RECOMMENDATIONS_LIST = {
"recommendations": [
{
"title": "آبیاری: ۶:۰۰ تا ۸:۰۰ صبح",
"subtitle": "۴۵۰ مترمکعب برای زون آ. بدون آبیاری، عملکرد ممکن است حدود ۸٪ کاهش یابد.",
"avatarIcon": "tabler-droplet",
"avatarColor": "primary",
},
{
"title": "کود: NPK 20-20-20",
"subtitle": "اعمال ۲۵ کیلوگرم در هکتار ظرف ۷ روز. کمبود نیتروژن فعلی در بخش ۲.",
"avatarIcon": "tabler-leaf",
"avatarColor": "success",
},
{
"title": "قارچ‌کش: پیشگیرانه",
"subtitle": "رطوبت و دما مساعد قارچ. سمپاشی بر پایه مس را در نظر بگیرید.",
"avatarIcon": "tabler-mushroom",
"avatarColor": "warning",
},
{
"title": "بازه برداشت: ۱۲ تا ۱۸ اکتبر",
"subtitle": "اوج رسیدگی حدود ۱۵ اکتبر. نیروی کار را متناسب برنامه‌ریزی کنید.",
"avatarIcon": "tabler-calendar-event",
"avatarColor": "info",
},
]
}
ECONOMIC_OVERVIEW = {
"economicData": [
{
"title": "هزینه آب",
"value": "€720",
"subtitle": "این ماه",
"avatarIcon": "tabler-droplet",
"avatarColor": "primary",
},
{
"title": "صرفه‌جویی آب هوشمند",
"value": "€156",
"subtitle": "۱۸٪ صرفه‌جویی شده",
"avatarIcon": "tabler-bulb",
"avatarColor": "success",
},
{
"title": "بازده سرمایه پلتفرم",
"value": "127%",
"subtitle": "نسبت به سال گذشته",
"avatarIcon": "tabler-chart-line",
"avatarColor": "info",
},
{
"title": "پیش‌بینی درآمد",
"value": "€42k",
"subtitle": "این فصل",
"avatarIcon": "tabler-currency-euro",
"avatarColor": "success",
},
],
"chartSeries": [
{"name": "هزینه آب", "data": [120, 115, 110, 125, 118, 122]},
{"name": "کود", "data": [80, 85, 90, 75, 82, 78]},
],
"chartCategories": ["ژانویه", "فوریه", "مارس", "آوریل", "می", "ژوئن"],
}
ALL_CARD_TEMPLATES = {
"farmOverviewKpis": FARM_OVERVIEW_KPIS,
"farmWeatherCard": FARM_WEATHER_CARD,
"farmAlertsTracker": FARM_ALERTS_TRACKER,
"sensorValuesList": SENSOR_VALUES_LIST,
"sensorRadarChart": {},
"sensorComparisonChart": {},
"anomalyDetectionCard": {},
"farmAlertsTimeline": FARM_ALERTS_TIMELINE,
"waterNeedPrediction": WATER_NEED_PREDICTION,
"harvestPredictionCard": HARVEST_PREDICTION_CARD,
"yieldPredictionChart": YIELD_PREDICTION_CHART,
"soilMoistureHeatmap": {},
"ndviHealthCard": {},
"recommendationsList": RECOMMENDATIONS_LIST,
"economicOverview": ECONOMIC_OVERVIEW,
}
def get_all_card_templates():
return deepcopy(ALL_CARD_TEMPLATES)
+1 -1
View File
@@ -7,7 +7,7 @@ from rest_framework.test import APIRequestFactory, force_authenticate
from access_control.models import AccessFeature, AccessRule
from farm_hub.models import FarmHub, FarmType
from .mock_data import DEFAULT_CONFIG
from .defaults import DEFAULT_CONFIG
from .models import FarmDashboardConfig
from .views import FarmDashboardCardsView, FarmDashboardConfigView
+5 -4
View File
@@ -11,8 +11,8 @@ from drf_spectacular.utils import OpenApiParameter, extend_schema, extend_schema
from config.swagger import code_response
from farm_hub.models import FarmHub
from .defaults import get_default_dashboard_config
from .services import get_farm_dashboard_cards
from .mock_data import DEFAULT_CONFIG
from .models import FarmDashboardConfig
from .serializers import FarmDashboardConfigPatchSerializer, FarmDashboardConfigSerializer
@@ -29,12 +29,13 @@ class FarmAccessMixin:
@staticmethod
def _get_or_create_dashboard_config(farm):
default_config = get_default_dashboard_config()
config, _created = FarmDashboardConfig.objects.get_or_create(
farm=farm,
defaults={
"disabled_card_ids": DEFAULT_CONFIG["disabled_card_ids"],
"row_order": DEFAULT_CONFIG["row_order"],
"enable_drag_reorder": DEFAULT_CONFIG["enable_drag_reorder"],
"disabled_card_ids": default_config["disabled_card_ids"],
"row_order": default_config["row_order"],
"enable_drag_reorder": default_config["enable_drag_reorder"],
},
)
return config
+45 -5
View File
@@ -4,11 +4,23 @@ import django.db.models.deletion
from django.db import migrations, models
def _create_model_if_missing(app_label, model_name):
def _operation(apps, schema_editor):
model = apps.get_model(app_label, model_name)
existing_tables = set(schema_editor.connection.introspection.table_names())
if model._meta.db_table in existing_tables:
return
schema_editor.create_model(model)
return _operation
class Migration(migrations.Migration):
initial = True
atomic = False
dependencies = [
("farm_hub", "0009_farmhub_irrigation_method_fields"),
("farm_hub", "0001_initial"),
]
operations = [
@@ -33,24 +45,48 @@ class Migration(migrations.Migration):
],
options={"db_table": "sensor_catalogs", "ordering": ["code"]},
),
],
),
migrations.RunPython(
_create_model_if_missing("device_hub", "SensorCatalog"),
migrations.RunPython.noop,
),
migrations.SeparateDatabaseAndState(
database_operations=[],
state_operations=[
migrations.CreateModel(
name="FarmDevice",
name="FarmSensor",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("uuid", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True)),
("physical_device_uuid", models.UUIDField(db_index=True, default=uuid.uuid4, unique=True)),
("name", models.CharField(max_length=255)),
("sensor_type", models.CharField(blank=True, default="", max_length=255)),
("is_active", models.BooleanField(default=True)),
("specifications", models.JSONField(blank=True, default=dict)),
("power_source", models.JSONField(blank=True, default=dict)),
("customization", models.JSONField(blank=True, default=dict)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("farm", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="sensors", to="farm_hub.farmhub")),
("sensor_catalog", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name="farm_devices", to="device_hub.sensorcatalog")),
(
"farm",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="sensors",
to="farm_hub.farmhub",
),
),
],
options={"db_table": "farm_sensors", "ordering": ["-created_at"]},
),
],
),
migrations.RunPython(
_create_model_if_missing("device_hub", "FarmSensor"),
migrations.RunPython.noop,
),
migrations.SeparateDatabaseAndState(
database_operations=[],
state_operations=[
migrations.CreateModel(
name="SensorExternalRequestLog",
fields=[
@@ -65,4 +101,8 @@ class Migration(migrations.Migration):
),
],
),
migrations.RunPython(
_create_model_if_missing("device_hub", "SensorExternalRequestLog"),
migrations.RunPython.noop,
),
]
@@ -1,10 +1,35 @@
from django.db import migrations
import uuid
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("device_hub", "0003_absorb_sensor_external_api"),
("farm_hub", "0003_farmsensor_catalog_and_physical_device"),
]
operations = []
operations = [
migrations.SeparateDatabaseAndState(
database_operations=[],
state_operations=[
migrations.AddField(
model_name="farmsensor",
name="physical_device_uuid",
field=models.UUIDField(db_index=True, default=uuid.uuid4, unique=True),
),
migrations.AddField(
model_name="farmsensor",
name="sensor_catalog",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="farm_sensors",
to="device_hub.sensorcatalog",
),
),
],
),
]
@@ -9,23 +9,34 @@ class Migration(migrations.Migration):
]
operations = [
migrations.RenameModel(
old_name="SensorCatalog",
new_name="DeviceCatalog",
),
migrations.AddField(
model_name="devicecatalog",
name="device_communication_type",
field=models.CharField(
choices=[("output_only", "Output Only"), ("input_only", "Input Only")],
db_index=True,
default="output_only",
max_length=32,
),
),
migrations.AlterField(
model_name="farmdevice",
name="sensor_catalog",
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name="farm_devices", to="device_hub.devicecatalog"),
migrations.SeparateDatabaseAndState(
database_operations=[],
state_operations=[
migrations.RenameModel(
old_name="SensorCatalog",
new_name="DeviceCatalog",
),
migrations.AddField(
model_name="devicecatalog",
name="device_communication_type",
field=models.CharField(
choices=[("output_only", "Output Only"), ("input_only", "Input Only")],
db_index=True,
default="output_only",
max_length=32,
),
),
migrations.AlterField(
model_name="farmdevice",
name="sensor_catalog",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="farm_devices",
to="device_hub.devicecatalog",
),
),
],
),
]
@@ -1,23 +1,51 @@
from django.db import migrations, models
def ensure_device_catalogs_m2m_table(apps, schema_editor):
FarmDevice = apps.get_model("device_hub", "FarmDevice")
through_model = FarmDevice._meta.get_field("device_catalogs").remote_field.through
existing_tables = set(schema_editor.connection.introspection.table_names())
if through_model._meta.db_table not in existing_tables:
schema_editor.create_model(through_model)
def copy_sensor_catalog_to_device_catalogs(apps, schema_editor):
FarmDevice = apps.get_model("device_hub", "FarmDevice")
for farm_device in FarmDevice.objects.exclude(sensor_catalog__isnull=True).iterator():
farm_device.device_catalogs.add(farm_device.sensor_catalog_id)
through_model = FarmDevice._meta.get_field("device_catalogs").remote_field.through
through_table = through_model._meta.db_table
farm_device_column = through_model._meta.get_field("farmdevice").column
device_catalog_column = through_model._meta.get_field("devicecatalog").column
with schema_editor.connection.cursor() as cursor:
for farm_device_id, sensor_catalog_id in FarmDevice.objects.exclude(sensor_catalog__isnull=True).values_list("pk", "sensor_catalog_id").iterator():
cursor.execute(
f"""
INSERT IGNORE INTO {schema_editor.quote_name(through_table)}
({schema_editor.quote_name(farm_device_column)}, {schema_editor.quote_name(device_catalog_column)})
VALUES (%s, %s)
""",
[farm_device_id, sensor_catalog_id],
)
class Migration(migrations.Migration):
atomic = False
dependencies = [
("device_hub", "0007_devicecatalog_dynamic_fields"),
]
operations = [
migrations.AddField(
model_name="farmdevice",
name="device_catalogs",
field=models.ManyToManyField(blank=True, related_name="composite_farm_devices", to="device_hub.devicecatalog"),
migrations.SeparateDatabaseAndState(
database_operations=[],
state_operations=[
migrations.AddField(
model_name="farmdevice",
name="device_catalogs",
field=models.ManyToManyField(blank=True, related_name="composite_farm_devices", to="device_hub.devicecatalog"),
),
],
),
migrations.RunPython(ensure_device_catalogs_m2m_table, migrations.RunPython.noop),
migrations.RunPython(copy_sensor_catalog_to_device_catalogs, migrations.RunPython.noop),
]
@@ -0,0 +1,47 @@
from django.db import migrations, models
def add_column_if_missing(schema_editor, table_name, column_name, field):
existing_columns = {
column.name
for column in schema_editor.connection.introspection.get_table_description(
schema_editor.connection.cursor(),
table_name,
)
}
if column_name in existing_columns:
return
field.set_attributes_from_name(column_name)
schema_editor.add_field(
field.model,
field,
)
def sync_devicecatalog_schema(apps, schema_editor):
DeviceCatalog = apps.get_model("device_hub", "DeviceCatalog")
table_name = DeviceCatalog._meta.db_table
fields = [
DeviceCatalog._meta.get_field("device_communication_type"),
DeviceCatalog._meta.get_field("payload_mapping"),
DeviceCatalog._meta.get_field("display_schema"),
DeviceCatalog._meta.get_field("supported_widgets"),
DeviceCatalog._meta.get_field("commands_schema"),
DeviceCatalog._meta.get_field("capabilities"),
]
for field in fields:
add_column_if_missing(schema_editor, table_name, field.column, field)
class Migration(migrations.Migration):
atomic = False
dependencies = [
("device_hub", "0008_farmdevice_device_catalogs"),
]
operations = [
migrations.RunPython(sync_devicecatalog_schema, migrations.RunPython.noop),
]
+270 -123
View File
@@ -6,12 +6,13 @@ from django.conf import settings
from django.db import OperationalError, ProgrammingError, transaction
from django.utils import timezone
from config.failure_contract import StructuredServiceError
from external_api_adapter import request as external_api_request
from external_api_adapter.exceptions import ExternalAPIRequestError
from notifications.services import create_notification_for_farm_uuid
from .mock_data import ANOMALY_DETECTION_CARD, AVG_SOIL_MOISTURE, SENSOR_COMPARISON_CHART, SENSOR_RADAR_CHART, SENSOR_VALUES_LIST, SOIL_MOISTURE_HEATMAP
from .models import FarmDevice, SensorExternalRequestLog
from .templates import AVG_SOIL_MOISTURE_TEMPLATE, SENSOR_META_TEMPLATE, SOIL_MOISTURE_HEATMAP_TEMPLATE
logger = logging.getLogger(__name__)
@@ -19,6 +20,17 @@ logger = logging.getLogger(__name__)
class FarmDataForwardError(Exception):
pass
class DeviceDataUnavailableError(StructuredServiceError):
def __init__(self, *, error_code: str, message: str, details: dict | None = None, retriable: bool = False):
super().__init__(
error_code=error_code,
message=message,
source="db",
details=details,
retriable=retriable,
)
SENSOR_FIELDS = [
{"id": "soil_moisture", "label": "رطوبت خاک", "unit": "%", "payload_keys": ("soil_moisture", "soilMoisture", "moisture"), "ideal_min": 45.0, "ideal_max": 65.0, "radar_label": "رطوبت"},
{"id": "soil_temperature", "label": "دمای خاک", "unit": "°C", "payload_keys": ("soil_temperature", "soilTemperature", "temperature"), "ideal_min": 18.0, "ideal_max": 28.0, "radar_label": "دما"},
@@ -257,21 +269,37 @@ def get_primary_soil_sensor(*, farm):
def _get_sensor_context(farm=None):
if farm is None:
return None
raise DeviceDataUnavailableError(
error_code="missing_farm",
message="Farm instance is required for sensor context lookup.",
)
primary_sensor = get_primary_soil_sensor(farm=farm)
if primary_sensor is None:
return None
raise DeviceDataUnavailableError(
error_code="sensor_not_found",
message=f"No primary soil sensor found for farm_uuid={farm.farm_uuid}.",
details={"farm_uuid": str(farm.farm_uuid)},
)
try:
logs_queryset = get_sensor_external_request_logs_for_farm(farm_uuid=farm.farm_uuid, physical_device_uuid=primary_sensor.physical_device_uuid)
except ValueError:
return None
except ValueError as exc:
raise DeviceDataUnavailableError(
error_code="history_unavailable",
message=f"Sensor history lookup failed for farm_uuid={farm.farm_uuid}.",
details={"farm_uuid": str(farm.farm_uuid)},
retriable=True,
) from exc
history = []
for log in logs_queryset[:MAX_HISTORY_ITEMS]:
readings = _extract_readings(log.payload)
if readings:
history.append((log, readings))
if not history:
return None
raise DeviceDataUnavailableError(
error_code="no_sensor_readings",
message=f"No sensor readings found for farm_uuid={farm.farm_uuid}.",
details={"farm_uuid": str(farm.farm_uuid)},
)
latest_log, latest_readings = history[0]
farm_device_map = get_farm_device_map_for_logs(logs=[latest_log])
farm_device = farm_device_map.get((latest_log.farm_uuid, latest_log.sensor_catalog_uuid, latest_log.physical_device_uuid)) or primary_sensor
@@ -304,40 +332,46 @@ def _calculate_status_chip(value):
def get_sensor_7_in_1_values_list_data(farm=None, context=None):
data = deepcopy(SENSOR_VALUES_LIST)
context = _get_sensor_context(farm) if context is None else context
data["sensor"] = _build_sensor_meta(context, data["sensor"])
if not context:
return data
data = {
"sensor": _build_sensor_meta(context, SENSOR_META_TEMPLATE),
"sensors": [],
}
latest_readings = context["latest_readings"]
previous_readings = context["previous_readings"]
sensors = []
for field in SENSOR_FIELDS:
value = latest_readings.get(field["id"])
if value is None:
continue
previous = previous_readings.get(field["id"])
change = 0.0 if previous is None else round(value - previous, 2)
sensors.append({"id": field["id"], "title": _format_value(value, field["unit"]), "subtitle": field["label"], "trendNumber": abs(change), "trend": "positive" if change >= 0 else "negative", "unit": field["unit"]})
if sensors:
data["sensors"] = sensors
data["sensors"].append({"id": field["id"], "title": _format_value(value, field["unit"]), "subtitle": field["label"], "trendNumber": abs(change), "trend": "positive" if change >= 0 else "negative", "unit": field["unit"]})
if not data["sensors"]:
raise DeviceDataUnavailableError(
error_code="no_numeric_readings",
message=f"Latest sensor payload has no usable numeric values for farm_uuid={farm.farm_uuid if farm else None}.",
)
return data
def get_sensor_7_in_1_avg_soil_moisture_data(farm=None, context=None):
data = deepcopy(AVG_SOIL_MOISTURE)
context = _get_sensor_context(farm) if context is None else context
if not context:
return data
moisture = context["latest_readings"].get("soil_moisture")
if moisture is None:
return data
raise DeviceDataUnavailableError(
error_code="missing_soil_moisture",
message=f"Latest sensor payload is missing soil_moisture for farm_uuid={farm.farm_uuid if farm else None}.",
)
chip_text, chip_color, avatar_color = _calculate_status_chip(moisture)
data["stats"] = _format_value(moisture, "%")
data["chipText"] = chip_text
data["chipColor"] = chip_color
data["avatarColor"] = avatar_color
return data
return {
**deepcopy(AVG_SOIL_MOISTURE_TEMPLATE),
"stats": _format_value(moisture, "%"),
"chipText": chip_text,
"chipColor": chip_color,
"avatarColor": avatar_color,
"status": "success",
"source": "db",
}
def _score_field(value, field):
@@ -353,10 +387,7 @@ def _score_field(value, field):
def get_sensor_7_in_1_radar_chart_data(farm=None, context=None):
data = deepcopy(SENSOR_RADAR_CHART)
context = _get_sensor_context(farm) if context is None else context
if not context:
return data
latest_readings = context["latest_readings"]
scores, labels = [], []
for field in SENSOR_FIELDS:
@@ -365,32 +396,42 @@ def get_sensor_7_in_1_radar_chart_data(farm=None, context=None):
continue
labels.append(field["radar_label"])
scores.append(_score_field(value, field))
if labels:
data["labels"] = labels
data["series"] = [{"name": "اکنون", "data": scores}, {"name": "هدف", "data": [100.0] * len(labels)}]
return data
if not labels:
raise DeviceDataUnavailableError(
error_code="no_radar_data",
message=f"No usable sensor readings found for radar chart farm_uuid={farm.farm_uuid if farm else None}.",
)
return {
"labels": labels,
"series": [{"name": "اکنون", "data": scores}, {"name": "هدف", "data": [100.0] * len(labels)}],
"status": "success",
"source": "db",
}
def get_sensor_7_in_1_comparison_chart_data(farm=None, context=None):
data = deepcopy(SENSOR_COMPARISON_CHART)
context = _get_sensor_context(farm) if context is None else context
if not context:
return data
history = list(reversed(context["history"][:MAX_CHART_POINTS]))
moisture_points = [(log.created_at.strftime("%m/%d %H:%M"), readings.get("soil_moisture")) for log, readings in history if readings.get("soil_moisture") is not None]
if not moisture_points:
return data
raise DeviceDataUnavailableError(
error_code="no_comparison_data",
message=f"No soil moisture history found for comparison chart farm_uuid={farm.farm_uuid if farm else None}.",
)
categories = [item[0] for item in moisture_points]
values = [round(item[1], 2) for item in moisture_points]
current_value = values[-1]
baseline_value = values[0] if len(values) > 1 else 55.0
percent_change = ((current_value - baseline_value) / baseline_value) * 100 if baseline_value else 0.0
data["currentValue"] = round(current_value, 2)
data["vsLastWeekValue"] = round(percent_change, 2)
data["vsLastWeek"] = f"{percent_change:+.1f}%"
data["categories"] = categories
data["series"] = [{"name": "رطوبت خاک", "data": values}, {"name": "بازه هدف", "data": [55.0] * len(values)}]
return data
return {
"currentValue": round(current_value, 2),
"vsLastWeekValue": round(percent_change, 2),
"vsLastWeek": f"{percent_change:+.1f}%",
"categories": categories,
"series": [{"name": "رطوبت خاک", "data": values}, {"name": "بازه هدف", "data": [55.0] * len(values)}],
"status": "success",
"source": "db",
}
def _build_anomaly_item(field, value):
@@ -408,10 +449,7 @@ def _build_anomaly_item(field, value):
def get_sensor_7_in_1_anomaly_detection_card_data(farm=None, context=None):
data = deepcopy(ANOMALY_DETECTION_CARD)
context = _get_sensor_context(farm) if context is None else context
if not context:
return data
anomalies = []
for field in SENSOR_FIELDS:
value = context["latest_readings"].get(field["id"])
@@ -420,27 +458,38 @@ def get_sensor_7_in_1_anomaly_detection_card_data(farm=None, context=None):
anomaly = _build_anomaly_item(field, value)
if anomaly is not None:
anomalies.append(anomaly)
data["anomalies"] = anomalies or [{"sensor": "سنسور 7 در 1 خاک", "value": "نرمال", "expected": "تمام شاخص‌ها در بازه مجاز هستند", "deviation": "0", "severity": "success"}]
return data
return {
"anomalies": anomalies,
"status": "success",
"source": "db",
"warnings": [] if anomalies else ["No anomalies detected from the latest sensor readings."],
}
def get_sensor_7_in_1_soil_moisture_heatmap_data(farm=None, context=None):
data = deepcopy(SOIL_MOISTURE_HEATMAP)
context = _get_sensor_context(farm) if context is None else context
if not context:
return data
history = list(reversed(context["history"][:MAX_CHART_POINTS]))
chart_points = [{"x": log.created_at.strftime("%H:%M"), "y": round(readings.get("soil_moisture"), 2)} for log, readings in history if readings.get("soil_moisture") is not None]
if not chart_points:
return data
sensor_name = data["zones"][0]
raise DeviceDataUnavailableError(
error_code="no_heatmap_data",
message=f"No soil moisture history found for heatmap farm_uuid={farm.farm_uuid if farm else None}.",
)
sensor_name = (
SOIL_MOISTURE_HEATMAP_TEMPLATE["zones"][0]
if SOIL_MOISTURE_HEATMAP_TEMPLATE["zones"]
else "سنسور خاک"
)
farm_device = context.get("farm_device")
if farm_device is not None and farm_device.name:
sensor_name = farm_device.name
data["zones"] = [sensor_name]
data["hours"] = [point["x"] for point in chart_points]
data["series"] = [{"name": sensor_name, "data": chart_points}]
return data
return {
"zones": [sensor_name],
"hours": [point["x"] for point in chart_points],
"series": [{"name": sensor_name, "data": chart_points}],
"status": "success",
"source": "db",
}
def get_sensor_7_in_1_summary_data(farm=None):
@@ -473,8 +522,10 @@ def get_sensor_comparison_chart_data(*, farm, physical_device_uuid, range_value)
start_date = timezone.localdate() - timedelta(days=days - 1)
try:
logs_queryset = get_sensor_external_request_logs_for_farm(farm_uuid=farm.farm_uuid, physical_device_uuid=physical_device_uuid, date_from=start_date)
except ValueError:
return {"series": [], "categories": [], "currentValue": 0.0, "vsLastWeek": "+0.0%"}
except ValueError as exc:
raise DeviceDataUnavailableError(
f"Sensor comparison chart data is unavailable for farm_uuid={farm.farm_uuid} device={physical_device_uuid}."
) from exc
grouped_logs = {}
for log in reversed(list(logs_queryset[: days * 24])):
bucket_date = timezone.localtime(log.created_at).date()
@@ -482,7 +533,9 @@ def get_sensor_comparison_chart_data(*, farm, physical_device_uuid, range_value)
if numeric_payload:
grouped_logs[bucket_date] = numeric_payload
if not grouped_logs:
return {"series": [], "categories": [], "currentValue": 0.0, "vsLastWeek": "+0.0%"}
raise DeviceDataUnavailableError(
f"No sensor history found for comparison chart farm_uuid={farm.farm_uuid} device={physical_device_uuid}."
)
sorted_dates = sorted(grouped_logs.keys())
categories = [_format_comparison_category(bucket_date, range_value) for bucket_date in sorted_dates]
series_map = {}
@@ -500,13 +553,17 @@ def get_sensor_values_list_data(*, farm, physical_device_uuid, range_value):
start_time = timezone.now() - VALUES_LIST_RANGES[range_value]
try:
logs_queryset = get_sensor_external_request_logs_for_farm(farm_uuid=farm.farm_uuid, physical_device_uuid=physical_device_uuid)
except ValueError:
return {"sensors": []}
except ValueError as exc:
raise DeviceDataUnavailableError(
f"Sensor values list data is unavailable for farm_uuid={farm.farm_uuid} device={physical_device_uuid}."
) from exc
logs = list(logs_queryset.filter(created_at__gte=start_time).order_by("created_at", "id"))
if not logs:
latest_log = logs_queryset.order_by("-created_at", "-id").first()
if latest_log is None:
return {"sensors": []}
raise DeviceDataUnavailableError(
f"No sensor logs found for values list farm_uuid={farm.farm_uuid} device={physical_device_uuid}."
)
logs = [latest_log]
earliest_payload, latest_payload = {}, {}
for log in logs:
@@ -517,7 +574,9 @@ def get_sensor_values_list_data(*, farm, physical_device_uuid, range_value):
earliest_payload = numeric_payload
latest_payload = numeric_payload
if not latest_payload:
return {"sensors": []}
raise DeviceDataUnavailableError(
f"Latest sensor payload has no numeric values for farm_uuid={farm.farm_uuid} device={physical_device_uuid}."
)
sensors = []
for field_name, title, unit in VALUES_LIST_FIELDS:
current_value = latest_payload.get(field_name)
@@ -533,13 +592,17 @@ def get_sensor_radar_chart_data(*, farm, physical_device_uuid, range_value):
start_time = timezone.now() - RADAR_CHART_RANGES[range_value]
try:
logs_queryset = get_sensor_external_request_logs_for_farm(farm_uuid=farm.farm_uuid, physical_device_uuid=physical_device_uuid)
except ValueError:
return {"labels": [], "series": []}
except ValueError as exc:
raise DeviceDataUnavailableError(
f"Sensor radar chart data is unavailable for farm_uuid={farm.farm_uuid} device={physical_device_uuid}."
) from exc
logs = list(logs_queryset.filter(created_at__gte=start_time).order_by("created_at", "id"))
if not logs:
latest_log = logs_queryset.order_by("-created_at", "-id").first()
if latest_log is None:
return {"labels": [], "series": []}
raise DeviceDataUnavailableError(
f"No sensor logs found for radar chart farm_uuid={farm.farm_uuid} device={physical_device_uuid}."
)
logs = [latest_log]
latest_payload = {}
for log in logs:
@@ -547,7 +610,9 @@ def get_sensor_radar_chart_data(*, farm, physical_device_uuid, range_value):
if numeric_payload:
latest_payload = numeric_payload
if not latest_payload:
return {"labels": [], "series": []}
raise DeviceDataUnavailableError(
f"Latest sensor payload has no numeric values for radar chart farm_uuid={farm.farm_uuid} device={physical_device_uuid}."
)
labels, current_data, ideal_data = [], [], []
for field_name, label, ideal_value in RADAR_CHART_FIELDS:
current_value = latest_payload.get(field_name)
@@ -728,11 +793,24 @@ def _get_device_supported_widgets(device_catalog):
def _get_device_history_context(farm_device):
if farm_device is None:
return None
raise DeviceDataUnavailableError(
error_code="device_not_found",
message="Farm device instance is required for history lookup.",
)
try:
logs_queryset = get_device_logs(farm_device)
except ValueError:
return None
except ValueError as exc:
logger.error(
"Device history lookup failed for farm_device_id=%s: %s",
getattr(farm_device, "id", None),
exc,
)
raise DeviceDataUnavailableError(
error_code="history_unavailable",
message=f"Device history lookup failed for farm_device_id={getattr(farm_device, 'id', None)}.",
details={"farm_device_id": getattr(farm_device, "id", None)},
retriable=True,
) from exc
history = []
device_catalog = get_device_catalog_for_farm_device(farm_device)
for log in logs_queryset[:MAX_HISTORY_ITEMS]:
@@ -741,14 +819,11 @@ def _get_device_history_context(farm_device):
if readings or normalized_payload:
history.append((log, readings, normalized_payload))
if not history:
return {
"farm_device": farm_device,
"latest_log": None,
"latest_readings": {},
"latest_payload": {},
"previous_readings": {},
"history": [],
}
raise DeviceDataUnavailableError(
error_code="no_device_history",
message=f"No device history found for farm_device_id={getattr(farm_device, 'id', None)}.",
details={"farm_device_id": getattr(farm_device, "id", None)},
)
latest_log, latest_readings, latest_payload = history[0]
return {
"farm_device": farm_device,
@@ -775,15 +850,11 @@ def build_device_latest_payload(farm_device, *, device_code):
device_catalog = validate_output_device_catalog(farm_device=farm_device, device_code=device_code)
latest_log = get_latest_device_log(farm_device, device_catalog=device_catalog)
if latest_log is None:
return {
"physical_device_uuid": farm_device.physical_device_uuid,
"device_code": device_code,
"device_catalog_code": device_catalog.code if device_catalog else None,
"raw_payload": {},
"normalized_payload": {},
"readings": {},
"created_at": None,
}
raise DeviceDataUnavailableError(
error_code="no_device_payload",
message=f"No device payload log found for farm_device_id={getattr(farm_device, 'id', None)} device_code={device_code}.",
details={"farm_device_id": getattr(farm_device, "id", None), "device_code": device_code},
)
return {
"physical_device_uuid": farm_device.physical_device_uuid,
"device_code": device_code,
@@ -798,14 +869,23 @@ def build_device_latest_payload(farm_device, *, device_code):
def build_device_values_list(farm_device, range_value, *, device_code):
try:
logs_queryset = get_device_logs(farm_device)
except ValueError:
return {"sensors": []}
except ValueError as exc:
raise DeviceDataUnavailableError(
error_code="history_unavailable",
message=f"Device values list data is unavailable for farm_device_id={getattr(farm_device, 'id', None)} device_code={device_code}.",
details={"farm_device_id": getattr(farm_device, "id", None), "device_code": device_code},
retriable=True,
) from exc
start_time = timezone.now() - VALUES_LIST_RANGES[range_value]
logs = list(logs_queryset.filter(created_at__gte=start_time).order_by("created_at", "id"))
if not logs:
latest_log = logs_queryset.order_by("-created_at", "-id").first()
if latest_log is None:
return {"sensors": []}
raise DeviceDataUnavailableError(
error_code="no_device_history",
message=f"No device logs found for values list farm_device_id={getattr(farm_device, 'id', None)} device_code={device_code}.",
details={"farm_device_id": getattr(farm_device, "id", None), "device_code": device_code},
)
logs = [latest_log]
device_catalog = validate_output_device_catalog(farm_device=farm_device, device_code=device_code)
earliest_payload = {}
@@ -818,7 +898,11 @@ def build_device_values_list(farm_device, range_value, *, device_code):
earliest_payload = normalized_payload
latest_payload = normalized_payload
if not latest_payload:
return {"sensors": []}
raise DeviceDataUnavailableError(
error_code="no_numeric_readings",
message=f"Latest device payload has no numeric values for farm_device_id={getattr(farm_device, 'id', None)} device_code={device_code}.",
details={"farm_device_id": getattr(farm_device, "id", None), "device_code": device_code},
)
sensors = []
for field in _get_device_field_definitions(device_catalog):
current_value = latest_payload.get(field["id"])
@@ -835,7 +919,13 @@ def build_device_values_list(farm_device, range_value, *, device_code):
"unit": field["unit"],
}
)
return {"sensors": sensors}
if not sensors:
raise DeviceDataUnavailableError(
error_code="no_numeric_readings",
message=f"No device values could be derived for farm_device_id={getattr(farm_device, 'id', None)} device_code={device_code}.",
details={"farm_device_id": getattr(farm_device, "id", None), "device_code": device_code},
)
return {"sensors": sensors, "status": "success", "source": "db"}
def build_device_summary_values_list(farm_device, context=None, *, device_catalog=None):
@@ -860,6 +950,12 @@ def build_device_summary_values_list(farm_device, context=None, *, device_catalo
"unit": field["unit"],
}
)
if not data["sensors"]:
raise DeviceDataUnavailableError(
error_code="no_numeric_readings",
message=f"No summary values available for farm_device_id={getattr(farm_device, 'id', None)}.",
details={"farm_device_id": getattr(farm_device, "id", None)},
)
return data
@@ -867,7 +963,11 @@ def build_device_radar_chart(farm_device, range_value=None, *, device_code):
device_catalog = validate_output_device_catalog(farm_device=farm_device, device_code=device_code)
context = _get_device_history_context(farm_device)
if not context or not context.get("latest_readings"):
return {"labels": [], "series": []}
raise DeviceDataUnavailableError(
error_code="no_radar_data",
message=f"Device radar chart data is unavailable for farm_device_id={getattr(farm_device, 'id', None)} device_code={device_code}.",
details={"farm_device_id": getattr(farm_device, "id", None), "device_code": device_code},
)
labels, current_data, ideal_data = [], [], []
for field in _get_device_field_definitions(device_catalog):
current_value = context["latest_readings"].get(field["id"])
@@ -877,7 +977,13 @@ def build_device_radar_chart(farm_device, range_value=None, *, device_code):
current_data.append(round(current_value, 2))
midpoint = (field["ideal_min"] + field["ideal_max"]) / 2
ideal_data.append(round(midpoint, 2))
return {"labels": labels, "series": [{"name": "وضعیت فعلی", "data": current_data}, {"name": "بازه ایده آل", "data": ideal_data}]}
if not labels:
raise DeviceDataUnavailableError(
error_code="no_radar_data",
message=f"No usable readings found for radar chart farm_device_id={getattr(farm_device, 'id', None)} device_code={device_code}.",
details={"farm_device_id": getattr(farm_device, "id", None), "device_code": device_code},
)
return {"labels": labels, "series": [{"name": "وضعیت فعلی", "data": current_data}, {"name": "بازه ایده آل", "data": ideal_data}], "status": "success", "source": "db"}
def build_device_comparison_chart(farm_device, range_value, *, device_code):
@@ -885,8 +991,13 @@ def build_device_comparison_chart(farm_device, range_value, *, device_code):
start_date = timezone.localdate() - timedelta(days=days - 1)
try:
logs_queryset = get_device_logs(farm_device, date_from=start_date)
except ValueError:
return {"series": [], "categories": [], "currentValue": 0.0, "vsLastWeek": "+0.0%"}
except ValueError as exc:
raise DeviceDataUnavailableError(
error_code="history_unavailable",
message=f"Device comparison chart data is unavailable for farm_device_id={getattr(farm_device, 'id', None)} device_code={device_code}.",
details={"farm_device_id": getattr(farm_device, "id", None), "device_code": device_code},
retriable=True,
) from exc
device_catalog = validate_output_device_catalog(farm_device=farm_device, device_code=device_code)
field_definitions = _get_device_field_definitions(device_catalog)
grouped_logs = {}
@@ -894,7 +1005,11 @@ def build_device_comparison_chart(farm_device, range_value, *, device_code):
bucket_date = timezone.localtime(log.created_at).date()
grouped_logs[bucket_date] = extract_device_readings(device_catalog, log.payload)
if not grouped_logs:
return {"series": [], "categories": [], "currentValue": 0.0, "vsLastWeek": "+0.0%"}
raise DeviceDataUnavailableError(
error_code="no_device_history",
message=f"No device history found for comparison chart farm_device_id={getattr(farm_device, 'id', None)} device_code={device_code}.",
details={"farm_device_id": getattr(farm_device, "id", None), "device_code": device_code},
)
sorted_dates = sorted(grouped_logs.keys())
categories = [_format_comparison_category(bucket_date, range_value) for bucket_date in sorted_dates]
series = []
@@ -912,12 +1027,18 @@ def build_device_comparison_chart(farm_device, range_value, *, device_code):
if not primary_data:
primary_data = data_points
if not series or not primary_data:
return {"series": [], "categories": [], "currentValue": 0.0, "vsLastWeek": "+0.0%"}
raise DeviceDataUnavailableError(
error_code="no_comparison_data",
message=f"Device comparison chart has no usable numeric series for farm_device_id={getattr(farm_device, 'id', None)} device_code={device_code}.",
details={"farm_device_id": getattr(farm_device, "id", None), "device_code": device_code},
)
return {
"series": series,
"categories": categories,
"currentValue": round(primary_data[-1], 2),
"vsLastWeek": _format_percent_change(primary_data[-1], primary_data[0]),
"status": "success",
"source": "db",
}
@@ -933,29 +1054,37 @@ def build_device_anomaly_detection_card(farm_device, context=None, *, device_cat
anomaly = _build_anomaly_item(field, value)
if anomaly is not None:
anomalies.append(anomaly)
if not latest_readings:
raise DeviceDataUnavailableError(
error_code="no_numeric_readings",
message=f"No latest readings available for anomaly detection farm_device_id={getattr(farm_device, 'id', None)}.",
details={"farm_device_id": getattr(farm_device, "id", None)},
)
return {
"anomalies": anomalies or [
{
"sensor": farm_device.name if farm_device else "Device",
"value": "نرمال",
"expected": "تمام شاخص‌ها در بازه مجاز هستند",
"deviation": "0",
"severity": "success",
}
]
"anomalies": anomalies,
"status": "success",
"source": "db",
"warnings": [] if anomalies else ["No anomalies detected from the latest device readings."],
}
def build_device_soil_moisture_heatmap(farm_device, context=None, *, device_catalog=None):
data = deepcopy(SOIL_MOISTURE_HEATMAP)
context = _get_device_history_context(farm_device) if context is None else context
if not context or not context.get("history"):
return data
raise DeviceDataUnavailableError(
error_code="no_heatmap_data",
message=f"Device heatmap data is unavailable for farm_device_id={getattr(farm_device, 'id', None)}.",
details={"farm_device_id": getattr(farm_device, "id", None)},
)
device_catalog = device_catalog or get_device_catalog_for_farm_device(farm_device)
field_definitions = _get_device_field_definitions(device_catalog)
primary_field = field_definitions[0] if field_definitions else None
if primary_field is None:
return data
raise DeviceDataUnavailableError(
error_code="invalid_schema",
message=f"Device field schema is missing for heatmap farm_device_id={getattr(farm_device, 'id', None)}.",
details={"farm_device_id": getattr(farm_device, "id", None)},
)
chart_points = []
for log, readings, _normalized_payload in reversed(context["history"][:MAX_CHART_POINTS]):
value = readings.get(primary_field["id"])
@@ -963,33 +1092,51 @@ def build_device_soil_moisture_heatmap(farm_device, context=None, *, device_cata
continue
chart_points.append({"x": log.created_at.strftime("%H:%M"), "y": round(value, 2)})
if not chart_points:
return data
sensor_name = farm_device.name if farm_device and farm_device.name else data["zones"][0]
data["zones"] = [sensor_name]
data["hours"] = [point["x"] for point in chart_points]
data["series"] = [{"name": sensor_name, "data": chart_points}]
return data
raise DeviceDataUnavailableError(
error_code="no_heatmap_data",
message=f"Device heatmap has no usable numeric series for farm_device_id={getattr(farm_device, 'id', None)}.",
details={"farm_device_id": getattr(farm_device, "id", None)},
)
sensor_name = farm_device.name if farm_device and farm_device.name else SOIL_MOISTURE_HEATMAP["zones"][0]
return {
"zones": [sensor_name],
"hours": [point["x"] for point in chart_points],
"series": [{"name": sensor_name, "data": chart_points}],
"status": "success",
"source": "db",
}
def build_device_avg_primary_metric(farm_device, context=None, *, device_catalog=None):
data = deepcopy(AVG_SOIL_MOISTURE)
context = _get_device_history_context(farm_device) if context is None else context
latest_readings = context.get("latest_readings", {}) if context else {}
device_catalog = device_catalog or get_device_catalog_for_farm_device(farm_device)
field_definitions = _get_device_field_definitions(device_catalog)
primary_field = field_definitions[0] if field_definitions else None
if primary_field is None:
return data
raise DeviceDataUnavailableError(
error_code="invalid_schema",
message=f"Device field schema is missing for farm_device_id={getattr(farm_device, 'id', None)}.",
details={"farm_device_id": getattr(farm_device, "id", None)},
)
primary_value = latest_readings.get(primary_field["id"])
if primary_value is None:
return data
raise DeviceDataUnavailableError(
error_code="missing_primary_metric",
message=f"Primary metric is missing for farm_device_id={getattr(farm_device, 'id', None)}.",
details={"farm_device_id": getattr(farm_device, "id", None)},
)
chip_text, chip_color, avatar_color = _calculate_status_chip(primary_value)
data["title"] = primary_field["label"]
data["stats"] = _format_value(primary_value, primary_field["unit"])
data["chipText"] = chip_text
data["chipColor"] = chip_color
data["avatarColor"] = avatar_color
return data
return {
**deepcopy(AVG_SOIL_MOISTURE),
"title": primary_field["label"],
"stats": _format_value(primary_value, primary_field["unit"]),
"chipText": chip_text,
"chipColor": chip_color,
"avatarColor": avatar_color,
"status": "success",
"source": "db",
}
def build_device_summary(farm_device, *, device_code):
+23
View File
@@ -0,0 +1,23 @@
AVG_SOIL_MOISTURE_TEMPLATE = {
"id": "avg_soil_moisture",
"title": "میانگین رطوبت خاک",
"subtitle": "سنسور 7 در 1 خاک",
"stats": None,
"avatarColor": "secondary",
"avatarIcon": "tabler-droplet",
"chipText": "بدون داده",
"chipColor": "secondary",
}
SENSOR_META_TEMPLATE = {
"name": "سنسور 7 در 1 خاک",
"physicalDeviceUuid": None,
"sensorCatalogCode": "sensor-7-in-1",
"updatedAt": None,
}
SOIL_MOISTURE_HEATMAP_TEMPLATE = {
"zones": [],
"hours": [],
"series": [],
}
+22
View File
@@ -5,6 +5,7 @@ from rest_framework.test import APIRequestFactory, force_authenticate
from farm_hub.models import FarmHub, FarmType
from .models import DeviceCatalog, SensorExternalRequestLog
from .services import DeviceDataUnavailableError, build_device_anomaly_detection_card
from .views import DeviceCommandView, DeviceDetailView, DeviceLatestPayloadView, DeviceSummaryView
@@ -91,6 +92,27 @@ class DeviceHubGenericViewsTests(TestCase):
self.assertIn("values_list", response.data["data"]["supportedWidgets"])
self.assertIn("sensorValuesList", response.data["data"])
def test_device_summary_view_returns_validation_error_when_history_missing(self):
SensorExternalRequestLog.objects.all().delete()
request = self.factory.get(
f"/api/device-hub/devices/{self.device.physical_device_uuid}/summary/",
{"device_code": self.catalog.code},
)
force_authenticate(request, user=self.user)
response = DeviceSummaryView.as_view()(request, physical_device_uuid=self.device.physical_device_uuid)
self.assertEqual(response.status_code, 400)
self.assertIn("no device history found", response.data["device_code"][0].lower())
def test_build_device_anomaly_detection_card_returns_explicit_empty_success(self):
payload = build_device_anomaly_detection_card(self.device)
self.assertEqual(payload["status"], "success")
self.assertEqual(payload["source"], "db")
self.assertEqual(payload["anomalies"], [])
self.assertTrue(payload["warnings"])
def test_input_only_device_command_view_rejects_input_only_device_code(self):
input_catalog = DeviceCatalog.objects.create(
code="valve_v1",
+37 -13
View File
@@ -13,7 +13,7 @@ from soil.serializers import SoilComparisonChartSerializer, SoilRadarChartSerial
from .authentication import SensorExternalAPIKeyAuthentication
from .sensor_serializers import DeviceSummarySerializer, Sensor7In1SummarySerializer, SensorComparisonChartQuerySerializer, SensorComparisonChartResponseSerializer, SensorRadarChartQuerySerializer, SensorRadarChartResponseSerializer, SensorValuesListQuerySerializer, SensorValuesListResponseSerializer
from .serializers import DeviceCatalogSerializer, DeviceCodeQuerySerializer, DeviceCommandRequestSerializer, DeviceCommandResponseSerializer, DeviceDetailSerializer, DeviceLatestPayloadSerializer, DeviceRangeQuerySerializer, SensorExternalRequestLogQuerySerializer, SensorExternalRequestLogSerializer, SensorExternalRequestSerializer
from .services import FarmDataForwardError, build_device_comparison_chart, build_device_latest_payload, build_device_radar_chart, build_device_summary, build_device_values_list, create_sensor_external_notification, execute_device_command, forward_sensor_payload_to_farm_data, get_farm_device_by_physical_uuid, get_farm_device_map_for_logs, get_primary_soil_sensor, get_sensor_7_in_1_comparison_chart_data, get_sensor_7_in_1_radar_chart_data, get_sensor_7_in_1_summary_data, get_sensor_comparison_chart_data, get_sensor_external_request_logs_for_farm, get_sensor_radar_chart_data, get_sensor_values_list_data, validate_output_device_catalog
from .services import DeviceDataUnavailableError, FarmDataForwardError, build_device_comparison_chart, build_device_latest_payload, build_device_radar_chart, build_device_summary, build_device_values_list, create_sensor_external_notification, execute_device_command, forward_sensor_payload_to_farm_data, get_farm_device_by_physical_uuid, get_farm_device_map_for_logs, get_primary_soil_sensor, get_sensor_7_in_1_comparison_chart_data, get_sensor_7_in_1_radar_chart_data, get_sensor_7_in_1_summary_data, get_sensor_comparison_chart_data, get_sensor_external_request_logs_for_farm, get_sensor_radar_chart_data, get_sensor_values_list_data, validate_output_device_catalog
class DeviceCatalogListView(APIView):
@@ -114,6 +114,12 @@ class DeviceRadarChartView(DeviceBaseView):
return Response(data, status=status.HTTP_200_OK)
class SensorExternalRequestLogPagination(PageNumberPagination):
page_size = 20
page_size_query_param = "page_size"
max_page_size = 100
class DeviceLogListView(DeviceBaseView):
pagination_class = SensorExternalRequestLogPagination
@@ -194,21 +200,33 @@ class Sensor7In1SummaryView(APIView):
@extend_schema(tags=["Sensor 7 in 1"], parameters=[farm_uuid_query_param(required=True, description="UUID of the farm for sensor 7 in 1 summary.")], responses={200: code_response("Sensor7In1SummaryResponse", data=Sensor7In1SummarySerializer())})
def get(self, request):
farm = self._get_farm(request)
return Response({"code": 200, "msg": "OK", "data": get_sensor_7_in_1_summary_data(farm)}, status=status.HTTP_200_OK)
try:
data = get_sensor_7_in_1_summary_data(farm)
except DeviceDataUnavailableError as exc:
raise serializers.ValidationError({"farm_uuid": [str(exc)]}) from exc
return Response({"code": 200, "msg": "OK", "data": data}, status=status.HTTP_200_OK)
class Sensor7In1RadarChartView(Sensor7In1SummaryView):
@extend_schema(tags=["Sensor 7 in 1"], parameters=[farm_uuid_query_param(required=True, description="UUID of the farm for sensor 7 in 1 radar chart.")], responses={200: code_response("Sensor7In1RadarChartResponse", data=SoilRadarChartSerializer())})
def get(self, request):
farm = self._get_farm(request)
return Response({"code": 200, "msg": "OK", "data": get_sensor_7_in_1_radar_chart_data(farm)}, status=status.HTTP_200_OK)
try:
data = get_sensor_7_in_1_radar_chart_data(farm)
except DeviceDataUnavailableError as exc:
raise serializers.ValidationError({"farm_uuid": [str(exc)]}) from exc
return Response({"code": 200, "msg": "OK", "data": data}, status=status.HTTP_200_OK)
class Sensor7In1ComparisonChartView(Sensor7In1SummaryView):
@extend_schema(tags=["Sensor 7 in 1"], parameters=[farm_uuid_query_param(required=True, description="UUID of the farm for sensor 7 in 1 comparison chart.")], responses={200: code_response("Sensor7In1ComparisonChartResponse", data=SoilComparisonChartSerializer())})
def get(self, request):
farm = self._get_farm(request)
return Response({"code": 200, "msg": "OK", "data": get_sensor_7_in_1_comparison_chart_data(farm)}, status=status.HTTP_200_OK)
try:
data = get_sensor_7_in_1_comparison_chart_data(farm)
except DeviceDataUnavailableError as exc:
raise serializers.ValidationError({"farm_uuid": [str(exc)]}) from exc
return Response({"code": 200, "msg": "OK", "data": data}, status=status.HTTP_200_OK)
class SensorComparisonChartView(Sensor7In1SummaryView):
@@ -218,7 +236,11 @@ class SensorComparisonChartView(Sensor7In1SummaryView):
serializer.is_valid(raise_exception=True)
farm = self._get_farm(request)
sensor = self._get_primary_sensor(farm=farm)
return Response(get_sensor_comparison_chart_data(farm=farm, physical_device_uuid=sensor.physical_device_uuid, range_value=serializer.validated_data["range"]), status=status.HTTP_200_OK)
try:
data = get_sensor_comparison_chart_data(farm=farm, physical_device_uuid=sensor.physical_device_uuid, range_value=serializer.validated_data["range"])
except DeviceDataUnavailableError as exc:
raise serializers.ValidationError({"farm_uuid": [str(exc)]}) from exc
return Response(data, status=status.HTTP_200_OK)
class SensorValuesListView(Sensor7In1SummaryView):
@@ -228,7 +250,11 @@ class SensorValuesListView(Sensor7In1SummaryView):
serializer.is_valid(raise_exception=True)
farm = self._get_farm(request)
sensor = self._get_primary_sensor(farm=farm)
return Response(get_sensor_values_list_data(farm=farm, physical_device_uuid=sensor.physical_device_uuid, range_value=serializer.validated_data["range"]), status=status.HTTP_200_OK)
try:
data = get_sensor_values_list_data(farm=farm, physical_device_uuid=sensor.physical_device_uuid, range_value=serializer.validated_data["range"])
except DeviceDataUnavailableError as exc:
raise serializers.ValidationError({"farm_uuid": [str(exc)]}) from exc
return Response(data, status=status.HTTP_200_OK)
class SensorRadarChartView(Sensor7In1SummaryView):
@@ -238,13 +264,11 @@ class SensorRadarChartView(Sensor7In1SummaryView):
serializer.is_valid(raise_exception=True)
farm = self._get_farm(request)
sensor = self._get_primary_sensor(farm=farm)
return Response(get_sensor_radar_chart_data(farm=farm, physical_device_uuid=sensor.physical_device_uuid, range_value=serializer.validated_data["range"]), status=status.HTTP_200_OK)
class SensorExternalRequestLogPagination(PageNumberPagination):
page_size = 20
page_size_query_param = "page_size"
max_page_size = 100
try:
data = get_sensor_radar_chart_data(farm=farm, physical_device_uuid=sensor.physical_device_uuid, range_value=serializer.validated_data["range"])
except DeviceDataUnavailableError as exc:
raise serializers.ValidationError({"farm_uuid": [str(exc)]}) from exc
return Response(data, status=status.HTTP_200_OK)
class SensorExternalAPIView(APIView):
+60 -172
View File
@@ -1,192 +1,80 @@
# نقشه سرویس کارت های داشبورد
این فایل فقط برای پیدا کردن منبع داده هر کارت داشبورد نوشته شده است تا قبل از پیاده سازی API تجمیعی بدانیم هر کارت باید از کدام app و کدام service تغذیه شود.
این سند مرجع فشرده `وضعیت واقعی کارت های داشبورد` است؛ نه طراحی آینده.
تمرکز آن روی منبع داده واقعی، status فعلی، و semantics پاسخ در runtime است.
## قانون runtime در برابر seed
- داده seed / bootstrap / fixture مجاز است و باید فقط از مسیرهای seeding و bootstrap در دسترس بماند.
- داده `mock/sample/demo` نباید در مسیر runtime سرویس، view یا adapter برای تولید پاسخ production-like استفاده شود.
- اگر داده واقعی وجود ندارد، سرویس باید `empty state` یا `failure contract` صریح برگرداند، نه داده ساختگی موفق.
## نقطه شروع فعلی
- تجمیع اصلی کارت ها الان در `dashboard/services.py` داخل تابع `get_farm_dashboard_cards` انجام می شود.
- endpoint فعلی ارسال کارت ها در `dashboard/views.py` داخل `FarmDashboardCardsView` قرار دارد.
- لیست کارت های معتبر در `dashboard/mock_data.py` داخل `VALID_CARD_IDS` نگهداری می شود.
- تجمیع اصلی کارتها در `dashboard/services.py` داخل `get_farm_dashboard_cards` انجام میشود.
- endpoint فعلی ارسال کارتها در `dashboard/views.py` داخل `FarmDashboardCardsView` قرار دارد.
- لیست کارتهای معتبر در `dashboard/defaults.py` داخل `VALID_CARD_IDS` نگهداری میشود.
## جمع بندی سریع
## جمعبندی سریع
| Card ID | منبع اصلی | تابع/سرویس فعلی | app داده | توضیح |
| --- | --- | --- | --- | --- |
| `farmOverviewKpis` | تجمیع چند سرویس | `_build_overview_kpis` | `dashboard` | خودش داده مستقل ندارد و از چند app ساخته می شود |
| `farmWeatherCard` | آب و هوا | `get_farm_weather_card_data` | `water` | از لاگ پیش بینی هوا می آید |
| `farmAlertsTracker` | هشدارها | `get_alert_tracker_data` | `farm_alerts` | شمارش هشدارهای فعال |
| `sensorValuesList` | سنسور 7-in-1 | `get_sensor_7_in_1_summary_data` | `sensor_7_in_1` | لیست آخرین مقادیر سنسور |
| `sensorRadarChart` | سنسور 7-in-1 | `get_sensor_7_in_1_summary_data` | `sensor_7_in_1` | امتیازدهی راداری وضعیت سنسور |
| `sensorComparisonChart` | سنسور 7-in-1 | `get_sensor_7_in_1_summary_data` | `sensor_7_in_1` | مقایسه تاریخی رطوبت خاک |
| `anomalyDetectionCard` | سنسور 7-in-1 | `get_sensor_7_in_1_summary_data` | `sensor_7_in_1` | ناهنجاری های خارج از بازه مجاز |
| `farmAlertsTimeline` | هشدارها | `get_alert_timeline_data` | `farm_alerts` | تایم لاین هشدارها |
| `waterNeedPrediction` | آبیاری | `get_water_need_prediction_data` | `water` | عملا از نتیجه آبیاری می خواند |
| `harvestPredictionCard` | برداشت/عملکرد | `get_yield_harvest_summary_data` | `yield_harvest` | زمان برداشت و بازه بهینه |
| `yieldPredictionChart` | برداشت/عملکرد | `get_yield_harvest_summary_data` | `yield_harvest` | نمودار پیش بینی عملکرد |
| `soilMoistureHeatmap` | سنسور 7-in-1 | `get_sensor_7_in_1_summary_data` | `sensor_7_in_1` | هیت مپ رطوبت خاک |
| `ndviHealthCard` | سلامت گیاه | `get_crop_health_summary_data` | `crop_health` | فعلا mock است |
| `recommendationsList` | تجمیع پیشنهادها | `_build_recommendations_list` | `dashboard` | از چند app کنار هم ساخته می شود |
| `economicOverview` | نمای اقتصادی | `get_economic_overview_data` | `economic_overview` | داده اقتصادی و سری نمودار |
| Card ID | Status | semantics | منبع اصلی | تابع/سرویس فعلی | app داده | توضیح |
| --- | --- | --- | --- | --- | --- | --- |
| `farmOverviewKpis` | `implemented / transitional` | aggregator | تجمیع چند سرویس | `_build_overview_kpis` | `dashboard` | منبع واحد ندارد |
| `farmWeatherCard` | `partial` | provider/persisted | آب و هوا | `get_farm_weather_card_data` | `water` | نباید fallback ساختگی runtime داشته باشد |
| `farmAlertsTracker` | `implemented` | cached snapshot | snapshot persisted | `get_alert_tracker_data` | `farm_alerts` | live AI نیست |
| `sensorValuesList` | `implemented / transitional` | persisted sensor log | سنسور 7-in-1 | `get_sensor_7_in_1_summary_data` | `sensor_7_in_1` | adoption کامل facade `farm_data` هنوز کامل نشده |
| `sensorRadarChart` | `implemented / transitional` | persisted sensor log | سنسور 7-in-1 | `get_sensor_7_in_1_summary_data` | `sensor_7_in_1` | همان وضعیت |
| `sensorComparisonChart` | `implemented / transitional` | persisted sensor log | سنسور 7-in-1 | `get_sensor_7_in_1_summary_data` | `sensor_7_in_1` | همان وضعیت |
| `anomalyDetectionCard` | `implemented / transitional` | derived from sensor logs | سنسور 7-in-1 | `get_sensor_7_in_1_summary_data` | `sensor_7_in_1` | ownership نهایی anomalyها هنوز کامل یکدست نشده |
| `farmAlertsTimeline` | `partial` | persisted timeline | هشدارها | `get_alert_timeline_data` | `farm_alerts` | نباید fallback ساختگی runtime داشته باشد |
| `waterNeedPrediction` | `implemented / proxy-derived` | derived from persisted irrigation recommendation | آبیاری | `get_water_need_prediction_data` | `water` | facade در `water` است ولی business source در `irrigation` قرار دارد |
| `harvestPredictionCard` | `implemented / proxy-derived` | persisted AI-derived | برداشت/عملکرد | `get_yield_harvest_summary_data` | `yield_harvest` | از لاگ persisted می‌آید |
| `yieldPredictionChart` | `implemented / proxy-derived` | persisted AI-derived | برداشت/عملکرد | `get_yield_harvest_summary_data` | `yield_harvest` | از لاگ persisted می‌آید |
| `soilMoistureHeatmap` | `implemented / transitional` | persisted sensor log | سنسور 7-in-1 | `get_sensor_7_in_1_summary_data` | `sensor_7_in_1` | facade نهایی همه خوانش‌ها را هنوز unify نکرده |
| `ndviHealthCard` | `disabled / partial` | not runtime-ready | سلامت گیاه | `get_crop_health_summary_data` | `crop_health` | نباید به‌عنوان کارت implemented کامل معرفی شود |
| `recommendationsList` | `implemented / transitional` | aggregator | تجمیع پیشنهادها | `_build_recommendations_list` | `dashboard` | از چند app کنار هم ساخته میشود |
| `economicOverview` | `implemented` | persisted/log-based | نمای اقتصادی | `get_economic_overview_data` | `economic_overview` | داده اقتصادی persisted |
## جزئیات هر کارت
## نکات مهم کارت‌ها
### 1) `farmOverviewKpis`
### `farmOverviewKpis`
- aggregator است و باید در `dashboard` بماند.
این کارت در `dashboard/services.py` و توسط `_build_overview_kpis` ساخته می شود و داده اش از چند app می آید:
### `farmWeatherCard`
- source: `water.models.WeatherForecastLog`
- قرارداد runtime: اگر داده هواشناسی موجود نباشد، باید `empty state` یا `failure contract` صریح برگردد، نه mock.
- `crop_health.services.get_crop_health_summary_data`
- KPI: `farmHealthScore`
- `water.services.get_water_stress_index_data`
- KPI: `water_stress_index`
- `sensor_7_in_1.services.get_sensor_7_in_1_summary_data`
- KPI: `avgSoilMoisture`
- `pest_detection.services.get_risk_summary_data`
- KPI ها: `disease_risk` و `pest_risk`
- `yield_harvest.services.get_yield_harvest_summary_data`
- KPI: `yield_prediction_card`
### `farmAlertsTracker`
- source: snapshot persisted
- semantics: `cached snapshot`
نتیجه: این بخش باید در API نهایی به صورت aggregator باقی بماند، چون منبع واحد ندارد.
### `waterNeedPrediction`
- facade فعلی در `water`
- business source واقعی: `irrigation.models.IrrigationRecommendationRequest`
- semantics: `proxy-derived persisted data`
### 2) `farmWeatherCard`
### `harvestPredictionCard` و `yieldPredictionChart`
- source: `yield_harvest.models.YieldHarvestPredictionLog`
- semantics: `persisted AI-derived`
- app: `water`
- service: `water/services.py` -> `get_farm_weather_card_data`
- model/source: `water.models.WeatherForecastLog`
- fallback: `water/mock_data.py` -> `FARM_WEATHER_CARD`
### `ndviHealthCard`
- status: `disabled / partial`
- تا زمانی که source runtime-ready پایدار برای NDVI نهایی نشود، نباید به عنوان کارت production-ready مستند شود.
اگر داده هواشناسی برای مزرعه ثبت نشده باشد، خروجی mock برمی گردد.
## Ownership و transitional boundaries
### 3) `farmAlertsTracker`
- plant catalog canonical در Backend شروع می‌شود.
- dashboard هنوز بعضی کارت‌ها را از facadeهای transitional می‌خواند.
- سنسور / plant / farm ownership به‌تدریج باید به facade `farm_data` نزدیک‌تر شود، ولی همه مصرف‌کننده‌ها هنوز migrate نشده‌اند.
- app: `farm_alerts`
- service: `farm_alerts/services.py` -> `get_alert_tracker_data`
- model/source: `farm_alerts.models.FarmAlert`
- منطق: هشدارهای `is_active=True` را می شمارد و top 3 را برمی گرداند.
## Response Semantics
### 4) `sensorValuesList`
- `farmAlertsTracker``cached snapshot`
- `waterNeedPrediction``derived from persisted irrigation recommendation`
- `harvestPredictionCard` / `yieldPredictionChart``persisted AI-derived snapshot`
- `farmOverviewKpis` / `recommendationsList``dashboard-owned aggregator`
- app: `sensor_7_in_1`
- service: `sensor_7_in_1/services.py` -> `get_sensor_7_in_1_summary_data`
- زیرسرویس واقعی: `get_sensor_7_in_1_values_list_data`
- source dependency:
- `farm.sensors`
- `sensor_external_api.services.get_sensor_external_request_logs_for_farm`
- `sensor_external_api.services.get_farm_sensor_map_for_logs`
## Known Gaps / Follow-up
این کارت وابسته به آخرین لاگ سنسور فیزیکی مزرعه است.
### 5) `sensorRadarChart`
- app: `sensor_7_in_1`
- service: `sensor_7_in_1/services.py` -> `get_sensor_7_in_1_summary_data`
- زیرسرویس واقعی: `get_sensor_7_in_1_radar_chart_data`
- source: همان context سنسور 7-in-1
### 6) `sensorComparisonChart`
- app: `sensor_7_in_1`
- service: `sensor_7_in_1/services.py` -> `get_sensor_7_in_1_summary_data`
- زیرسرویس واقعی: `get_sensor_7_in_1_comparison_chart_data`
- source: history لاگ های سنسور در `sensor_external_api`
### 7) `anomalyDetectionCard`
- app: `sensor_7_in_1`
- service: `sensor_7_in_1/services.py` -> `get_sensor_7_in_1_summary_data`
- زیرسرویس واقعی: `get_sensor_7_in_1_anomaly_detection_card_data`
- منطق: از روی بازه مجاز هر فیلد سنسور anomaly می سازد.
نکته: در `farm_alerts` هم تابع `get_anomaly_detection_data` وجود دارد، ولی در داشبورد فعلی استفاده نشده و کارت anomaly از `sensor_7_in_1` می آید.
### 8) `farmAlertsTimeline`
- app: `farm_alerts`
- service: `farm_alerts/services.py` -> `get_alert_timeline_data`
- model/source: `farm_alerts.models.FarmAlert`
- fallback: `farm_alerts/mock_data.py` -> `FARM_ALERTS_TIMELINE`
### 9) `waterNeedPrediction`
- app aggregator call: `water`
- service: `water/services.py` -> `get_water_need_prediction_data`
- source واقعی داده: `irrigation.models.IrrigationRecommendationRequest`
- منطق: از `response_payload` آخرین recommendation آبیاری، `water_balance.daily` را می خواند.
نکته مهم: تابعی با همین نام در `irrigation/services.py` هم وجود دارد، اما داشبورد فعلی نسخه `water` را صدا می زند. پس منبع business data عملا app آبیاری است، ولی facade فعلی داخل app `water` قرار دارد.
### 10) `harvestPredictionCard`
- app: `yield_harvest`
- service: `yield_harvest/services.py` -> `get_yield_harvest_summary_data`
- model/source: `yield_harvest.models.YieldHarvestPredictionLog`
- داده های مهم: `harvest_date`, `days_until_harvest`, `optimal_window_start`, `optimal_window_end`
### 11) `yieldPredictionChart`
- app: `yield_harvest`
- service: `yield_harvest/services.py` -> `get_yield_harvest_summary_data`
- model/source: `yield_harvest.models.YieldHarvestPredictionLog`
- داده مهم: `chart_data`
### 12) `soilMoistureHeatmap`
- app: `sensor_7_in_1`
- service: `sensor_7_in_1/services.py` -> `get_sensor_7_in_1_summary_data`
- زیرسرویس واقعی: `get_sensor_7_in_1_soil_moisture_heatmap_data`
- source: history رطوبت خاک از لاگ سنسورها
### 13) `ndviHealthCard`
- app: `crop_health`
- service: `crop_health/services.py` -> `get_crop_health_summary_data`
- وضعیت فعلی: فعلا مستقیم از mock برمی گردد.
- fallback/source: `crop_health/mock_data.py`
نکته: این app الان منبع DB-driven در این سرویس ندارد و اگر بخواهیم داده واقعی بدهیم باید مدل یا integration منبع NDVI را همینجا اضافه کنیم.
### 14) `recommendationsList`
این کارت در `dashboard/services.py` و توسط `_build_recommendations_list` ساخته می شود. منابع آن:
- `farm_alerts.services.get_recommendations_list_data`
- model/source: `farm_alerts.models.Recommendation`
- `irrigation.services.get_irrigation_dashboard_recommendation`
- model/source: `irrigation.models.IrrigationRecommendationRequest`
- `fertilization.services.get_fertilization_dashboard_recommendation`
- model/source: `fertilization.models.FertilizationRecommendationRequest`
- `yield_harvest.services.get_yield_harvest_summary_data`
- برای ساخت recommendation مرتبط با بازه برداشت
نتیجه: این کارت هم aggregator است و بهتر است داخل app `dashboard` بماند.
### 15) `economicOverview`
- app: `economic_overview`
- service: `economic_overview/services.py` -> `get_economic_overview_data`
- model/source: `economic_overview.models.EconomicOverviewLog`
- فیلدهای مهم: `economic_data`, `chart_series`, `chart_categories`
## سرویس هایی که الان ماهیت aggregator دارند
این بخش ها باید در API نهایی dashboard به صورت orchestration بین app ها مدیریت شوند:
- `farmOverviewKpis`
- `recommendationsList`
- کل تابع `get_farm_dashboard_cards`
## app هایی که الان بیشتر mock هستند
این app ها در مسیر داشبورد فعلی هنوز کاملا به داده واقعی وصل نشده اند یا بخشی از خروجی آن ها mock است:
- `crop_health`
- `pest_detection`
- بعضی fallback های `water`, `farm_alerts`, `yield_harvest`, `sensor_7_in_1`
## پیشنهاد برای مرحله بعد
برای ساخت API تجمیعی تمیز، بهتر است این قرارداد را نگه داریم:
1. هر app فقط یک service کوچک برای data payload کارت خودش بدهد.
2. app `dashboard` فقط orchestration و merge انجام دهد.
3. برای کارت های ترکیبی مثل `farmOverviewKpis` و `recommendationsList` منطق join داخل `dashboard/services.py` بماند.
4. اگر خواستی endpoint جدید بسازی، `dashboard/views.py` بهترین محل برای API نهایی است چون همین حالا هم farm validation و access control آنجا انجام می شود.
- ownership نهایی خوانش سنسور بین facade `farm_data` و سرویس‌های legacy هنوز در بعضی کارت‌ها transitional است.
- `ndviHealthCard` هنوز برای runtime production-ready نیست.
+8
View File
@@ -0,0 +1,8 @@
EMPTY_ECONOMIC_OVERVIEW = {
"economicData": [],
"chartSeries": [],
"chartCategories": [],
"status": "empty",
"source": "db",
"warnings": ["No persisted economic overview data is available for this farm."],
}
+5 -2
View File
@@ -1,11 +1,11 @@
from copy import deepcopy
from .mock_data import ECONOMIC_OVERVIEW
from .defaults import EMPTY_ECONOMIC_OVERVIEW
from .models import EconomicOverviewLog
def get_economic_overview_data(farm=None):
data = deepcopy(ECONOMIC_OVERVIEW)
data = deepcopy(EMPTY_ECONOMIC_OVERVIEW)
if farm is None:
return data
@@ -14,6 +14,9 @@ def get_economic_overview_data(farm=None):
if log is None:
return data
data["status"] = "success"
data["source"] = "db"
data["warnings"] = []
if log.economic_data:
data["economicData"] = deepcopy(log.economic_data)
if log.chart_series:
+16
View File
@@ -79,3 +79,19 @@ class EconomyOverviewViewTests(TestCase):
with self.assertRaises(Resolver404):
resolve("/api/economic-overview/summary/")
@patch("economic_overview.views.external_api_request")
def test_overview_returns_structured_502_for_invalid_upstream_payload(self, mock_external_api_request):
mock_external_api_request.return_value = AdapterResponse(
status_code=200,
data={"data": []},
)
request = self.factory.post("/api/economy/overview/", {"farm_uuid": str(self.farm.farm_uuid)}, format="json")
force_authenticate(request, user=self.user)
response = EconomyOverviewView.as_view()(request)
self.assertEqual(response.status_code, 502)
self.assertEqual(response.data["data"]["error_code"], "invalid_payload")
self.assertEqual(response.data["data"]["source"], "ai_provider")
+78 -28
View File
@@ -1,16 +1,58 @@
import logging
from drf_spectacular.utils import extend_schema
from rest_framework import status
from rest_framework.response import Response
from rest_framework.views import APIView
from config.failure_contract import StructuredServiceError
from config.swagger import status_response
from external_api_adapter import request as external_api_request
from external_api_adapter.exceptions import ExternalAPIRequestError
from farm_hub.models import FarmHub
from .models import EconomicOverviewLog
from .serializers import EconomicOverviewRequestSerializer, EconomicOverviewSerializer
logger = logging.getLogger(__name__)
class EconomicOverviewAdapterError(StructuredServiceError):
def __init__(self, *, error_code: str, message: str, source: str, retriable: bool = False, details: dict | None = None):
super().__init__(
error_code=error_code,
message=message,
source=source,
retriable=retriable,
details=details,
)
class EconomyOverviewView(APIView):
@staticmethod
def _extract_result_or_error(adapter_data):
if not isinstance(adapter_data, dict):
raise EconomicOverviewAdapterError(
error_code="invalid_payload",
message="Economic overview adapter returned a non-object payload.",
source="ai_provider",
)
data = adapter_data.get("data")
if isinstance(data, dict) and isinstance(data.get("result"), dict):
return data["result"]
if isinstance(data, dict):
return data
result = adapter_data.get("result")
if isinstance(result, dict):
return result
raise EconomicOverviewAdapterError(
error_code="invalid_payload",
message="Economic overview adapter payload did not contain structured result data.",
source="ai_provider",
)
@staticmethod
def _get_farm(request, farm_uuid):
if not farm_uuid:
@@ -26,27 +68,14 @@ class EconomyOverviewView(APIView):
status=status.HTTP_404_NOT_FOUND,
)
@staticmethod
def _extract_result(adapter_data):
if not isinstance(adapter_data, dict):
return {}
data = adapter_data.get("data")
if isinstance(data, dict) and isinstance(data.get("result"), dict):
return data["result"]
if isinstance(data, dict):
return data
result = adapter_data.get("result")
if isinstance(result, dict):
return result
return adapter_data
@staticmethod
def _persist_log(farm, overview_data):
if not isinstance(overview_data, dict):
return
raise EconomicOverviewAdapterError(
error_code="invalid_payload",
message="Economic overview data must be a JSON object before persistence.",
source="backend",
)
EconomicOverviewLog.objects.create(
farm=farm,
economic_data=overview_data.get("economicData", []),
@@ -68,12 +97,26 @@ class EconomyOverviewView(APIView):
return error_response
payload = {"farm_uuid": str(farm.farm_uuid)}
adapter_response = external_api_request(
"ai",
"/api/economy/overview/",
method="POST",
payload=payload,
)
try:
adapter_response = external_api_request(
"ai",
"/api/economy/overview/",
method="POST",
payload=payload,
)
except ExternalAPIRequestError as exc:
logger.error("Economic overview upstream request failed for farm_uuid=%s: %s", farm.farm_uuid, exc)
failure = EconomicOverviewAdapterError(
error_code="upstream_unavailable",
message="Economic overview upstream request failed.",
source="ai_provider",
retriable=True,
details={"farm_uuid": str(farm.farm_uuid)},
)
return Response(
{"code": 503, "msg": "error", "data": failure.to_dict()},
status=status.HTTP_503_SERVICE_UNAVAILABLE,
)
if adapter_response.status_code >= 400:
response_data = (
@@ -86,8 +129,15 @@ class EconomyOverviewView(APIView):
status=adapter_response.status_code,
)
overview_data = self._extract_result(adapter_response.data)
if isinstance(overview_data, dict):
overview_data.setdefault("farm_uuid", str(farm.farm_uuid))
self._persist_log(farm, overview_data)
try:
overview_data = self._extract_result_or_error(adapter_response.data)
if isinstance(overview_data, dict):
overview_data.setdefault("farm_uuid", str(farm.farm_uuid))
self._persist_log(farm, overview_data)
except EconomicOverviewAdapterError as exc:
logger.error("Economic overview payload handling failed for farm_uuid=%s: %s", farm.farm_uuid, exc)
return Response(
{"code": 502, "msg": "error", "data": exc.to_dict()},
status=status.HTTP_502_BAD_GATEWAY,
)
return Response({"code": 200, "msg": "success", "data": overview_data}, status=status.HTTP_200_OK)
+356 -89
View File
@@ -4,622 +4,889 @@
"path": "/api/dashboard-data/generate/",
"status_code": 202,
"description": "Dashboard data task queued",
"file": "json/mock_data/dashboard-data/generate/post_202.json"
"file": "json/mock_data/dashboard-data/generate/post_202.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Mock/spec example only; verify actual route registration before treating as implemented."
},
{
"method": "POST",
"path": "/api/dashboard-data/generate/",
"status_code": 400,
"description": "Missing sensor_id",
"file": "json/mock_data/dashboard-data/generate/post_400.json"
"file": "json/mock_data/dashboard-data/generate/post_400.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Mock/spec example only; verify actual route registration before treating as implemented."
},
{
"method": "GET",
"path": "/api/dashboard-data/{task_id}/status/",
"status_code": 200,
"description": "Pending dashboard task",
"file": "json/mock_data/dashboard-data/status/get_200_pending.json"
"file": "json/mock_data/dashboard-data/status/get_200_pending.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Mock/spec example only; verify actual route registration before treating as implemented."
},
{
"method": "GET",
"path": "/api/dashboard-data/{task_id}/status/",
"status_code": 200,
"description": "Dashboard task in progress",
"file": "json/mock_data/dashboard-data/status/get_200_progress.json"
"file": "json/mock_data/dashboard-data/status/get_200_progress.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Mock/spec example only; verify actual route registration before treating as implemented."
},
{
"method": "GET",
"path": "/api/dashboard-data/{task_id}/status/",
"status_code": 200,
"description": "Successful dashboard task",
"file": "json/mock_data/dashboard-data/status/get_200_success.json"
"file": "json/mock_data/dashboard-data/status/get_200_success.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Mock/spec example only; verify actual route registration before treating as implemented."
},
{
"method": "GET",
"path": "/api/dashboard-data/{task_id}/status/",
"status_code": 200,
"description": "Failed dashboard task",
"file": "json/mock_data/dashboard-data/status/get_200_failure.json"
"file": "json/mock_data/dashboard-data/status/get_200_failure.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Mock/spec example only; verify actual route registration before treating as implemented."
},
{
"method": "POST",
"path": "/api/fertilization/recommend/",
"status_code": 202,
"description": "Fertilization task queued",
"file": "json/mock_data/fertilization/recommend/post_202.json"
"file": "json/mock_data/fertilization/recommend/post_202.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only."
},
{
"method": "POST",
"path": "/api/fertilization/recommend/",
"status_code": 400,
"description": "Validation error",
"file": "json/mock_data/fertilization/recommend/post_400.json"
"file": "json/mock_data/fertilization/recommend/post_400.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only."
},
{
"method": "GET",
"path": "/api/fertilization/recommend/{task_id}/status/",
"status_code": 200,
"description": "Fertilization status pending",
"file": "json/mock_data/fertilization/status/get_200_pending.json"
"file": "json/mock_data/fertilization/status/get_200_pending.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Mock/spec example only; verify actual route registration before treating as implemented."
},
{
"method": "GET",
"path": "/api/fertilization/recommend/{task_id}/status/",
"status_code": 200,
"description": "Fertilization status progress",
"file": "json/mock_data/fertilization/status/get_200_progress.json"
"file": "json/mock_data/fertilization/status/get_200_progress.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Mock/spec example only; verify actual route registration before treating as implemented."
},
{
"method": "GET",
"path": "/api/fertilization/recommend/{task_id}/status/",
"status_code": 200,
"description": "Fertilization status success",
"file": "json/mock_data/fertilization/status/get_200_success.json"
"file": "json/mock_data/fertilization/status/get_200_success.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Mock/spec example only; verify actual route registration before treating as implemented."
},
{
"method": "GET",
"path": "/api/fertilization/recommend/{task_id}/status/",
"status_code": 200,
"description": "Fertilization status failure",
"file": "json/mock_data/fertilization/status/get_200_failure.json"
"file": "json/mock_data/fertilization/status/get_200_failure.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Mock/spec example only; verify actual route registration before treating as implemented."
},
{
"method": "GET",
"path": "/api/irrigation/",
"status_code": 200,
"description": "List irrigation methods",
"file": "json/mock_data/irrigation/methods/get_200.json"
"file": "json/mock_data/irrigation/methods/get_200.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only."
},
{
"method": "POST",
"path": "/api/irrigation/",
"status_code": 201,
"description": "Create irrigation method",
"file": "json/mock_data/irrigation/methods/post_201.json"
"file": "json/mock_data/irrigation/methods/post_201.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only."
},
{
"method": "POST",
"path": "/api/irrigation/",
"status_code": 400,
"description": "Irrigation create validation error",
"file": "json/mock_data/irrigation/methods/post_400.json"
"file": "json/mock_data/irrigation/methods/post_400.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only."
},
{
"method": "POST",
"path": "/api/irrigation/recommend/",
"status_code": 202,
"description": "Irrigation recommendation task queued",
"file": "json/mock_data/irrigation/recommend/post_202.json"
"file": "json/mock_data/irrigation/recommend/post_202.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Mock/spec example only; verify actual route registration before treating as implemented."
},
{
"method": "POST",
"path": "/api/irrigation/recommend/",
"status_code": 400,
"description": "Irrigation recommendation validation error",
"file": "json/mock_data/irrigation/recommend/post_400.json"
"file": "json/mock_data/irrigation/recommend/post_400.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Mock/spec example only; verify actual route registration before treating as implemented."
},
{
"method": "GET",
"path": "/api/irrigation/recommend/{task_id}/status/",
"status_code": 200,
"description": "Irrigation recommendation status pending",
"file": "json/mock_data/irrigation/recommend/status/get_200_pending.json"
"file": "json/mock_data/irrigation/recommend/status/get_200_pending.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Mock/spec example only; verify actual route registration before treating as implemented."
},
{
"method": "GET",
"path": "/api/irrigation/recommend/{task_id}/status/",
"status_code": 200,
"description": "Irrigation recommendation status progress",
"file": "json/mock_data/irrigation/recommend/status/get_200_progress.json"
"file": "json/mock_data/irrigation/recommend/status/get_200_progress.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Mock/spec example only; verify actual route registration before treating as implemented."
},
{
"method": "GET",
"path": "/api/irrigation/recommend/{task_id}/status/",
"status_code": 200,
"description": "Irrigation recommendation status success",
"file": "json/mock_data/irrigation/recommend/status/get_200_success.json"
"file": "json/mock_data/irrigation/recommend/status/get_200_success.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Mock/spec example only; verify actual route registration before treating as implemented."
},
{
"method": "GET",
"path": "/api/irrigation/recommend/{task_id}/status/",
"status_code": 200,
"description": "Irrigation recommendation status failure",
"file": "json/mock_data/irrigation/recommend/status/get_200_failure.json"
"file": "json/mock_data/irrigation/recommend/status/get_200_failure.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Mock/spec example only; verify actual route registration before treating as implemented."
},
{
"method": "GET",
"path": "/api/irrigation/{pk}/",
"status_code": 200,
"description": "Irrigation method get success",
"file": "json/mock_data/irrigation/method-detail/get_200.json"
"file": "json/mock_data/irrigation/method-detail/get_200.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only."
},
{
"method": "GET",
"path": "/api/irrigation/{pk}/",
"status_code": 404,
"description": "Irrigation method get not found",
"file": "json/mock_data/irrigation/method-detail/get_404.json"
"file": "json/mock_data/irrigation/method-detail/get_404.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only."
},
{
"method": "PUT",
"path": "/api/irrigation/{pk}/",
"status_code": 200,
"description": "Irrigation method put success",
"file": "json/mock_data/irrigation/method-detail/put_200.json"
"file": "json/mock_data/irrigation/method-detail/put_200.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only."
},
{
"method": "PUT",
"path": "/api/irrigation/{pk}/",
"status_code": 400,
"description": "Irrigation method put validation error",
"file": "json/mock_data/irrigation/method-detail/put_400.json"
"file": "json/mock_data/irrigation/method-detail/put_400.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only."
},
{
"method": "PUT",
"path": "/api/irrigation/{pk}/",
"status_code": 404,
"description": "Irrigation method put not found",
"file": "json/mock_data/irrigation/method-detail/put_404.json"
"file": "json/mock_data/irrigation/method-detail/put_404.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only."
},
{
"method": "PATCH",
"path": "/api/irrigation/{pk}/",
"status_code": 200,
"description": "Irrigation method patch success",
"file": "json/mock_data/irrigation/method-detail/patch_200.json"
"file": "json/mock_data/irrigation/method-detail/patch_200.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only."
},
{
"method": "PATCH",
"path": "/api/irrigation/{pk}/",
"status_code": 400,
"description": "Irrigation method patch validation error",
"file": "json/mock_data/irrigation/method-detail/patch_400.json"
"file": "json/mock_data/irrigation/method-detail/patch_400.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only."
},
{
"method": "PATCH",
"path": "/api/irrigation/{pk}/",
"status_code": 404,
"description": "Irrigation method patch not found",
"file": "json/mock_data/irrigation/method-detail/patch_404.json"
"file": "json/mock_data/irrigation/method-detail/patch_404.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only."
},
{
"method": "DELETE",
"path": "/api/irrigation/{pk}/",
"status_code": 200,
"description": "Delete irrigation method",
"file": "json/mock_data/irrigation/method-detail/delete_200.json"
"file": "json/mock_data/irrigation/method-detail/delete_200.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only."
},
{
"method": "DELETE",
"path": "/api/irrigation/{pk}/",
"status_code": 404,
"description": "Delete irrigation method not found",
"file": "json/mock_data/irrigation/method-detail/delete_404.json"
"file": "json/mock_data/irrigation/method-detail/delete_404.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only."
},
{
"method": "GET",
"path": "/api/soil-data/",
"status_code": 200,
"description": "Soil data served from database",
"file": "json/mock_data/soil-data/get_200_database.json"
"file": "json/mock_data/soil-data/get_200_database.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only."
},
{
"method": "GET",
"path": "/api/soil-data/",
"status_code": 202,
"description": "Soil data fetch task queued",
"file": "json/mock_data/soil-data/get_202_queued.json"
"file": "json/mock_data/soil-data/get_202_queued.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only."
},
{
"method": "GET",
"path": "/api/soil-data/",
"status_code": 400,
"description": "Soil data validation error",
"file": "json/mock_data/soil-data/get_400.json"
"file": "json/mock_data/soil-data/get_400.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only."
},
{
"method": "POST",
"path": "/api/soil-data/",
"status_code": 200,
"description": "Soil data POST served from database",
"file": "json/mock_data/soil-data/post_200_database.json"
"file": "json/mock_data/soil-data/post_200_database.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only."
},
{
"method": "POST",
"path": "/api/soil-data/",
"status_code": 202,
"description": "Soil data POST task queued",
"file": "json/mock_data/soil-data/post_202_queued.json"
"file": "json/mock_data/soil-data/post_202_queued.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only."
},
{
"method": "POST",
"path": "/api/soil-data/",
"status_code": 400,
"description": "Soil data POST validation error",
"file": "json/mock_data/soil-data/post_400.json"
"file": "json/mock_data/soil-data/post_400.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only."
},
{
"method": "GET",
"path": "/api/soil-data/tasks/{task_id}/status/",
"status_code": 200,
"description": "Soil task status pending",
"file": "json/mock_data/soil-data/status/get_200_pending.json"
"file": "json/mock_data/soil-data/status/get_200_pending.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only."
},
{
"method": "GET",
"path": "/api/soil-data/tasks/{task_id}/status/",
"status_code": 200,
"description": "Soil task status progress",
"file": "json/mock_data/soil-data/status/get_200_progress.json"
"file": "json/mock_data/soil-data/status/get_200_progress.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only."
},
{
"method": "GET",
"path": "/api/soil-data/tasks/{task_id}/status/",
"status_code": 200,
"description": "Soil task status success",
"file": "json/mock_data/soil-data/status/get_200_success.json"
"file": "json/mock_data/soil-data/status/get_200_success.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only."
},
{
"method": "GET",
"path": "/api/soil-data/tasks/{task_id}/status/",
"status_code": 200,
"description": "Soil task status failure",
"file": "json/mock_data/soil-data/status/get_200_failure.json"
"file": "json/mock_data/soil-data/status/get_200_failure.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only."
},
{
"method": "GET",
"path": "/api/plants/",
"status_code": 200,
"description": "List plants",
"file": "json/mock_data/plant/list-get_200.json"
"file": "json/mock_data/plant/list-get_200.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only."
},
{
"method": "POST",
"path": "/api/plants/",
"status_code": 201,
"description": "Create plant",
"file": "json/mock_data/plant/create-post_201.json"
"file": "json/mock_data/plant/create-post_201.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only."
},
{
"method": "POST",
"path": "/api/plants/",
"status_code": 400,
"description": "Plant create validation error",
"file": "json/mock_data/plant/create-post_400.json"
"file": "json/mock_data/plant/create-post_400.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only."
},
{
"method": "GET",
"path": "/api/plants/{pk}/",
"status_code": 200,
"description": "Plant detail get success",
"file": "json/mock_data/plant/detail-get_200.json"
"file": "json/mock_data/plant/detail-get_200.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only."
},
{
"method": "GET",
"path": "/api/plants/{pk}/",
"status_code": 404,
"description": "Plant detail get not found",
"file": "json/mock_data/plant/detail-get_404.json"
"file": "json/mock_data/plant/detail-get_404.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only."
},
{
"method": "PUT",
"path": "/api/plants/{pk}/",
"status_code": 200,
"description": "Plant detail put success",
"file": "json/mock_data/plant/detail-put_200.json"
"file": "json/mock_data/plant/detail-put_200.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only."
},
{
"method": "PUT",
"path": "/api/plants/{pk}/",
"status_code": 400,
"description": "Plant detail put validation error",
"file": "json/mock_data/plant/detail-put_400.json"
"file": "json/mock_data/plant/detail-put_400.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only."
},
{
"method": "PUT",
"path": "/api/plants/{pk}/",
"status_code": 404,
"description": "Plant detail put not found",
"file": "json/mock_data/plant/detail-put_404.json"
"file": "json/mock_data/plant/detail-put_404.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only."
},
{
"method": "PATCH",
"path": "/api/plants/{pk}/",
"status_code": 200,
"description": "Plant detail patch success",
"file": "json/mock_data/plant/detail-patch_200.json"
"file": "json/mock_data/plant/detail-patch_200.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only."
},
{
"method": "PATCH",
"path": "/api/plants/{pk}/",
"status_code": 400,
"description": "Plant detail patch validation error",
"file": "json/mock_data/plant/detail-patch_400.json"
"file": "json/mock_data/plant/detail-patch_400.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only."
},
{
"method": "PATCH",
"path": "/api/plants/{pk}/",
"status_code": 404,
"description": "Plant detail patch not found",
"file": "json/mock_data/plant/detail-patch_404.json"
"file": "json/mock_data/plant/detail-patch_404.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only."
},
{
"method": "DELETE",
"path": "/api/plants/{pk}/",
"status_code": 200,
"description": "Delete plant success",
"file": "json/mock_data/plant/detail-delete_200.json"
"file": "json/mock_data/plant/detail-delete_200.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only."
},
{
"method": "DELETE",
"path": "/api/plants/{pk}/",
"status_code": 404,
"description": "Delete plant not found",
"file": "json/mock_data/plant/detail-delete_404.json"
"file": "json/mock_data/plant/detail-delete_404.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only."
},
{
"method": "POST",
"path": "/api/plants/fetch-info/",
"status_code": 200,
"description": "Fetch plant info success",
"file": "json/mock_data/plant/fetch-info-post_200.json"
"file": "json/mock_data/plant/fetch-info-post_200.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only."
},
{
"method": "POST",
"path": "/api/plants/fetch-info/",
"status_code": 400,
"description": "Fetch plant info missing name",
"file": "json/mock_data/plant/fetch-info-post_400.json"
"file": "json/mock_data/plant/fetch-info-post_400.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only."
},
{
"method": "POST",
"path": "/api/plants/fetch-info/",
"status_code": 503,
"description": "Fetch plant info service unavailable",
"file": "json/mock_data/plant/fetch-info-post_503.json"
"file": "json/mock_data/plant/fetch-info-post_503.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only."
},
{
"method": "POST",
"path": "/api/rag/chat/",
"status_code": 200,
"description": "RAG chat streaming response",
"file": "json/mock_data/rag/chat-post_200_stream.json"
"file": "json/mock_data/rag/chat-post_200_stream.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only."
},
{
"method": "POST",
"path": "/api/rag/chat/",
"status_code": 400,
"description": "Missing query",
"file": "json/mock_data/rag/chat-post_400_missing_query.json"
"file": "json/mock_data/rag/chat-post_400_missing_query.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only."
},
{
"method": "POST",
"path": "/api/rag/chat/",
"status_code": 400,
"description": "Invalid service id",
"file": "json/mock_data/rag/chat-post_400_invalid_service.json"
"file": "json/mock_data/rag/chat-post_400_invalid_service.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only."
},
{
"method": "POST",
"path": "/api/rag/chat/",
"status_code": 400,
"description": "Missing user_id for service",
"file": "json/mock_data/rag/chat-post_400_missing_user.json"
"file": "json/mock_data/rag/chat-post_400_missing_user.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only."
},
{
"method": "POST",
"path": "/api/rag/recommend/irrigation/",
"status_code": 202,
"description": "RAG irrigation task queued",
"file": "json/mock_data/rag/irrigation/post_202.json"
"file": "json/mock_data/rag/irrigation/post_202.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Mock/spec example only; verify actual route registration before treating as implemented."
},
{
"method": "POST",
"path": "/api/rag/recommend/irrigation/",
"status_code": 400,
"description": "RAG irrigation validation error",
"file": "json/mock_data/rag/irrigation/post_400.json"
"file": "json/mock_data/rag/irrigation/post_400.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Mock/spec example only; verify actual route registration before treating as implemented."
},
{
"method": "GET",
"path": "/api/rag/recommend/irrigation/{task_id}/status/",
"status_code": 200,
"description": "RAG irrigation status pending",
"file": "json/mock_data/rag/irrigation/status/get_200_pending.json"
"file": "json/mock_data/rag/irrigation/status/get_200_pending.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Mock/spec example only; verify actual route registration before treating as implemented."
},
{
"method": "GET",
"path": "/api/rag/recommend/irrigation/{task_id}/status/",
"status_code": 200,
"description": "RAG irrigation status progress",
"file": "json/mock_data/rag/irrigation/status/get_200_progress.json"
"file": "json/mock_data/rag/irrigation/status/get_200_progress.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Mock/spec example only; verify actual route registration before treating as implemented."
},
{
"method": "GET",
"path": "/api/rag/recommend/irrigation/{task_id}/status/",
"status_code": 200,
"description": "RAG irrigation status success",
"file": "json/mock_data/rag/irrigation/status/get_200_success.json"
"file": "json/mock_data/rag/irrigation/status/get_200_success.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Mock/spec example only; verify actual route registration before treating as implemented."
},
{
"method": "GET",
"path": "/api/rag/recommend/irrigation/{task_id}/status/",
"status_code": 200,
"description": "RAG irrigation status failure",
"file": "json/mock_data/rag/irrigation/status/get_200_failure.json"
"file": "json/mock_data/rag/irrigation/status/get_200_failure.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Mock/spec example only; verify actual route registration before treating as implemented."
},
{
"method": "POST",
"path": "/api/rag/recommend/fertilization/",
"status_code": 202,
"description": "RAG fertilization task queued",
"file": "json/mock_data/rag/fertilization/post_202.json"
"file": "json/mock_data/rag/fertilization/post_202.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Mock/spec example only; verify actual route registration before treating as implemented."
},
{
"method": "POST",
"path": "/api/rag/recommend/fertilization/",
"status_code": 400,
"description": "RAG fertilization validation error",
"file": "json/mock_data/rag/fertilization/post_400.json"
"file": "json/mock_data/rag/fertilization/post_400.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Mock/spec example only; verify actual route registration before treating as implemented."
},
{
"method": "GET",
"path": "/api/rag/recommend/fertilization/{task_id}/status/",
"status_code": 200,
"description": "RAG fertilization status pending",
"file": "json/mock_data/rag/fertilization/status/get_200_pending.json"
"file": "json/mock_data/rag/fertilization/status/get_200_pending.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Mock/spec example only; verify actual route registration before treating as implemented."
},
{
"method": "GET",
"path": "/api/rag/recommend/fertilization/{task_id}/status/",
"status_code": 200,
"description": "RAG fertilization status progress",
"file": "json/mock_data/rag/fertilization/status/get_200_progress.json"
"file": "json/mock_data/rag/fertilization/status/get_200_progress.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Mock/spec example only; verify actual route registration before treating as implemented."
},
{
"method": "GET",
"path": "/api/rag/recommend/fertilization/{task_id}/status/",
"status_code": 200,
"description": "RAG fertilization status success",
"file": "json/mock_data/rag/fertilization/status/get_200_success.json"
"file": "json/mock_data/rag/fertilization/status/get_200_success.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Mock/spec example only; verify actual route registration before treating as implemented."
},
{
"method": "GET",
"path": "/api/rag/recommend/fertilization/{task_id}/status/",
"status_code": 200,
"description": "RAG fertilization status failure",
"file": "json/mock_data/rag/fertilization/status/get_200_failure.json"
"file": "json/mock_data/rag/fertilization/status/get_200_failure.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Mock/spec example only; verify actual route registration before treating as implemented."
},
{
"method": "PUT",
"path": "/api/sensor-data/{farm_uuid}/",
"status_code": 200,
"description": "Sensor update put success",
"file": "json/mock_data/sensor-data/update-put_200.json"
"file": "json/mock_data/sensor-data/update-put_200.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Mock/spec example only; verify actual route registration before treating as implemented."
},
{
"method": "PUT",
"path": "/api/sensor-data/{farm_uuid}/",
"status_code": 400,
"description": "Sensor update put validation error",
"file": "json/mock_data/sensor-data/update-put_400.json"
"file": "json/mock_data/sensor-data/update-put_400.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Mock/spec example only; verify actual route registration before treating as implemented."
},
{
"method": "PUT",
"path": "/api/sensor-data/{farm_uuid}/",
"status_code": 404,
"description": "Sensor update put location not found",
"file": "json/mock_data/sensor-data/update-put_404.json"
"file": "json/mock_data/sensor-data/update-put_404.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Mock/spec example only; verify actual route registration before treating as implemented."
},
{
"method": "PATCH",
"path": "/api/sensor-data/{farm_uuid}/",
"status_code": 200,
"description": "Sensor update patch success",
"file": "json/mock_data/sensor-data/update-patch_200.json"
"file": "json/mock_data/sensor-data/update-patch_200.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Mock/spec example only; verify actual route registration before treating as implemented."
},
{
"method": "PATCH",
"path": "/api/sensor-data/{farm_uuid}/",
"status_code": 400,
"description": "Sensor update patch validation error",
"file": "json/mock_data/sensor-data/update-patch_400.json"
"file": "json/mock_data/sensor-data/update-patch_400.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Mock/spec example only; verify actual route registration before treating as implemented."
},
{
"method": "PATCH",
"path": "/api/sensor-data/{farm_uuid}/",
"status_code": 404,
"description": "Sensor update patch location not found",
"file": "json/mock_data/sensor-data/update-patch_404.json"
"file": "json/mock_data/sensor-data/update-patch_404.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Mock/spec example only; verify actual route registration before treating as implemented."
},
{
"method": "POST",
"path": "/api/sensor-data/parameters/",
"status_code": 201,
"description": "Create sensor parameter",
"file": "json/mock_data/sensor-data/parameters-post_201.json"
"file": "json/mock_data/sensor-data/parameters-post_201.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Mock/spec example only; verify actual route registration before treating as implemented."
},
{
"method": "POST",
"path": "/api/sensor-data/parameters/",
"status_code": 400,
"description": "Sensor parameter validation error",
"file": "json/mock_data/sensor-data/parameters-post_400.json"
"file": "json/mock_data/sensor-data/parameters-post_400.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Mock/spec example only; verify actual route registration before treating as implemented."
},
{
"method": "POST",
"path": "/api/tasks/",
"status_code": 200,
"description": "Task trigger success",
"file": "json/mock_data/tasks/post_200.json"
"file": "json/mock_data/tasks/post_200.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Mock/spec example only; verify actual route registration before treating as implemented."
},
{
"method": "GET",
"path": "/api/tasks/{task_id}/status/",
"status_code": 200,
"description": "Task status pending",
"file": "json/mock_data/tasks/status/get_200_pending.json"
"file": "json/mock_data/tasks/status/get_200_pending.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Mock/spec example only; verify actual route registration before treating as implemented."
},
{
"method": "GET",
"path": "/api/tasks/{task_id}/status/",
"status_code": 200,
"description": "Task status progress",
"file": "json/mock_data/tasks/status/get_200_progress.json"
"file": "json/mock_data/tasks/status/get_200_progress.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Mock/spec example only; verify actual route registration before treating as implemented."
},
{
"method": "GET",
"path": "/api/tasks/{task_id}/status/",
"status_code": 200,
"description": "Task status success",
"file": "json/mock_data/tasks/status/get_200_success.json"
"file": "json/mock_data/tasks/status/get_200_success.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Mock/spec example only; verify actual route registration before treating as implemented."
},
{
"method": "GET",
"path": "/api/tasks/{task_id}/status/",
"status_code": 200,
"description": "Task status failure",
"file": "json/mock_data/tasks/status/get_200_failure.json"
"file": "json/mock_data/tasks/status/get_200_failure.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Mock/spec example only; verify actual route registration before treating as implemented."
},
{
"method": "GET",
"path": "/api/pest-detection/risk-summary/",
"status_code": 200,
"description": "Pest and disease risk summary success",
"file": "json/ai/pest-detection/risk-summary/get_200_success.json"
"file": "json/ai/pest-detection/risk-summary/get_200_success.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only."
},
{
"method": "GET",
"path": "/api/weather-forecast/card/",
"status_code": 200,
"description": "Farm weather card data",
"file": "json/ai/weather-forecast/card/get_200_success.json"
"file": "json/ai/weather-forecast/card/get_200_success.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only."
},
{
"method": "GET",
"path": "/api/yield-harvest/summary/",
"status_code": 200,
"description": "Yield prediction card, chart and harvest prediction card",
"file": "json/ai/yield-harvest/summary/get_200_success.json"
"file": "json/ai/yield-harvest/summary/get_200_success.json",
"contract_status": "contract_only",
"integration_status": "mock_spec",
"notes": "Mock/spec example only; verify actual route registration before treating as implemented."
}
]
+9
View File
@@ -0,0 +1,9 @@
CONTEXT_RESPONSE_TEMPLATE = {
"soilType": None,
"waterEC": None,
"selectedCrop": None,
"growthStage": None,
"lastIrrigationStatus": None,
"status": "success",
"source": "default_template",
}
+2 -2
View File
@@ -18,7 +18,7 @@ from config.swagger import status_response
from external_api_adapter import request as external_api_request
from external_api_adapter.exceptions import ExternalAPIRequestError
from farm_hub.models import FarmHub
from .mock_data import CONTEXT_RESPONSE_DATA
from .defaults import CONTEXT_RESPONSE_TEMPLATE
from .models import Conversation, Message
from .serializers import (
ChatPostSerializer,
@@ -66,7 +66,7 @@ class ContextView(FarmAccessMixin, APIView):
)
def get(self, request):
farm = self._get_optional_farm(request, request.query_params.get("farm_uuid"))
data = deepcopy(CONTEXT_RESPONSE_DATA)
data = deepcopy(CONTEXT_RESPONSE_TEMPLATE)
data["farm_uuid"] = self._farm_uuid_or_none(farm)
return Response(
{"status": "success", "data": data},
+29
View File
@@ -0,0 +1,29 @@
EMPTY_ALERT_TRACKER = {
"totalAlerts": 0,
"radialBarValue": 0,
"alertStats": [],
"status": "empty",
"source": "db",
"warnings": ["No active farm alerts were found."],
}
EMPTY_ALERT_TIMELINE = {
"alerts": [],
"status": "empty",
"source": "db",
"warnings": ["No farm alert timeline entries were found."],
}
EMPTY_ANOMALY_CARD = {
"anomalies": [],
"status": "empty",
"source": "db",
"warnings": ["No persisted anomaly detections were found."],
}
EMPTY_RECOMMENDATIONS = {
"recommendations": [],
"status": "empty",
"source": "db",
"warnings": ["No persisted farm recommendations were found."],
}
+24 -17
View File
@@ -10,12 +10,7 @@ from farm_hub.models import FarmHub
from notifications.models import FarmNotification
from notifications.services import create_notification_for_farm_uuid, get_recent_notifications_for_farm
from .mock_data import (
ANOMALY_DETECTION_CARD,
ARM_ALERTS_TRACKER,
FARM_ALERTS_TIMELINE,
RECOMMENDATIONS_LIST,
)
from .defaults import EMPTY_ALERT_TIMELINE, EMPTY_ALERT_TRACKER, EMPTY_ANOMALY_CARD, EMPTY_RECOMMENDATIONS
from .models import AnomalyDetection, FarmAlert, FarmAlertTrackerSnapshot, Recommendation
@@ -383,11 +378,11 @@ def sync_all_farm_alert_trackers():
return {"processed": len(results), "results": results}
def get_alert_tracker_data(farm=None):
if farm is None:
return deepcopy(ARM_ALERTS_TRACKER)
return deepcopy(EMPTY_ALERT_TRACKER)
alerts = list(FarmAlert.objects.filter(farm=farm, is_active=True)[:20])
if not alerts:
return deepcopy(ARM_ALERTS_TRACKER)
return deepcopy(EMPTY_ALERT_TRACKER)
counts = Counter(alert.title for alert in alerts)
alert_stats = []
@@ -406,16 +401,19 @@ def get_alert_tracker_data(farm=None):
"totalAlerts": len(alerts),
"radialBarValue": min(len(alerts) * 10, 100),
"alertStats": alert_stats,
"status": "success",
"source": "db",
"warnings": [],
}
def get_alert_timeline_data(farm=None):
if farm is None:
return deepcopy(FARM_ALERTS_TIMELINE)
return deepcopy(EMPTY_ALERT_TIMELINE)
alerts = list(FarmAlert.objects.filter(farm=farm)[:10])
if not alerts:
return deepcopy(FARM_ALERTS_TIMELINE)
return deepcopy(EMPTY_ALERT_TIMELINE)
return {
"alerts": [
@@ -426,17 +424,20 @@ def get_alert_timeline_data(farm=None):
"color": alert.color,
}
for alert in alerts
]
],
"status": "success",
"source": "db",
"warnings": [],
}
def get_anomaly_detection_data(farm=None):
if farm is None:
return deepcopy(ANOMALY_DETECTION_CARD)
return deepcopy(EMPTY_ANOMALY_CARD)
anomalies = list(AnomalyDetection.objects.filter(farm=farm)[:10])
if not anomalies:
return deepcopy(ANOMALY_DETECTION_CARD)
return deepcopy(EMPTY_ANOMALY_CARD)
return {
"anomalies": [
@@ -448,17 +449,20 @@ def get_anomaly_detection_data(farm=None):
"severity": anomaly.severity,
}
for anomaly in anomalies
]
],
"status": "success",
"source": "db",
"warnings": [],
}
def get_recommendations_list_data(farm=None):
if farm is None:
return deepcopy(RECOMMENDATIONS_LIST)
return deepcopy(EMPTY_RECOMMENDATIONS)
recommendations = list(Recommendation.objects.filter(farm=farm)[:10])
if not recommendations:
return deepcopy(RECOMMENDATIONS_LIST)
return deepcopy(EMPTY_RECOMMENDATIONS)
return {
"recommendations": [
@@ -469,5 +473,8 @@ def get_recommendations_list_data(farm=None):
"avatarColor": recommendation.avatar_color or "info",
}
for recommendation in recommendations
]
],
"status": "success",
"source": "db",
"warnings": [],
}
+3
View File
@@ -89,6 +89,9 @@ class FarmAlertsTrackerViewTests(TestCase):
self.assertEqual(response.data["data"]["status_level"], "warning")
self.assertEqual(len(response.data["data"]["notifications"]), 1)
self.assertEqual(response.data["data"]["notifications"][0]["endpoint"], "tracker")
self.assertEqual(response.data["meta"]["flow_type"], "cached_snapshot")
self.assertTrue(response.data["meta"]["cached"])
self.assertEqual(response.data["meta"]["ownership"], "backend")
def test_tracker_limits_cached_notifications_to_ten(self):
for index in range(12):
+20 -1
View File
@@ -6,6 +6,7 @@ from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from config.integration_contract import build_integration_meta
from config.swagger import code_response
from farm_hub.models import FarmHub
@@ -61,4 +62,22 @@ class AlertTrackerView(FarmAlertsBaseView):
response_data,
)
serializer = AlertTrackerAIResponseSerializer(instance=response_data)
return Response({"code": 200, "msg": "success", "data": serializer.data}, status=status.HTTP_200_OK)
snapshot = getattr(farm, "alert_tracker_snapshot", None)
return Response(
{
"code": 200,
"msg": "success",
"data": serializer.data,
"meta": build_integration_meta(
flow_type="cached_snapshot",
source_type="cached_snapshot",
source_service="backend_farm_alerts_snapshot",
ownership="backend",
live=False,
cached=True,
snapshot_at=getattr(snapshot, "updated_at", None),
notes=["Returns persisted tracker snapshot, not live AI inference."],
),
},
status=status.HTTP_200_OK,
)
+35
View File
@@ -0,0 +1,35 @@
CONFIG_RESPONSE_TEMPLATE = {
"farmData": {
"soilType": None,
"organicMatter": None,
"waterEC": None,
},
"growthStages": [
{"id": "prePlanting", "icon": "tabler-seedling"},
{"id": "earlyGrowth", "icon": "tabler-leaf"},
{"id": "flowering", "icon": "tabler-flower"},
{"id": "fruiting", "icon": "tabler-apple"},
{"id": "postHarvest", "icon": "tabler-basket"},
],
"cropOptions": [
{"id": "wheat", "labelKey": "wheat", "icon": "tabler-wheat"},
{"id": "corn", "labelKey": "corn", "icon": "tabler-plant-2"},
{"id": "cotton", "labelKey": "cotton", "icon": "tabler-flower"},
{"id": "saffron", "labelKey": "saffron", "icon": "tabler-flower-2"},
{"id": "canola", "labelKey": "canola", "icon": "tabler-leaf"},
{"id": "vegetables", "labelKey": "vegetables", "icon": "tabler-carrot"},
],
"status": "success",
"source": "default_template",
}
FERTILIZATION_DASHBOARD_TEMPLATE = {
"title": "کود",
"subtitle": "داده توصیه کودهی هنوز ثبت نشده است.",
"avatarIcon": "tabler-leaf",
"avatarColor": "success",
"status": "empty",
"source": "db",
"warnings": ["No persisted fertilization recommendation is available for this farm."],
}
+8 -3
View File
@@ -1,6 +1,6 @@
from copy import deepcopy
from .mock_data import FERTILIZATION_DASHBOARD_RECOMMENDATION, RECOMMEND_RESPONSE_DATA
from .defaults import FERTILIZATION_DASHBOARD_TEMPLATE
from .models import FertilizationPlan, FertilizationRecommendationRequest
@@ -77,9 +77,11 @@ def build_active_plan_context(farm):
def get_fertilization_dashboard_recommendation(farm=None):
default_item = deepcopy(FERTILIZATION_DASHBOARD_RECOMMENDATION)
default_item = deepcopy(FERTILIZATION_DASHBOARD_TEMPLATE)
result = _get_latest_result(farm)
plan = result.get("plan") or RECOMMEND_RESPONSE_DATA.get("plan", {})
plan = result.get("plan") or {}
if not isinstance(plan, dict) or not plan:
return default_item
npk_ratio = plan.get("npkRatio") or "20-20-20 (NPK)"
amount = plan.get("amountPerHectare")
@@ -91,5 +93,8 @@ def get_fertilization_dashboard_recommendation(farm=None):
default_item["title"] = f"کود: {npk_ratio}"
if subtitle_parts:
default_item["subtitle"] = "، ".join(subtitle_parts)
default_item["status"] = "success"
default_item["source"] = "db"
default_item["warnings"] = []
return default_item
+2 -2
View File
@@ -18,7 +18,7 @@ from farm_hub.models import FarmHub
from farmer_calendar import PLAN_TYPE_FERTILIZATION, delete_plan_events, sync_plan_events
from .models import FertilizationPlan, FertilizationRecommendationRequest
from .services import build_active_plan_context
from .mock_data import CONFIG_RESPONSE_DATA
from .defaults import CONFIG_RESPONSE_TEMPLATE
from .serializers import (
FreeTextPlanParserRequestSerializer,
FreeTextPlanParserResponseDataSerializer,
@@ -81,7 +81,7 @@ class ConfigView(FarmAccessMixin, APIView):
)
def get(self, request):
farm = self._get_farm(request, request.query_params.get("farm_uuid"))
data = dict(CONFIG_RESPONSE_DATA)
data = dict(CONFIG_RESPONSE_TEMPLATE)
data["farm_uuid"] = str(farm.farm_uuid)
return Response({"status": "success", "data": data}, status=status.HTTP_200_OK)
+28
View File
@@ -0,0 +1,28 @@
CONFIG_RESPONSE_TEMPLATE = {
"farmInfo": {
"soilType": None,
"waterQuality": None,
"climateZone": None,
},
"cropOptions": [
{"id": "wheat", "labelKey": "wheat", "icon": "tabler-wheat"},
{"id": "corn", "labelKey": "corn", "icon": "tabler-plant-2"},
{"id": "cotton", "labelKey": "cotton", "icon": "tabler-flower"},
{"id": "saffron", "labelKey": "saffron", "icon": "tabler-flower-2"},
{"id": "canola", "labelKey": "canola", "icon": "tabler-leaf"},
{"id": "vegetables", "labelKey": "vegetables", "icon": "tabler-carrot"},
],
"status": "success",
"source": "default_template",
}
IRRIGATION_DASHBOARD_TEMPLATE = {
"title": "آبیاری",
"subtitle": "داده توصیه آبیاری هنوز ثبت نشده است.",
"avatarIcon": "tabler-droplet",
"avatarColor": "primary",
"status": "empty",
"source": "db",
"warnings": ["No persisted irrigation recommendation is available for this farm."],
}
+87 -18
View File
@@ -1,12 +1,30 @@
from copy import deepcopy
import logging
from .mock_data import IRRIGATION_DASHBOARD_RECOMMENDATION, RECOMMEND_RESPONSE_DATA, WATER_NEED_PREDICTION
from config.failure_contract import StructuredServiceError
from .defaults import IRRIGATION_DASHBOARD_TEMPLATE
from .models import IrrigationPlan, IrrigationRecommendationRequest
logger = logging.getLogger(__name__)
class IrrigationDataUnavailableError(StructuredServiceError):
def __init__(self, *, error_code: str, message: str, details: dict | None = None):
super().__init__(
error_code=error_code,
message=message,
source="db",
details=details,
)
def _extract_result(response_payload):
if not isinstance(response_payload, dict):
return {}
raise IrrigationDataUnavailableError(
error_code="invalid_payload",
message="Irrigation recommendation payload must be a JSON object.",
)
data = response_payload.get("data")
if isinstance(data, dict):
@@ -22,24 +40,47 @@ def _extract_result(response_payload):
if any(key in response_payload for key in ("plan", "water_balance", "timeline", "sections")):
return response_payload
return {}
return None
def _get_latest_result(farm):
if farm is None:
return {}
raise IrrigationDataUnavailableError(
error_code="missing_farm",
message="Farm instance is required for irrigation result lookup.",
)
for request in IrrigationRecommendationRequest.objects.filter(farm=farm).order_by("-created_at", "-id"):
result = _extract_result(request.response_payload)
try:
result = _extract_result(request.response_payload)
except IrrigationDataUnavailableError as exc:
logger.error(
"Invalid irrigation response payload for farm_id=%s request_id=%s: %s",
getattr(farm, "id", None),
request.id,
exc,
)
raise IrrigationDataUnavailableError(
error_code=exc.contract.error_code,
message=f"Invalid irrigation recommendation payload for request_id={request.id}.",
details={"farm_id": getattr(farm, "id", None), "request_id": request.id},
) from exc
if result:
return result
return {}
raise IrrigationDataUnavailableError(
error_code="no_data",
message=f"No irrigation recommendation result found for farm_id={getattr(farm, 'id', None)}.",
details={"farm_id": getattr(farm, "id", None)},
)
def get_active_plan_payload(farm):
if farm is None:
return {}
raise IrrigationDataUnavailableError(
error_code="missing_farm",
message="Farm instance is required for active irrigation plan lookup.",
)
plan = (
IrrigationPlan.objects.filter(farm=farm, is_active=True, is_deleted=False)
@@ -47,15 +88,17 @@ def get_active_plan_payload(farm):
.first()
)
if plan is None or not isinstance(plan.plan_payload, dict):
return {}
raise IrrigationDataUnavailableError(
error_code="no_active_plan",
message=f"No active irrigation plan payload found for farm_id={getattr(farm, 'id', None)}.",
details={"farm_id": getattr(farm, "id", None)},
)
return deepcopy(plan.plan_payload)
def build_active_plan_context(farm):
plan_payload = get_active_plan_payload(farm)
if not plan_payload:
return {}
context = {"plan_payload": plan_payload}
@@ -200,24 +243,37 @@ def _normalize_sections(raw_sections):
def build_recommendation_response(adapter_payload):
result = _extract_result(adapter_payload)
fallback_plan = RECOMMEND_RESPONSE_DATA.get("plan", {})
if not isinstance(result, dict):
raise IrrigationDataUnavailableError(
error_code="no_result",
message="Irrigation recommendation payload did not include a result object.",
)
if not isinstance(result.get("plan"), dict):
raise IrrigationDataUnavailableError(
error_code="invalid_payload",
message="Irrigation recommendation payload is missing a valid `plan` object.",
)
return {
"plan": _normalize_plan(result.get("plan") or fallback_plan),
response = {
"plan": _normalize_plan(result.get("plan")),
"water_balance": _normalize_water_balance(result.get("water_balance")),
"timeline": _normalize_timeline(result.get("timeline")),
"sections": _normalize_sections(result.get("sections")),
}
return response
def get_water_need_prediction_data(farm=None):
default_data = deepcopy(WATER_NEED_PREDICTION)
result = _get_latest_result(farm)
water_balance = result.get("water_balance", {})
daily = water_balance.get("daily", [])
if not daily:
return default_data
raise IrrigationDataUnavailableError(
error_code="empty_daily_data",
message=f"Water need prediction data is missing daily entries for farm_id={getattr(farm, 'id', None)}.",
details={"farm_id": getattr(farm, "id", None)},
)
categories = [item.get("forecast_date") or f"روز {index + 1}" for index, item in enumerate(daily)]
series_data = [float(item.get("gross_irrigation_mm") or 0) for item in daily]
@@ -231,9 +287,19 @@ def get_water_need_prediction_data(farm=None):
def get_irrigation_dashboard_recommendation(farm=None):
default_item = deepcopy(IRRIGATION_DASHBOARD_RECOMMENDATION)
result = _get_latest_result(farm)
plan = result.get("plan") or RECOMMEND_RESPONSE_DATA.get("plan", {})
default_item = deepcopy(IRRIGATION_DASHBOARD_TEMPLATE)
try:
result = _get_latest_result(farm)
except IrrigationDataUnavailableError as exc:
logger.info(
"Irrigation dashboard recommendation unavailable for farm_id=%s: %s",
getattr(farm, "id", None),
exc,
)
return default_item
plan = result.get("plan")
if not isinstance(plan, dict):
return default_item
best_time = plan.get("bestTimeOfDay") or "05:00 - 07:00"
frequency = plan.get("frequencyPerWeek")
@@ -252,5 +318,8 @@ def get_irrigation_dashboard_recommendation(farm=None):
default_item["title"] = f"آبیاری: {best_time}"
if subtitle_parts:
default_item["subtitle"] = ". ".join(subtitle_parts)
default_item["status"] = "success"
default_item["source"] = "db"
default_item["warnings"] = []
return default_item
+84
View File
@@ -9,6 +9,7 @@ from farm_hub.models import FarmHub, FarmType
from farmer_calendar.models import FarmerCalendarEvent
from .models import IrrigationPlan, IrrigationRecommendationRequest
from .services import IrrigationDataUnavailableError, build_recommendation_response
from .views import (
IrrigationMethodListView,
IrrigationPlanDetailView,
@@ -22,6 +23,37 @@ from .views import (
)
class IrrigationServiceFailureTests(TestCase):
def setUp(self):
self.user = get_user_model().objects.create_user(
username="irrigation-service-user",
password="secret123",
email="irrigation-service@example.com",
phone_number="09120000009",
)
self.farm_type = FarmType.objects.create(name="زراعی")
self.farm = FarmHub.objects.create(owner=self.user, farm_type=self.farm_type, name="Service Farm")
def test_get_water_need_prediction_raises_structured_error_for_missing_daily_entries(self):
IrrigationRecommendationRequest.objects.create(
farm=self.farm,
response_payload={"data": {"result": {"plan": {"bestTimeOfDay": "05:00"}}}},
)
from .services import get_water_need_prediction_data
with self.assertRaises(IrrigationDataUnavailableError) as exc_info:
get_water_need_prediction_data(self.farm)
self.assertEqual(exc_info.exception.contract.error_code, "empty_daily_data")
def test_build_recommendation_response_rejects_non_object_payload(self):
with self.assertRaises(IrrigationDataUnavailableError) as exc_info:
build_recommendation_response(["not-a-dict"])
self.assertEqual(exc_info.exception.contract.error_code, "invalid_payload")
class WaterStressViewTests(TestCase):
def setUp(self):
self.factory = APIRequestFactory()
@@ -72,6 +104,8 @@ class WaterStressViewTests(TestCase):
self.assertEqual(response.data["data"]["waterStressIndex"], 12)
self.assertEqual(response.data["data"]["level"], "پایین")
self.assertEqual(response.data["data"]["sourceMetric"], {"soilMoisture": 24})
self.assertEqual(response.data["meta"]["flow_type"], "direct_proxy")
self.assertEqual(response.data["meta"]["source_service"], "ai_irrigation_water_stress")
mock_external_api_request.assert_called_once_with(
"ai",
"/api/irrigation/water-stress/",
@@ -93,6 +127,27 @@ class WaterStressViewTests(TestCase):
self.assertEqual(response.data["code"], 404)
self.assertEqual(response.data["data"]["farm_uuid"][0], "Farm not found.")
@patch("irrigation.views.external_api_request")
def test_post_returns_upstream_failure_without_masking_as_empty(self, mock_external_api_request):
mock_external_api_request.return_value = AdapterResponse(
status_code=503,
data={"message": "AI unavailable", "status": "error"},
)
request = self.factory.post(
"/api/irrigation/water-stress/",
{"farm_uuid": str(self.farm.farm_uuid)},
format="json",
)
force_authenticate(request, user=self.user)
response = WaterStressView.as_view()(request)
self.assertEqual(response.status_code, 503)
self.assertEqual(response.data["data"]["message"], "AI unavailable")
self.assertNotEqual(response.data.get("data"), [])
self.assertNotEqual(response.data.get("data"), {})
class IrrigationPlanFromTextViewTests(TestCase):
def setUp(self):
@@ -136,6 +191,8 @@ class IrrigationPlanFromTextViewTests(TestCase):
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["data"]["status"], "completed")
self.assertEqual(response.data["meta"]["flow_type"], "direct_proxy")
self.assertEqual(response.data["meta"]["ownership"], "backend")
self.assertEqual(IrrigationPlan.objects.count(), 1)
plan = IrrigationPlan.objects.get()
self.assertEqual(plan.source, IrrigationPlan.SOURCE_FREE_TEXT)
@@ -184,6 +241,8 @@ class IrrigationMethodListViewTests(TestCase):
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["code"], 200)
self.assertEqual(response.data["data"][0]["name"], "Drip")
self.assertEqual(response.data["meta"]["flow_type"], "direct_proxy")
self.assertTrue(response.data["meta"]["live"])
mock_external_api_request.assert_called_once_with(
"ai",
"/api/irrigation/",
@@ -208,6 +267,7 @@ class IrrigationMethodListViewTests(TestCase):
self.assertEqual(response.status_code, 201)
self.assertEqual(response.data["data"]["name"], "Drip")
self.assertEqual(response.data["meta"]["source_service"], "ai_irrigation")
mock_external_api_request.assert_called_once_with(
"ai",
"/api/irrigation/",
@@ -319,6 +379,30 @@ class RecommendViewTests(TestCase):
self.assertEqual(response.data["data"]["water_balance"]["active_kc"], 0.93)
self.assertEqual(response.data["data"]["timeline"][0]["step_number"], 1)
self.assertEqual(response.data["data"]["sections"][0]["type"], "warning")
@patch("irrigation.views.external_api_request")
def test_recommend_view_persists_real_response_and_never_returns_fake_success_on_invalid_payload(self, mock_external_api_request):
mock_external_api_request.return_value = AdapterResponse(
status_code=200,
data={"data": {"result": {"plan": {"bestTimeOfDay": "05:00"}}}},
)
request = self.factory.post(
"/api/irrigation/recommend/",
{
"farm_uuid": str(self.farm.farm_uuid),
"plant_name": "گوجه فرنگی",
"growth_stage": "گلدهی",
},
format="json",
)
force_authenticate(request, user=self.user)
response = RecommendView.as_view()(request)
self.assertEqual(response.status_code, 502)
self.assertEqual(IrrigationRecommendationRequest.objects.count(), 1)
self.assertEqual(IrrigationRecommendationRequest.objects.get().status, IrrigationRecommendationRequest.STATUS_ERROR)
mock_external_api_request.assert_called_once_with(
"ai",
"/api/irrigation/recommend/",
+108 -19
View File
@@ -12,13 +12,14 @@ from rest_framework.response import Response
from rest_framework.views import APIView
from drf_spectacular.utils import extend_schema
from config.integration_contract import build_integration_meta
from config.swagger import code_response, status_response
from external_api_adapter import request as external_api_request
from farm_hub.models import FarmHub
from farmer_calendar import PLAN_TYPE_IRRIGATION, delete_plan_events, sync_plan_events
from water.serializers import WaterStressIndexSerializer
from water.views import WaterStressIndexView
from .mock_data import CONFIG_RESPONSE_DATA
from .defaults import CONFIG_RESPONSE_TEMPLATE
from .models import IrrigationPlan, IrrigationRecommendationRequest
from .serializers import (
FreeTextPlanParserRequestSerializer,
@@ -36,6 +37,7 @@ from .serializers import (
)
from .services import build_recommendation_response
from .services import build_active_plan_context
from .services import IrrigationDataUnavailableError
logger = logging.getLogger(__name__)
@@ -86,7 +88,7 @@ class ConfigView(FarmAccessMixin, APIView):
)
def get(self, request):
farm = self._get_farm(request, request.query_params.get("farm_uuid"))
data = dict(CONFIG_RESPONSE_DATA)
data = dict(CONFIG_RESPONSE_TEMPLATE)
data["farm_uuid"] = str(farm.farm_uuid)
return Response({"status": "success", "data": data}, status=status.HTTP_200_OK)
@@ -128,7 +130,19 @@ class IrrigationMethodListView(APIView):
)
return Response(
{"code": 200, "msg": "success", "data": self._extract_methods(adapter_response.data)},
{
"code": 200,
"msg": "success",
"data": self._extract_methods(adapter_response.data),
"meta": build_integration_meta(
flow_type="direct_proxy",
source_type="provider",
source_service="ai_irrigation",
ownership="ai",
live=True,
cached=False,
),
},
status=status.HTTP_200_OK,
)
@@ -157,7 +171,19 @@ class IrrigationMethodListView(APIView):
payload = response_data.get("data", response_data)
return Response(
{"code": adapter_response.status_code, "msg": "success", "data": payload},
{
"code": adapter_response.status_code,
"msg": "success",
"data": payload,
"meta": build_integration_meta(
flow_type="direct_proxy",
source_type="provider",
source_service="ai_irrigation",
ownership="ai",
live=True,
cached=False,
),
},
status=adapter_response.status_code,
)
@@ -188,7 +214,10 @@ class RecommendView(FarmAccessMixin, APIView):
@staticmethod
def _enrich_ai_payload(payload, farm):
enriched_payload = payload.copy()
active_plan_context = build_active_plan_context(farm)
try:
active_plan_context = build_active_plan_context(farm)
except IrrigationDataUnavailableError:
active_plan_context = None
if active_plan_context:
enriched_payload["active_irrigation_plan"] = active_plan_context
return enriched_payload
@@ -224,16 +253,6 @@ class RecommendView(FarmAccessMixin, APIView):
)
response_data = adapter_response.data if isinstance(adapter_response.data, dict) else {}
recommendation_data = build_recommendation_response(response_data)
logger.warning(
"Irrigation recommendation response parsed: farm_uuid=%s status_code=%s response_keys=%s sections_count=%s",
str(farm.farm_uuid),
adapter_response.status_code,
sorted(response_data.keys()) if isinstance(response_data, dict) else None,
len(recommendation_data["sections"]),
)
recommendation = IrrigationRecommendationRequest.objects.create(
farm=farm,
crop_id=payload.get("plant_name", ""),
@@ -256,6 +275,23 @@ class RecommendView(FarmAccessMixin, APIView):
},
status=adapter_response.status_code,
)
try:
recommendation_data = build_recommendation_response(response_data)
except IrrigationDataUnavailableError as exc:
recommendation.status = IrrigationRecommendationRequest.STATUS_ERROR
recommendation.save(update_fields=["status"])
return Response(
{"code": 502, "msg": "error", "data": {"detail": str(exc)}},
status=status.HTTP_502_BAD_GATEWAY,
)
logger.warning(
"Irrigation recommendation response parsed: farm_uuid=%s status_code=%s response_keys=%s sections_count=%s",
str(farm.farm_uuid),
adapter_response.status_code,
sorted(response_data.keys()) if isinstance(response_data, dict) else None,
len(recommendation_data["sections"]),
)
self._create_plan_from_recommendation(recommendation, recommendation_data)
@@ -272,6 +308,14 @@ class RecommendView(FarmAccessMixin, APIView):
"code": 200,
"msg": "success",
"data": recommendation_data,
"meta": build_integration_meta(
flow_type="backend_owned_data_with_ai_enrichment",
source_type="provider",
source_service="ai_irrigation",
ownership="backend",
live=True,
cached=False,
),
},
status=status.HTTP_200_OK,
)
@@ -329,7 +373,13 @@ class RecommendationDetailView(FarmAccessMixin, APIView):
if recommendation is None:
return Response({"code": 404, "msg": "Recommendation not found."}, status=status.HTTP_404_NOT_FOUND)
data = build_recommendation_response(recommendation.response_payload)
try:
data = build_recommendation_response(recommendation.response_payload)
except IrrigationDataUnavailableError as exc:
return Response(
{"code": 502, "msg": "error", "data": {"detail": str(exc)}},
status=status.HTTP_502_BAD_GATEWAY,
)
request_payload = recommendation.request_payload if isinstance(recommendation.request_payload, dict) else {}
data["recommendation_uuid"] = str(recommendation.uuid)
data["crop_id"] = recommendation.crop_id
@@ -338,7 +388,22 @@ class RecommendationDetailView(FarmAccessMixin, APIView):
data["irrigation_method_name"] = str(request_payload.get("irrigation_method_name") or "")
data["status"] = recommendation.status
data["status_label"] = recommendation.get_status_display()
return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK)
return Response(
{
"code": 200,
"msg": "success",
"data": data,
"meta": build_integration_meta(
flow_type="backend_owned_data_with_ai_enrichment",
source_type="db",
source_service="backend_irrigation",
ownership="backend",
live=False,
cached=False,
),
},
status=status.HTTP_200_OK,
)
class WaterStressView(APIView):
@@ -392,7 +457,19 @@ class WaterStressView(APIView):
stress_payload = WaterStressIndexView.extract_stress_payload(adapter_response.data, farm.farm_uuid)
return Response(
{"code": 200, "msg": "success", "data": stress_payload},
{
"code": 200,
"msg": "success",
"data": stress_payload,
"meta": build_integration_meta(
flow_type="direct_proxy",
source_type="provider",
source_service="ai_irrigation_water_stress",
ownership="ai",
live=True,
cached=False,
),
},
status=status.HTTP_200_OK,
)
@@ -471,7 +548,19 @@ class PlanFromTextView(FarmAccessMixin, APIView):
sync_plan_events(plan, PLAN_TYPE_IRRIGATION)
return Response(
{"code": 200, "msg": response_data.get("msg", "موفق"), "data": response_data.get("data", response_data)},
{
"code": 200,
"msg": response_data.get("msg", "موفق"),
"data": response_data.get("data", response_data),
"meta": build_integration_meta(
flow_type="direct_proxy",
source_type="provider",
source_service="ai_irrigation_plan_parser",
ownership="backend" if final_plan and farm_uuid else "ai",
live=True,
cached=False,
),
},
status=status.HTTP_200_OK,
)
+48 -26
View File
@@ -3,8 +3,8 @@ from django.test import TestCase
from rest_framework.test import APIRequestFactory, force_authenticate
from unittest.mock import patch
from external_api_adapter.adapter import AdapterResponse
from farm_hub.models import FarmHub, FarmType, Product
from .services import PlantSyncError
from .views import PlantListView, PlantNameListView, SelectedPlantListView
@@ -19,30 +19,15 @@ class PlantApiTests(TestCase):
)
self.farm_type = FarmType.objects.create(name="زراعی")
@patch("plants.services.external_api_request")
def test_list_syncs_plants_from_ai_and_returns_full_payload(self, mock_external_api_request):
mock_external_api_request.return_value = AdapterResponse(
status_code=200,
data={
"code": 200,
"msg": "success",
"data": [
{
"name": "Tomato",
"light": "full sun",
"watering": "regular",
"soil": "loam",
"temperature": "20-30",
"growth_stage": "vegetative",
"planting_season": "spring",
"harvest_time": "70-90 days",
"spacing": "45-60 cm",
"fertilizer": "NPK",
"icon": "tomato",
"growth_profile": {"stage_thresholds": {"flowering": 300, "fruiting": 500}},
}
],
},
@patch("plants.views.push_plants_to_ai")
def test_list_returns_backend_catalog_with_sync_metadata(self, mock_push_plants_to_ai):
mock_push_plants_to_ai.return_value = []
Product.objects.create(
farm_type=self.farm_type,
name="Tomato",
icon="tomato",
growth_stage="vegetative",
growth_profile={"stage_thresholds": {"flowering": 300, "fruiting": 500}},
)
request = self.factory.get("/api/plants/")
force_authenticate(request, user=self.user)
@@ -54,7 +39,10 @@ class PlantApiTests(TestCase):
self.assertEqual(response.data["data"][0]["name"], "Tomato")
self.assertEqual(response.data["data"][0]["icon"], "tomato")
self.assertIn("flowering", response.data["data"][0]["growth_stages"])
mock_external_api_request.assert_called_once_with("ai", "/api/plants/", method="GET")
self.assertEqual(response.data["meta"]["flow_type"], "backend_owned_data_with_ai_enrichment")
self.assertEqual(response.data["meta"]["source_type"], "db")
self.assertEqual(response.data["meta"]["sync_status"], "synced")
mock_push_plants_to_ai.assert_called_once()
@patch("plants.views.push_plants_to_ai")
def test_names_endpoint_fills_default_icon_and_growth_stages(self, mock_push_plants_to_ai):
@@ -78,6 +66,9 @@ class PlantApiTests(TestCase):
product.refresh_from_db()
self.assertEqual(product.icon, "leaf")
self.assertEqual(product.growth_stages, ["initial", "vegetative", "flowering", "fruiting", "maturity"])
self.assertEqual(response.data["meta"]["flow_type"], "backend_owned_data")
self.assertEqual(response.data["meta"]["source_type"], "db")
self.assertEqual(response.data["meta"]["sync_status"], "synced")
@patch("plants.views.push_plants_to_ai")
def test_selected_endpoint_returns_farmer_products(self, mock_push_plants_to_ai):
@@ -97,3 +88,34 @@ class PlantApiTests(TestCase):
self.assertEqual(response.data["data"][0]["name"], "Pepper")
self.assertEqual(set(response.data["data"][0].keys()), {"name", "icon", "growth_stages"})
self.assertNotEqual(response.data["data"][0]["name"], tomato.name)
self.assertEqual(response.data["meta"]["ownership"], "backend")
self.assertEqual(response.data["meta"]["sync_status"], "synced")
@patch("plants.views.push_plants_to_ai")
def test_list_exposes_backend_ownership_even_when_ai_sync_fails(self, mock_push_plants_to_ai):
mock_push_plants_to_ai.side_effect = PlantSyncError("sync failed")
Product.objects.create(farm_type=self.farm_type, name="Tomato", icon="leaf", growth_stages=["vegetative"])
request = self.factory.get("/api/plants/")
force_authenticate(request, user=self.user)
response = PlantListView.as_view()(request)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["meta"]["flow_type"], "backend_owned_data_with_ai_enrichment")
self.assertEqual(response.data["meta"]["sync_status"], "failed")
def test_selected_endpoint_reads_seeded_backend_products_without_runtime_mock_data(self):
tomato = Product.objects.create(farm_type=self.farm_type, name="Tomato", icon="leaf", growth_stages=["vegetative"])
pepper = Product.objects.create(farm_type=self.farm_type, name="Pepper", icon="leaf", growth_stages=["flowering"])
farm = FarmHub.objects.create(owner=self.user, farm_type=self.farm_type, name="seeded-farm")
farm.products.add(tomato, pepper)
request = self.factory.get(f"/api/plants/selected/?farm_uuid={farm.farm_uuid}")
force_authenticate(request, user=self.user)
with patch("plants.views.push_plants_to_ai", return_value=[]):
response = SelectedPlantListView.as_view()(request)
self.assertEqual(response.status_code, 200)
self.assertCountEqual([item["name"] for item in response.data["data"]], ["Tomato", "Pepper"])
self.assertEqual(response.data["meta"]["source_type"], "db")
+64 -8
View File
@@ -5,6 +5,7 @@ from rest_framework.views import APIView
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, extend_schema
from config.integration_contract import build_integration_meta
from config.swagger import code_response, farm_uuid_query_param
from farm_hub.models import FarmHub, Product
from .serializers import PlantNameSerializer, PlantSerializer
@@ -15,12 +16,12 @@ class PlantBaseView(APIView):
permission_classes = [IsAuthenticated]
@staticmethod
def _sync_plants_if_possible():
def _attempt_ai_catalog_sync():
try:
push_plants_to_ai()
except PlantSyncError:
return False
return True
return False, "failed"
return True, "synced"
@staticmethod
def _get_farm(request, farm_uuid):
@@ -39,13 +40,34 @@ class PlantListView(PlantBaseView):
)
def get(self, request):
products = ensure_plant_defaults(Product.objects.order_by("name"))
sync_attempted = True
sync_status = "synced"
try:
push_plants_to_ai(products)
except PlantSyncError as exc:
sync_status = "failed"
if not products:
return Response({"code": 503, "msg": str(exc)}, status=status.HTTP_503_SERVICE_UNAVAILABLE)
data = PlantSerializer(products, many=True).data
return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK)
return Response(
{
"code": 200,
"msg": "success",
"data": data,
"meta": build_integration_meta(
flow_type="backend_owned_data_with_ai_enrichment",
source_type="db",
source_service="backend_plants",
ownership="backend",
live=False,
cached=False,
sync_attempted=sync_attempted,
sync_status=sync_status,
notes=["Backend plant catalog is canonical; AI receives sync snapshots only."],
),
},
status=status.HTTP_200_OK,
)
class PlantDetailView(PlantBaseView):
@@ -74,10 +96,27 @@ class PlantNameListView(PlantBaseView):
responses={200: code_response("PlantNameListResponse", data=PlantNameSerializer(many=True))},
)
def get(self, request):
self._sync_plants_if_possible()
sync_attempted, sync_status = self._attempt_ai_catalog_sync()
products = ensure_plant_defaults(Product.objects.order_by("name"))
data = PlantNameSerializer(products, many=True).data
return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK)
return Response(
{
"code": 200,
"msg": "success",
"data": data,
"meta": build_integration_meta(
flow_type="backend_owned_data",
source_type="db",
source_service="backend_plants",
ownership="backend",
live=False,
cached=False,
sync_attempted=sync_attempted,
sync_status=sync_status,
),
},
status=status.HTTP_200_OK,
)
class SelectedPlantListView(PlantBaseView):
@@ -87,9 +126,26 @@ class SelectedPlantListView(PlantBaseView):
responses={200: code_response("SelectedPlantListResponse", data=PlantNameSerializer(many=True))},
)
def get(self, request):
self._sync_plants_if_possible()
sync_attempted, sync_status = self._attempt_ai_catalog_sync()
farm = self._get_farm(request, request.query_params.get("farm_uuid"))
ensure_plant_defaults(farm.products.all())
products = farm.products.order_by("name")
data = PlantNameSerializer(products, many=True).data
return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK)
return Response(
{
"code": 200,
"msg": "success",
"data": data,
"meta": build_integration_meta(
flow_type="backend_owned_data",
source_type="db",
source_service="backend_plants",
ownership="backend",
live=False,
cached=False,
sync_attempted=sync_attempted,
sync_status=sync_status,
),
},
status=status.HTTP_200_OK,
)
+36
View File
@@ -0,0 +1,36 @@
EMPTY_FARM_WEATHER_CARD = {
"condition": None,
"temperature": None,
"unit": "°C",
"humidity": None,
"windSpeed": None,
"windUnit": "km/h",
"chartData": {"labels": [], "series": [[]]},
"status": "empty",
"source": "db",
"warnings": ["No persisted weather data is available for this farm."],
}
EMPTY_WATER_NEED_PREDICTION = {
"totalNext7Days": 0,
"unit": "mm",
"categories": [],
"series": [{"name": "نیاز آبی", "data": []}],
"status": "empty",
"source": "db",
"warnings": ["No persisted irrigation water-balance data is available for this farm."],
}
EMPTY_WATER_STRESS_INDEX = {
"id": "water_stress_index",
"title": "شاخص تنش آبی",
"subtitle": "فعلی",
"stats": None,
"avatarColor": "secondary",
"avatarIcon": "tabler-droplet",
"chipText": "بدون داده",
"chipColor": "secondary",
"status": "empty",
"source": "db",
"warnings": ["No persisted irrigation stress data is available for this farm."],
}
+21 -12
View File
@@ -2,26 +2,29 @@ from copy import deepcopy
from irrigation.models import IrrigationRecommendationRequest
from .mock_data import FARM_WEATHER_CARD, WATER_NEED_PREDICTION, WATER_STRESS_INDEX
from .defaults import EMPTY_FARM_WEATHER_CARD, EMPTY_WATER_NEED_PREDICTION, EMPTY_WATER_STRESS_INDEX
from .models import WeatherForecastLog
def get_farm_weather_card_data(farm=None):
if farm is None:
return deepcopy(FARM_WEATHER_CARD)
return deepcopy(EMPTY_FARM_WEATHER_CARD)
log = WeatherForecastLog.objects.filter(farm=farm).first()
if log is None:
return deepcopy(FARM_WEATHER_CARD)
return deepcopy(EMPTY_FARM_WEATHER_CARD)
return {
"condition": log.condition or FARM_WEATHER_CARD["condition"],
"temperature": log.temperature if log.temperature is not None else FARM_WEATHER_CARD["temperature"],
"unit": log.unit or FARM_WEATHER_CARD["unit"],
"humidity": log.humidity if log.humidity is not None else FARM_WEATHER_CARD["humidity"],
"windSpeed": log.wind_speed if log.wind_speed is not None else FARM_WEATHER_CARD["windSpeed"],
"windUnit": log.wind_unit or FARM_WEATHER_CARD["windUnit"],
"chartData": deepcopy(log.chart_data or FARM_WEATHER_CARD["chartData"]),
"condition": log.condition or None,
"temperature": log.temperature if log.temperature is not None else None,
"unit": log.unit or EMPTY_FARM_WEATHER_CARD["unit"],
"humidity": log.humidity if log.humidity is not None else None,
"windSpeed": log.wind_speed if log.wind_speed is not None else None,
"windUnit": log.wind_unit or EMPTY_FARM_WEATHER_CARD["windUnit"],
"chartData": deepcopy(log.chart_data or EMPTY_FARM_WEATHER_CARD["chartData"]),
"status": "success",
"source": "db",
"warnings": [],
}
@@ -53,7 +56,7 @@ def _get_latest_irrigation_result(farm):
def get_water_need_prediction_data(farm=None):
default_data = deepcopy(WATER_NEED_PREDICTION)
default_data = deepcopy(EMPTY_WATER_NEED_PREDICTION)
result = _get_latest_irrigation_result(farm)
water_balance = result.get("water_balance", {})
daily = water_balance.get("daily", [])
@@ -69,11 +72,14 @@ def get_water_need_prediction_data(farm=None):
"unit": "mm",
"categories": categories,
"series": [{"name": "نیاز آبی", "data": series_data}],
"status": "success",
"source": "db",
"warnings": [],
}
def get_water_stress_index_data(farm=None):
data = deepcopy(WATER_STRESS_INDEX)
data = deepcopy(EMPTY_WATER_STRESS_INDEX)
result = _get_latest_irrigation_result(farm)
moisture_level = (result.get("plan") or {}).get("moistureLevel")
@@ -95,6 +101,9 @@ def get_water_stress_index_data(farm=None):
data["avatarColor"] = "error"
data["stats"] = f"{stress_value}%"
data["status"] = "success"
data["source"] = "db"
data["warnings"] = []
return data
+31
View File
@@ -0,0 +1,31 @@
EMPTY_YIELD_HARVEST_SUMMARY = {
"yield_prediction_card": {
"id": "yield_prediction",
"title": "پیش‌بینی عملکرد",
"subtitle": "این فصل",
"stats": None,
"avatarColor": "secondary",
"avatarIcon": "tabler-chart-bar",
"chipText": "بدون داده",
"chipColor": "secondary",
"status": "empty",
"source": "db",
},
"yield_prediction_chart": {
"categories": [],
"series": [],
"summary": [],
"status": "empty",
"source": "db",
},
"harvest_prediction_card": {
"date": None,
"dateFormatted": None,
"daysUntil": None,
"description": "داده پیش‌بینی برداشت هنوز ثبت نشده است.",
"optimalWindowStart": None,
"optimalWindowEnd": None,
"status": "empty",
"source": "db",
},
}
+9 -6
View File
@@ -1,15 +1,11 @@
from copy import deepcopy
from .mock_data import HARVEST_PREDICTION_CARD, YIELD_PREDICTION_CARD, YIELD_PREDICTION_CHART
from .defaults import EMPTY_YIELD_HARVEST_SUMMARY
from .models import YieldHarvestPredictionLog
def get_yield_harvest_summary_data(farm=None):
data = {
"yield_prediction_card": deepcopy(YIELD_PREDICTION_CARD),
"yield_prediction_chart": deepcopy(YIELD_PREDICTION_CHART),
"harvest_prediction_card": deepcopy(HARVEST_PREDICTION_CARD),
}
data = deepcopy(EMPTY_YIELD_HARVEST_SUMMARY)
if farm is None:
return data
@@ -18,6 +14,13 @@ def get_yield_harvest_summary_data(farm=None):
if log is None:
return data
data["yield_prediction_card"]["status"] = "success"
data["yield_prediction_card"]["source"] = "db"
data["yield_prediction_chart"]["status"] = "success"
data["yield_prediction_chart"]["source"] = "db"
data["harvest_prediction_card"]["status"] = "success"
data["harvest_prediction_card"]["source"] = "db"
if log.yield_stats:
data["yield_prediction_card"]["stats"] = log.yield_stats
if log.yield_chip_text:
+58
View File
@@ -4,6 +4,7 @@ from django.contrib.auth import get_user_model
from django.test import TestCase
from rest_framework.test import APIClient, APIRequestFactory, force_authenticate
from config.observability import METRICS
from external_api_adapter.adapter import AdapterResponse
from farm_hub.models import FarmHub, FarmType, Product
from fertilization.models import FertilizationPlan
@@ -42,6 +43,9 @@ class CropSimulationViewTests(TestCase):
self.farm.products.add(self.product)
self.api_client.force_authenticate(user=self.user)
def tearDown(self):
METRICS.clear()
@patch("yield_harvest.views.external_api_request")
def test_growth_queues_simulation_task(self, mock_external_api_request):
mock_external_api_request.return_value = AdapterResponse(
@@ -664,6 +668,60 @@ class CropSimulationViewTests(TestCase):
self.assertEqual(sent_query["irrigation_plan"]["id"], irrigation_plan.id)
self.assertEqual(sent_query["fertilization_plan"]["id"], fertilization_plan.id)
@patch("yield_harvest.views.external_api_request")
def test_yield_harvest_summary_records_empty_result_metric(self, mock_external_api_request):
mock_external_api_request.return_value = AdapterResponse(status_code=200, data={"data": {"result": {}}})
response = self.api_client.get(f"/api/yield-harvest/yield-harvest-summary/?farm_uuid={self.farm.farm_uuid}")
self.assertEqual(response.status_code, 200)
self.assertEqual(METRICS["yield_harvest.ai.empty_result|operation=yield_harvest_summary"], 1)
@patch("yield_harvest.views.external_api_request")
def test_yield_harvest_summary_persists_seeded_log_from_realistic_ai_contract(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),
"yield_prediction": {"predicted_yield_tons": 5.1, "unit": "tons"},
"harvest_prediction_card": {
"harvest_date": "2026-09-28",
"days_until": 152,
"optimalWindowStart": "2026-09-25",
"optimalWindowEnd": "2026-10-01",
},
"yield_prediction_chart": {"series": [{"name": "yield", "data": []}]},
}
}
},
)
response = self.api_client.get(f"/api/yield-harvest/yield-harvest-summary/?farm_uuid={self.farm.farm_uuid}")
self.assertEqual(response.status_code, 200)
self.assertTrue(self.farm.yield_harvest_prediction_logs.exists())
log = self.farm.yield_harvest_prediction_logs.latest("id")
self.assertEqual(log.yield_stats, "5.1")
self.assertEqual(str(log.harvest_date), "2026-09-28")
@patch("yield_harvest.views.external_api_request")
def test_yield_prediction_provider_unavailable_returns_explicit_failure(self, mock_external_api_request):
mock_external_api_request.return_value = AdapterResponse(
status_code=503,
data={"message": "provider unavailable"},
)
response = self.api_client.post(
"/api/yield-harvest/yield-prediction/",
{"farm_uuid": str(self.farm.farm_uuid)},
format="json",
)
self.assertEqual(response.status_code, 503)
self.assertEqual(response.json()["data"]["message"], "provider unavailable")
def test_crop_simulation_rejects_foreign_farm_uuid(self):
request = self.factory.post(
"/api/yield-harvest/crop-simulation/yield-prediction/",
+53 -8
View File
@@ -1,11 +1,15 @@
"""Yield & Harvest Prediction and Crop Simulation API views."""
import logging
import time
from rest_framework import serializers, status
from rest_framework.response import Response
from rest_framework.views import APIView
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, extend_schema
from config.observability import classify_exception, log_event, observe_operation, record_metric
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
@@ -23,6 +27,8 @@ from .serializers import (
YieldPredictionSerializer,
)
logger = logging.getLogger(__name__)
class YieldHarvestSummaryView(APIView):
"""
@@ -110,16 +116,31 @@ class YieldHarvestSummaryView(APIView):
return plan_error
query.update(ai_payload)
adapter_response = external_api_request(
"ai",
"/api/crop-simulation/yield-harvest-summary/",
method="GET",
query=query,
)
with observe_operation(source="backend.yield_harvest", provider="ai", operation="yield_harvest_summary"):
started_at = time.monotonic()
adapter_response = external_api_request(
"ai",
"/api/crop-simulation/yield-harvest-summary/",
method="GET",
query=query,
)
if adapter_response.status_code >= 400:
record_metric("yield_harvest.ai.failure", status_code=adapter_response.status_code, operation="yield_harvest_summary")
return CropSimulationBaseView._error_response(adapter_response)
summary = CropSimulationBaseView._extract_result(adapter_response.data)
if not summary:
record_metric("yield_harvest.ai.empty_result", operation="yield_harvest_summary")
log_event(
level=logging.WARNING,
message="yield harvest summary returned empty result",
source="backend.yield_harvest",
provider="ai",
operation="yield_harvest_summary",
result_status="empty",
duration_ms=(time.monotonic() - started_at) * 1000,
farm_uuid=str(farm.farm_uuid),
)
self._persist_log(farm.farm_uuid, summary)
@@ -134,8 +155,21 @@ class YieldHarvestSummaryView(APIView):
if farm_uuid:
try:
farm = FarmHub.objects.get(farm_uuid=farm_uuid)
except (FarmHub.DoesNotExist, Exception):
pass
except FarmHub.DoesNotExist:
logger.warning("yield_harvest log persistence skipped because farm was not found farm_uuid=%s", farm_uuid)
except Exception as exc:
failure = classify_exception(exc)
log_event(
level=logging.ERROR,
message="yield_harvest log persistence failed",
source="backend.yield_harvest",
provider="db",
operation="persist_log",
result_status="error",
error_code=failure.error_code,
farm_uuid=str(farm_uuid),
)
return
yield_card = summary.get("yield_prediction") or summary.get("yield_prediction_card") or {}
harvest_card = summary.get("harvest_prediction_card", {})
@@ -178,6 +212,7 @@ class CropSimulationBaseView(APIView):
@staticmethod
def _extract_result(adapter_data):
if not isinstance(adapter_data, dict):
record_metric("yield_harvest.ai.invalid_payload", operation="extract_result")
return {}
data = adapter_data.get("data")
@@ -199,6 +234,16 @@ class CropSimulationBaseView(APIView):
if isinstance(adapter_response.data, dict)
else {"message": str(adapter_response.data)}
)
log_event(
level=logging.ERROR,
message="yield_harvest upstream request failed",
source="backend.yield_harvest",
provider="ai",
operation="external_api",
result_status="error",
error_code="provider_error",
status_code=adapter_response.status_code,
)
return Response(
{"code": adapter_response.status_code, "msg": "error", "data": response_data},
status=adapter_response.status_code,