This commit is contained in:
2026-04-29 02:58:56 +03:30
parent f0f2ac34b7
commit 27784ee8b9
15 changed files with 1277 additions and 36 deletions
+192
View File
@@ -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 آنجا انجام می شود.
+13 -2
View File
@@ -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()
+60
View File
@@ -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)})
+436
View File
@@ -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=""),
),
]
+6
View File
@@ -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)
+42
View File
@@ -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
View File
@@ -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)
+197
View File
@@ -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
View File
@@ -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
View File
@@ -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,
),
]
+6
View File
@@ -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"
+8
View File
@@ -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",
] ]
+27 -1
View File
@@ -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)