UPDATE
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
# نقشه سرویس کارت های داشبورد
|
||||
|
||||
این فایل فقط برای پیدا کردن منبع داده هر کارت داشبورد نوشته شده است تا قبل از پیاده سازی API تجمیعی بدانیم هر کارت باید از کدام app و کدام service تغذیه شود.
|
||||
|
||||
## نقطه شروع فعلی
|
||||
|
||||
- تجمیع اصلی کارت ها الان در `dashboard/services.py` داخل تابع `get_farm_dashboard_cards` انجام می شود.
|
||||
- endpoint فعلی ارسال کارت ها در `dashboard/views.py` داخل `FarmDashboardCardsView` قرار دارد.
|
||||
- لیست کارت های معتبر در `dashboard/mock_data.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` | داده اقتصادی و سری نمودار |
|
||||
|
||||
## جزئیات هر کارت
|
||||
|
||||
### 1) `farmOverviewKpis`
|
||||
|
||||
این کارت در `dashboard/services.py` و توسط `_build_overview_kpis` ساخته می شود و داده اش از چند app می آید:
|
||||
|
||||
- `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`
|
||||
|
||||
نتیجه: این بخش باید در API نهایی به صورت aggregator باقی بماند، چون منبع واحد ندارد.
|
||||
|
||||
### 2) `farmWeatherCard`
|
||||
|
||||
- app: `water`
|
||||
- service: `water/services.py` -> `get_farm_weather_card_data`
|
||||
- model/source: `water.models.WeatherForecastLog`
|
||||
- fallback: `water/mock_data.py` -> `FARM_WEATHER_CARD`
|
||||
|
||||
اگر داده هواشناسی برای مزرعه ثبت نشده باشد، خروجی mock برمی گردد.
|
||||
|
||||
### 3) `farmAlertsTracker`
|
||||
|
||||
- app: `farm_alerts`
|
||||
- service: `farm_alerts/services.py` -> `get_alert_tracker_data`
|
||||
- model/source: `farm_alerts.models.FarmAlert`
|
||||
- منطق: هشدارهای `is_active=True` را می شمارد و top 3 را برمی گرداند.
|
||||
|
||||
### 4) `sensorValuesList`
|
||||
|
||||
- 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`
|
||||
|
||||
این کارت وابسته به آخرین لاگ سنسور فیزیکی مزرعه است.
|
||||
|
||||
### 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_recommendation.models.IrrigationRecommendationRequest`
|
||||
- منطق: از `response_payload` آخرین recommendation آبیاری، `water_balance.daily` را می خواند.
|
||||
|
||||
نکته مهم: تابعی با همین نام در `irrigation_recommendation/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_recommendation.services.get_irrigation_dashboard_recommendation`
|
||||
- model/source: `irrigation_recommendation.models.IrrigationRecommendationRequest`
|
||||
- `fertilization_recommendation.services.get_fertilization_dashboard_recommendation`
|
||||
- model/source: `fertilization_recommendation.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 آنجا انجام می شود.
|
||||
@@ -1,8 +1,10 @@
|
||||
from dataclasses import dataclass, field
|
||||
import json
|
||||
import logging
|
||||
|
||||
import requests
|
||||
from django.conf import settings
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
|
||||
from .exceptions import ExternalAPIRequestError
|
||||
from .exceptions import MockDirectoryNotFound, MockFileNotFound
|
||||
@@ -72,7 +74,8 @@ class ExternalAPIAdapter:
|
||||
url = f"{base_url}/{str(path).lstrip('/')}"
|
||||
|
||||
files = None
|
||||
request_payload = payload
|
||||
request_payload = self._make_json_safe(payload)
|
||||
request_query = self._make_json_safe(query)
|
||||
request_headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
@@ -95,7 +98,7 @@ class ExternalAPIAdapter:
|
||||
request_kwargs = {
|
||||
"method": method,
|
||||
"url": url,
|
||||
"params": query,
|
||||
"params": request_query,
|
||||
"headers": request_headers,
|
||||
"timeout": getattr(settings, "EXTERNAL_API_TIMEOUT", 30),
|
||||
}
|
||||
@@ -150,6 +153,14 @@ class ExternalAPIAdapter:
|
||||
if method not in supported_methods:
|
||||
raise ValueError(f"Unsupported HTTP method '{method}'. Supported methods: {sorted(supported_methods)}")
|
||||
|
||||
@staticmethod
|
||||
def _make_json_safe(value):
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
# Match Django/DRF JSON rendering so UUID/date-like values can be forwarded safely.
|
||||
return json.loads(json.dumps(value, cls=DjangoJSONEncoder))
|
||||
|
||||
|
||||
_default_adapter = ExternalAPIAdapter()
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import uuid
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.test import SimpleTestCase, override_settings
|
||||
|
||||
from .adapter import ExternalAPIAdapter
|
||||
|
||||
|
||||
class ExternalAPIAdapterTests(SimpleTestCase):
|
||||
@override_settings(EXTERNAL_API_TIMEOUT=30)
|
||||
@patch("external_api_adapter.adapter.requests.request")
|
||||
def test_request_serializes_uuid_payload_for_json_requests(self, mock_request):
|
||||
mock_request.return_value.status_code = 200
|
||||
mock_request.return_value.json.return_value = {"ok": True}
|
||||
farm_uuid = uuid.uuid4()
|
||||
|
||||
adapter = ExternalAPIAdapter(
|
||||
service_registry=type(
|
||||
"Registry",
|
||||
(),
|
||||
{"get": lambda self, name: {"base_url": "https://example.com", "api_key": "token"}},
|
||||
)()
|
||||
)
|
||||
|
||||
adapter.request(
|
||||
"ai",
|
||||
"/api/farm-alerts/tracker/",
|
||||
method="POST",
|
||||
payload={"farm_uuid": farm_uuid},
|
||||
)
|
||||
|
||||
mock_request.assert_called_once()
|
||||
request_kwargs = mock_request.call_args.kwargs
|
||||
self.assertEqual(request_kwargs["json"], {"farm_uuid": str(farm_uuid)})
|
||||
|
||||
@override_settings(EXTERNAL_API_TIMEOUT=30)
|
||||
@patch("external_api_adapter.adapter.requests.request")
|
||||
def test_request_serializes_uuid_payload_for_multipart_requests(self, mock_request):
|
||||
mock_request.return_value.status_code = 200
|
||||
mock_request.return_value.json.return_value = {"ok": True}
|
||||
farm_uuid = uuid.uuid4()
|
||||
|
||||
adapter = ExternalAPIAdapter(
|
||||
service_registry=type(
|
||||
"Registry",
|
||||
(),
|
||||
{"get": lambda self, name: {"base_url": "https://example.com", "api_key": "token"}},
|
||||
)()
|
||||
)
|
||||
|
||||
adapter.request(
|
||||
"ai",
|
||||
"/api/upload/",
|
||||
method="POST",
|
||||
payload={"farm_uuid": farm_uuid, "__files__": {"image": ("leaf.jpg", b"data", "image/jpeg")}},
|
||||
)
|
||||
|
||||
mock_request.assert_called_once()
|
||||
request_kwargs = mock_request.call_args.kwargs
|
||||
self.assertEqual(request_kwargs["data"], {"farm_uuid": str(farm_uuid)})
|
||||
@@ -0,0 +1,436 @@
|
||||
# راهنمای فرانت برای API هشدارهای مزرعه
|
||||
|
||||
این سند برای تیم فرانت نوشته شده تا بداند endpoint `tracker` چه ورودیای میگیرد، چه کاری انجام میدهد، و response آن را چطور باید در UI مصرف کند.
|
||||
|
||||
## Endpoint
|
||||
|
||||
- `POST /api/farm-alerts/tracker/`
|
||||
|
||||
## احراز هویت
|
||||
|
||||
- این API نیاز به `Bearer Token` دارد.
|
||||
- کاربر فقط به مزرعههای متعلق به خودش دسترسی دارد.
|
||||
|
||||
## کاربرد API
|
||||
|
||||
فرانت با ارسال alertهای جدید مربوط به یک مزرعه:
|
||||
|
||||
- alertها را در بکاند ذخیره میکند
|
||||
- notificationهای 3 روز اخیر همان مزرعه را هم به context اضافه میکند
|
||||
- همه دادهها را برای AI میفرستد
|
||||
- AI یک جمعبندی کوتاه، وضعیت کلی، و notificationهای مهم برمیگرداند
|
||||
- notificationهای خروجی AI هم در دیتابیس ذخیره میشوند
|
||||
|
||||
این endpoint هم برای تحلیل وضعیت هشدارها مناسب است، هم برای ساخت کارت summary، هم برای notification center.
|
||||
|
||||
## Request Body
|
||||
|
||||
فیلدهای ورودی:
|
||||
|
||||
- `farm_uuid`: شناسه مزرعه - اجباری
|
||||
- `alerts`: لیست هشدارهای جدید - اختیاری
|
||||
|
||||
### ساختار هر alert
|
||||
|
||||
هر آیتم داخل `alerts` میتواند این فیلدها را داشته باشد:
|
||||
|
||||
- `alert_id`: شناسه یکتای هشدار در سمت منبع یا فرانت
|
||||
- `level`: شدت هشدار مثل `info`، `warning`، `danger`
|
||||
- `title`: عنوان هشدار
|
||||
- `message`: متن هشدار
|
||||
- `suggested_action`: اقدام پیشنهادی
|
||||
- `source_metric_type`: نوع شاخص مثل `moisture`
|
||||
- `timestamp`: زمان هشدار با فرمت datetime - اختیاری
|
||||
- `payload`: داده تکمیلی JSON - اختیاری
|
||||
|
||||
## نمونه request
|
||||
|
||||
```json
|
||||
{
|
||||
"farm_uuid": "11111111-1111-1111-1111-111111111111",
|
||||
"alerts": [
|
||||
{
|
||||
"alert_id": "soil-moisture-001",
|
||||
"level": "warning",
|
||||
"title": "افت رطوبت خاک",
|
||||
"message": "رطوبت خاک کمتر از حد مطلوب گزارش شده است.",
|
||||
"suggested_action": "آبیاری اصلاحی بررسی شود.",
|
||||
"source_metric_type": "moisture"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## نمونه curl
|
||||
|
||||
```bash
|
||||
curl -X POST \
|
||||
'http://localhost:8000/api/farm-alerts/tracker/' \
|
||||
-H 'accept: application/json' \
|
||||
-H 'Authorization: Bearer <access_token>' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"farm_uuid": "11111111-1111-1111-1111-111111111111",
|
||||
"alerts": [
|
||||
{
|
||||
"alert_id": "soil-moisture-001",
|
||||
"level": "warning",
|
||||
"title": "افت رطوبت خاک",
|
||||
"message": "رطوبت خاک کمتر از حد مطلوب گزارش شده است.",
|
||||
"suggested_action": "آبیاری اصلاحی بررسی شود.",
|
||||
"source_metric_type": "moisture"
|
||||
}
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
## رفتار بکاند
|
||||
|
||||
بعد از دریافت request:
|
||||
|
||||
1. مزرعه را با `farm_uuid` پیدا میکند و ownership را چک میکند.
|
||||
2. alertهای ارسالی را در جدول `farm_alerts` ذخیره میکند.
|
||||
3. حداکثر 10 notification ثبتشده در 3 روز اخیر همان مزرعه را برمیدارد.
|
||||
4. `alerts` جدید + `recent_notifications` را برای AI میفرستد.
|
||||
5. notificationهای مهم تولیدشده توسط AI را در جدول `farm_notifications` ذخیره میکند.
|
||||
6. response نهایی را به فرانت برمیگرداند.
|
||||
|
||||
## ساختار response موفق
|
||||
|
||||
response داخل envelope استاندارد برمیگردد:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"farm_uuid": "11111111-1111-1111-1111-111111111111",
|
||||
"service_id": "farm_alerts",
|
||||
"tracker": {},
|
||||
"headline": "بررسی رطوبت خاک در مزرعه",
|
||||
"overview": "افت خفیف رطوبت خاک گزارش شده است که نیاز به پایش دارد.",
|
||||
"status_level": "warning",
|
||||
"notifications": [],
|
||||
"raw_llm_response": "...",
|
||||
"structured_context": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## توضیح فیلدهای اصلی response
|
||||
|
||||
### `farm_uuid`
|
||||
|
||||
شناسه مزرعهای که تحلیل برای آن انجام شده است.
|
||||
|
||||
### `service_id`
|
||||
|
||||
شناسه سرویس. فعلا مقدار آن `farm_alerts` است.
|
||||
|
||||
### `tracker`
|
||||
|
||||
بخش اصلی داده برای ساخت UI هشدارها.
|
||||
|
||||
این بخش ممکن است شامل این فیلدها باشد:
|
||||
|
||||
- `totalAlerts`: تعداد کل alertهای فعلی
|
||||
- `alerts`: لیست alertهای تحلیلشده
|
||||
- `alertStats`: آمار خلاصه برای کارتها
|
||||
- `alertClusters`: گروهبندی alertها
|
||||
- `mostCriticalIssue`: مهمترین هشدار فعلی
|
||||
- `prioritizedAlertSummaries`: خلاصههای اولویتدار
|
||||
- `recommendedOperationalActions`: اقدامهای عملیاتی پیشنهادی
|
||||
- `humanReadableExplanations`: توضیحهای متنی ساده برای کاربر
|
||||
|
||||
### `headline`
|
||||
|
||||
تیتر کوتاه برای بالای کارت یا صفحه.
|
||||
|
||||
### `overview`
|
||||
|
||||
جمعبندی کوتاه و اجرایی برای کاربر.
|
||||
|
||||
### `status_level`
|
||||
|
||||
وضعیت کلی تحلیل برای رنگبندی UI.
|
||||
|
||||
مقادیر معمول:
|
||||
|
||||
- `info`
|
||||
- `warning`
|
||||
- `error`
|
||||
- `success`
|
||||
|
||||
### `notifications`
|
||||
|
||||
لیست notificationهای مهمی که AI تولید کرده و در دیتابیس ذخیره شدهاند.
|
||||
|
||||
هر notification ممکن است این فیلدها را داشته باشد:
|
||||
|
||||
- `id`: شناسه دیتابیسی
|
||||
- `uuid`: شناسه یکتا
|
||||
- `farm_uuid`: شناسه مزرعه
|
||||
- `since_id`: همان `id` برای برخی flowهای polling
|
||||
- `endpoint`: منبع notification، اینجا معمولا `tracker`
|
||||
- `title`: عنوان
|
||||
- `message`: متن
|
||||
- `level`: شدت
|
||||
- `suggested_action`: اقدام پیشنهادی
|
||||
- `source_alert_id`: شناسه alert اصلی
|
||||
- `source_metric_type`: نوع شاخص
|
||||
- `payload`: داده تکمیلی
|
||||
- `is_read`: خوانده شده یا نه
|
||||
- `metadata`: اطلاعات داخلی
|
||||
- `created_at`: زمان ایجاد
|
||||
- `updated_at`: زمان آخرین بهروزرسانی
|
||||
|
||||
### `raw_llm_response`
|
||||
|
||||
پاسخ خام AI برای debug یا audit.
|
||||
|
||||
برای UI اصلی معمولا لازم نیست مستقیم نمایش داده شود.
|
||||
|
||||
### `structured_context`
|
||||
|
||||
context تکمیلی که برای AI ساخته شده.
|
||||
|
||||
ممکن است شامل این بخشها باشد:
|
||||
|
||||
- `farm_profile`
|
||||
- `tracker`
|
||||
- `forecasts`
|
||||
- `incoming_alerts`
|
||||
|
||||
این فیلد بیشتر برای debug، مانیتورینگ، یا صفحههای تخصصی مفید است.
|
||||
|
||||
## استفاده پیشنهادی در فرانت
|
||||
|
||||
### هدر صفحه یا کارت summary
|
||||
|
||||
از این فیلدها استفاده کنید:
|
||||
|
||||
- `headline`
|
||||
- `overview`
|
||||
- `status_level`
|
||||
|
||||
### لیست هشدارهای فعلی
|
||||
|
||||
از:
|
||||
|
||||
- `tracker.alerts`
|
||||
|
||||
### مهمترین هشدار
|
||||
|
||||
از:
|
||||
|
||||
- `tracker.mostCriticalIssue`
|
||||
|
||||
### کارت آمار هشدار
|
||||
|
||||
از:
|
||||
|
||||
- `tracker.totalAlerts`
|
||||
- `tracker.alertStats`
|
||||
|
||||
### اقدامهای پیشنهادی
|
||||
|
||||
از:
|
||||
|
||||
- `tracker.recommendedOperationalActions`
|
||||
|
||||
### توضیح ساده برای کاربر
|
||||
|
||||
از:
|
||||
|
||||
- `tracker.humanReadableExplanations`
|
||||
|
||||
### notification center یا drawer
|
||||
|
||||
از:
|
||||
|
||||
- `notifications`
|
||||
|
||||
## نمونه mapping برای فرانت
|
||||
|
||||
```ts
|
||||
const result = response.data.data;
|
||||
|
||||
const headerTitle = result.headline;
|
||||
const headerText = result.overview;
|
||||
const severity = result.status_level;
|
||||
|
||||
const totalAlerts = result.tracker.totalAlerts;
|
||||
const alerts = result.tracker.alerts;
|
||||
const stats = result.tracker.alertStats;
|
||||
const criticalIssue = result.tracker.mostCriticalIssue;
|
||||
const suggestedActions = result.tracker.recommendedOperationalActions;
|
||||
const notifications = result.notifications;
|
||||
```
|
||||
|
||||
## نمونه response واقعی
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"farm_uuid": "11111111-1111-1111-1111-111111111111",
|
||||
"service_id": "farm_alerts",
|
||||
"tracker": {
|
||||
"totalAlerts": 1,
|
||||
"alerts": [
|
||||
{
|
||||
"metric_type": "moisture",
|
||||
"title": "تنش رطوبتی",
|
||||
"current_value": 42.3,
|
||||
"threshold_value": 45,
|
||||
"severity": "low",
|
||||
"duration_hours": 2.8,
|
||||
"duration": "3 ساعت",
|
||||
"timestamp": "2026-04-28T20:31:39.594431+00:00",
|
||||
"sensor_id": "11111111-1111-1111-1111-111111111111",
|
||||
"zone_id": null,
|
||||
"domain": "water_balance",
|
||||
"direction": "below",
|
||||
"unit": "%",
|
||||
"icon": "tabler-droplet-half-2",
|
||||
"summary": "افت رطوبت خاک ثبت شده و نیاز به پایش نزدیکتر دارد.",
|
||||
"recommended_action": "روند رطوبت را در نوبت بعدی کنترل و یکنواختی آبیاری را بررسی کنید.",
|
||||
"explanation": "رطوبت فعلی 42.3% به زیر آستانه 45.0% رسیده است و این وضعیت 3 ساعت ادامه داشته است.",
|
||||
"metadata": {}
|
||||
}
|
||||
],
|
||||
"alertStats": [
|
||||
{
|
||||
"title": "تنش رطوبتی",
|
||||
"count": "1",
|
||||
"avatarColor": "info",
|
||||
"avatarIcon": "tabler-droplet-half-2",
|
||||
"severity": "low",
|
||||
"topSummary": "افت رطوبت خاک ثبت شده و نیاز به پایش نزدیکتر دارد."
|
||||
}
|
||||
],
|
||||
"alertClusters": [
|
||||
{
|
||||
"domain": "water_balance",
|
||||
"title": "تعادل آب",
|
||||
"alert_count": 1,
|
||||
"highest_severity": "low",
|
||||
"primary_metric": "moisture",
|
||||
"summary": "افت رطوبت خاک ثبت شده و نیاز به پایش نزدیکتر دارد.",
|
||||
"alert_ids": [
|
||||
"moisture:2026-04-28T20:31:39.594431+00:00"
|
||||
]
|
||||
}
|
||||
],
|
||||
"mostCriticalIssue": {
|
||||
"metric_type": "moisture",
|
||||
"title": "تنش رطوبتی",
|
||||
"current_value": 42.3,
|
||||
"threshold_value": 45,
|
||||
"severity": "low",
|
||||
"duration_hours": 2.8,
|
||||
"duration": "3 ساعت",
|
||||
"timestamp": "2026-04-28T20:31:39.594431+00:00",
|
||||
"sensor_id": "11111111-1111-1111-1111-111111111111",
|
||||
"zone_id": null,
|
||||
"domain": "water_balance",
|
||||
"direction": "below",
|
||||
"unit": "%",
|
||||
"icon": "tabler-droplet-half-2",
|
||||
"summary": "افت رطوبت خاک ثبت شده و نیاز به پایش نزدیکتر دارد.",
|
||||
"recommended_action": "روند رطوبت را در نوبت بعدی کنترل و یکنواختی آبیاری را بررسی کنید.",
|
||||
"explanation": "رطوبت فعلی 42.3% به زیر آستانه 45.0% رسیده است و این وضعیت 3 ساعت ادامه داشته است.",
|
||||
"metadata": {}
|
||||
},
|
||||
"prioritizedAlertSummaries": [
|
||||
"افت رطوبت خاک ثبت شده و نیاز به پایش نزدیکتر دارد."
|
||||
],
|
||||
"recommendedOperationalActions": [
|
||||
"روند رطوبت را در نوبت بعدی کنترل و یکنواختی آبیاری را بررسی کنید."
|
||||
],
|
||||
"humanReadableExplanations": [
|
||||
"رطوبت فعلی 42.3% به زیر آستانه 45.0% رسیده است و این وضعیت 3 ساعت ادامه داشته است."
|
||||
]
|
||||
},
|
||||
"headline": "بررسی رطوبت خاک در مزرعه",
|
||||
"overview": "افت خفیف رطوبت خاک گزارش شده است که نیاز به پایش دارد.",
|
||||
"status_level": "warning",
|
||||
"notifications": [
|
||||
{
|
||||
"id": 1,
|
||||
"uuid": "640e6187-49d9-4256-ad0d-18927712d496",
|
||||
"farm_uuid": "11111111-1111-1111-1111-111111111111",
|
||||
"since_id": 1,
|
||||
"endpoint": "tracker",
|
||||
"title": "افت رطوبت خاک",
|
||||
"message": "رطوبت خاک کمتر از حد مطلوب گزارش شده است.",
|
||||
"level": "warning",
|
||||
"suggested_action": "روند رطوبت را در نوبت بعدی کنترل و یکنواختی آبیاری را بررسی کنید.",
|
||||
"source_alert_id": "soil-moisture-001",
|
||||
"source_metric_type": "moisture",
|
||||
"payload": {},
|
||||
"is_read": false,
|
||||
"metadata": {
|
||||
"source": "farm_alerts_tracker_ai"
|
||||
},
|
||||
"created_at": "2026-04-28T23:20:19.750658Z",
|
||||
"updated_at": "2026-04-28T23:20:19.750719Z"
|
||||
}
|
||||
],
|
||||
"raw_llm_response": "{...}",
|
||||
"structured_context": {
|
||||
"incoming_alerts": [
|
||||
{
|
||||
"alert_id": "soil-moisture-001",
|
||||
"level": "warning",
|
||||
"title": "افت رطوبت خاک",
|
||||
"message": "رطوبت خاک کمتر از حد مطلوب گزارش شده است.",
|
||||
"suggested_action": "آبیاری اصلاحی بررسی شود.",
|
||||
"source_metric_type": "moisture",
|
||||
"timestamp": null,
|
||||
"payload": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## خطاهای متداول
|
||||
|
||||
### مزرعه پیدا نشد
|
||||
|
||||
اگر `farm_uuid` متعلق به کاربر نباشد یا وجود نداشته باشد:
|
||||
|
||||
```json
|
||||
{
|
||||
"farm_uuid": [
|
||||
"Farm not found."
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### بدنه نامعتبر
|
||||
|
||||
اگر فیلدهای نامعتبر بفرستید:
|
||||
|
||||
```json
|
||||
{
|
||||
"unexpected_field": [
|
||||
"This field is not allowed."
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### احراز هویت نامعتبر
|
||||
|
||||
- در صورت نبود token یا نامعتبر بودن آن، پاسخ `401 Unauthorized` برمیگردد.
|
||||
|
||||
## توصیه برای فرانت
|
||||
|
||||
- برای هر alert یک `alert_id` پایدار بفرستید.
|
||||
- اگر alert جدیدی ندارید، میتوانید فقط `farm_uuid` بفرستید.
|
||||
- از `headline` و `overview` برای summary UI استفاده کنید.
|
||||
- از `notifications` برای notification list یا toast استفاده کنید.
|
||||
- از `tracker.mostCriticalIssue` و `tracker.recommendedOperationalActions` برای CTA و نمایش اقدام فوری استفاده کنید.
|
||||
@@ -0,0 +1,43 @@
|
||||
# Generated by Django 5.1.15 on 2026-04-28 23:30
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("farm_alerts", "0002_alter_anomalydetection_severity_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="farmalert",
|
||||
name="external_alert_id",
|
||||
field=models.CharField(blank=True, db_index=True, default="", max_length=255),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="farmalert",
|
||||
name="occurred_at",
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="farmalert",
|
||||
name="payload",
|
||||
field=models.JSONField(blank=True, default=dict),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="farmalert",
|
||||
name="raw_alert",
|
||||
field=models.JSONField(blank=True, default=dict),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="farmalert",
|
||||
name="source_metric_type",
|
||||
field=models.CharField(blank=True, default="", max_length=255),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="farmalert",
|
||||
name="suggested_action",
|
||||
field=models.TextField(blank=True, default=""),
|
||||
),
|
||||
]
|
||||
@@ -16,9 +16,15 @@ SEVERITY_CHOICES = [
|
||||
class FarmAlert(models.Model):
|
||||
uuid = models.UUIDField(default=uuid_lib.uuid4, unique=True, editable=False, db_index=True)
|
||||
farm = models.ForeignKey(FarmHub, on_delete=models.CASCADE, related_name="farm_alerts", null=True, blank=True)
|
||||
external_alert_id = models.CharField(max_length=255, blank=True, default="", db_index=True)
|
||||
title = models.CharField(max_length=255)
|
||||
description = models.TextField(blank=True, default="")
|
||||
color = models.CharField(max_length=32, default="info", choices=SEVERITY_CHOICES)
|
||||
suggested_action = models.TextField(blank=True, default="")
|
||||
source_metric_type = models.CharField(max_length=255, blank=True, default="")
|
||||
occurred_at = models.DateTimeField(null=True, blank=True)
|
||||
payload = models.JSONField(default=dict, blank=True)
|
||||
raw_alert = models.JSONField(default=dict, blank=True)
|
||||
avatar_icon = models.CharField(max_length=64, blank=True, default="")
|
||||
avatar_color = models.CharField(max_length=32, blank=True, default="")
|
||||
is_active = models.BooleanField(default=True)
|
||||
|
||||
@@ -1,5 +1,47 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from notifications.serializers import FarmNotificationSerializer
|
||||
|
||||
|
||||
ALLOWED_TRACKER_FIELDS = {"farm_uuid", "alerts"}
|
||||
|
||||
|
||||
class FarmAlertInputSerializer(serializers.Serializer):
|
||||
alert_id = serializers.CharField(required=False, allow_blank=True)
|
||||
level = serializers.CharField(required=False, allow_blank=True)
|
||||
title = serializers.CharField(required=False, allow_blank=True)
|
||||
message = serializers.CharField(required=False, allow_blank=True)
|
||||
suggested_action = serializers.CharField(required=False, allow_blank=True)
|
||||
source_metric_type = serializers.CharField(required=False, allow_blank=True)
|
||||
timestamp = serializers.DateTimeField(required=False, allow_null=True)
|
||||
payload = serializers.JSONField(required=False)
|
||||
|
||||
|
||||
class FarmAlertsTrackerRequestSerializer(serializers.Serializer):
|
||||
farm_uuid = serializers.UUIDField(required=True, help_text="UUID مزرعه.")
|
||||
alerts = FarmAlertInputSerializer(many=True, required=False, default=list)
|
||||
|
||||
def validate(self, attrs):
|
||||
initial_keys = set(getattr(self, "initial_data", {}).keys())
|
||||
extra_fields = initial_keys - ALLOWED_TRACKER_FIELDS
|
||||
if extra_fields:
|
||||
raise serializers.ValidationError(
|
||||
{field: ["This field is not allowed."] for field in sorted(extra_fields)}
|
||||
)
|
||||
return attrs
|
||||
|
||||
|
||||
class AlertTrackerAIResponseSerializer(serializers.Serializer):
|
||||
farm_uuid = serializers.UUIDField(read_only=True)
|
||||
service_id = serializers.CharField()
|
||||
tracker = serializers.JSONField()
|
||||
headline = serializers.CharField(allow_blank=True)
|
||||
overview = serializers.CharField(allow_blank=True)
|
||||
status_level = serializers.CharField()
|
||||
notifications = FarmNotificationSerializer(many=True)
|
||||
raw_llm_response = serializers.CharField(allow_blank=True)
|
||||
structured_context = serializers.JSONField()
|
||||
|
||||
|
||||
class AlertStatSerializer(serializers.Serializer):
|
||||
title = serializers.CharField()
|
||||
|
||||
+148
-5
@@ -1,8 +1,10 @@
|
||||
from collections import Counter
|
||||
from copy import deepcopy
|
||||
import json
|
||||
|
||||
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,
|
||||
@@ -13,7 +15,22 @@ from .mock_data import (
|
||||
from .models import AnomalyDetection, FarmAlert, Recommendation
|
||||
|
||||
|
||||
LEVEL_ALIAS_MAP = {
|
||||
"danger": "error",
|
||||
"critical": "error",
|
||||
"warn": "warning",
|
||||
}
|
||||
|
||||
|
||||
class AlertService:
|
||||
@staticmethod
|
||||
def normalize_level(level):
|
||||
normalized = str(level or "info").strip().lower()
|
||||
normalized = LEVEL_ALIAS_MAP.get(normalized, normalized)
|
||||
if normalized not in {"info", "warning", "error", "success"}:
|
||||
return "info"
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def create_alert(
|
||||
title: str,
|
||||
@@ -26,7 +43,7 @@ class AlertService:
|
||||
farm = None
|
||||
if farm_uuid:
|
||||
try:
|
||||
farm = FarmHub.objects.get(uuid=farm_uuid)
|
||||
farm = FarmHub.objects.get(farm_uuid=farm_uuid)
|
||||
except FarmHub.DoesNotExist:
|
||||
pass
|
||||
|
||||
@@ -34,7 +51,7 @@ class AlertService:
|
||||
farm=farm,
|
||||
title=title,
|
||||
description=description,
|
||||
color=color,
|
||||
color=AlertService.normalize_level(color),
|
||||
avatar_icon=avatar_icon,
|
||||
avatar_color=avatar_color,
|
||||
)
|
||||
@@ -42,22 +59,148 @@ class AlertService:
|
||||
AlertService._send_notification(alert, farm)
|
||||
return alert
|
||||
|
||||
@staticmethod
|
||||
def persist_incoming_alerts(*, farm, alerts):
|
||||
saved_alerts = []
|
||||
for alert_data in alerts:
|
||||
title = alert_data.get("title") or alert_data.get("message") or "Incoming alert"
|
||||
level = AlertService.normalize_level(alert_data.get("level"))
|
||||
saved_alerts.append(
|
||||
FarmAlert.objects.create(
|
||||
farm=farm,
|
||||
external_alert_id=alert_data.get("alert_id", ""),
|
||||
title=title[:255],
|
||||
description=alert_data.get("message", ""),
|
||||
color=level,
|
||||
suggested_action=alert_data.get("suggested_action", ""),
|
||||
source_metric_type=alert_data.get("source_metric_type", ""),
|
||||
occurred_at=alert_data.get("timestamp"),
|
||||
payload=alert_data.get("payload") or {},
|
||||
raw_alert=alert_data,
|
||||
is_active=level != "success",
|
||||
)
|
||||
)
|
||||
return saved_alerts
|
||||
|
||||
@staticmethod
|
||||
def _send_notification(alert: FarmAlert, farm) -> None:
|
||||
if farm is None:
|
||||
return
|
||||
|
||||
level_map = {"error": "error", "warning": "warning", "info": "info", "success": "success"}
|
||||
|
||||
FarmNotification.objects.create(
|
||||
farm=farm,
|
||||
title=alert.title,
|
||||
message=alert.description,
|
||||
level=level_map.get(alert.color, "info"),
|
||||
level=alert.color,
|
||||
source_alert_id=alert.external_alert_id,
|
||||
source_metric_type=alert.source_metric_type,
|
||||
suggested_action=alert.suggested_action,
|
||||
payload=alert.payload,
|
||||
metadata={"alert_uuid": str(alert.uuid), "color": alert.color},
|
||||
)
|
||||
|
||||
|
||||
def serialize_notifications_for_ai(*, farm, since_days=3, limit=10):
|
||||
notifications = get_recent_notifications_for_farm(farm=farm, since_days=since_days, limit=limit)
|
||||
return [
|
||||
{
|
||||
"id": notification.id,
|
||||
"farm_uuid": str(notification.farm.farm_uuid),
|
||||
"endpoint": notification.endpoint,
|
||||
"level": notification.level,
|
||||
"title": notification.title,
|
||||
"message": notification.message,
|
||||
"suggested_action": notification.suggested_action,
|
||||
"source_alert_id": notification.source_alert_id,
|
||||
"source_metric_type": notification.source_metric_type,
|
||||
"payload": notification.payload,
|
||||
"created_at": notification.created_at.isoformat(),
|
||||
"updated_at": notification.updated_at.isoformat(),
|
||||
}
|
||||
for notification in notifications
|
||||
]
|
||||
|
||||
|
||||
def save_tracker_notifications(*, farm_uuid, notifications):
|
||||
saved_notifications = []
|
||||
for notification_data in notifications:
|
||||
title = notification_data.get("title") or ""
|
||||
message = notification_data.get("message") or ""
|
||||
if not title and not message:
|
||||
continue
|
||||
|
||||
source_alert_id = notification_data.get("source_alert_id", "")
|
||||
existing = FarmNotification.objects.filter(
|
||||
farm__farm_uuid=farm_uuid,
|
||||
endpoint="tracker",
|
||||
title=title,
|
||||
message=message,
|
||||
source_alert_id=source_alert_id,
|
||||
).first()
|
||||
if existing:
|
||||
saved_notifications.append(existing)
|
||||
continue
|
||||
|
||||
saved_notifications.append(
|
||||
create_notification_for_farm_uuid(
|
||||
farm_uuid=farm_uuid,
|
||||
endpoint="tracker",
|
||||
title=title,
|
||||
message=message,
|
||||
level=AlertService.normalize_level(notification_data.get("level")),
|
||||
suggested_action=notification_data.get("suggested_action", ""),
|
||||
source_alert_id=source_alert_id,
|
||||
source_metric_type=notification_data.get("source_metric_type", ""),
|
||||
payload=notification_data.get("payload") or {},
|
||||
metadata={"source": "farm_alerts_tracker_ai"},
|
||||
)
|
||||
)
|
||||
return saved_notifications
|
||||
|
||||
|
||||
def build_tracker_context(*, farm, alerts):
|
||||
recent_notifications = serialize_notifications_for_ai(farm=farm, since_days=3, limit=10)
|
||||
counts = Counter(
|
||||
AlertService.normalize_level(alert.get("level"))
|
||||
for alert in alerts
|
||||
if alert.get("level")
|
||||
)
|
||||
structured_context = {
|
||||
"farm_uuid": str(farm.farm_uuid),
|
||||
"alerts_count": len(alerts),
|
||||
"recent_notifications_count": len(recent_notifications),
|
||||
"recent_notifications_window_days": 3,
|
||||
"recent_notifications_limit": 10,
|
||||
"alert_levels": dict(counts),
|
||||
}
|
||||
return {
|
||||
"farm_uuid": str(farm.farm_uuid),
|
||||
"alerts": alerts,
|
||||
"recent_notifications": recent_notifications,
|
||||
"structured_context": structured_context,
|
||||
}
|
||||
|
||||
|
||||
def build_tracker_response(*, farm, adapter_payload):
|
||||
notifications_payload = adapter_payload.get("notifications") or []
|
||||
saved_notifications = save_tracker_notifications(farm_uuid=farm.farm_uuid, notifications=notifications_payload)
|
||||
raw_llm_response = adapter_payload.get("raw_llm_response", "")
|
||||
if not raw_llm_response:
|
||||
raw_llm_response = json.dumps(adapter_payload, ensure_ascii=False)
|
||||
|
||||
return {
|
||||
"farm_uuid": str(farm.farm_uuid),
|
||||
"service_id": adapter_payload.get("service_id", "farm_alerts"),
|
||||
"tracker": adapter_payload.get("tracker") or {},
|
||||
"headline": adapter_payload.get("headline", ""),
|
||||
"overview": adapter_payload.get("overview", ""),
|
||||
"status_level": AlertService.normalize_level(adapter_payload.get("status_level")),
|
||||
"notifications": saved_notifications,
|
||||
"raw_llm_response": raw_llm_response,
|
||||
"structured_context": adapter_payload.get("structured_context") or {},
|
||||
}
|
||||
|
||||
|
||||
def get_alert_tracker_data(farm=None):
|
||||
if farm is None:
|
||||
return deepcopy(ARM_ALERTS_TRACKER)
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
from datetime import timedelta
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import SimpleTestCase, TestCase
|
||||
from django.utils import timezone
|
||||
from rest_framework.test import APIRequestFactory, force_authenticate
|
||||
|
||||
from external_api_adapter.adapter import AdapterResponse
|
||||
from farm_hub.models import FarmHub, FarmType
|
||||
from notifications.models import FarmNotification
|
||||
|
||||
from .models import FarmAlert
|
||||
from .serializers import FarmAlertsTrackerRequestSerializer
|
||||
from .views import AlertTrackerView
|
||||
|
||||
|
||||
class FarmAlertsTrackerRequestSerializerTests(SimpleTestCase):
|
||||
def test_accepts_farm_uuid_and_optional_alerts(self):
|
||||
serializer = FarmAlertsTrackerRequestSerializer(
|
||||
data={
|
||||
"farm_uuid": "11111111-1111-1111-1111-111111111111",
|
||||
"alerts": [
|
||||
{
|
||||
"alert_id": "soil-1",
|
||||
"level": "warning",
|
||||
"title": "Low moisture",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
self.assertTrue(serializer.is_valid(), serializer.errors)
|
||||
|
||||
def test_rejects_extra_fields(self):
|
||||
serializer = FarmAlertsTrackerRequestSerializer(
|
||||
data={
|
||||
"farm_uuid": "11111111-1111-1111-1111-111111111111",
|
||||
"unexpected": True,
|
||||
}
|
||||
)
|
||||
|
||||
self.assertFalse(serializer.is_valid())
|
||||
self.assertEqual(serializer.errors["unexpected"][0], "This field is not allowed.")
|
||||
|
||||
|
||||
class FarmAlertsTrackerViewTests(TestCase):
|
||||
def setUp(self):
|
||||
self.factory = APIRequestFactory()
|
||||
self.user = get_user_model().objects.create_user(
|
||||
username="farm-alerts-user",
|
||||
password="secret123",
|
||||
email="farm-alerts@example.com",
|
||||
phone_number="09120000999",
|
||||
)
|
||||
self.other_user = get_user_model().objects.create_user(
|
||||
username="farm-alerts-other",
|
||||
password="secret123",
|
||||
email="farm-alerts-other@example.com",
|
||||
phone_number="09120000998",
|
||||
)
|
||||
self.farm_type = FarmType.objects.create(name="مرکبات")
|
||||
self.farm = FarmHub.objects.create(owner=self.user, farm_type=self.farm_type, name="Farm Alerts")
|
||||
|
||||
@patch("farm_alerts.views.external_api_request")
|
||||
def test_tracker_persists_incoming_alerts_and_sends_recent_notifications_to_ai(self, mock_external_api_request):
|
||||
recent_notification = FarmNotification.objects.create(
|
||||
farm=self.farm,
|
||||
endpoint="tracker",
|
||||
title="Recent alert",
|
||||
message="Recent notification",
|
||||
level="warning",
|
||||
)
|
||||
old_notification = FarmNotification.objects.create(
|
||||
farm=self.farm,
|
||||
endpoint="tracker",
|
||||
title="Old alert",
|
||||
message="Old notification",
|
||||
level="info",
|
||||
)
|
||||
FarmNotification.objects.filter(id=old_notification.id).update(created_at=timezone.now() - timedelta(days=4))
|
||||
old_notification.refresh_from_db()
|
||||
|
||||
mock_external_api_request.return_value = AdapterResponse(
|
||||
status_code=200,
|
||||
data={
|
||||
"data": {
|
||||
"headline": "وضعیت هشدارها",
|
||||
"overview": "دو مورد نیاز به پیگیری دارد.",
|
||||
"status_level": "warning",
|
||||
"tracker": {"active": 2},
|
||||
"notifications": [
|
||||
{
|
||||
"title": "افت رطوبت خاک",
|
||||
"message": "تنش رطوبتی ادامه دارد.",
|
||||
"level": "warning",
|
||||
"suggested_action": "آبیاری جبرانی انجام شود.",
|
||||
"source_alert_id": "soil-1",
|
||||
"source_metric_type": "moisture",
|
||||
"payload": {"current_value": 38.5},
|
||||
}
|
||||
],
|
||||
"structured_context": {"source": "ai"},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
request = self.factory.post(
|
||||
"/api/farm-alerts/tracker/",
|
||||
{
|
||||
"farm_uuid": str(self.farm.farm_uuid),
|
||||
"alerts": [
|
||||
{
|
||||
"alert_id": "soil-1",
|
||||
"level": "danger",
|
||||
"title": "افت رطوبت خاک",
|
||||
"message": "رطوبت خاک کمتر از حد مطلوب است.",
|
||||
"suggested_action": "آبیاری اصلاحی بررسی شود.",
|
||||
"source_metric_type": "moisture",
|
||||
"payload": {"current_value": 38.5},
|
||||
}
|
||||
],
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
force_authenticate(request, user=self.user)
|
||||
|
||||
response = AlertTrackerView.as_view()(request)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(FarmAlert.objects.filter(farm=self.farm).count(), 1)
|
||||
|
||||
saved_alert = FarmAlert.objects.get(farm=self.farm)
|
||||
self.assertEqual(saved_alert.external_alert_id, "soil-1")
|
||||
self.assertEqual(saved_alert.color, "error")
|
||||
self.assertEqual(saved_alert.source_metric_type, "moisture")
|
||||
|
||||
mock_external_api_request.assert_called_once()
|
||||
outbound_payload = mock_external_api_request.call_args.kwargs["payload"]
|
||||
self.assertEqual(outbound_payload["farm_uuid"], str(self.farm.farm_uuid))
|
||||
self.assertEqual(len(outbound_payload["alerts"]), 1)
|
||||
self.assertEqual(len(outbound_payload["recent_notifications"]), 1)
|
||||
self.assertEqual(outbound_payload["recent_notifications"][0]["id"], recent_notification.id)
|
||||
|
||||
self.assertEqual(response.data["data"]["headline"], "وضعیت هشدارها")
|
||||
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")
|
||||
|
||||
persisted_notification = FarmNotification.objects.filter(
|
||||
farm=self.farm,
|
||||
title="افت رطوبت خاک",
|
||||
endpoint="tracker",
|
||||
).latest("id")
|
||||
self.assertEqual(persisted_notification.source_alert_id, "soil-1")
|
||||
self.assertEqual(persisted_notification.suggested_action, "آبیاری جبرانی انجام شود.")
|
||||
|
||||
@patch("farm_alerts.views.external_api_request")
|
||||
def test_tracker_limits_recent_notifications_to_ten(self, mock_external_api_request):
|
||||
for index in range(12):
|
||||
FarmNotification.objects.create(
|
||||
farm=self.farm,
|
||||
endpoint="tracker",
|
||||
title=f"Notification {index}",
|
||||
message="msg",
|
||||
)
|
||||
|
||||
mock_external_api_request.return_value = AdapterResponse(
|
||||
status_code=200,
|
||||
data={"data": {"headline": "", "overview": "", "status_level": "info", "notifications": []}},
|
||||
)
|
||||
|
||||
request = self.factory.post(
|
||||
"/api/farm-alerts/tracker/",
|
||||
{"farm_uuid": str(self.farm.farm_uuid)},
|
||||
format="json",
|
||||
)
|
||||
force_authenticate(request, user=self.user)
|
||||
|
||||
response = AlertTrackerView.as_view()(request)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
outbound_payload = mock_external_api_request.call_args.kwargs["payload"]
|
||||
self.assertEqual(len(outbound_payload["recent_notifications"]), 10)
|
||||
|
||||
def test_tracker_rejects_unowned_farm(self):
|
||||
request = self.factory.post(
|
||||
"/api/farm-alerts/tracker/",
|
||||
{"farm_uuid": str(self.farm.farm_uuid)},
|
||||
format="json",
|
||||
)
|
||||
force_authenticate(request, user=self.other_user)
|
||||
|
||||
response = AlertTrackerView.as_view()(request)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.data["farm_uuid"][0], "Farm not found.")
|
||||
+1
-2
@@ -1,8 +1,7 @@
|
||||
from django.urls import path
|
||||
|
||||
from .views import AlertTimelineView, AlertTrackerView
|
||||
from .views import AlertTrackerView
|
||||
|
||||
urlpatterns = [
|
||||
path("tracker/", AlertTrackerView.as_view(), name="farm-alerts-tracker"),
|
||||
path("timeline/", AlertTimelineView.as_view(), name="farm-alerts-timeline"),
|
||||
]
|
||||
|
||||
+54
-26
@@ -1,13 +1,20 @@
|
||||
from rest_framework import status
|
||||
from drf_spectacular.utils import OpenApiExample, extend_schema
|
||||
from rest_framework import serializers, status
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from config.swagger import code_response
|
||||
from external_api_adapter import request as external_api_request
|
||||
from farm_hub.models import FarmHub
|
||||
|
||||
from .serializers import AlertTimelineSerializer, AlertTrackerSerializer
|
||||
from .serializers import AlertTrackerAIResponseSerializer, FarmAlertsTrackerRequestSerializer
|
||||
from .services import AlertService, build_tracker_context, build_tracker_response
|
||||
|
||||
|
||||
class FarmAlertsBaseView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
@staticmethod
|
||||
def _extract_result(adapter_data):
|
||||
if not isinstance(adapter_data, dict):
|
||||
@@ -37,39 +44,60 @@ class FarmAlertsBaseView(APIView):
|
||||
status=adapter_response.status_code,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _get_farm(request, farm_uuid):
|
||||
if not farm_uuid:
|
||||
raise serializers.ValidationError({"farm_uuid": ["This field is required."]})
|
||||
try:
|
||||
return FarmHub.objects.get(farm_uuid=farm_uuid, owner=request.user)
|
||||
except FarmHub.DoesNotExist as exc:
|
||||
raise serializers.ValidationError({"farm_uuid": ["Farm not found."]}) from exc
|
||||
|
||||
|
||||
class AlertTrackerView(FarmAlertsBaseView):
|
||||
@extend_schema(
|
||||
tags=["Farm Alerts"],
|
||||
request=FarmAlertsTrackerRequestSerializer,
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Tracker Request",
|
||||
value={
|
||||
"farm_uuid": "11111111-1111-1111-1111-111111111111",
|
||||
"alerts": [
|
||||
{
|
||||
"alert_id": "soil-moisture-001",
|
||||
"level": "warning",
|
||||
"title": "افت رطوبت خاک",
|
||||
"message": "رطوبت خاک کمتر از حد مطلوب گزارش شده است.",
|
||||
"suggested_action": "آبیاری اصلاحی بررسی شود.",
|
||||
"source_metric_type": "moisture",
|
||||
}
|
||||
],
|
||||
},
|
||||
request_only=True,
|
||||
)
|
||||
],
|
||||
responses={200: code_response("FarmAlertsTrackerResponse", data=AlertTrackerAIResponseSerializer())},
|
||||
)
|
||||
def post(self, request):
|
||||
request_serializer = FarmAlertsTrackerRequestSerializer(data=request.data)
|
||||
request_serializer.is_valid(raise_exception=True)
|
||||
|
||||
farm = self._get_farm(request, request_serializer.validated_data["farm_uuid"])
|
||||
incoming_alerts = request_serializer.validated_data.get("alerts", [])
|
||||
AlertService.persist_incoming_alerts(farm=farm, alerts=incoming_alerts)
|
||||
|
||||
tracker_payload = build_tracker_context(farm=farm, alerts=incoming_alerts)
|
||||
adapter_response = external_api_request(
|
||||
"ai",
|
||||
"/api/farm-alerts/tracker/",
|
||||
method="POST",
|
||||
payload=request.data,
|
||||
payload=tracker_payload,
|
||||
)
|
||||
if adapter_response.status_code >= 400:
|
||||
return self._error_response(adapter_response)
|
||||
|
||||
payload = self._extract_result(adapter_response.data)
|
||||
serializer = AlertTrackerSerializer(data=payload)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
return Response({"code": 200, "msg": "success", "data": serializer.validated_data}, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class AlertTimelineView(FarmAlertsBaseView):
|
||||
def post(self, request):
|
||||
adapter_response = external_api_request(
|
||||
"ai",
|
||||
"/api/farm-alerts/timeline/",
|
||||
method="POST",
|
||||
payload=request.data,
|
||||
)
|
||||
if adapter_response.status_code >= 400:
|
||||
return self._error_response(adapter_response)
|
||||
|
||||
payload = self._extract_result(adapter_response.data)
|
||||
serializer = AlertTimelineSerializer(data=payload)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
return Response(
|
||||
{"code": 200, "msg": "success", "data": serializer.validated_data},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
response_data = build_tracker_response(farm=farm, adapter_payload=payload)
|
||||
serializer = AlertTrackerAIResponseSerializer(instance=response_data)
|
||||
return Response({"code": 200, "msg": "success", "data": serializer.data}, status=status.HTTP_200_OK)
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# Generated by Django 5.1.15 on 2026-04-28 23:30
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("notifications", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="farmnotification",
|
||||
name="endpoint",
|
||||
field=models.CharField(blank=True, default="", max_length=64),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="farmnotification",
|
||||
name="payload",
|
||||
field=models.JSONField(blank=True, default=dict),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="farmnotification",
|
||||
name="source_alert_id",
|
||||
field=models.CharField(blank=True, db_index=True, default="", max_length=255),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="farmnotification",
|
||||
name="source_metric_type",
|
||||
field=models.CharField(blank=True, default="", max_length=255),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="farmnotification",
|
||||
name="suggested_action",
|
||||
field=models.TextField(blank=True, default=""),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="farmnotification",
|
||||
name="updated_at",
|
||||
field=models.DateTimeField(auto_now=True, default=None),
|
||||
preserve_default=False,
|
||||
),
|
||||
]
|
||||
@@ -13,9 +13,15 @@ class FarmNotification(models.Model):
|
||||
title = models.CharField(max_length=255)
|
||||
message = models.TextField()
|
||||
level = models.CharField(max_length=32, default="info")
|
||||
endpoint = models.CharField(max_length=64, blank=True, default="")
|
||||
suggested_action = models.TextField(blank=True, default="")
|
||||
source_alert_id = models.CharField(max_length=255, blank=True, default="", db_index=True)
|
||||
source_metric_type = models.CharField(max_length=255, blank=True, default="")
|
||||
payload = models.JSONField(default=dict, blank=True)
|
||||
is_read = models.BooleanField(default=False)
|
||||
metadata = models.JSONField(default=dict, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "farm_notifications"
|
||||
|
||||
@@ -4,19 +4,27 @@ from .models import FarmNotification
|
||||
|
||||
|
||||
class FarmNotificationSerializer(serializers.ModelSerializer):
|
||||
id = serializers.IntegerField(read_only=True)
|
||||
farm_uuid = serializers.UUIDField(source="farm.farm_uuid", read_only=True)
|
||||
since_id = serializers.IntegerField(source="id", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = FarmNotification
|
||||
fields = [
|
||||
"id",
|
||||
"uuid",
|
||||
"farm_uuid",
|
||||
"since_id",
|
||||
"endpoint",
|
||||
"title",
|
||||
"message",
|
||||
"level",
|
||||
"suggested_action",
|
||||
"source_alert_id",
|
||||
"source_metric_type",
|
||||
"payload",
|
||||
"is_read",
|
||||
"metadata",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
@@ -2,6 +2,7 @@ import time
|
||||
|
||||
from django.db import OperationalError, ProgrammingError
|
||||
from django.db.models import Case, IntegerField, QuerySet, Value, When
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
from farm_hub.models import FarmHub
|
||||
@@ -13,7 +14,19 @@ DEFAULT_POLL_TIMEOUT_SECONDS = 15
|
||||
DEFAULT_POLL_INTERVAL_SECONDS = 1
|
||||
|
||||
|
||||
def create_notification_for_farm_uuid(*, farm_uuid, title, message, level="info", metadata=None):
|
||||
def create_notification_for_farm_uuid(
|
||||
*,
|
||||
farm_uuid,
|
||||
title,
|
||||
message,
|
||||
level="info",
|
||||
endpoint="",
|
||||
suggested_action="",
|
||||
source_alert_id="",
|
||||
source_metric_type="",
|
||||
payload=None,
|
||||
metadata=None,
|
||||
):
|
||||
farm = FarmHub.objects.filter(farm_uuid=farm_uuid).first()
|
||||
if farm is None:
|
||||
raise ValueError("Farm not found.")
|
||||
@@ -24,12 +37,25 @@ def create_notification_for_farm_uuid(*, farm_uuid, title, message, level="info"
|
||||
title=title,
|
||||
message=message,
|
||||
level=level,
|
||||
endpoint=endpoint,
|
||||
suggested_action=suggested_action,
|
||||
source_alert_id=source_alert_id,
|
||||
source_metric_type=source_metric_type,
|
||||
payload=payload or {},
|
||||
metadata=metadata or {},
|
||||
)
|
||||
except (ProgrammingError, OperationalError) as exc:
|
||||
raise ValueError("Notifications table is not migrated.") from exc
|
||||
|
||||
|
||||
def get_recent_notifications_for_farm(*, farm: FarmHub, since_days=3, limit=10) -> QuerySet[FarmNotification]:
|
||||
try:
|
||||
since = timezone.now() - timezone.timedelta(days=max(since_days, 0))
|
||||
return FarmNotification.objects.filter(farm=farm, created_at__gte=since).order_by("-created_at", "-id")[:limit]
|
||||
except (ProgrammingError, OperationalError) as exc:
|
||||
raise ValueError("Notifications table is not migrated.") from exc
|
||||
|
||||
|
||||
def get_notifications_for_farm(*, farm: FarmHub, since_id=None) -> QuerySet[FarmNotification]:
|
||||
try:
|
||||
queryset = FarmNotification.objects.filter(farm=farm)
|
||||
|
||||
Reference in New Issue
Block a user