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
|
from dataclasses import dataclass, field
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.core.serializers.json import DjangoJSONEncoder
|
||||||
|
|
||||||
from .exceptions import ExternalAPIRequestError
|
from .exceptions import ExternalAPIRequestError
|
||||||
from .exceptions import MockDirectoryNotFound, MockFileNotFound
|
from .exceptions import MockDirectoryNotFound, MockFileNotFound
|
||||||
@@ -72,7 +74,8 @@ class ExternalAPIAdapter:
|
|||||||
url = f"{base_url}/{str(path).lstrip('/')}"
|
url = f"{base_url}/{str(path).lstrip('/')}"
|
||||||
|
|
||||||
files = None
|
files = None
|
||||||
request_payload = payload
|
request_payload = self._make_json_safe(payload)
|
||||||
|
request_query = self._make_json_safe(query)
|
||||||
request_headers = {
|
request_headers = {
|
||||||
"Authorization": f"Bearer {api_key}",
|
"Authorization": f"Bearer {api_key}",
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
@@ -95,7 +98,7 @@ class ExternalAPIAdapter:
|
|||||||
request_kwargs = {
|
request_kwargs = {
|
||||||
"method": method,
|
"method": method,
|
||||||
"url": url,
|
"url": url,
|
||||||
"params": query,
|
"params": request_query,
|
||||||
"headers": request_headers,
|
"headers": request_headers,
|
||||||
"timeout": getattr(settings, "EXTERNAL_API_TIMEOUT", 30),
|
"timeout": getattr(settings, "EXTERNAL_API_TIMEOUT", 30),
|
||||||
}
|
}
|
||||||
@@ -150,6 +153,14 @@ class ExternalAPIAdapter:
|
|||||||
if method not in supported_methods:
|
if method not in supported_methods:
|
||||||
raise ValueError(f"Unsupported HTTP method '{method}'. Supported methods: {sorted(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()
|
_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):
|
class FarmAlert(models.Model):
|
||||||
uuid = models.UUIDField(default=uuid_lib.uuid4, unique=True, editable=False, db_index=True)
|
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)
|
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)
|
title = models.CharField(max_length=255)
|
||||||
description = models.TextField(blank=True, default="")
|
description = models.TextField(blank=True, default="")
|
||||||
color = models.CharField(max_length=32, default="info", choices=SEVERITY_CHOICES)
|
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_icon = models.CharField(max_length=64, blank=True, default="")
|
||||||
avatar_color = models.CharField(max_length=32, blank=True, default="")
|
avatar_color = models.CharField(max_length=32, blank=True, default="")
|
||||||
is_active = models.BooleanField(default=True)
|
is_active = models.BooleanField(default=True)
|
||||||
|
|||||||
@@ -1,5 +1,47 @@
|
|||||||
from rest_framework import serializers
|
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):
|
class AlertStatSerializer(serializers.Serializer):
|
||||||
title = serializers.CharField()
|
title = serializers.CharField()
|
||||||
|
|||||||
+148
-5
@@ -1,8 +1,10 @@
|
|||||||
from collections import Counter
|
from collections import Counter
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
|
import json
|
||||||
|
|
||||||
from farm_hub.models import FarmHub
|
from farm_hub.models import FarmHub
|
||||||
from notifications.models import FarmNotification
|
from notifications.models import FarmNotification
|
||||||
|
from notifications.services import create_notification_for_farm_uuid, get_recent_notifications_for_farm
|
||||||
|
|
||||||
from .mock_data import (
|
from .mock_data import (
|
||||||
ANOMALY_DETECTION_CARD,
|
ANOMALY_DETECTION_CARD,
|
||||||
@@ -13,7 +15,22 @@ from .mock_data import (
|
|||||||
from .models import AnomalyDetection, FarmAlert, Recommendation
|
from .models import AnomalyDetection, FarmAlert, Recommendation
|
||||||
|
|
||||||
|
|
||||||
|
LEVEL_ALIAS_MAP = {
|
||||||
|
"danger": "error",
|
||||||
|
"critical": "error",
|
||||||
|
"warn": "warning",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class AlertService:
|
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
|
@staticmethod
|
||||||
def create_alert(
|
def create_alert(
|
||||||
title: str,
|
title: str,
|
||||||
@@ -26,7 +43,7 @@ class AlertService:
|
|||||||
farm = None
|
farm = None
|
||||||
if farm_uuid:
|
if farm_uuid:
|
||||||
try:
|
try:
|
||||||
farm = FarmHub.objects.get(uuid=farm_uuid)
|
farm = FarmHub.objects.get(farm_uuid=farm_uuid)
|
||||||
except FarmHub.DoesNotExist:
|
except FarmHub.DoesNotExist:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -34,7 +51,7 @@ class AlertService:
|
|||||||
farm=farm,
|
farm=farm,
|
||||||
title=title,
|
title=title,
|
||||||
description=description,
|
description=description,
|
||||||
color=color,
|
color=AlertService.normalize_level(color),
|
||||||
avatar_icon=avatar_icon,
|
avatar_icon=avatar_icon,
|
||||||
avatar_color=avatar_color,
|
avatar_color=avatar_color,
|
||||||
)
|
)
|
||||||
@@ -42,22 +59,148 @@ class AlertService:
|
|||||||
AlertService._send_notification(alert, farm)
|
AlertService._send_notification(alert, farm)
|
||||||
return alert
|
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
|
@staticmethod
|
||||||
def _send_notification(alert: FarmAlert, farm) -> None:
|
def _send_notification(alert: FarmAlert, farm) -> None:
|
||||||
if farm is None:
|
if farm is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
level_map = {"error": "error", "warning": "warning", "info": "info", "success": "success"}
|
|
||||||
|
|
||||||
FarmNotification.objects.create(
|
FarmNotification.objects.create(
|
||||||
farm=farm,
|
farm=farm,
|
||||||
title=alert.title,
|
title=alert.title,
|
||||||
message=alert.description,
|
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},
|
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):
|
def get_alert_tracker_data(farm=None):
|
||||||
if farm is None:
|
if farm is None:
|
||||||
return deepcopy(ARM_ALERTS_TRACKER)
|
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 django.urls import path
|
||||||
|
|
||||||
from .views import AlertTimelineView, AlertTrackerView
|
from .views import AlertTrackerView
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("tracker/", AlertTrackerView.as_view(), name="farm-alerts-tracker"),
|
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.response import Response
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
|
from config.swagger import code_response
|
||||||
from external_api_adapter import request as external_api_request
|
from external_api_adapter import request as external_api_request
|
||||||
|
from farm_hub.models import FarmHub
|
||||||
|
|
||||||
from .serializers import AlertTimelineSerializer, AlertTrackerSerializer
|
from .serializers import AlertTrackerAIResponseSerializer, FarmAlertsTrackerRequestSerializer
|
||||||
|
from .services import AlertService, build_tracker_context, build_tracker_response
|
||||||
|
|
||||||
|
|
||||||
class FarmAlertsBaseView(APIView):
|
class FarmAlertsBaseView(APIView):
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _extract_result(adapter_data):
|
def _extract_result(adapter_data):
|
||||||
if not isinstance(adapter_data, dict):
|
if not isinstance(adapter_data, dict):
|
||||||
@@ -37,39 +44,60 @@ class FarmAlertsBaseView(APIView):
|
|||||||
status=adapter_response.status_code,
|
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):
|
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):
|
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(
|
adapter_response = external_api_request(
|
||||||
"ai",
|
"ai",
|
||||||
"/api/farm-alerts/tracker/",
|
"/api/farm-alerts/tracker/",
|
||||||
method="POST",
|
method="POST",
|
||||||
payload=request.data,
|
payload=tracker_payload,
|
||||||
)
|
)
|
||||||
if adapter_response.status_code >= 400:
|
if adapter_response.status_code >= 400:
|
||||||
return self._error_response(adapter_response)
|
return self._error_response(adapter_response)
|
||||||
|
|
||||||
payload = self._extract_result(adapter_response.data)
|
payload = self._extract_result(adapter_response.data)
|
||||||
serializer = AlertTrackerSerializer(data=payload)
|
response_data = build_tracker_response(farm=farm, adapter_payload=payload)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer = AlertTrackerAIResponseSerializer(instance=response_data)
|
||||||
return Response({"code": 200, "msg": "success", "data": serializer.validated_data}, status=status.HTTP_200_OK)
|
return Response({"code": 200, "msg": "success", "data": serializer.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,
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -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)
|
title = models.CharField(max_length=255)
|
||||||
message = models.TextField()
|
message = models.TextField()
|
||||||
level = models.CharField(max_length=32, default="info")
|
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)
|
is_read = models.BooleanField(default=False)
|
||||||
metadata = models.JSONField(default=dict, blank=True)
|
metadata = models.JSONField(default=dict, blank=True)
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
db_table = "farm_notifications"
|
db_table = "farm_notifications"
|
||||||
|
|||||||
@@ -4,19 +4,27 @@ from .models import FarmNotification
|
|||||||
|
|
||||||
|
|
||||||
class FarmNotificationSerializer(serializers.ModelSerializer):
|
class FarmNotificationSerializer(serializers.ModelSerializer):
|
||||||
|
id = serializers.IntegerField(read_only=True)
|
||||||
farm_uuid = serializers.UUIDField(source="farm.farm_uuid", read_only=True)
|
farm_uuid = serializers.UUIDField(source="farm.farm_uuid", read_only=True)
|
||||||
since_id = serializers.IntegerField(source="id", read_only=True)
|
since_id = serializers.IntegerField(source="id", read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = FarmNotification
|
model = FarmNotification
|
||||||
fields = [
|
fields = [
|
||||||
|
"id",
|
||||||
"uuid",
|
"uuid",
|
||||||
"farm_uuid",
|
"farm_uuid",
|
||||||
"since_id",
|
"since_id",
|
||||||
|
"endpoint",
|
||||||
"title",
|
"title",
|
||||||
"message",
|
"message",
|
||||||
"level",
|
"level",
|
||||||
|
"suggested_action",
|
||||||
|
"source_alert_id",
|
||||||
|
"source_metric_type",
|
||||||
|
"payload",
|
||||||
"is_read",
|
"is_read",
|
||||||
"metadata",
|
"metadata",
|
||||||
"created_at",
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import time
|
|||||||
|
|
||||||
from django.db import OperationalError, ProgrammingError
|
from django.db import OperationalError, ProgrammingError
|
||||||
from django.db.models import Case, IntegerField, QuerySet, Value, When
|
from django.db.models import Case, IntegerField, QuerySet, Value, When
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
|
||||||
from farm_hub.models import FarmHub
|
from farm_hub.models import FarmHub
|
||||||
@@ -13,7 +14,19 @@ DEFAULT_POLL_TIMEOUT_SECONDS = 15
|
|||||||
DEFAULT_POLL_INTERVAL_SECONDS = 1
|
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()
|
farm = FarmHub.objects.filter(farm_uuid=farm_uuid).first()
|
||||||
if farm is None:
|
if farm is None:
|
||||||
raise ValueError("Farm not found.")
|
raise ValueError("Farm not found.")
|
||||||
@@ -24,12 +37,25 @@ def create_notification_for_farm_uuid(*, farm_uuid, title, message, level="info"
|
|||||||
title=title,
|
title=title,
|
||||||
message=message,
|
message=message,
|
||||||
level=level,
|
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 {},
|
metadata=metadata or {},
|
||||||
)
|
)
|
||||||
except (ProgrammingError, OperationalError) as exc:
|
except (ProgrammingError, OperationalError) as exc:
|
||||||
raise ValueError("Notifications table is not migrated.") from 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]:
|
def get_notifications_for_farm(*, farm: FarmHub, since_id=None) -> QuerySet[FarmNotification]:
|
||||||
try:
|
try:
|
||||||
queryset = FarmNotification.objects.filter(farm=farm)
|
queryset = FarmNotification.objects.filter(farm=farm)
|
||||||
|
|||||||
Reference in New Issue
Block a user