From cfe60f67293ff35ebcdc29163f21752dbb1e0e23 Mon Sep 17 00:00:00 2001 From: Mohammad Sajad Pourajam Date: Tue, 5 May 2026 00:56:05 +0330 Subject: [PATCH] UPDATE --- Dockerfile.Dev | 44 ++ access_control/migrations/0001_initial.py | 4 +- access_control/models.py | 2 +- celerybeat-schedule | Bin 16384 -> 16384 bytes config/settings.py | 4 +- config/urls.py | 8 +- dashboard/services.py | 2 +- {sensor_7_in_1 => device_hub}/__init__.py | 0 {sensor_external_api => device_hub}/apps.py | 6 +- .../authentication.py | 4 - .../__init__.py => device_hub/catalog_seed.py | 3 +- .../comparison_urls.py | 2 +- .../management/__init__.py | 0 .../management/commands/__init__.py | 0 .../management/commands/seed_sensor_7_in_1.py | 2 +- .../commands/seed_sensor_catalog.py | 3 +- device_hub/migrations/0001_initial.py | 68 ++ .../migrations/0002_absorb_sensor_7_in_1.py | 9 + .../0003_absorb_sensor_external_api.py | 9 + .../migrations/0004_absorb_sensor_catalog.py | 10 + device_hub/migrations/__init__.py | 1 + device_hub/mock_data.py | 57 ++ device_hub/models.py | 67 ++ device_hub/seeds.py | 59 ++ device_hub/sensor_7_in_1_urls.py | 9 + .../sensor_catalog_urls.py | 1 + .../sensor_external_api_urls.py | 1 + .../sensor_serializers.py | 9 +- .../serializers.py | 92 ++- device_hub/services.py | 559 ++++++++++++++ device_hub/urls.py | 11 + device_hub/views.py | 142 ++++ docker-compose-prod.yaml | 36 +- ..._farmsensor_catalog_and_physical_device.py | 4 +- .../0010_move_farmsensor_to_device_hub.py | 17 + farm_hub/models.py | 31 - farm_hub/seeds.py | 4 +- farm_hub/serializers.py | 4 +- farm_hub/services.py | 2 + farm_hub/tests.py | 2 +- farmer_calendar/__init__.py | 309 ++++++++ fertilization/FERTILIZATION_PLAN_APIS.md | 9 +- ...0004_fertilizationplan_default_inactive.py | 16 + fertilization/models.py | 2 +- fertilization/tests.py | 76 +- fertilization/views.py | 22 +- irrigation/IRRIGATION_PLAN_APIS.md | 9 +- .../0004_irrigationplan_default_inactive.py | 16 + irrigation/models.py | 2 +- irrigation/tests.py | 74 +- irrigation/views.py | 22 +- plants/serializers.py | 5 + plants/services.py | 127 ++-- plants/tests.py | 12 +- plants/views.py | 15 +- sensor_7_in_1/apps.py | 8 - sensor_7_in_1/mock_data.py | 133 ---- sensor_7_in_1/models.py | 4 - sensor_7_in_1/seeds.py | 174 ----- sensor_7_in_1/services.py | 704 ------------------ sensor_7_in_1/tests.py | 281 ------- sensor_7_in_1/urls.py | 10 - sensor_7_in_1/views.py | 183 ----- sensor_catalog/SensorCatalogListView.md | 302 -------- sensor_catalog/__init__.py | 0 sensor_catalog/apps.py | 7 - .../management/commands/__init__.py | 0 sensor_catalog/migrations/0001_initial.py | 32 - ...2_sensorcatalog_supported_power_sources.py | 17 - .../migrations/0003_sensorcatalog_code.py | 44 -- .../0004_alter_sensorcatalog_options.py | 17 - sensor_catalog/migrations/__init__.py | 0 sensor_catalog/models.py | 24 - sensor_catalog/serializers.py | 20 - sensor_catalog/tests.py | 57 -- sensor_catalog/views.py | 22 - sensor_external_api/SensorExternalAPIView.md | 356 --------- .../SensorExternalRequestLogListAPIView.md | 317 -------- sensor_external_api/__init__.py | 0 .../migrations/0001_initial.py | 27 - sensor_external_api/migrations/__init__.py | 0 sensor_external_api/models.py | 16 - sensor_external_api/services.py | 290 -------- sensor_external_api/tests.py | 380 ---------- sensor_external_api/views.py | 197 ----- 85 files changed, 1786 insertions(+), 3840 deletions(-) create mode 100644 Dockerfile.Dev rename {sensor_7_in_1 => device_hub}/__init__.py (100%) rename {sensor_external_api => device_hub}/apps.py (50%) rename {sensor_external_api => device_hub}/authentication.py (99%) rename sensor_catalog/management/__init__.py => device_hub/catalog_seed.py (97%) rename {sensor_7_in_1 => device_hub}/comparison_urls.py (100%) rename {sensor_7_in_1 => device_hub}/management/__init__.py (100%) rename {sensor_7_in_1 => device_hub}/management/commands/__init__.py (100%) rename {sensor_7_in_1 => device_hub}/management/commands/seed_sensor_7_in_1.py (92%) rename {sensor_catalog => device_hub}/management/commands/seed_sensor_catalog.py (92%) create mode 100644 device_hub/migrations/0001_initial.py create mode 100644 device_hub/migrations/0002_absorb_sensor_7_in_1.py create mode 100644 device_hub/migrations/0003_absorb_sensor_external_api.py create mode 100644 device_hub/migrations/0004_absorb_sensor_catalog.py create mode 100644 device_hub/migrations/__init__.py create mode 100644 device_hub/mock_data.py create mode 100644 device_hub/models.py create mode 100644 device_hub/seeds.py create mode 100644 device_hub/sensor_7_in_1_urls.py rename sensor_catalog/urls.py => device_hub/sensor_catalog_urls.py (99%) rename sensor_external_api/urls.py => device_hub/sensor_external_api_urls.py (99%) rename sensor_7_in_1/serializers.py => device_hub/sensor_serializers.py (94%) rename {sensor_external_api => device_hub}/serializers.py (87%) create mode 100644 device_hub/services.py create mode 100644 device_hub/urls.py create mode 100644 device_hub/views.py create mode 100644 farm_hub/migrations/0010_move_farmsensor_to_device_hub.py create mode 100644 fertilization/migrations/0004_fertilizationplan_default_inactive.py create mode 100644 irrigation/migrations/0004_irrigationplan_default_inactive.py delete mode 100644 sensor_7_in_1/apps.py delete mode 100644 sensor_7_in_1/mock_data.py delete mode 100644 sensor_7_in_1/models.py delete mode 100644 sensor_7_in_1/seeds.py delete mode 100644 sensor_7_in_1/services.py delete mode 100644 sensor_7_in_1/tests.py delete mode 100644 sensor_7_in_1/urls.py delete mode 100644 sensor_7_in_1/views.py delete mode 100644 sensor_catalog/SensorCatalogListView.md delete mode 100644 sensor_catalog/__init__.py delete mode 100644 sensor_catalog/apps.py delete mode 100644 sensor_catalog/management/commands/__init__.py delete mode 100644 sensor_catalog/migrations/0001_initial.py delete mode 100644 sensor_catalog/migrations/0002_sensorcatalog_supported_power_sources.py delete mode 100644 sensor_catalog/migrations/0003_sensorcatalog_code.py delete mode 100644 sensor_catalog/migrations/0004_alter_sensorcatalog_options.py delete mode 100644 sensor_catalog/migrations/__init__.py delete mode 100644 sensor_catalog/models.py delete mode 100644 sensor_catalog/serializers.py delete mode 100644 sensor_catalog/tests.py delete mode 100644 sensor_catalog/views.py delete mode 100644 sensor_external_api/SensorExternalAPIView.md delete mode 100644 sensor_external_api/SensorExternalRequestLogListAPIView.md delete mode 100644 sensor_external_api/__init__.py delete mode 100644 sensor_external_api/migrations/0001_initial.py delete mode 100644 sensor_external_api/migrations/__init__.py delete mode 100644 sensor_external_api/models.py delete mode 100644 sensor_external_api/services.py delete mode 100644 sensor_external_api/tests.py delete mode 100644 sensor_external_api/views.py diff --git a/Dockerfile.Dev b/Dockerfile.Dev new file mode 100644 index 0000000..debcaf8 --- /dev/null +++ b/Dockerfile.Dev @@ -0,0 +1,44 @@ +ARG BASE_IMAGE=mirror-docker.runflare.com/library/python:3.10-slim-bookworm +FROM ${BASE_IMAGE} + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 \ + PIP_NO_CACHE_DIR=1 + +ARG APT_MIRROR=mirror-linux.runflare.com/debian +ARG APT_SECURITY_MIRROR=mirror-linux.runflare.com/debian-security +ARG PIP_INDEX_URL=https://mirror-pypi.runflare.com/simple +ARG PIP_TRUSTED_HOST=mirror-pypi.runflare.com + +WORKDIR /app + +# Route Debian packages through the requested mirror. +RUN printf '%s\n' \ + "deb https://${APT_MIRROR} bookworm main contrib non-free non-free-firmware" \ + "deb https://${APT_MIRROR} bookworm-updates main contrib non-free non-free-firmware" \ + "deb https://${APT_SECURITY_MIRROR} bookworm-security main contrib non-free non-free-firmware" \ + > /etc/apt/sources.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + build-essential \ + default-libmysqlclient-dev \ + pkg-config \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt /app/requirements.txt + +# Route Python packages through the requested mirror. +RUN pip config set global.index-url "${PIP_INDEX_URL}" \ + && pip config set global.trusted-host "${PIP_TRUSTED_HOST}" \ + && pip install -r /app/requirements.txt + +COPY entrypoint.sh /app/entrypoint.sh +COPY . /app + +RUN chmod +x /app/entrypoint.sh + +EXPOSE 8000 + +ENTRYPOINT ["sh", "/app/entrypoint.sh"] +CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000"] diff --git a/access_control/migrations/0001_initial.py b/access_control/migrations/0001_initial.py index bffea3f..3fcfd29 100644 --- a/access_control/migrations/0001_initial.py +++ b/access_control/migrations/0001_initial.py @@ -8,7 +8,7 @@ class Migration(migrations.Migration): dependencies = [ ("farm_hub", "0006_seed_expanded_product_catalog"), - ("sensor_catalog", "0003_sensorcatalog_code"), + ("device_hub", "0001_initial"), ] operations = [ @@ -61,7 +61,7 @@ class Migration(migrations.Migration): ("farm_types", models.ManyToManyField(blank=True, related_name="access_rules", to="farm_hub.farmtype")), ("features", models.ManyToManyField(blank=True, related_name="rules", to="access_control.accessfeature")), ("products", models.ManyToManyField(blank=True, related_name="access_rules", to="farm_hub.product")), - ("sensor_catalogs", models.ManyToManyField(blank=True, related_name="access_rules", to="sensor_catalog.sensorcatalog")), + ("sensor_catalogs", models.ManyToManyField(blank=True, related_name="access_rules", to="device_hub.sensorcatalog")), ("subscription_plans", models.ManyToManyField(blank=True, related_name="access_rules", to="access_control.subscriptionplan")), ], options={"db_table": "access_rules", "ordering": ["priority", "name"]}, diff --git a/access_control/models.py b/access_control/models.py index 5c1f4e0..0c16cbc 100644 --- a/access_control/models.py +++ b/access_control/models.py @@ -70,7 +70,7 @@ class AccessRule(models.Model): subscription_plans = models.ManyToManyField("SubscriptionPlan", related_name="access_rules", blank=True) farm_types = models.ManyToManyField("farm_hub.FarmType", related_name="access_rules", blank=True) products = models.ManyToManyField("farm_hub.Product", related_name="access_rules", blank=True) - sensor_catalogs = models.ManyToManyField("sensor_catalog.SensorCatalog", related_name="access_rules", blank=True) + sensor_catalogs = models.ManyToManyField("device_hub.SensorCatalog", related_name="access_rules", blank=True) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) diff --git a/celerybeat-schedule b/celerybeat-schedule index 449fca2f3461d42d743d443e3ae498336ac6f098..0c42804408f68bde863550b33ef846dfd92b6245 100644 GIT binary patch delta 28 jcmZo@U~Fh$+|Xvs&LYa9$H%^I@<)R}#(>TDjTLzSevSyq delta 28 jcmZo@U~Fh$+|Xvs&cw^Wz?gJ-@<)R}M(54= 400: + raise FarmDataForwardError(f"Farm data API returned status {response.status_code}: {response.data}") + return request_payload + + +def _get_farm_boundary(*, sensor): + crop_area = sensor.farm.current_crop_area or sensor.farm.crop_areas.order_by("-created_at", "-id").first() + if crop_area is None: + raise FarmDataForwardError("Farm boundary is not configured for this farm.") + geometry = crop_area.geometry or {} + if geometry.get("type") == "Feature": + geometry = geometry.get("geometry") or {} + if geometry.get("type") != "Polygon": + raise FarmDataForwardError("Farm boundary geometry must be a Polygon.") + return geometry + + +def _normalize_sensor_payload(*, sensor_key, sensor_payload): + if not sensor_payload: + return {} + if not isinstance(sensor_payload, dict): + raise FarmDataForwardError("`payload` must be a JSON object.") + if all(isinstance(value, dict) for value in sensor_payload.values()): + return sensor_payload + return {sensor_key: sensor_payload} + + +def _get_sensor_key(*, sensor): + if sensor.sensor_catalog and sensor.sensor_catalog.code: + return sensor.sensor_catalog.code + return "sensor-7-1" + + +def _get_farm_data_path(): + return getattr(settings, "FARM_DATA_API_PATH", "/api/farm-data/") + + +def _to_float(value): + if value is None or isinstance(value, bool): + return None + try: + return float(value) + except (TypeError, ValueError): + return None + + +def _extract_payload(payload): + if not isinstance(payload, dict): + return {} + if isinstance(payload.get("payload"), dict): + payload = payload["payload"] + if isinstance(payload.get("data"), dict): + nested = payload["data"] + if any(any(key in nested for key in field["payload_keys"]) for field in SENSOR_FIELDS): + payload = nested + return payload + + +def _extract_numeric_payload(payload): + payload = _extract_payload(payload) + return {key: numeric_value for key, value in payload.items() if (numeric_value := _to_float(value)) is not None} + + +def _extract_readings(payload): + payload = _extract_payload(payload) + readings = {} + for field in SENSOR_FIELDS: + for key in field["payload_keys"]: + value = _to_float(payload.get(key)) + if value is not None: + readings[field["id"]] = value + break + return readings + + +def _format_number(value): + if value is None: + return "" + if float(value).is_integer(): + return str(int(value)) + return f"{value:.1f}".rstrip("0").rstrip(".") + + +def _format_value(value, unit): + number = _format_number(value) + if not number: + return number + if unit in {"", "pH"}: + return number + if unit in {"%", "°C"}: + return f"{number}{unit}" + return f"{number} {unit}" + + +def _format_range(field): + lower = _format_number(field["ideal_min"]) + upper = _format_number(field["ideal_max"]) + if field["unit"] in {"", "pH"}: + return f"{lower}-{upper}" + return f"{lower}-{upper} {field['unit']}" + + +def get_primary_soil_sensor(*, farm): + soil_sensors = list(farm.sensors.select_related("sensor_catalog").order_by("created_at", "id")) + + def _sensor_priority(sensor): + sensor_type = (sensor.sensor_type or "").lower() + catalog_code = (sensor.sensor_catalog.code if sensor.sensor_catalog else "").lower() + catalog_name = (sensor.sensor_catalog.name if sensor.sensor_catalog else "").lower() + sensor_name = (sensor.name or "").lower() + haystack = " ".join([sensor_type, catalog_code, catalog_name, sensor_name]) + if "sensor-7-in-1" in catalog_code or "soil_7_in_1" in sensor_type: + return 0 + if "7 in 1" in haystack or "7-in-1" in haystack or "7in1" in haystack: + return 1 + if "soil" in haystack: + return 2 + return 3 + + prioritized_sensors = sorted(soil_sensors, key=_sensor_priority) + if prioritized_sensors and _sensor_priority(prioritized_sensors[0]) < 3: + return prioritized_sensors[0] + return soil_sensors[0] if soil_sensors else None + + +def _get_sensor_context(farm=None): + if farm is None: + return None + primary_sensor = get_primary_soil_sensor(farm=farm) + if primary_sensor is None: + return None + try: + logs_queryset = get_sensor_external_request_logs_for_farm(farm_uuid=farm.farm_uuid, physical_device_uuid=primary_sensor.physical_device_uuid) + except ValueError: + return None + history = [] + for log in logs_queryset[:MAX_HISTORY_ITEMS]: + readings = _extract_readings(log.payload) + if readings: + history.append((log, readings)) + if not history: + return None + latest_log, latest_readings = history[0] + farm_sensor_map = get_farm_sensor_map_for_logs(logs=[latest_log]) + farm_sensor = farm_sensor_map.get((latest_log.farm_uuid, latest_log.sensor_catalog_uuid, latest_log.physical_device_uuid)) or primary_sensor + return {"farm_sensor": farm_sensor, "latest_log": latest_log, "latest_readings": latest_readings, "previous_readings": history[1][1] if len(history) > 1 else {}, "history": history} + + +def _build_sensor_meta(context, fallback_sensor): + sensor = deepcopy(fallback_sensor) + if not context: + return sensor + farm_sensor = context.get("farm_sensor") + latest_log = context["latest_log"] + sensor["physicalDeviceUuid"] = str(latest_log.physical_device_uuid) + sensor["updatedAt"] = latest_log.created_at.isoformat() + if farm_sensor is not None: + sensor["name"] = farm_sensor.name or sensor["name"] + if farm_sensor.sensor_catalog is not None: + sensor["sensorCatalogCode"] = farm_sensor.sensor_catalog.code + return sensor + + +def _calculate_status_chip(value): + if value is None: + return ("نامشخص", "secondary", "secondary") + if value >= 60: + return ("بهینه", "success", "primary") + if value >= 45: + return ("متوسط", "warning", "warning") + return ("کم", "error", "error") + + +def get_sensor_7_in_1_values_list_data(farm=None, context=None): + data = deepcopy(SENSOR_VALUES_LIST) + context = _get_sensor_context(farm) if context is None else context + data["sensor"] = _build_sensor_meta(context, data["sensor"]) + if not context: + return data + latest_readings = context["latest_readings"] + previous_readings = context["previous_readings"] + sensors = [] + for field in SENSOR_FIELDS: + value = latest_readings.get(field["id"]) + if value is None: + continue + previous = previous_readings.get(field["id"]) + change = 0.0 if previous is None else round(value - previous, 2) + sensors.append({"id": field["id"], "title": _format_value(value, field["unit"]), "subtitle": field["label"], "trendNumber": abs(change), "trend": "positive" if change >= 0 else "negative", "unit": field["unit"]}) + if sensors: + data["sensors"] = sensors + return data + + +def get_sensor_7_in_1_avg_soil_moisture_data(farm=None, context=None): + data = deepcopy(AVG_SOIL_MOISTURE) + context = _get_sensor_context(farm) if context is None else context + if not context: + return data + moisture = context["latest_readings"].get("soil_moisture") + if moisture is None: + return data + chip_text, chip_color, avatar_color = _calculate_status_chip(moisture) + data["stats"] = _format_value(moisture, "%") + data["chipText"] = chip_text + data["chipColor"] = chip_color + data["avatarColor"] = avatar_color + return data + + +def _score_field(value, field): + min_value = field["ideal_min"] + max_value = field["ideal_max"] + midpoint = (min_value + max_value) / 2 + half_span = max((max_value - min_value) / 2, 0.1) + distance = abs(value - midpoint) + if min_value <= value <= max_value: + return round(max(80.0, 100.0 - ((distance / half_span) * 20.0)), 1) + overflow = max(0.0, distance - half_span) + return round(max(0.0, 80.0 - ((overflow / half_span) * 80.0)), 1) + + +def get_sensor_7_in_1_radar_chart_data(farm=None, context=None): + data = deepcopy(SENSOR_RADAR_CHART) + context = _get_sensor_context(farm) if context is None else context + if not context: + return data + latest_readings = context["latest_readings"] + scores, labels = [], [] + for field in SENSOR_FIELDS: + value = latest_readings.get(field["id"]) + if value is None: + continue + labels.append(field["radar_label"]) + scores.append(_score_field(value, field)) + if labels: + data["labels"] = labels + data["series"] = [{"name": "اکنون", "data": scores}, {"name": "هدف", "data": [100.0] * len(labels)}] + return data + + +def get_sensor_7_in_1_comparison_chart_data(farm=None, context=None): + data = deepcopy(SENSOR_COMPARISON_CHART) + context = _get_sensor_context(farm) if context is None else context + if not context: + return data + history = list(reversed(context["history"][:MAX_CHART_POINTS])) + moisture_points = [(log.created_at.strftime("%m/%d %H:%M"), readings.get("soil_moisture")) for log, readings in history if readings.get("soil_moisture") is not None] + if not moisture_points: + return data + categories = [item[0] for item in moisture_points] + values = [round(item[1], 2) for item in moisture_points] + current_value = values[-1] + baseline_value = values[0] if len(values) > 1 else 55.0 + percent_change = ((current_value - baseline_value) / baseline_value) * 100 if baseline_value else 0.0 + data["currentValue"] = round(current_value, 2) + data["vsLastWeekValue"] = round(percent_change, 2) + data["vsLastWeek"] = f"{percent_change:+.1f}%" + data["categories"] = categories + data["series"] = [{"name": "رطوبت خاک", "data": values}, {"name": "بازه هدف", "data": [55.0] * len(values)}] + return data + + +def _build_anomaly_item(field, value): + lower = field["ideal_min"] + upper = field["ideal_max"] + if lower <= value <= upper: + return None + deviation = value - upper if value > upper else value - lower + severity = "warning" + span = max(upper - lower, 0.1) + if abs(deviation) >= span * 0.5: + severity = "error" + sign = "+" if deviation > 0 else "" + return {"sensor": field["label"], "value": _format_value(value, field["unit"]), "expected": _format_range(field), "deviation": f"{sign}{_format_value(deviation, field['unit'])}", "severity": severity} + + +def get_sensor_7_in_1_anomaly_detection_card_data(farm=None, context=None): + data = deepcopy(ANOMALY_DETECTION_CARD) + context = _get_sensor_context(farm) if context is None else context + if not context: + return data + anomalies = [] + for field in SENSOR_FIELDS: + value = context["latest_readings"].get(field["id"]) + if value is None: + continue + anomaly = _build_anomaly_item(field, value) + if anomaly is not None: + anomalies.append(anomaly) + data["anomalies"] = anomalies or [{"sensor": "سنسور 7 در 1 خاک", "value": "نرمال", "expected": "تمام شاخص‌ها در بازه مجاز هستند", "deviation": "0", "severity": "success"}] + return data + + +def get_sensor_7_in_1_soil_moisture_heatmap_data(farm=None, context=None): + data = deepcopy(SOIL_MOISTURE_HEATMAP) + context = _get_sensor_context(farm) if context is None else context + if not context: + return data + history = list(reversed(context["history"][:MAX_CHART_POINTS])) + chart_points = [{"x": log.created_at.strftime("%H:%M"), "y": round(readings.get("soil_moisture"), 2)} for log, readings in history if readings.get("soil_moisture") is not None] + if not chart_points: + return data + sensor_name = data["zones"][0] + farm_sensor = context.get("farm_sensor") + if farm_sensor is not None and farm_sensor.name: + sensor_name = farm_sensor.name + data["zones"] = [sensor_name] + data["hours"] = [point["x"] for point in chart_points] + data["series"] = [{"name": sensor_name, "data": chart_points}] + return data + + +def get_sensor_7_in_1_summary_data(farm=None): + context = _get_sensor_context(farm) + values_list = get_sensor_7_in_1_values_list_data(farm, context=context) + return {"sensor": values_list["sensor"], "sensorValuesList": values_list, "avgSoilMoisture": get_sensor_7_in_1_avg_soil_moisture_data(farm, context=context), "sensorRadarChart": get_sensor_7_in_1_radar_chart_data(farm, context=context), "sensorComparisonChart": get_sensor_7_in_1_comparison_chart_data(farm, context=context), "anomalyDetectionCard": get_sensor_7_in_1_anomaly_detection_card_data(farm, context=context), "soilMoistureHeatmap": get_sensor_7_in_1_soil_moisture_heatmap_data(farm, context=context)} + + +def _normalize_comparison_chart_field(field_name): + return COMPARISON_CHART_FIELD_ALIASES.get(field_name, field_name) + + +def _format_comparison_category(bucket_date, range_value): + return PERSIAN_WEEKDAYS[bucket_date.weekday()] if range_value == "7d" else bucket_date.strftime("%m/%d") + + +def _format_percent_change(current_value, baseline_value): + if not baseline_value: + return "+0.0%" + return f"{((current_value - baseline_value) / baseline_value) * 100:+.1f}%" + + +def _format_current_value_subtitle(title, value, unit): + rendered_value = _format_value(value, unit) + return f"مقدار فعلی: {rendered_value or title}" + + +def get_sensor_comparison_chart_data(*, farm, physical_device_uuid, range_value): + days = COMPARISON_CHART_RANGES[range_value] + start_date = timezone.localdate() - timedelta(days=days - 1) + try: + logs_queryset = get_sensor_external_request_logs_for_farm(farm_uuid=farm.farm_uuid, physical_device_uuid=physical_device_uuid, date_from=start_date) + except ValueError: + return {"series": [], "categories": [], "currentValue": 0.0, "vsLastWeek": "+0.0%"} + grouped_logs = {} + for log in reversed(list(logs_queryset[: days * 24])): + bucket_date = timezone.localtime(log.created_at).date() + numeric_payload = _extract_numeric_payload(log.payload) + if numeric_payload: + grouped_logs[bucket_date] = numeric_payload + if not grouped_logs: + return {"series": [], "categories": [], "currentValue": 0.0, "vsLastWeek": "+0.0%"} + sorted_dates = sorted(grouped_logs.keys()) + categories = [_format_comparison_category(bucket_date, range_value) for bucket_date in sorted_dates] + series_map = {} + for bucket_date in sorted_dates: + normalized_payload = {_normalize_comparison_chart_field(key): value for key, value in grouped_logs[bucket_date].items()} + for key, value in normalized_payload.items(): + series_map.setdefault(key, []).append(round(value, 2)) + ordered_field_names = [field_name for field_name in COMPARISON_CHART_PRIMARY_FIELDS if field_name in series_map] + sorted(field_name for field_name in series_map if field_name not in COMPARISON_CHART_PRIMARY_FIELDS) + series = [{"name": field_name, "data": series_map[field_name]} for field_name in ordered_field_names] + primary_data = series_map[ordered_field_names[0]] + return {"series": series, "categories": categories, "currentValue": round(primary_data[-1], 2), "vsLastWeek": _format_percent_change(primary_data[-1], primary_data[0])} + + +def get_sensor_values_list_data(*, farm, physical_device_uuid, range_value): + start_time = timezone.now() - VALUES_LIST_RANGES[range_value] + try: + logs_queryset = get_sensor_external_request_logs_for_farm(farm_uuid=farm.farm_uuid, physical_device_uuid=physical_device_uuid) + except ValueError: + return {"sensors": []} + logs = list(logs_queryset.filter(created_at__gte=start_time).order_by("created_at", "id")) + if not logs: + latest_log = logs_queryset.order_by("-created_at", "-id").first() + if latest_log is None: + return {"sensors": []} + logs = [latest_log] + earliest_payload, latest_payload = {}, {} + for log in logs: + numeric_payload = {_normalize_comparison_chart_field(key): value for key, value in _extract_numeric_payload(log.payload).items()} + if not numeric_payload: + continue + if not earliest_payload: + earliest_payload = numeric_payload + latest_payload = numeric_payload + if not latest_payload: + return {"sensors": []} + sensors = [] + for field_name, title, unit in VALUES_LIST_FIELDS: + current_value = latest_payload.get(field_name) + if current_value is None: + continue + previous_value = earliest_payload.get(field_name, current_value) + delta = round(current_value - previous_value, 2) + sensors.append({"title": title, "subtitle": _format_current_value_subtitle(title, current_value, unit), "trendNumber": abs(delta), "trend": "positive" if delta >= 0 else "negative", "unit": unit}) + return {"sensors": sensors} + + +def get_sensor_radar_chart_data(*, farm, physical_device_uuid, range_value): + start_time = timezone.now() - RADAR_CHART_RANGES[range_value] + try: + logs_queryset = get_sensor_external_request_logs_for_farm(farm_uuid=farm.farm_uuid, physical_device_uuid=physical_device_uuid) + except ValueError: + return {"labels": [], "series": []} + logs = list(logs_queryset.filter(created_at__gte=start_time).order_by("created_at", "id")) + if not logs: + latest_log = logs_queryset.order_by("-created_at", "-id").first() + if latest_log is None: + return {"labels": [], "series": []} + logs = [latest_log] + latest_payload = {} + for log in logs: + numeric_payload = {_normalize_comparison_chart_field(key): value for key, value in _extract_numeric_payload(log.payload).items()} + if numeric_payload: + latest_payload = numeric_payload + if not latest_payload: + return {"labels": [], "series": []} + labels, current_data, ideal_data = [], [], [] + for field_name, label, ideal_value in RADAR_CHART_FIELDS: + current_value = latest_payload.get(field_name) + if current_value is None: + continue + labels.append(label) + current_data.append(round(current_value, 2)) + ideal_data.append(round(ideal_value, 2)) + return {"labels": labels, "series": [{"name": "وضعیت فعلی", "data": current_data}, {"name": "بازه ایده آل", "data": ideal_data}]} diff --git a/device_hub/urls.py b/device_hub/urls.py new file mode 100644 index 0000000..da695ca --- /dev/null +++ b/device_hub/urls.py @@ -0,0 +1,11 @@ +from django.urls import path + +from .views import Sensor7In1SummaryView, SensorCatalogListView, SensorExternalAPIView, SensorExternalRequestLogListAPIView + +urlpatterns = [ + path("summary/", Sensor7In1SummaryView.as_view(), name="sensor-7-in-1-summary"), + path("", SensorCatalogListView.as_view(), name="sensor-catalog-list"), + path("external/", SensorExternalAPIView.as_view(), name="sensor-external-api"), + path("external/logs/", SensorExternalRequestLogListAPIView.as_view(), name="sensor-external-api-log-list"), +] + diff --git a/device_hub/views.py b/device_hub/views.py new file mode 100644 index 0000000..7598c8d --- /dev/null +++ b/device_hub/views.py @@ -0,0 +1,142 @@ +from rest_framework import serializers, status +from rest_framework.pagination import PageNumberPagination +from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView +from drf_spectacular.utils import OpenApiExample, OpenApiParameter, OpenApiTypes, extend_schema + +from config.swagger import code_response, farm_uuid_query_param +from farm_hub.models import FarmHub +from notifications.serializers import FarmNotificationSerializer +from soil.serializers import SoilComparisonChartSerializer, SoilRadarChartSerializer + +from .authentication import SensorExternalAPIKeyAuthentication +from .sensor_serializers import Sensor7In1SummarySerializer, SensorComparisonChartQuerySerializer, SensorComparisonChartResponseSerializer, SensorRadarChartQuerySerializer, SensorRadarChartResponseSerializer, SensorValuesListQuerySerializer, SensorValuesListResponseSerializer +from .serializers import SensorCatalogSerializer, SensorExternalRequestLogQuerySerializer, SensorExternalRequestLogSerializer, SensorExternalRequestSerializer +from .services import FarmDataForwardError, create_sensor_external_notification, forward_sensor_payload_to_farm_data, get_farm_sensor_map_for_logs, get_primary_soil_sensor, get_sensor_7_in_1_comparison_chart_data, get_sensor_7_in_1_radar_chart_data, get_sensor_7_in_1_summary_data, get_sensor_comparison_chart_data, get_sensor_external_request_logs_for_farm, get_sensor_radar_chart_data, get_sensor_values_list_data + + +class SensorCatalogListView(APIView): + permission_classes = [IsAuthenticated] + + @extend_schema(tags=["Sensor Catalog"], responses={200: code_response("SensorCatalogListResponse", data=SensorCatalogSerializer(many=True))}) + def get(self, request): + from .models import SensorCatalog + return Response({"code": 200, "msg": "success", "data": SensorCatalogSerializer(SensorCatalog.objects.order_by("code"), many=True).data}, status=status.HTTP_200_OK) + + +class Sensor7In1SummaryView(APIView): + permission_classes = [IsAuthenticated] + required_feature_code = "sensor-7-in-1" + + @staticmethod + def _get_farm(request): + farm_uuid = request.query_params.get("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 + + @staticmethod + def _get_primary_sensor(*, farm): + sensor = get_primary_soil_sensor(farm=farm) + if sensor is None: + raise serializers.ValidationError({"farm_uuid": ["No sensor found for this farm."]}) + return sensor + + @extend_schema(tags=["Sensor 7 in 1"], parameters=[farm_uuid_query_param(required=True, description="UUID of the farm for sensor 7 in 1 summary.")], responses={200: code_response("Sensor7In1SummaryResponse", data=Sensor7In1SummarySerializer())}) + def get(self, request): + farm = self._get_farm(request) + return Response({"code": 200, "msg": "OK", "data": get_sensor_7_in_1_summary_data(farm)}, status=status.HTTP_200_OK) + + +class Sensor7In1RadarChartView(Sensor7In1SummaryView): + @extend_schema(tags=["Sensor 7 in 1"], parameters=[farm_uuid_query_param(required=True, description="UUID of the farm for sensor 7 in 1 radar chart.")], responses={200: code_response("Sensor7In1RadarChartResponse", data=SoilRadarChartSerializer())}) + def get(self, request): + farm = self._get_farm(request) + return Response({"code": 200, "msg": "OK", "data": get_sensor_7_in_1_radar_chart_data(farm)}, status=status.HTTP_200_OK) + + +class Sensor7In1ComparisonChartView(Sensor7In1SummaryView): + @extend_schema(tags=["Sensor 7 in 1"], parameters=[farm_uuid_query_param(required=True, description="UUID of the farm for sensor 7 in 1 comparison chart.")], responses={200: code_response("Sensor7In1ComparisonChartResponse", data=SoilComparisonChartSerializer())}) + def get(self, request): + farm = self._get_farm(request) + return Response({"code": 200, "msg": "OK", "data": get_sensor_7_in_1_comparison_chart_data(farm)}, status=status.HTTP_200_OK) + + +class SensorComparisonChartView(Sensor7In1SummaryView): + @extend_schema(tags=["Sensor 7 in 1"], parameters=[farm_uuid_query_param(required=True, description="UUID of the farm."), OpenApiParameter(name="range", type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, required=False, description="Chart range, supported values: 7d, 30d. Defaults to 7d.")], responses={200: SensorComparisonChartResponseSerializer}) + def get(self, request): + serializer = SensorComparisonChartQuerySerializer(data=request.query_params) + serializer.is_valid(raise_exception=True) + farm = self._get_farm(request) + sensor = self._get_primary_sensor(farm=farm) + return Response(get_sensor_comparison_chart_data(farm=farm, physical_device_uuid=sensor.physical_device_uuid, range_value=serializer.validated_data["range"]), status=status.HTTP_200_OK) + + +class SensorValuesListView(Sensor7In1SummaryView): + @extend_schema(tags=["Sensor 7 in 1"], parameters=[farm_uuid_query_param(required=True, description="UUID of the farm."), OpenApiParameter(name="range", type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, required=False, description="Values list range, supported values: 1h, 24h, 7d. Defaults to 7d.")], responses={200: SensorValuesListResponseSerializer}) + def get(self, request): + serializer = SensorValuesListQuerySerializer(data=request.query_params) + serializer.is_valid(raise_exception=True) + farm = self._get_farm(request) + sensor = self._get_primary_sensor(farm=farm) + return Response(get_sensor_values_list_data(farm=farm, physical_device_uuid=sensor.physical_device_uuid, range_value=serializer.validated_data["range"]), status=status.HTTP_200_OK) + + +class SensorRadarChartView(Sensor7In1SummaryView): + @extend_schema(tags=["Sensor 7 in 1"], parameters=[farm_uuid_query_param(required=True, description="UUID of the farm."), OpenApiParameter(name="range", type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, required=False, description="Radar chart range, supported values: today, 7d, 30d. Defaults to 7d.")], responses={200: SensorRadarChartResponseSerializer}) + def get(self, request): + serializer = SensorRadarChartQuerySerializer(data=request.query_params) + serializer.is_valid(raise_exception=True) + farm = self._get_farm(request) + sensor = self._get_primary_sensor(farm=farm) + return Response(get_sensor_radar_chart_data(farm=farm, physical_device_uuid=sensor.physical_device_uuid, range_value=serializer.validated_data["range"]), status=status.HTTP_200_OK) + + +class SensorExternalRequestLogPagination(PageNumberPagination): + page_size = 20 + page_size_query_param = "page_size" + max_page_size = 100 + + +class SensorExternalAPIView(APIView): + authentication_classes = [SensorExternalAPIKeyAuthentication] + permission_classes = [AllowAny] + + @extend_schema(tags=["Sensor External API"], request=SensorExternalRequestSerializer, examples=[OpenApiExample("Sensor External API Request", value={"uuid": "22222222-2222-2222-2222-222222222222", "payload": {"moisture_percent": 32.5, "temperature_c": 21.3, "ph": 6.7, "ec_ds_m": 1.1, "nitrogen_mg_kg": 42, "phosphorus_mg_kg": 18, "potassium_mg_kg": 210}}, request_only=True)], parameters=[OpenApiParameter(name="X-API-Key", type=OpenApiTypes.STR, location=OpenApiParameter.HEADER, required=True, default="12345", description="API key for sensor external API.")], responses={201: code_response("SensorExternalAPIResponse", data=FarmNotificationSerializer()), 401: code_response("SensorExternalAPIUnauthorizedResponse"), 404: code_response("SensorExternalAPIDeviceNotFoundResponse"), 503: code_response("SensorExternalAPIUnavailableResponse")}) + def post(self, request): + serializer = SensorExternalRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + try: + notification = create_sensor_external_notification(physical_device_uuid=serializer.validated_data["uuid"], payload=serializer.validated_data.get("payload")) + forward_sensor_payload_to_farm_data(physical_device_uuid=serializer.validated_data["uuid"], payload=serializer.validated_data.get("payload")) + except ValueError as exc: + if "not migrated" in str(exc): + return Response({"code": 503, "msg": "Required tables are not ready. Run migrations."}, status=status.HTTP_503_SERVICE_UNAVAILABLE) + return Response({"code": 404, "msg": "Physical device not found."}, status=status.HTTP_404_NOT_FOUND) + except FarmDataForwardError as exc: + return Response({"code": 503, "msg": str(exc)}, status=status.HTTP_503_SERVICE_UNAVAILABLE) + return Response({"code": 201, "msg": "success", "data": FarmNotificationSerializer(notification).data}, status=status.HTTP_201_CREATED) + + +class SensorExternalRequestLogListAPIView(APIView): + permission_classes = [IsAuthenticated] + pagination_class = SensorExternalRequestLogPagination + + @extend_schema(tags=["Sensor External API"], parameters=[OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=True, default="11111111-1111-1111-1111-111111111111"), OpenApiParameter(name="page", type=OpenApiTypes.INT, location=OpenApiParameter.QUERY, required=True), OpenApiParameter(name="page_size", type=OpenApiTypes.INT, location=OpenApiParameter.QUERY, required=True), OpenApiParameter(name="physical_device_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=False), OpenApiParameter(name="sensor_type", type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, required=False), OpenApiParameter(name="date_from", type=OpenApiTypes.DATE, location=OpenApiParameter.QUERY, required=False), OpenApiParameter(name="date_to", type=OpenApiTypes.DATE, location=OpenApiParameter.QUERY, required=False)], responses={200: code_response("SensorExternalRequestLogListResponse", data=SensorExternalRequestLogSerializer(many=True), extra_fields={"count": serializers.IntegerField(), "next": serializers.CharField(allow_null=True), "previous": serializers.CharField(allow_null=True)}), 401: code_response("SensorExternalRequestLogListUnauthorizedResponse"), 503: code_response("SensorExternalRequestLogListUnavailableResponse")}) + def get(self, request): + serializer = SensorExternalRequestLogQuerySerializer(data=request.query_params) + serializer.is_valid(raise_exception=True) + try: + queryset = get_sensor_external_request_logs_for_farm(farm_uuid=serializer.validated_data["farm_uuid"], physical_device_uuid=serializer.validated_data.get("physical_device_uuid"), sensor_type=serializer.validated_data.get("sensor_type"), date_from=serializer.validated_data.get("date_from"), date_to=serializer.validated_data.get("date_to")) + except ValueError: + return Response({"code": 503, "msg": "Required tables are not ready. Run migrations."}, status=status.HTTP_503_SERVICE_UNAVAILABLE) + paginator = self.pagination_class() + paginator.page_size = serializer.validated_data["page_size"] + page = paginator.paginate_queryset(queryset, request, view=self) + farm_sensor_map = get_farm_sensor_map_for_logs(logs=page) + data = SensorExternalRequestLogSerializer(page, many=True, context={"farm_sensor_map": farm_sensor_map}).data + return Response({"code": 200, "msg": "success", "count": paginator.page.paginator.count, "next": paginator.get_next_link(), "previous": paginator.get_previous_link(), "data": data}, status=status.HTTP_200_OK) diff --git a/docker-compose-prod.yaml b/docker-compose-prod.yaml index 3214fb1..d7484a2 100644 --- a/docker-compose-prod.yaml +++ b/docker-compose-prod.yaml @@ -1,6 +1,6 @@ services: db: - image: docker.iranserver.com/mysql:8 + image: mirror-docker.runflare.com/library/mysql:8 container_name: croplogic-db environment: MYSQL_DATABASE: ${DB_NAME} @@ -21,7 +21,7 @@ services: - crop_network redis: - image: docker.iranserver.com/redis + image: mirror-docker.runflare.com/library/redis:7-alpine container_name: backend-redis command: ["redis-server", "--appendonly", "yes", "--save", "60", "1"] restart: unless-stopped @@ -36,7 +36,7 @@ services: - crop_network accsess: - image: openpolicyagent/opa:0.67.1-static + image: mirror-docker.runflare.com/openpolicyagent/opa:0.67.1-static container_name: backend-accsess command: ["run", "--server", "--addr=0.0.0.0:8181", "/policies/authz.rego"] volumes: @@ -48,7 +48,15 @@ services: web: container_name: backend-web - build: . + build: + context: . + dockerfile: Dockerfile.Dev + args: + BASE_IMAGE: mirror-docker.runflare.com/library/python:3.10-slim-bookworm + APT_MIRROR: mirror-linux.runflare.com/debian + APT_SECURITY_MIRROR: mirror-linux.runflare.com/debian-security + PIP_INDEX_URL: https://mirror-pypi.runflare.com/simple + PIP_TRUSTED_HOST: mirror-pypi.runflare.com env_file: - .env environment: @@ -76,7 +84,15 @@ services: celery: container_name: backend-celery - build: . + build: + context: . + dockerfile: Dockerfile.Dev + args: + BASE_IMAGE: mirror-docker.runflare.com/library/python:3.10-slim-bookworm + APT_MIRROR: mirror-linux.runflare.com/debian + APT_SECURITY_MIRROR: mirror-linux.runflare.com/debian-security + PIP_INDEX_URL: https://mirror-pypi.runflare.com/simple + PIP_TRUSTED_HOST: mirror-pypi.runflare.com command: ["celery", "-A", "config", "worker", "-l", "info"] env_file: - .env @@ -104,7 +120,15 @@ services: celery-beat: container_name: backend-celery-beat - build: . + build: + context: . + dockerfile: Dockerfile.Dev + args: + BASE_IMAGE: mirror-docker.runflare.com/library/python:3.10-slim-bookworm + APT_MIRROR: mirror-linux.runflare.com/debian + APT_SECURITY_MIRROR: mirror-linux.runflare.com/debian-security + PIP_INDEX_URL: https://mirror-pypi.runflare.com/simple + PIP_TRUSTED_HOST: mirror-pypi.runflare.com command: ["celery", "-A", "config", "beat", "-l", "info"] env_file: - .env diff --git a/farm_hub/migrations/0003_farmsensor_catalog_and_physical_device.py b/farm_hub/migrations/0003_farmsensor_catalog_and_physical_device.py index 603463d..08ad4c3 100644 --- a/farm_hub/migrations/0003_farmsensor_catalog_and_physical_device.py +++ b/farm_hub/migrations/0003_farmsensor_catalog_and_physical_device.py @@ -7,7 +7,7 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ("sensor_catalog", "0002_sensorcatalog_supported_power_sources"), + ("device_hub", "0001_initial"), ("farm_hub", "0002_seed_default_catalog"), ] @@ -25,7 +25,7 @@ class Migration(migrations.Migration): null=True, on_delete=django.db.models.deletion.PROTECT, related_name="farm_sensors", - to="sensor_catalog.sensorcatalog", + to="device_hub.sensorcatalog", ), ), ] diff --git a/farm_hub/migrations/0010_move_farmsensor_to_device_hub.py b/farm_hub/migrations/0010_move_farmsensor_to_device_hub.py new file mode 100644 index 0000000..7e206d7 --- /dev/null +++ b/farm_hub/migrations/0010_move_farmsensor_to_device_hub.py @@ -0,0 +1,17 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("device_hub", "0001_initial"), + ("farm_hub", "0009_farmhub_irrigation_method_fields"), + ] + + operations = [ + migrations.SeparateDatabaseAndState( + database_operations=[], + state_operations=[ + migrations.DeleteModel(name="FarmSensor"), + ], + ), + ] diff --git a/farm_hub/models.py b/farm_hub/models.py index ce65231..c3571b7 100644 --- a/farm_hub/models.py +++ b/farm_hub/models.py @@ -2,7 +2,6 @@ import uuid as uuid_lib from django.conf import settings from django.db import models -from sensor_catalog.models import SensorCatalog class FarmType(models.Model): @@ -123,33 +122,3 @@ class FarmHub(models.Model): def __str__(self): return f"{self.name} ({self.farm_uuid})" - -class FarmSensor(models.Model): - uuid = models.UUIDField(default=uuid_lib.uuid4, unique=True, editable=False, db_index=True) - farm = models.ForeignKey( - FarmHub, - on_delete=models.CASCADE, - related_name="sensors", - ) - sensor_catalog = models.ForeignKey( - SensorCatalog, - on_delete=models.PROTECT, - related_name="farm_sensors", - null=True, - blank=True, - ) - physical_device_uuid = models.UUIDField(default=uuid_lib.uuid4, unique=True, db_index=True) - name = models.CharField(max_length=255) - sensor_type = models.CharField(max_length=255, blank=True, default="") - is_active = models.BooleanField(default=True) - specifications = models.JSONField(default=dict, blank=True) - power_source = models.JSONField(default=dict, blank=True) - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - - class Meta: - db_table = "farm_sensors" - ordering = ["-created_at"] - - def __str__(self): - return f"{self.name} ({self.uuid})" diff --git a/farm_hub/seeds.py b/farm_hub/seeds.py index e606e82..031ae3c 100644 --- a/farm_hub/seeds.py +++ b/farm_hub/seeds.py @@ -3,8 +3,8 @@ import uuid from django.db import transaction from account.seeds import seed_admin_user -from sensor_catalog.management import seed_sensor_catalog -from sensor_catalog.models import SensorCatalog +from device_hub.catalog_seed import seed_sensor_catalog +from device_hub.models import SensorCatalog from .catalog import CATALOG_SEED_DATA from .models import FarmHub, FarmType, Product diff --git a/farm_hub/serializers.py b/farm_hub/serializers.py index ffdff02..02e4e07 100644 --- a/farm_hub/serializers.py +++ b/farm_hub/serializers.py @@ -3,10 +3,10 @@ from access_control.models import SubscriptionPlan from access_control.serializers import SubscriptionPlanSerializer from access_control.catalog import GOLD_PLAN_CODE from access_control.services import get_effective_subscription_plan +from device_hub.models import FarmSensor, SensorCatalog -from .models import FarmHub, FarmSensor, FarmType, Product +from .models import FarmHub, FarmType, Product from .services import normalize_farm_boundary_input -from sensor_catalog.models import SensorCatalog class FarmTypeSerializer(serializers.ModelSerializer): diff --git a/farm_hub/services.py b/farm_hub/services.py index a281ce1..ea512c1 100644 --- a/farm_hub/services.py +++ b/farm_hub/services.py @@ -11,6 +11,7 @@ from crop_zoning.services import ( ) from external_api_adapter import request as external_api_request from external_api_adapter.exceptions import ExternalAPIRequestError +from plants.services import push_plants_to_ai logger = logging.getLogger(__name__) @@ -65,6 +66,7 @@ def sync_farm_data( plant_ids=None, irrigation_method_id=None, ): + push_plants_to_ai() request_payload = { "farm_uuid": str(farm.farm_uuid), "farm_boundary": _extract_boundary_geometry(area_feature, farm=farm), diff --git a/farm_hub/tests.py b/farm_hub/tests.py index 2547762..2732048 100644 --- a/farm_hub/tests.py +++ b/farm_hub/tests.py @@ -7,12 +7,12 @@ from access_control.models import AccessFeature, AccessRule, FarmAccessProfile, from access_control.services import build_farm_access_profile from access_control.views import FarmAccessProfileView from crop_zoning.models import CropArea +from device_hub.models import SensorCatalog from external_api_adapter.adapter import AdapterResponse from farm_hub.models import FarmHub, FarmType, Product from farm_hub.serializers import FarmHubSerializer from farm_hub.seeds import seed_admin_farm from farm_hub.views import FarmDetailView, FarmListCreateView, FarmTypeListView, FarmTypeProductsView -from sensor_catalog.models import SensorCatalog AREA_GEOJSON = { diff --git a/farmer_calendar/__init__.py b/farmer_calendar/__init__.py index e69de29..9c2d6ef 100644 --- a/farmer_calendar/__init__.py +++ b/farmer_calendar/__init__.py @@ -0,0 +1,309 @@ +from __future__ import annotations + +from datetime import date, datetime, time, timedelta + +from django.apps import apps as django_apps +from django.utils import timezone +from django.utils.dateparse import parse_date + +AUTO_PLAN_SOURCE = "auto_plan_sync" +PLAN_TYPE_IRRIGATION = "irrigation" +PLAN_TYPE_FERTILIZATION = "fertilization" + + +def create_event_for_farm( + *, + farm, + title, + description="", + start=None, + end=None, + scheduled_date=None, + event_time=None, + priority=None, + tags=None, + zone_value="برنامه خودکار", + extended_props=None, +): + FarmerCalendarEvent = django_apps.get_model("farmer_calendar", "FarmerCalendarEvent") + FarmerCalendarZone = django_apps.get_model("farmer_calendar", "FarmerCalendarZone") + from .enums import FarmerPriority + + if priority is None: + priority = FarmerPriority.MEDIUM + zone, _ = FarmerCalendarZone.objects.get_or_create( + farm=farm, + value=zone_value, + defaults={"label": zone_value}, + ) + if zone.label != zone_value: + zone.label = zone_value + zone.save(update_fields=["label", "updated_at"]) + + payload = dict(extended_props or {}) + payload["tags"] = list(tags or []) + + return FarmerCalendarEvent.objects.create( + farm=farm, + zone=zone, + title=title, + description=description, + deadline=int(end.timestamp()) if end else None, + scheduled_date=scheduled_date, + time=event_time, + start=start, + end=end, + priority=priority, + status=FarmerCalendarEvent.STATUS_OPEN, + extended_props=payload, + ) + + +def delete_plan_events(*, farm, plan_type, plan_uuid): + FarmerCalendarEvent = django_apps.get_model("farmer_calendar", "FarmerCalendarEvent") + for event in FarmerCalendarEvent.objects.filter(farm=farm): + props = event.extended_props or {} + if ( + props.get("source") == AUTO_PLAN_SOURCE + and props.get("plan_type") == plan_type + and str(props.get("plan_uuid")) == str(plan_uuid) + ): + event.delete() + + +def sync_plan_events(plan, plan_type): + from .enums import FarmerPriority + + delete_plan_events(farm=plan.farm, plan_type=plan_type, plan_uuid=plan.uuid) + + if getattr(plan, "is_deleted", False) or not getattr(plan, "is_active", False): + return [] + + if plan_type == PLAN_TYPE_IRRIGATION: + items = _build_irrigation_events(plan) + elif plan_type == PLAN_TYPE_FERTILIZATION: + items = _build_fertilization_events(plan) + else: + items = [] + + created = [] + for index, item in enumerate(items, start=1): + created.append( + create_event_for_farm( + farm=plan.farm, + title=item["title"], + description=item.get("description", ""), + start=item.get("start"), + end=item.get("end"), + scheduled_date=item.get("scheduled_date"), + event_time=item.get("time"), + priority=item.get("priority", FarmerPriority.MEDIUM), + tags=item.get("tags", []), + zone_value=item.get("zone_value", "برنامه خودکار"), + extended_props={ + "source": AUTO_PLAN_SOURCE, + "plan_type": plan_type, + "plan_uuid": str(plan.uuid), + "plan_title": plan.title, + "entry_index": index, + **item.get("extended_props", {}), + }, + ) + ) + return created + + +def _build_irrigation_events(plan): + from .enums import FarmerPriority, FarmerTag + + payload = plan.plan_payload if isinstance(plan.plan_payload, dict) else {} + plan_data = payload.get("plan") if isinstance(payload.get("plan"), dict) else {} + water_balance = payload.get("water_balance") if isinstance(payload.get("water_balance"), dict) else {} + daily_entries = water_balance.get("daily") if isinstance(water_balance.get("daily"), list) else [] + + created = [] + for entry in daily_entries: + if not isinstance(entry, dict): + continue + scheduled = _parse_date(entry.get("forecast_date")) + if not scheduled: + continue + start_time, end_time = _parse_time_range(entry.get("irrigation_timing") or plan_data.get("bestTimeOfDay")) + start, end = _build_datetimes(scheduled, start_time, end_time, default_duration_minutes=plan_data.get("durationMinutes")) + gross_amount = entry.get("gross_irrigation_mm") + title = f"آبیاری - {plan.crop_id or plan.title or 'مزرعه'}" + description_parts = [] + if gross_amount not in (None, ""): + description_parts.append(f"مقدار آبیاری: {gross_amount} mm") + if plan_data.get("durationMinutes"): + description_parts.append(f"مدت زمان: {plan_data.get('durationMinutes')} دقیقه") + if entry.get("irrigation_timing"): + description_parts.append(f"بازه اجرا: {entry.get('irrigation_timing')}") + created.append( + { + "title": title, + "description": " | ".join(description_parts), + "scheduled_date": scheduled, + "time": start_time, + "start": start, + "end": end, + "priority": FarmerPriority.HIGH, + "tags": [FarmerTag.IRRIGATION.value], + "zone_value": "آبیاری", + "extended_props": { + "kind": "irrigation", + "gross_irrigation_mm": gross_amount, + "irrigation_timing": entry.get("irrigation_timing"), + }, + } + ) + + if created: + return created + + scheduled = timezone.localdate() + start_time, end_time = _parse_time_range(plan_data.get("bestTimeOfDay")) + start, end = _build_datetimes(scheduled, start_time, end_time, default_duration_minutes=plan_data.get("durationMinutes")) + return [ + { + "title": f"آبیاری - {plan.crop_id or plan.title or 'مزرعه'}", + "description": f"برنامه فعال آبیاری: {plan.title}".strip(), + "scheduled_date": scheduled, + "time": start_time, + "start": start, + "end": end, + "priority": FarmerPriority.HIGH, + "tags": [FarmerTag.IRRIGATION.value], + "zone_value": "آبیاری", + "extended_props": {"kind": "irrigation_fallback"}, + } + ] + + +def _build_fertilization_events(plan): + from .enums import FarmerPriority, FarmerTag + + payload = plan.plan_payload if isinstance(plan.plan_payload, dict) else {} + primary = payload.get("primary_recommendation") if isinstance(payload.get("primary_recommendation"), dict) else {} + guide = payload.get("application_guide") if isinstance(payload.get("application_guide"), dict) else {} + steps = guide.get("steps") if isinstance(guide.get("steps"), list) else [] + interval = primary.get("application_interval") if isinstance(primary.get("application_interval"), dict) else {} + interval_days = _safe_int(interval.get("value")) + + base_date = timezone.localdate() + fertilizer_name = primary.get("display_title") or primary.get("fertilizer_name") or plan.title or "برنامه کودی" + created = [] + + for index, step in enumerate(steps): + if not isinstance(step, dict): + continue + scheduled = _extract_step_date(step) or (base_date + timedelta(days=(index * interval_days if interval_days else index))) + start = timezone.make_aware(datetime.combine(scheduled, time(hour=8, minute=0))) + end = start + timedelta(minutes=30) + description = str(step.get("description") or guide.get("safety_warning") or "").strip() + created.append( + { + "title": f"کوددهی - {fertilizer_name}", + "description": description, + "scheduled_date": scheduled, + "time": start.time(), + "start": start, + "end": end, + "priority": FarmerPriority.MEDIUM, + "tags": [FarmerTag.FERTILIZATION.value], + "zone_value": "کوددهی", + "extended_props": { + "kind": "fertilization", + "step_number": step.get("step_number"), + "fertilizer_code": primary.get("fertilizer_code"), + }, + } + ) + + if created: + return created + + scheduled = base_date + start = timezone.make_aware(datetime.combine(scheduled, time(hour=8, minute=0))) + end = start + timedelta(minutes=30) + interval_label = interval.get("label") or "" + description = " | ".join(part for part in [str(primary.get("summary") or "").strip(), str(interval_label).strip()] if part) + return [ + { + "title": f"کوددهی - {fertilizer_name}", + "description": description, + "scheduled_date": scheduled, + "time": start.time(), + "start": start, + "end": end, + "priority": FarmerPriority.MEDIUM, + "tags": [FarmerTag.FERTILIZATION.value], + "zone_value": "کوددهی", + "extended_props": { + "kind": "fertilization_fallback", + "fertilizer_code": primary.get("fertilizer_code"), + }, + } + ] + + +def _parse_date(value): + if isinstance(value, date): + return value + if not value: + return None + return parse_date(str(value)) + + +def _parse_time_range(value): + if not value: + return None, None + raw = str(value).replace("تا", "-").replace("–", "-") + parts = [part.strip() for part in raw.split("-") if part.strip()] + if not parts: + return None, None + start_time = _parse_time(parts[0]) + end_time = _parse_time(parts[1]) if len(parts) > 1 else None + return start_time, end_time + + +def _parse_time(value): + if isinstance(value, time): + return value + if not value: + return None + cleaned = str(value).strip() + for fmt in ("%H:%M", "%H:%M:%S"): + try: + return datetime.strptime(cleaned, fmt).time() + except ValueError: + continue + return None + + +def _build_datetimes(scheduled, start_time, end_time, default_duration_minutes=None): + if scheduled is None: + return None, None + if start_time is None: + start_time = time(hour=6, minute=0) + start = timezone.make_aware(datetime.combine(scheduled, start_time)) + if end_time is not None: + end = timezone.make_aware(datetime.combine(scheduled, end_time)) + else: + end = start + timedelta(minutes=_safe_int(default_duration_minutes) or 30) + return start, end + + +def _extract_step_date(step): + for key in ("date", "scheduled_date", "application_date", "target_date", "forecast_date"): + parsed = _parse_date(step.get(key)) + if parsed: + return parsed + return None + + +def _safe_int(value): + try: + return int(value) + except (TypeError, ValueError): + return None diff --git a/fertilization/FERTILIZATION_PLAN_APIS.md b/fertilization/FERTILIZATION_PLAN_APIS.md index 129da9e..3894542 100644 --- a/fertilization/FERTILIZATION_PLAN_APIS.md +++ b/fertilization/FERTILIZATION_PLAN_APIS.md @@ -42,7 +42,7 @@ GET /api/fertilization/plans/?farm_uuid=11111111-1111-1111-1111-111111111111&pag "crop_id": "گندم", "plant_name": "گندم", "growth_stage": "flowering", - "is_active": true, + "is_active": false, "created_at": "2025-02-24T10:20:30Z" } ], @@ -63,6 +63,7 @@ GET /api/fertilization/plans/?farm_uuid=11111111-1111-1111-1111-111111111111&pag - فقط planهایی برگردانده می‌شوند که `is_deleted=False` باشند. - ترتیب لیست از جدید به قدیم است. +- در هر مزرعه، در هر نوع plan فقط یک plan می‌تواند `is_active=true` باشد. --- @@ -95,7 +96,7 @@ GET /api/fertilization/plans/6d6a1f0d-1a9b-4f2f-8fe1-2d73d9d2d9f1/ "crop_id": "گندم", "plant_name": "گندم", "growth_stage": "flowering", - "is_active": true, + "is_active": false, "created_at": "2025-02-24T10:20:30Z", "updated_at": "2025-02-24T10:20:30Z", "plan_payload": { @@ -198,7 +199,7 @@ Content-Type: application/json "msg": "success", "data": { "plan_uuid": "6d6a1f0d-1a9b-4f2f-8fe1-2d73d9d2d9f1", - "is_active": false + "is_active": true } } ``` @@ -226,6 +227,8 @@ Content-Type: application/json ## Summary +- planهای جدید به‌صورت پیش‌فرض `inactive` ساخته می‌شوند. +- در هر مزرعه فقط یک plan از این نوع می‌تواند `active` باشد. - `GET /api/fertilization/plans/` لیست برنامه‌ها - `GET /api/fertilization/plans/{plan_uuid}/` جزئیات برنامه - `DELETE /api/fertilization/plans/{plan_uuid}/` حذف نرم برنامه diff --git a/fertilization/migrations/0004_fertilizationplan_default_inactive.py b/fertilization/migrations/0004_fertilizationplan_default_inactive.py new file mode 100644 index 0000000..e676889 --- /dev/null +++ b/fertilization/migrations/0004_fertilizationplan_default_inactive.py @@ -0,0 +1,16 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("fertilization_recommendation", "0003_fertilizationplan"), + ] + + operations = [ + migrations.AlterField( + model_name="fertilizationplan", + name="is_active", + field=models.BooleanField(db_index=True, default=False), + ), + ] diff --git a/fertilization/models.py b/fertilization/models.py index 4309e5a..bf3b326 100644 --- a/fertilization/models.py +++ b/fertilization/models.py @@ -72,7 +72,7 @@ class FertilizationPlan(models.Model): plan_payload = models.JSONField(default=dict, blank=True) request_payload = models.JSONField(default=dict, blank=True) response_payload = models.JSONField(default=dict, blank=True) - is_active = models.BooleanField(default=True, db_index=True) + is_active = models.BooleanField(default=False, db_index=True) is_deleted = models.BooleanField(default=False, db_index=True) deleted_at = models.DateTimeField(null=True, blank=True) created_at = models.DateTimeField(auto_now_add=True) diff --git a/fertilization/tests.py b/fertilization/tests.py index 310f61f..757d0cd 100644 --- a/fertilization/tests.py +++ b/fertilization/tests.py @@ -5,6 +5,7 @@ from unittest.mock import patch from external_api_adapter.adapter import AdapterResponse from farm_hub.models import FarmHub, FarmType +from farmer_calendar.models import FarmerCalendarEvent from .models import FertilizationPlan, FertilizationRecommendationRequest from .views import ( FertilizationPlanDetailView, @@ -159,7 +160,7 @@ class FertilizationRecommendViewTests(TestCase): self.assertEqual(saved_request.growth_stage, "vegetative") self.assertEqual(saved_plan.source, FertilizationPlan.SOURCE_RECOMMENDATION) self.assertEqual(saved_plan.recommendation_id, saved_request.id) - self.assertTrue(saved_plan.is_active) + self.assertFalse(saved_plan.is_active) self.assertFalse(saved_plan.is_deleted) self.assertEqual(saved_plan.plan_payload["primary_recommendation"]["fertilizer_code"], "npk-202020") self.assertEqual( @@ -280,7 +281,7 @@ class FertilizationRecommendViewTests(TestCase): self.assertEqual(plan.title, "برنامه کوددهی گندم") self.assertEqual(plan.crop_id, "گندم") self.assertEqual(plan.growth_stage, "flowering") - self.assertTrue(plan.is_active) + self.assertFalse(plan.is_active) self.assertFalse(plan.is_deleted) def test_recommendation_list_returns_paginated_summary_items(self): @@ -473,7 +474,7 @@ class FertilizationPlanApiTests(TestCase): def test_plan_status_patch_updates_is_active(self): request = self.factory.patch( f"/api/fertilization/plans/{self.plan.uuid}/status/", - {"is_active": False}, + {"is_active": True}, format="json", ) force_authenticate(request, user=self.user) @@ -482,4 +483,71 @@ class FertilizationPlanApiTests(TestCase): self.assertEqual(response.status_code, 200) self.plan.refresh_from_db() - self.assertFalse(self.plan.is_active) + self.assertTrue(self.plan.is_active) + + def test_activating_one_plan_deactivates_other_active_plan(self): + other_plan = FertilizationPlan.objects.create( + farm=self.farm, + source=FertilizationPlan.SOURCE_FREE_TEXT, + title="برنامه دوم", + is_active=True, + ) + + request = self.factory.patch( + f"/api/fertilization/plans/{self.plan.uuid}/status/", + {"is_active": True}, + format="json", + ) + force_authenticate(request, user=self.user) + + response = FertilizationPlanStatusView.as_view()(request, plan_uuid=self.plan.uuid) + + self.assertEqual(response.status_code, 200) + self.plan.refresh_from_db() + other_plan.refresh_from_db() + self.assertTrue(self.plan.is_active) + self.assertFalse(other_plan.is_active) + + def test_plan_status_patch_syncs_calendar_events(self): + self.plan.plan_payload = { + "primary_recommendation": { + "fertilizer_code": "npk-202020", + "fertilizer_name": "NPK 20-20-20", + "application_interval": {"value": 14, "unit": "day", "label": "هر 14 روز"}, + }, + "application_guide": { + "steps": [ + {"step_number": 1, "title": "مرحله اول", "description": "در آب حل شود", "date": "2025-02-14"} + ] + }, + } + self.plan.is_active = False + self.plan.save(update_fields=["plan_payload", "is_active", "updated_at"]) + + activate_request = self.factory.patch( + f"/api/fertilization/plans/{self.plan.uuid}/status/", + {"is_active": True}, + format="json", + ) + force_authenticate(activate_request, user=self.user) + + activate_response = FertilizationPlanStatusView.as_view()(activate_request, plan_uuid=self.plan.uuid) + + self.assertEqual(activate_response.status_code, 200) + events = FarmerCalendarEvent.objects.filter(farm=self.farm, extended_props__plan_uuid=str(self.plan.uuid)) + self.assertEqual(events.count(), 1) + self.assertEqual(events.first().extended_props["plan_type"], "fertilization") + + deactivate_request = self.factory.patch( + f"/api/fertilization/plans/{self.plan.uuid}/status/", + {"is_active": False}, + format="json", + ) + force_authenticate(deactivate_request, user=self.user) + + deactivate_response = FertilizationPlanStatusView.as_view()(deactivate_request, plan_uuid=self.plan.uuid) + + self.assertEqual(deactivate_response.status_code, 200) + self.assertFalse( + FarmerCalendarEvent.objects.filter(farm=self.farm, extended_props__plan_uuid=str(self.plan.uuid)).exists() + ) diff --git a/fertilization/views.py b/fertilization/views.py index 7f9c800..eb8dff0 100644 --- a/fertilization/views.py +++ b/fertilization/views.py @@ -15,6 +15,7 @@ from drf_spectacular.utils import extend_schema from config.swagger import code_response, status_response from external_api_adapter import request as external_api_request from farm_hub.models import FarmHub +from farmer_calendar import PLAN_TYPE_FERTILIZATION, delete_plan_events, sync_plan_events from .models import FertilizationPlan, FertilizationRecommendationRequest from .services import build_active_plan_context from .mock_data import CONFIG_RESPONSE_DATA @@ -371,7 +372,7 @@ class RecommendView(FarmAccessMixin, APIView): def _create_plan_from_recommendation(self, recommendation, public_data): primary_recommendation = public_data.get("primary_recommendation", {}) - FertilizationPlan.objects.create( + plan = FertilizationPlan.objects.create( farm=recommendation.farm, source=FertilizationPlan.SOURCE_RECOMMENDATION, recommendation=recommendation, @@ -382,6 +383,7 @@ class RecommendView(FarmAccessMixin, APIView): request_payload=recommendation.request_payload, response_payload=recommendation.response_payload, ) + sync_plan_events(plan, PLAN_TYPE_FERTILIZATION) @staticmethod def _enrich_ai_payload(payload, farm): @@ -581,7 +583,7 @@ class PlanFromTextView(FarmAccessMixin, APIView): final_plan = self._extract_final_plan(response_data) if final_plan and farm_uuid: - FertilizationPlan.objects.create( + plan = FertilizationPlan.objects.create( farm=farm, source=FertilizationPlan.SOURCE_FREE_TEXT, title=self._build_free_text_plan_title(final_plan), @@ -596,6 +598,7 @@ class PlanFromTextView(FarmAccessMixin, APIView): request_payload=payload, response_payload=response_data, ) + sync_plan_events(plan, PLAN_TYPE_FERTILIZATION) return Response( {"code": 200, "msg": response_data.get("msg", "موفق"), "data": response_data.get("data", response_data)}, @@ -663,6 +666,7 @@ class FertilizationPlanDetailView(APIView): return Response({"code": 404, "msg": "Plan not found."}, status=status.HTTP_404_NOT_FOUND) plan.soft_delete() + delete_plan_events(farm=plan.farm, plan_type=PLAN_TYPE_FERTILIZATION, plan_uuid=plan.uuid) return Response({"code": 200, "msg": "success", "data": {"plan_uuid": str(plan.uuid), "is_deleted": True}}, status=status.HTTP_200_OK) @@ -684,8 +688,20 @@ class FertilizationPlanStatusView(APIView): if plan is None: return Response({"code": 404, "msg": "Plan not found."}, status=status.HTTP_404_NOT_FOUND) - plan.is_active = serializer.validated_data["is_active"] + new_is_active = serializer.validated_data["is_active"] + if new_is_active: + FertilizationPlan.objects.filter( + farm=plan.farm, + is_deleted=False, + is_active=True, + ).exclude(pk=plan.pk).update(is_active=False) + + plan.is_active = new_is_active plan.save(update_fields=["is_active", "updated_at"]) + if plan.is_active: + sync_plan_events(plan, PLAN_TYPE_FERTILIZATION) + else: + delete_plan_events(farm=plan.farm, plan_type=PLAN_TYPE_FERTILIZATION, plan_uuid=plan.uuid) return Response( {"code": 200, "msg": "success", "data": {"plan_uuid": str(plan.uuid), "is_active": plan.is_active}}, status=status.HTTP_200_OK, diff --git a/irrigation/IRRIGATION_PLAN_APIS.md b/irrigation/IRRIGATION_PLAN_APIS.md index e1b47cf..67bc70f 100644 --- a/irrigation/IRRIGATION_PLAN_APIS.md +++ b/irrigation/IRRIGATION_PLAN_APIS.md @@ -42,7 +42,7 @@ GET /api/irrigation/plans/?farm_uuid=11111111-1111-1111-1111-111111111111&page=1 "crop_id": "گندم", "plant_name": "گندم", "growth_stage": "flowering", - "is_active": true, + "is_active": false, "created_at": "2025-02-24T10:20:30Z" } ], @@ -63,6 +63,7 @@ GET /api/irrigation/plans/?farm_uuid=11111111-1111-1111-1111-111111111111&page=1 - فقط planهایی برگردانده می‌شوند که `is_deleted=False` باشند. - ترتیب لیست از جدید به قدیم است. +- در هر مزرعه، در هر نوع plan فقط یک plan می‌تواند `is_active=true` باشد. --- @@ -95,7 +96,7 @@ GET /api/irrigation/plans/6d6a1f0d-1a9b-4f2f-8fe1-2d73d9d2d9f1/ "crop_id": "گندم", "plant_name": "گندم", "growth_stage": "flowering", - "is_active": true, + "is_active": false, "created_at": "2025-02-24T10:20:30Z", "updated_at": "2025-02-24T10:20:30Z", "plan_payload": { @@ -195,7 +196,7 @@ Content-Type: application/json "msg": "success", "data": { "plan_uuid": "6d6a1f0d-1a9b-4f2f-8fe1-2d73d9d2d9f1", - "is_active": false + "is_active": true } } ``` @@ -223,6 +224,8 @@ Content-Type: application/json ## Summary +- planهای جدید به‌صورت پیش‌فرض `inactive` ساخته می‌شوند. +- در هر مزرعه فقط یک plan از این نوع می‌تواند `active` باشد. - `GET /api/irrigation/plans/` لیست برنامه‌ها - `GET /api/irrigation/plans/{plan_uuid}/` جزئیات برنامه - `DELETE /api/irrigation/plans/{plan_uuid}/` حذف نرم برنامه diff --git a/irrigation/migrations/0004_irrigationplan_default_inactive.py b/irrigation/migrations/0004_irrigationplan_default_inactive.py new file mode 100644 index 0000000..1865a4a --- /dev/null +++ b/irrigation/migrations/0004_irrigationplan_default_inactive.py @@ -0,0 +1,16 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("irrigation_recommendation", "0003_irrigationplan"), + ] + + operations = [ + migrations.AlterField( + model_name="irrigationplan", + name="is_active", + field=models.BooleanField(db_index=True, default=False), + ), + ] diff --git a/irrigation/models.py b/irrigation/models.py index 070f7cb..17d8456 100644 --- a/irrigation/models.py +++ b/irrigation/models.py @@ -74,7 +74,7 @@ class IrrigationPlan(models.Model): plan_payload = models.JSONField(default=dict, blank=True) request_payload = models.JSONField(default=dict, blank=True) response_payload = models.JSONField(default=dict, blank=True) - is_active = models.BooleanField(default=True, db_index=True) + is_active = models.BooleanField(default=False, db_index=True) is_deleted = models.BooleanField(default=False, db_index=True) deleted_at = models.DateTimeField(null=True, blank=True) created_at = models.DateTimeField(auto_now_add=True) diff --git a/irrigation/tests.py b/irrigation/tests.py index 95b1885..b157680 100644 --- a/irrigation/tests.py +++ b/irrigation/tests.py @@ -6,6 +6,7 @@ from rest_framework.test import APIRequestFactory, force_authenticate from external_api_adapter.adapter import AdapterResponse from farm_hub.models import FarmHub, FarmType +from farmer_calendar.models import FarmerCalendarEvent from .models import IrrigationPlan, IrrigationRecommendationRequest from .views import ( @@ -312,7 +313,7 @@ class RecommendViewTests(TestCase): self.assertEqual(IrrigationPlan.objects.count(), 1) plan = IrrigationPlan.objects.get() self.assertEqual(plan.source, IrrigationPlan.SOURCE_RECOMMENDATION) - self.assertTrue(plan.is_active) + self.assertFalse(plan.is_active) self.assertFalse(plan.is_deleted) self.assertEqual(response.data["data"]["plan"]["durationMinutes"], 38) self.assertEqual(response.data["data"]["water_balance"]["active_kc"], 0.93) @@ -570,7 +571,7 @@ class IrrigationPlanApiTests(TestCase): def test_plan_status_patch_updates_is_active(self): request = self.factory.patch( f"/api/irrigation/plans/{self.plan.uuid}/status/", - {"is_active": False}, + {"is_active": True}, format="json", ) force_authenticate(request, user=self.user) @@ -579,4 +580,71 @@ class IrrigationPlanApiTests(TestCase): self.assertEqual(response.status_code, 200) self.plan.refresh_from_db() - self.assertFalse(self.plan.is_active) + self.assertTrue(self.plan.is_active) + + def test_activating_one_plan_deactivates_other_active_plan(self): + other_plan = IrrigationPlan.objects.create( + farm=self.farm, + source=IrrigationPlan.SOURCE_FREE_TEXT, + title="برنامه دوم", + is_active=True, + ) + + request = self.factory.patch( + f"/api/irrigation/plans/{self.plan.uuid}/status/", + {"is_active": True}, + format="json", + ) + force_authenticate(request, user=self.user) + + response = IrrigationPlanStatusView.as_view()(request, plan_uuid=self.plan.uuid) + + self.assertEqual(response.status_code, 200) + self.plan.refresh_from_db() + other_plan.refresh_from_db() + self.assertTrue(self.plan.is_active) + self.assertFalse(other_plan.is_active) + + def test_plan_status_patch_syncs_calendar_events(self): + self.plan.plan_payload = { + "plan": {"durationMinutes": 25, "bestTimeOfDay": "05:30 - 06:00"}, + "water_balance": { + "daily": [ + { + "forecast_date": "2025-02-12", + "gross_irrigation_mm": 17, + "irrigation_timing": "05:30 - 06:00", + } + ] + }, + } + self.plan.is_active = False + self.plan.save(update_fields=["plan_payload", "is_active", "updated_at"]) + + activate_request = self.factory.patch( + f"/api/irrigation/plans/{self.plan.uuid}/status/", + {"is_active": True}, + format="json", + ) + force_authenticate(activate_request, user=self.user) + + activate_response = IrrigationPlanStatusView.as_view()(activate_request, plan_uuid=self.plan.uuid) + + self.assertEqual(activate_response.status_code, 200) + events = FarmerCalendarEvent.objects.filter(farm=self.farm, extended_props__plan_uuid=str(self.plan.uuid)) + self.assertEqual(events.count(), 1) + self.assertEqual(events.first().extended_props["plan_type"], "irrigation") + + deactivate_request = self.factory.patch( + f"/api/irrigation/plans/{self.plan.uuid}/status/", + {"is_active": False}, + format="json", + ) + force_authenticate(deactivate_request, user=self.user) + + deactivate_response = IrrigationPlanStatusView.as_view()(deactivate_request, plan_uuid=self.plan.uuid) + + self.assertEqual(deactivate_response.status_code, 200) + self.assertFalse( + FarmerCalendarEvent.objects.filter(farm=self.farm, extended_props__plan_uuid=str(self.plan.uuid)).exists() + ) diff --git a/irrigation/views.py b/irrigation/views.py index 39dc12b..ff1137d 100644 --- a/irrigation/views.py +++ b/irrigation/views.py @@ -15,6 +15,7 @@ from drf_spectacular.utils import extend_schema from config.swagger import code_response, status_response from external_api_adapter import request as external_api_request from farm_hub.models import FarmHub +from farmer_calendar import PLAN_TYPE_IRRIGATION, delete_plan_events, sync_plan_events from water.serializers import WaterStressIndexSerializer from water.views import WaterStressIndexView from .mock_data import CONFIG_RESPONSE_DATA @@ -171,7 +172,7 @@ class RecommendView(FarmAccessMixin, APIView): return " - ".join(parts) if parts else "برنامه آبیاری" def _create_plan_from_recommendation(self, recommendation, recommendation_data): - IrrigationPlan.objects.create( + plan = IrrigationPlan.objects.create( farm=recommendation.farm, source=IrrigationPlan.SOURCE_RECOMMENDATION, recommendation=recommendation, @@ -182,6 +183,7 @@ class RecommendView(FarmAccessMixin, APIView): request_payload=recommendation.request_payload, response_payload=recommendation.response_payload, ) + sync_plan_events(plan, PLAN_TYPE_IRRIGATION) @staticmethod def _enrich_ai_payload(payload, farm): @@ -451,7 +453,7 @@ class PlanFromTextView(FarmAccessMixin, APIView): final_plan = self._extract_final_plan(response_data) if final_plan and farm_uuid: - IrrigationPlan.objects.create( + plan = IrrigationPlan.objects.create( farm=farm, source=IrrigationPlan.SOURCE_FREE_TEXT, title=self._build_free_text_plan_title(final_plan), @@ -466,6 +468,7 @@ class PlanFromTextView(FarmAccessMixin, APIView): request_payload=payload, response_payload=response_data, ) + sync_plan_events(plan, PLAN_TYPE_IRRIGATION) return Response( {"code": 200, "msg": response_data.get("msg", "موفق"), "data": response_data.get("data", response_data)}, @@ -533,6 +536,7 @@ class IrrigationPlanDetailView(APIView): return Response({"code": 404, "msg": "Plan not found."}, status=status.HTTP_404_NOT_FOUND) plan.soft_delete() + delete_plan_events(farm=plan.farm, plan_type=PLAN_TYPE_IRRIGATION, plan_uuid=plan.uuid) return Response({"code": 200, "msg": "success", "data": {"plan_uuid": str(plan.uuid), "is_deleted": True}}, status=status.HTTP_200_OK) @@ -554,8 +558,20 @@ class IrrigationPlanStatusView(APIView): if plan is None: return Response({"code": 404, "msg": "Plan not found."}, status=status.HTTP_404_NOT_FOUND) - plan.is_active = serializer.validated_data["is_active"] + new_is_active = serializer.validated_data["is_active"] + if new_is_active: + IrrigationPlan.objects.filter( + farm=plan.farm, + is_deleted=False, + is_active=True, + ).exclude(pk=plan.pk).update(is_active=False) + + plan.is_active = new_is_active plan.save(update_fields=["is_active", "updated_at"]) + if plan.is_active: + sync_plan_events(plan, PLAN_TYPE_IRRIGATION) + else: + delete_plan_events(farm=plan.farm, plan_type=PLAN_TYPE_IRRIGATION, plan_uuid=plan.uuid) return Response( {"code": 200, "msg": "success", "data": {"plan_uuid": str(plan.uuid), "is_active": plan.is_active}}, status=status.HTTP_200_OK, diff --git a/plants/serializers.py b/plants/serializers.py index ff7742c..aaf5d0e 100644 --- a/plants/serializers.py +++ b/plants/serializers.py @@ -11,6 +11,8 @@ class PlantSerializer(serializers.ModelSerializer): fields = [ "id", "name", + "description", + "metadata", "light", "watering", "soil", @@ -22,6 +24,9 @@ class PlantSerializer(serializers.ModelSerializer): "harvest_time", "spacing", "fertilizer", + "health_profile", + "irrigation_profile", + "growth_profile", "created_at", "updated_at", ] diff --git a/plants/services.py b/plants/services.py index 2ff7637..88bba1d 100644 --- a/plants/services.py +++ b/plants/services.py @@ -1,4 +1,5 @@ from django.db import transaction +from django.conf import settings from external_api_adapter import request as external_api_request from external_api_adapter.exceptions import ExternalAPIRequestError @@ -14,33 +15,13 @@ DEFAULT_GROWTH_STAGES = [ "fruiting", "maturity", ] -AI_PLANTS_PATH = "/api/plants/" +AI_FARM_DATA_PLANT_SYNC_PATH = "/api/farm-data/plants/sync/" class PlantSyncError(Exception): pass -def _extract_plant_items(adapter_data): - if isinstance(adapter_data, list): - return adapter_data - if not isinstance(adapter_data, dict): - return [] - - data = adapter_data.get("data") - if isinstance(data, list): - return data - if isinstance(data, dict): - result = data.get("result") - if isinstance(result, list): - return result - - result = adapter_data.get("result") - if isinstance(result, list): - return result - return [] - - def _clean_stage_name(value): stage = str(value or "").strip() return stage @@ -110,60 +91,62 @@ def ensure_plant_defaults(queryset=None): return products -@transaction.atomic -def sync_plants_from_ai(): +def serialize_products_for_ai(products=None): + products = list(products if products is not None else Product.objects.select_related("farm_type").all().order_by("name")) + ensure_plant_defaults(products) + payload = [] + for product in products: + payload.append( + { + "id": product.id, + "name": product.name, + "slug": "", + "icon": product.icon, + "description": product.description, + "metadata": product.metadata if isinstance(product.metadata, dict) else {}, + "light": product.light, + "watering": product.watering, + "soil": product.soil, + "temperature": product.temperature, + "growth_stage": product.growth_stage, + "growth_stages": product.growth_stages or [], + "planting_season": product.planting_season, + "harvest_time": product.harvest_time, + "spacing": product.spacing, + "fertilizer": product.fertilizer, + "health_profile": product.health_profile if isinstance(product.health_profile, dict) else {}, + "irrigation_profile": product.irrigation_profile if isinstance(product.irrigation_profile, dict) else {}, + "growth_profile": product.growth_profile if isinstance(product.growth_profile, dict) else {}, + "is_active": True, + "updated_at": product.updated_at.isoformat() if product.updated_at else None, + "farm_type": product.farm_type.name if product.farm_type_id else DEFAULT_FARM_TYPE_NAME, + } + ) + return payload + + +def push_plants_to_ai(products=None): + api_key = getattr(settings, "FARM_DATA_API_KEY", "") + if not api_key: + raise PlantSyncError("FARM_DATA_API_KEY is not configured.") + + payload = serialize_products_for_ai(products) try: - adapter_response = external_api_request("ai", AI_PLANTS_PATH, method="GET") + adapter_response = external_api_request( + "ai", + AI_FARM_DATA_PLANT_SYNC_PATH, + method="POST", + payload=payload, + headers={ + "Accept": "application/json", + "Content-Type": "application/json", + "X-API-Key": api_key, + "Authorization": f"Api-Key {api_key}", + }, + ) except ExternalAPIRequestError as exc: raise PlantSyncError(str(exc)) from exc if adapter_response.status_code >= 400: raise PlantSyncError(f"AI service returned status {adapter_response.status_code}.") - - products = [] - for item in _extract_plant_items(adapter_response.data): - if not isinstance(item, dict): - continue - - name = str(item.get("name") or "").strip() - if not name: - continue - - farm_type_name = str(item.get("farm_type") or DEFAULT_FARM_TYPE_NAME).strip() or DEFAULT_FARM_TYPE_NAME - farm_type, _ = FarmType.objects.get_or_create(name=farm_type_name) - - growth_profile = item.get("growth_profile") if isinstance(item.get("growth_profile"), dict) else {} - growth_stages = item.get("growth_stages") if isinstance(item.get("growth_stages"), list) else [] - normalized_growth_stages = [] - for stage in growth_stages: - normalized = _clean_stage_name(stage) - if normalized: - normalized_growth_stages.append(normalized) - - defaults = { - "description": str(item.get("description") or "").strip(), - "metadata": item.get("metadata") if isinstance(item.get("metadata"), dict) else {}, - "light": str(item.get("light") or "").strip(), - "watering": str(item.get("watering") or "").strip(), - "soil": str(item.get("soil") or "").strip(), - "temperature": str(item.get("temperature") or "").strip(), - "growth_stage": str(item.get("growth_stage") or "").strip(), - "growth_stages": normalized_growth_stages, - "planting_season": str(item.get("planting_season") or "").strip(), - "harvest_time": str(item.get("harvest_time") or "").strip(), - "spacing": str(item.get("spacing") or "").strip(), - "fertilizer": str(item.get("fertilizer") or "").strip(), - "icon": str(item.get("icon") or "").strip(), - "health_profile": item.get("health_profile") if isinstance(item.get("health_profile"), dict) else {}, - "irrigation_profile": item.get("irrigation_profile") if isinstance(item.get("irrigation_profile"), dict) else {}, - "growth_profile": growth_profile, - } - product, _ = Product.objects.update_or_create( - farm_type=farm_type, - name=name, - defaults=defaults, - ) - products.append(product) - - ensure_plant_defaults(products) - return products + return payload diff --git a/plants/tests.py b/plants/tests.py index f742e0b..af70fe3 100644 --- a/plants/tests.py +++ b/plants/tests.py @@ -56,9 +56,9 @@ class PlantApiTests(TestCase): self.assertIn("flowering", response.data["data"][0]["growth_stages"]) mock_external_api_request.assert_called_once_with("ai", "/api/plants/", method="GET") - @patch("plants.views.sync_plants_from_ai") - def test_names_endpoint_fills_default_icon_and_growth_stages(self, mock_sync_plants_from_ai): - mock_sync_plants_from_ai.return_value = [] + @patch("plants.views.push_plants_to_ai") + def test_names_endpoint_fills_default_icon_and_growth_stages(self, mock_push_plants_to_ai): + mock_push_plants_to_ai.return_value = [] product = Product.objects.create( farm_type=self.farm_type, name="Pepper", @@ -79,9 +79,9 @@ class PlantApiTests(TestCase): self.assertEqual(product.icon, "leaf") self.assertEqual(product.growth_stages, ["initial", "vegetative", "flowering", "fruiting", "maturity"]) - @patch("plants.views.sync_plants_from_ai") - def test_selected_endpoint_returns_farmer_products(self, mock_sync_plants_from_ai): - mock_sync_plants_from_ai.return_value = [] + @patch("plants.views.push_plants_to_ai") + def test_selected_endpoint_returns_farmer_products(self, mock_push_plants_to_ai): + mock_push_plants_to_ai.return_value = [] tomato = Product.objects.create(farm_type=self.farm_type, name="Tomato", icon="leaf", growth_stages=["vegetative"]) pepper = Product.objects.create(farm_type=self.farm_type, name="Pepper", icon="leaf", growth_stages=["flowering"]) farm = FarmHub.objects.create(owner=self.user, farm_type=self.farm_type, name="farm-a") diff --git a/plants/views.py b/plants/views.py index 082e97c..b82cc20 100644 --- a/plants/views.py +++ b/plants/views.py @@ -8,7 +8,7 @@ from drf_spectacular.utils import OpenApiParameter, extend_schema from config.swagger import code_response, farm_uuid_query_param from farm_hub.models import FarmHub, Product from .serializers import PlantNameSerializer, PlantSerializer -from .services import PlantSyncError, ensure_plant_defaults, sync_plants_from_ai +from .services import PlantSyncError, ensure_plant_defaults, push_plants_to_ai class PlantBaseView(APIView): @@ -17,7 +17,7 @@ class PlantBaseView(APIView): @staticmethod def _sync_plants_if_possible(): try: - sync_plants_from_ai() + push_plants_to_ai() except PlantSyncError: return False return True @@ -38,13 +38,12 @@ class PlantListView(PlantBaseView): responses={200: code_response("PlantListResponse", data=PlantSerializer(many=True))}, ) def get(self, request): - try: - sync_plants_from_ai() - except PlantSyncError as exc: - if not Product.objects.exists(): - return Response({"code": 503, "msg": str(exc)}, status=status.HTTP_503_SERVICE_UNAVAILABLE) - products = ensure_plant_defaults(Product.objects.order_by("name")) + try: + push_plants_to_ai(products) + except PlantSyncError as exc: + if not products: + return Response({"code": 503, "msg": str(exc)}, status=status.HTTP_503_SERVICE_UNAVAILABLE) data = PlantSerializer(products, many=True).data return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK) diff --git a/sensor_7_in_1/apps.py b/sensor_7_in_1/apps.py deleted file mode 100644 index 1e231cf..0000000 --- a/sensor_7_in_1/apps.py +++ /dev/null @@ -1,8 +0,0 @@ -from django.apps import AppConfig - - -class Sensor7In1Config(AppConfig): - default_auto_field = "django.db.models.BigAutoField" - name = "sensor_7_in_1" - verbose_name = "Sensor 7 in 1" - diff --git a/sensor_7_in_1/mock_data.py b/sensor_7_in_1/mock_data.py deleted file mode 100644 index 6334c08..0000000 --- a/sensor_7_in_1/mock_data.py +++ /dev/null @@ -1,133 +0,0 @@ -AVG_SOIL_MOISTURE = { - "id": "avg_soil_moisture", - "title": "میانگین رطوبت خاک", - "subtitle": "سنسور 7 در 1 خاک", - "stats": "45%", - "avatarColor": "primary", - "avatarIcon": "tabler-droplet", - "chipText": "متوسط", - "chipColor": "warning", -} - - -SENSOR_VALUES_LIST = { - "sensor": { - "name": "سنسور 7 در 1 خاک", - "physicalDeviceUuid": None, - "sensorCatalogCode": "sensor-7-in-1", - "updatedAt": None, - }, - "sensors": [ - { - "id": "soil_moisture", - "title": "45%", - "subtitle": "رطوبت خاک", - "trendNumber": 1.5, - "trend": "positive", - "unit": "%", - }, - { - "id": "soil_temperature", - "title": "22.5°C", - "subtitle": "دمای خاک", - "trendNumber": 0.8, - "trend": "positive", - "unit": "°C", - }, - { - "id": "soil_ph", - "title": "6.8", - "subtitle": "pH خاک", - "trendNumber": 0.1, - "trend": "positive", - "unit": "pH", - }, - { - "id": "electrical_conductivity", - "title": "1.2 dS/m", - "subtitle": "هدایت الکتریکی", - "trendNumber": -0.1, - "trend": "negative", - "unit": "dS/m", - }, - { - "id": "nitrogen", - "title": "30 mg/kg", - "subtitle": "نیتروژن", - "trendNumber": 2.0, - "trend": "positive", - "unit": "mg/kg", - }, - { - "id": "phosphorus", - "title": "15 mg/kg", - "subtitle": "فسفر", - "trendNumber": 1.0, - "trend": "positive", - "unit": "mg/kg", - }, - { - "id": "potassium", - "title": "20 mg/kg", - "subtitle": "پتاسیم", - "trendNumber": -1.0, - "trend": "negative", - "unit": "mg/kg", - }, - ], -} - - -SENSOR_RADAR_CHART = { - "labels": ["رطوبت", "دما", "pH", "EC", "نیتروژن", "فسفر", "پتاسیم"], - "series": [ - {"name": "اکنون", "data": [82, 76, 90, 72, 68, 62, 70]}, - {"name": "هدف", "data": [100, 100, 100, 100, 100, 100, 100]}, - ], -} - - -SENSOR_COMPARISON_CHART = { - "currentValue": 45, - "vsLastWeek": "+4.7%", - "vsLastWeekValue": 4.7, - "categories": ["08:00", "10:00", "12:00", "14:00", "16:00", "18:00", "20:00"], - "series": [ - {"name": "رطوبت خاک", "data": [42, 44, 45, 47, 46, 45, 45]}, - {"name": "بازه هدف", "data": [55, 55, 55, 55, 55, 55, 55]}, - ], -} - - -ANOMALY_DETECTION_CARD = { - "anomalies": [ - { - "sensor": "هدایت الکتریکی", - "value": "1.2 dS/m", - "expected": "0.8-1.1 dS/m", - "deviation": "+0.1 dS/m", - "severity": "warning", - } - ] -} - - -SOIL_MOISTURE_HEATMAP = { - "zones": ["سنسور 7 در 1 خاک"], - "hours": ["08:00", "10:00", "12:00", "14:00", "16:00", "18:00", "20:00"], - "series": [ - { - "name": "سنسور 7 در 1 خاک", - "data": [ - {"x": "08:00", "y": 42}, - {"x": "10:00", "y": 44}, - {"x": "12:00", "y": 45}, - {"x": "14:00", "y": 47}, - {"x": "16:00", "y": 46}, - {"x": "18:00", "y": 45}, - {"x": "20:00", "y": 45}, - ], - } - ], -} - diff --git a/sensor_7_in_1/models.py b/sensor_7_in_1/models.py deleted file mode 100644 index c0fec14..0000000 --- a/sensor_7_in_1/models.py +++ /dev/null @@ -1,4 +0,0 @@ -""" -This app is service-based and does not define local database models. -""" - diff --git a/sensor_7_in_1/seeds.py b/sensor_7_in_1/seeds.py deleted file mode 100644 index b93aea9..0000000 --- a/sensor_7_in_1/seeds.py +++ /dev/null @@ -1,174 +0,0 @@ -from datetime import timedelta -import uuid - -from django.db import transaction -from django.utils import timezone - -from farm_hub.models import FarmSensor -from farm_hub.seeds import seed_admin_farm -from sensor_catalog.models import SensorCatalog -from sensor_external_api.models import SensorExternalRequestLog - - -SENSOR_7_IN_1_CATALOG_CODE = "sensor-7-in-1" -SENSOR_7_IN_1_DEVICE_UUID = uuid.UUID("77777777-7777-7777-7777-777777777777") -SENSOR_7_IN_1_LOG_SERIES = [ - { - "days_ago": 6, - "payload": { - "soil_moisture": 44.0, - "soil_temperature": 20.6, - "soil_ph": 6.3, - "electrical_conductivity": 1.0, - "nitrogen": 25.0, - "phosphorus": 13.0, - "potassium": 21.0, - }, - }, - { - "days_ago": 5, - "payload": { - "soil_moisture": 45.5, - "soil_temperature": 21.1, - "soil_ph": 6.4, - "electrical_conductivity": 1.1, - "nitrogen": 26.0, - "phosphorus": 13.8, - "potassium": 21.8, - }, - }, - { - "days_ago": 4, - "payload": { - "soil_moisture": 46.8, - "soil_temperature": 21.7, - "soil_ph": 6.5, - "electrical_conductivity": 1.1, - "nitrogen": 27.4, - "phosphorus": 14.2, - "potassium": 22.5, - }, - }, - { - "days_ago": 3, - "payload": { - "soil_moisture": 48.2, - "soil_temperature": 22.0, - "soil_ph": 6.6, - "electrical_conductivity": 1.2, - "nitrogen": 28.9, - "phosphorus": 15.1, - "potassium": 23.3, - }, - }, - { - "days_ago": 2, - "payload": { - "soil_moisture": 49.6, - "soil_temperature": 22.4, - "soil_ph": 6.6, - "electrical_conductivity": 1.2, - "nitrogen": 29.7, - "phosphorus": 15.7, - "potassium": 24.1, - }, - }, - { - "days_ago": 1, - "payload": { - "soil_moisture": 50.9, - "soil_temperature": 22.8, - "soil_ph": 6.7, - "electrical_conductivity": 1.3, - "nitrogen": 30.8, - "phosphorus": 16.2, - "potassium": 24.8, - }, - }, - { - "days_ago": 0, - "payload": { - "soil_moisture": 52.4, - "soil_temperature": 23.1, - "soil_ph": 6.8, - "electrical_conductivity": 1.3, - "nitrogen": 32.0, - "phosphorus": 16.8, - "potassium": 25.6, - }, - }, -] - - -def seed_sensor_7_in_1_catalog(): - sensor_catalog, created = SensorCatalog.objects.update_or_create( - code=SENSOR_7_IN_1_CATALOG_CODE, - defaults={ - "name": "Sensor 7 in 1 Soil Sensor", - "description": "Demo 7 in 1 soil sensor for dashboard summary and chart endpoints.", - "customizable_fields": [], - "supported_power_sources": ["solar", "battery", "direct_power"], - "returned_data_fields": [ - "soil_moisture", - "soil_temperature", - "soil_ph", - "electrical_conductivity", - "nitrogen", - "phosphorus", - "potassium", - ], - "sample_payload": SENSOR_7_IN_1_LOG_SERIES[-1]["payload"], - "is_active": True, - }, - ) - return sensor_catalog, created - - -@transaction.atomic -def seed_sensor_7_in_1_demo_data(): - farm, _ = seed_admin_farm() - sensor_catalog, catalog_created = seed_sensor_7_in_1_catalog() - - sensor, sensor_created = FarmSensor.objects.update_or_create( - farm=farm, - physical_device_uuid=SENSOR_7_IN_1_DEVICE_UUID, - defaults={ - "sensor_catalog": sensor_catalog, - "name": "Sensor 7 in 1 Demo", - "sensor_type": "soil_7_in_1", - "is_active": True, - "specifications": { - "capabilities": sensor_catalog.returned_data_fields, - "demo_seed": True, - }, - "power_source": {"type": "solar"}, - }, - ) - - SensorExternalRequestLog.objects.filter( - farm_uuid=farm.farm_uuid, - physical_device_uuid=sensor.physical_device_uuid, - ).delete() - - base_time = timezone.now().replace(hour=12, minute=0, second=0, microsecond=0) - created_logs = [] - for item in SENSOR_7_IN_1_LOG_SERIES: - log = SensorExternalRequestLog.objects.create( - farm_uuid=farm.farm_uuid, - sensor_catalog_uuid=sensor_catalog.uuid, - physical_device_uuid=sensor.physical_device_uuid, - payload=item["payload"], - ) - created_at = base_time - timedelta(days=item["days_ago"]) - SensorExternalRequestLog.objects.filter(id=log.id).update(created_at=created_at) - log.created_at = created_at - created_logs.append(log) - - return { - "farm": farm, - "sensor_catalog": sensor_catalog, - "sensor": sensor, - "catalog_created": catalog_created, - "sensor_created": sensor_created, - "log_count": len(created_logs), - } diff --git a/sensor_7_in_1/services.py b/sensor_7_in_1/services.py deleted file mode 100644 index fb2ca19..0000000 --- a/sensor_7_in_1/services.py +++ /dev/null @@ -1,704 +0,0 @@ -from copy import deepcopy -from datetime import timedelta - -from sensor_external_api.services import get_farm_sensor_map_for_logs, get_sensor_external_request_logs_for_farm -from django.utils import timezone - -from .mock_data import ( - ANOMALY_DETECTION_CARD, - AVG_SOIL_MOISTURE, - SENSOR_COMPARISON_CHART, - SENSOR_RADAR_CHART, - SENSOR_VALUES_LIST, - SOIL_MOISTURE_HEATMAP, -) - - -SENSOR_FIELDS = [ - { - "id": "soil_moisture", - "label": "رطوبت خاک", - "unit": "%", - "payload_keys": ("soil_moisture", "soilMoisture", "moisture"), - "ideal_min": 45.0, - "ideal_max": 65.0, - "radar_label": "رطوبت", - }, - { - "id": "soil_temperature", - "label": "دمای خاک", - "unit": "°C", - "payload_keys": ("soil_temperature", "soilTemperature", "temperature"), - "ideal_min": 18.0, - "ideal_max": 28.0, - "radar_label": "دما", - }, - { - "id": "soil_ph", - "label": "pH خاک", - "unit": "pH", - "payload_keys": ("soil_ph", "soilPh", "ph"), - "ideal_min": 6.0, - "ideal_max": 7.5, - "radar_label": "pH", - }, - { - "id": "electrical_conductivity", - "label": "هدایت الکتریکی", - "unit": "dS/m", - "payload_keys": ("electrical_conductivity", "electricalConductivity", "ec"), - "ideal_min": 0.8, - "ideal_max": 1.8, - "radar_label": "EC", - }, - { - "id": "nitrogen", - "label": "نیتروژن", - "unit": "mg/kg", - "payload_keys": ("nitrogen", "n"), - "ideal_min": 20.0, - "ideal_max": 40.0, - "radar_label": "نیتروژن", - }, - { - "id": "phosphorus", - "label": "فسفر", - "unit": "mg/kg", - "payload_keys": ("phosphorus", "p"), - "ideal_min": 10.0, - "ideal_max": 25.0, - "radar_label": "فسفر", - }, - { - "id": "potassium", - "label": "پتاسیم", - "unit": "mg/kg", - "payload_keys": ("potassium", "k"), - "ideal_min": 15.0, - "ideal_max": 35.0, - "radar_label": "پتاسیم", - }, -] - -MIN_REQUIRED_SENSOR_FIELDS = 4 -MAX_HISTORY_ITEMS = 20 -MAX_CHART_POINTS = 7 -COMPARISON_CHART_RANGES = {"7d": 7, "30d": 30} -VALUES_LIST_RANGES = {"1h": timedelta(hours=1), "24h": timedelta(hours=24), "7d": timedelta(days=7)} -RADAR_CHART_RANGES = {"today": timedelta(days=1), "7d": timedelta(days=7), "30d": timedelta(days=30)} -PERSIAN_WEEKDAYS = { - 0: "دوشنبه", - 1: "سه شنبه", - 2: "چهارشنبه", - 3: "پنج شنبه", - 4: "جمعه", - 5: "شنبه", - 6: "یکشنبه", -} -COMPARISON_CHART_FIELD_ALIASES = { - "soil_moisture": "moisture", - "soilMoisture": "moisture", - "moisture": "moisture", - "soil_temperature": "temperature", - "soilTemperature": "temperature", - "temperature": "temperature", - "humidity": "humidity", - "soil_ph": "ph", - "soilPh": "ph", - "ph": "ph", - "electrical_conductivity": "ec", - "electricalConductivity": "ec", - "ec": "ec", - "nitrogen": "nitrogen", - "n": "nitrogen", - "phosphorus": "phosphorus", - "p": "phosphorus", - "potassium": "potassium", - "k": "potassium", -} -COMPARISON_CHART_PRIMARY_FIELDS = ("moisture", "temperature", "humidity", "ph", "ec", "nitrogen", "phosphorus", "potassium") -VALUES_LIST_FIELDS = [ - ("moisture", "Moisture", "%"), - ("temperature", "Temperature", "°C"), - ("humidity", "Humidity", "%"), - ("ph", "pH", "pH"), - ("ec", "EC", "dS/m"), - ("nitrogen", "Nitrogen", "mg/kg"), - ("phosphorus", "Phosphorus", "mg/kg"), - ("potassium", "Potassium", "mg/kg"), -] -RADAR_CHART_FIELDS = [ - ("moisture", "Moisture", 60.0), - ("temperature", "Temperature", 26.0), - ("humidity", "Humidity", 55.0), - ("ph", "PH", 6.5), - ("ec", "EC", 1.3), - ("nitrogen", "Nitrogen", 42.0), - ("potassium", "Potassium", 38.0), -] - - -def _to_float(value): - if value is None or isinstance(value, bool): - return None - try: - return float(value) - except (TypeError, ValueError): - return None - - -def _extract_payload(payload): - if not isinstance(payload, dict): - return {} - - if isinstance(payload.get("payload"), dict): - payload = payload["payload"] - - if isinstance(payload.get("data"), dict): - nested = payload["data"] - if any(any(key in nested for key in field["payload_keys"]) for field in SENSOR_FIELDS): - payload = nested - - return payload - - -def _extract_numeric_payload(payload): - payload = _extract_payload(payload) - numeric_payload = {} - for key, value in payload.items(): - numeric_value = _to_float(value) - if numeric_value is not None: - numeric_payload[key] = numeric_value - return numeric_payload - - -def _extract_readings(payload): - payload = _extract_payload(payload) - readings = {} - for field in SENSOR_FIELDS: - for key in field["payload_keys"]: - value = _to_float(payload.get(key)) - if value is not None: - readings[field["id"]] = value - break - return readings - - -def _format_number(value): - if value is None: - return "" - if float(value).is_integer(): - return str(int(value)) - return f"{value:.1f}".rstrip("0").rstrip(".") - - -def _format_value(value, unit): - number = _format_number(value) - if not number: - return number - if unit in {"", "pH"}: - return number - if unit in {"%", "°C"}: - return f"{number}{unit}" - return f"{number} {unit}" - - -def _format_range(field): - lower = _format_number(field["ideal_min"]) - upper = _format_number(field["ideal_max"]) - unit = field["unit"] - if unit in {"", "pH"}: - return f"{lower}-{upper}" - return f"{lower}-{upper} {unit}" - - -def _get_sensor_context(farm=None): - if farm is None: - return None - - primary_sensor = get_primary_soil_sensor(farm=farm) - if primary_sensor is None: - return None - - try: - logs_queryset = get_sensor_external_request_logs_for_farm( - farm_uuid=farm.farm_uuid, - physical_device_uuid=primary_sensor.physical_device_uuid, - ) - except ValueError: - return None - - history = [] - for log in logs_queryset[:MAX_HISTORY_ITEMS]: - readings = _extract_readings(log.payload) - if readings: - history.append((log, readings)) - - if not history: - return None - - latest_log, latest_readings = history[0] - farm_sensor_map = get_farm_sensor_map_for_logs(logs=[latest_log]) - farm_sensor = farm_sensor_map.get( - (latest_log.farm_uuid, latest_log.sensor_catalog_uuid, latest_log.physical_device_uuid) - ) or primary_sensor - - return { - "farm_sensor": farm_sensor, - "latest_log": latest_log, - "latest_readings": latest_readings, - "previous_readings": history[1][1] if len(history) > 1 else {}, - "history": history, - } - - -def get_primary_soil_sensor(*, farm): - soil_sensors = list( - farm.sensors.select_related("sensor_catalog") - .order_by("created_at", "id") - ) - - def _sensor_priority(sensor): - sensor_type = (sensor.sensor_type or "").lower() - catalog_code = (sensor.sensor_catalog.code if sensor.sensor_catalog else "").lower() - catalog_name = (sensor.sensor_catalog.name if sensor.sensor_catalog else "").lower() - sensor_name = (sensor.name or "").lower() - haystack = " ".join([sensor_type, catalog_code, catalog_name, sensor_name]) - - if "sensor-7-in-1" in catalog_code or "soil_7_in_1" in sensor_type: - return 0 - if "7 in 1" in haystack or "7-in-1" in haystack or "7in1" in haystack: - return 1 - if "soil" in haystack: - return 2 - return 3 - - prioritized_sensors = sorted(soil_sensors, key=_sensor_priority) - if prioritized_sensors and _sensor_priority(prioritized_sensors[0]) < 3: - return prioritized_sensors[0] - return soil_sensors[0] if soil_sensors else None - - -def _build_sensor_meta(context, fallback_sensor): - sensor = deepcopy(fallback_sensor) - if not context: - return sensor - - farm_sensor = context.get("farm_sensor") - latest_log = context["latest_log"] - sensor["physicalDeviceUuid"] = str(latest_log.physical_device_uuid) - sensor["updatedAt"] = latest_log.created_at.isoformat() - - if farm_sensor is not None: - sensor["name"] = farm_sensor.name or sensor["name"] - if farm_sensor.sensor_catalog is not None: - sensor["sensorCatalogCode"] = farm_sensor.sensor_catalog.code - - return sensor - - -def _calculate_status_chip(value): - if value is None: - return ("نامشخص", "secondary", "secondary") - if value >= 60: - return ("بهینه", "success", "primary") - if value >= 45: - return ("متوسط", "warning", "warning") - return ("کم", "error", "error") - - -def get_sensor_7_in_1_values_list_data(farm=None, context=None): - data = deepcopy(SENSOR_VALUES_LIST) - context = _get_sensor_context(farm) if context is None else context - data["sensor"] = _build_sensor_meta(context, data["sensor"]) - if not context: - return data - - latest_readings = context["latest_readings"] - previous_readings = context["previous_readings"] - sensors = [] - - for field in SENSOR_FIELDS: - value = latest_readings.get(field["id"]) - if value is None: - continue - previous = previous_readings.get(field["id"]) - change = 0.0 if previous is None else round(value - previous, 2) - sensors.append( - { - "id": field["id"], - "title": _format_value(value, field["unit"]), - "subtitle": field["label"], - "trendNumber": abs(change), - "trend": "positive" if change >= 0 else "negative", - "unit": field["unit"], - } - ) - - if sensors: - data["sensors"] = sensors - return data - - -def get_sensor_7_in_1_avg_soil_moisture_data(farm=None, context=None): - data = deepcopy(AVG_SOIL_MOISTURE) - context = _get_sensor_context(farm) if context is None else context - if not context: - return data - - moisture = context["latest_readings"].get("soil_moisture") - if moisture is None: - return data - - chip_text, chip_color, avatar_color = _calculate_status_chip(moisture) - data["stats"] = _format_value(moisture, "%") - data["chipText"] = chip_text - data["chipColor"] = chip_color - data["avatarColor"] = avatar_color - return data - - -def _score_field(value, field): - min_value = field["ideal_min"] - max_value = field["ideal_max"] - midpoint = (min_value + max_value) / 2 - half_span = max((max_value - min_value) / 2, 0.1) - distance = abs(value - midpoint) - - if min_value <= value <= max_value: - return round(max(80.0, 100.0 - ((distance / half_span) * 20.0)), 1) - - overflow = max(0.0, distance - half_span) - return round(max(0.0, 80.0 - ((overflow / half_span) * 80.0)), 1) - - -def get_sensor_7_in_1_radar_chart_data(farm=None, context=None): - data = deepcopy(SENSOR_RADAR_CHART) - context = _get_sensor_context(farm) if context is None else context - if not context: - return data - - latest_readings = context["latest_readings"] - scores = [] - labels = [] - for field in SENSOR_FIELDS: - value = latest_readings.get(field["id"]) - if value is None: - continue - labels.append(field["radar_label"]) - scores.append(_score_field(value, field)) - - if labels: - data["labels"] = labels - data["series"] = [ - {"name": "اکنون", "data": scores}, - {"name": "هدف", "data": [100.0] * len(labels)}, - ] - return data - - -def get_sensor_7_in_1_comparison_chart_data(farm=None, context=None): - data = deepcopy(SENSOR_COMPARISON_CHART) - context = _get_sensor_context(farm) if context is None else context - if not context: - return data - - history = list(reversed(context["history"][:MAX_CHART_POINTS])) - moisture_points = [ - (log.created_at.strftime("%m/%d %H:%M"), readings.get("soil_moisture")) - for log, readings in history - if readings.get("soil_moisture") is not None - ] - if not moisture_points: - return data - - categories = [item[0] for item in moisture_points] - values = [round(item[1], 2) for item in moisture_points] - current_value = values[-1] - baseline_value = values[0] if len(values) > 1 else 55.0 - percent_change = 0.0 - if baseline_value: - percent_change = ((current_value - baseline_value) / baseline_value) * 100 - - data["currentValue"] = round(current_value, 2) - data["vsLastWeekValue"] = round(percent_change, 2) - data["vsLastWeek"] = f"{percent_change:+.1f}%" - data["categories"] = categories - data["series"] = [ - {"name": "رطوبت خاک", "data": values}, - {"name": "بازه هدف", "data": [55.0] * len(values)}, - ] - return data - - -def _build_anomaly_item(field, value): - lower = field["ideal_min"] - upper = field["ideal_max"] - if lower <= value <= upper: - return None - - deviation = value - upper if value > upper else value - lower - severity = "warning" - span = max(upper - lower, 0.1) - if abs(deviation) >= span * 0.5: - severity = "error" - - sign = "+" if deviation > 0 else "" - return { - "sensor": field["label"], - "value": _format_value(value, field["unit"]), - "expected": _format_range(field), - "deviation": f"{sign}{_format_value(deviation, field['unit'])}", - "severity": severity, - } - - -def get_sensor_7_in_1_anomaly_detection_card_data(farm=None, context=None): - data = deepcopy(ANOMALY_DETECTION_CARD) - context = _get_sensor_context(farm) if context is None else context - if not context: - return data - - anomalies = [] - for field in SENSOR_FIELDS: - value = context["latest_readings"].get(field["id"]) - if value is None: - continue - anomaly = _build_anomaly_item(field, value) - if anomaly is not None: - anomalies.append(anomaly) - - if anomalies: - data["anomalies"] = anomalies - else: - data["anomalies"] = [ - { - "sensor": "سنسور 7 در 1 خاک", - "value": "نرمال", - "expected": "تمام شاخص‌ها در بازه مجاز هستند", - "deviation": "0", - "severity": "success", - } - ] - - return data - - -def get_sensor_7_in_1_soil_moisture_heatmap_data(farm=None, context=None): - data = deepcopy(SOIL_MOISTURE_HEATMAP) - context = _get_sensor_context(farm) if context is None else context - if not context: - return data - - history = list(reversed(context["history"][:MAX_CHART_POINTS])) - chart_points = [ - {"x": log.created_at.strftime("%H:%M"), "y": round(readings.get("soil_moisture"), 2)} - for log, readings in history - if readings.get("soil_moisture") is not None - ] - if not chart_points: - return data - - sensor_name = data["zones"][0] - farm_sensor = context.get("farm_sensor") - if farm_sensor is not None and farm_sensor.name: - sensor_name = farm_sensor.name - - data["zones"] = [sensor_name] - data["hours"] = [point["x"] for point in chart_points] - data["series"] = [{"name": sensor_name, "data": chart_points}] - return data - - -def get_sensor_7_in_1_summary_data(farm=None): - context = _get_sensor_context(farm) - values_list = get_sensor_7_in_1_values_list_data(farm, context=context) - return { - "sensor": values_list["sensor"], - "sensorValuesList": values_list, - "avgSoilMoisture": get_sensor_7_in_1_avg_soil_moisture_data(farm, context=context), - "sensorRadarChart": get_sensor_7_in_1_radar_chart_data(farm, context=context), - "sensorComparisonChart": get_sensor_7_in_1_comparison_chart_data(farm, context=context), - "anomalyDetectionCard": get_sensor_7_in_1_anomaly_detection_card_data(farm, context=context), - "soilMoistureHeatmap": get_sensor_7_in_1_soil_moisture_heatmap_data(farm, context=context), - } - - -def _normalize_comparison_chart_field(field_name): - return COMPARISON_CHART_FIELD_ALIASES.get(field_name, field_name) - - -def _format_comparison_category(bucket_date, range_value): - if range_value == "7d": - return PERSIAN_WEEKDAYS[bucket_date.weekday()] - return bucket_date.strftime("%m/%d") - - -def _format_percent_change(current_value, baseline_value): - if not baseline_value: - return "+0.0%" - percent_change = ((current_value - baseline_value) / baseline_value) * 100 - return f"{percent_change:+.1f}%" - - -def _format_current_value_subtitle(title, value, unit): - rendered_value = _format_value(value, unit) - return f"مقدار فعلی: {rendered_value or title}" - - -def get_sensor_comparison_chart_data(*, farm, physical_device_uuid, range_value): - days = COMPARISON_CHART_RANGES[range_value] - start_date = timezone.localdate() - timedelta(days=days - 1) - - try: - logs_queryset = get_sensor_external_request_logs_for_farm( - farm_uuid=farm.farm_uuid, - physical_device_uuid=physical_device_uuid, - date_from=start_date, - ) - except ValueError: - return {"series": [], "categories": [], "currentValue": 0.0, "vsLastWeek": "+0.0%"} - - grouped_logs = {} - for log in reversed(list(logs_queryset[: days * 24])): - bucket_date = timezone.localtime(log.created_at).date() - numeric_payload = _extract_numeric_payload(log.payload) - if not numeric_payload: - continue - grouped_logs[bucket_date] = numeric_payload - - if not grouped_logs: - return {"series": [], "categories": [], "currentValue": 0.0, "vsLastWeek": "+0.0%"} - - sorted_dates = sorted(grouped_logs.keys()) - categories = [_format_comparison_category(bucket_date, range_value) for bucket_date in sorted_dates] - - series_map = {} - for bucket_date in sorted_dates: - payload = grouped_logs[bucket_date] - normalized_payload = {} - for key, value in payload.items(): - normalized_key = _normalize_comparison_chart_field(key) - normalized_payload[normalized_key] = value - for key, value in normalized_payload.items(): - series_map.setdefault(key, []).append(round(value, 2)) - - ordered_field_names = [ - field_name for field_name in COMPARISON_CHART_PRIMARY_FIELDS if field_name in series_map - ] + sorted(field_name for field_name in series_map if field_name not in COMPARISON_CHART_PRIMARY_FIELDS) - - series = [{"name": field_name, "data": series_map[field_name]} for field_name in ordered_field_names] - primary_field = ordered_field_names[0] - primary_data = series_map[primary_field] - - return { - "series": series, - "categories": categories, - "currentValue": round(primary_data[-1], 2), - "vsLastWeek": _format_percent_change(primary_data[-1], primary_data[0]), - } - - -def get_sensor_values_list_data(*, farm, physical_device_uuid, range_value): - start_time = timezone.now() - VALUES_LIST_RANGES[range_value] - - try: - logs_queryset = get_sensor_external_request_logs_for_farm( - farm_uuid=farm.farm_uuid, - physical_device_uuid=physical_device_uuid, - ) - except ValueError: - return {"sensors": []} - - logs = list(logs_queryset.filter(created_at__gte=start_time).order_by("created_at", "id")) - if not logs: - latest_log = logs_queryset.order_by("-created_at", "-id").first() - if latest_log is None: - return {"sensors": []} - logs = [latest_log] - - earliest_payload = {} - latest_payload = {} - for log in logs: - numeric_payload = { - _normalize_comparison_chart_field(key): value - for key, value in _extract_numeric_payload(log.payload).items() - } - if not numeric_payload: - continue - if not earliest_payload: - earliest_payload = numeric_payload - latest_payload = numeric_payload - - if not latest_payload: - return {"sensors": []} - - sensors = [] - for field_name, title, unit in VALUES_LIST_FIELDS: - current_value = latest_payload.get(field_name) - if current_value is None: - continue - - previous_value = earliest_payload.get(field_name, current_value) - delta = round(current_value - previous_value, 2) - sensors.append( - { - "title": title, - "subtitle": _format_current_value_subtitle(title, current_value, unit), - "trendNumber": abs(delta), - "trend": "positive" if delta >= 0 else "negative", - "unit": unit, - } - ) - - return {"sensors": sensors} - - -def get_sensor_radar_chart_data(*, farm, physical_device_uuid, range_value): - start_time = timezone.now() - RADAR_CHART_RANGES[range_value] - - try: - logs_queryset = get_sensor_external_request_logs_for_farm( - farm_uuid=farm.farm_uuid, - physical_device_uuid=physical_device_uuid, - ) - except ValueError: - return {"labels": [], "series": []} - - logs = list(logs_queryset.filter(created_at__gte=start_time).order_by("created_at", "id")) - if not logs: - latest_log = logs_queryset.order_by("-created_at", "-id").first() - if latest_log is None: - return {"labels": [], "series": []} - logs = [latest_log] - - latest_payload = {} - for log in logs: - numeric_payload = { - _normalize_comparison_chart_field(key): value - for key, value in _extract_numeric_payload(log.payload).items() - } - if numeric_payload: - latest_payload = numeric_payload - - if not latest_payload: - return {"labels": [], "series": []} - - labels = [] - current_data = [] - ideal_data = [] - for field_name, label, ideal_value in RADAR_CHART_FIELDS: - current_value = latest_payload.get(field_name) - if current_value is None: - continue - labels.append(label) - current_data.append(round(current_value, 2)) - ideal_data.append(round(ideal_value, 2)) - - return { - "labels": labels, - "series": [ - {"name": "وضعیت فعلی", "data": current_data}, - {"name": "بازه ایده آل", "data": ideal_data}, - ], - } diff --git a/sensor_7_in_1/tests.py b/sensor_7_in_1/tests.py deleted file mode 100644 index b88e10d..0000000 --- a/sensor_7_in_1/tests.py +++ /dev/null @@ -1,281 +0,0 @@ -from datetime import datetime, timedelta, timezone as dt_timezone - -from django.contrib.auth import get_user_model -from django.test import TestCase, override_settings -from rest_framework.test import APIRequestFactory, force_authenticate - -from farm_hub.models import FarmHub, FarmSensor, FarmType -from sensor_catalog.models import SensorCatalog -from sensor_external_api.models import SensorExternalRequestLog - -from dashboard.services import get_farm_dashboard_cards - -from .seeds import seed_sensor_7_in_1_demo_data -from .services import ( - get_sensor_7_in_1_summary_data, - get_sensor_comparison_chart_data, - get_primary_soil_sensor, - get_sensor_radar_chart_data, - get_sensor_values_list_data, -) -from .views import ( - Sensor7In1SummaryView, - SensorComparisonChartView, - SensorRadarChartView, - SensorValuesListView, -) - - -class Sensor7In1BaseTestCase(TestCase): - def setUp(self): - self.factory = APIRequestFactory() - self.user = get_user_model().objects.create_user( - username="sensor-7-in-1-user", - password="secret123", - email="sensor7@example.com", - phone_number="09120000017", - ) - self.farm_type = FarmType.objects.create(name="مزرعه سنسور 7 در 1") - self.farm = FarmHub.objects.create( - owner=self.user, - farm_type=self.farm_type, - name="Farm Sensor 7 in 1", - farm_uuid="11111111-1111-1111-1111-111111111111", - ) - self.sensor_catalog = SensorCatalog.objects.create( - code="sensor-7-in-1", - name="7 in 1 Soil Sensor", - returned_data_fields=[ - "soil_moisture", - "soil_temperature", - "soil_ph", - "electrical_conductivity", - "nitrogen", - "phosphorus", - "potassium", - ], - ) - self.sensor = FarmSensor.objects.create( - farm=self.farm, - sensor_catalog=self.sensor_catalog, - physical_device_uuid="33333333-3333-3333-3333-333333333333", - name="Soil Sensor 7-in-1", - sensor_type="soil_7_in_1", - ) - self.chart_sensor = FarmSensor.objects.create( - farm=self.farm, - sensor_catalog=self.sensor_catalog, - physical_device_uuid="44444444-4444-4444-4444-444444444444", - name="Comparison Sensor 7-in-1", - sensor_type="soil_7_in_1", - ) - SensorExternalRequestLog.objects.create( - farm_uuid=self.farm.farm_uuid, - sensor_catalog_uuid=self.sensor_catalog.uuid, - physical_device_uuid=self.sensor.physical_device_uuid, - payload={ - "soil_moisture": 41.0, - "soil_temperature": 21.0, - "soil_ph": 6.5, - "electrical_conductivity": 1.0, - "nitrogen": 28.0, - "phosphorus": 14.0, - "potassium": 19.0, - }, - ) - self.latest_log = SensorExternalRequestLog.objects.create( - farm_uuid=self.farm.farm_uuid, - sensor_catalog_uuid=self.sensor_catalog.uuid, - physical_device_uuid=self.sensor.physical_device_uuid, - payload={ - "soil_moisture": 48.5, - "soil_temperature": 23.2, - "soil_ph": 6.8, - "electrical_conductivity": 1.4, - "nitrogen": 31.0, - "phosphorus": 16.0, - "potassium": 24.0, - }, - ) - now_utc = datetime.now(dt_timezone.utc) - base_time = now_utc.replace(hour=12, minute=0, second=0, microsecond=0) - for index, moisture in enumerate([56, 58, 55, 60, 62, 61, 59]): - log = SensorExternalRequestLog.objects.create( - farm_uuid=self.farm.farm_uuid, - sensor_catalog_uuid=self.sensor_catalog.uuid, - physical_device_uuid=self.chart_sensor.physical_device_uuid, - payload={ - "moisture": moisture, - "temperature": round(26.2 + (index * 0.2), 1), - "humidity": 50 + index, - }, - ) - SensorExternalRequestLog.objects.filter(id=log.id).update( - created_at=base_time - timedelta(days=6 - index) - ) - - -class Sensor7In1ServiceTests(Sensor7In1BaseTestCase): - def test_primary_sensor_prefers_7_in_1_sensor_over_generic_soil_probe(self): - sensor = get_primary_soil_sensor(farm=self.farm) - - self.assertEqual(sensor.id, self.sensor.id) - self.assertEqual(str(sensor.physical_device_uuid), str(self.sensor.physical_device_uuid)) - - def test_summary_returns_latest_specific_sensor_data(self): - data = get_sensor_7_in_1_summary_data(self.farm) - - self.assertEqual(data["sensor"]["name"], "Soil Sensor 7-in-1") - self.assertEqual(data["sensor"]["physicalDeviceUuid"], str(self.sensor.physical_device_uuid)) - self.assertEqual(data["sensorValuesList"]["sensors"][0]["id"], "soil_moisture") - self.assertEqual(data["avgSoilMoisture"]["stats"], "48.5%") - self.assertEqual(data["sensorComparisonChart"]["currentValue"], 48.5) - self.assertEqual(data["soilMoistureHeatmap"]["series"][0]["name"], "Soil Sensor 7-in-1") - - def test_dashboard_cards_use_sensor_service_outputs(self): - cards = get_farm_dashboard_cards(self.farm) - - self.assertEqual(cards["sensorValuesList"]["sensor"]["physicalDeviceUuid"], str(self.sensor.physical_device_uuid)) - self.assertEqual(cards["sensorValuesList"]["sensors"][0]["title"], "48.5%") - self.assertEqual(cards["sensorRadarChart"]["series"][0]["name"], "اکنون") - self.assertEqual(cards["soilMoistureHeatmap"]["series"][0]["name"], "Soil Sensor 7-in-1") - self.assertEqual(cards["farmOverviewKpis"]["kpis"][2]["stats"], "48.5%") - - def test_comparison_chart_service_returns_raw_chart_data(self): - data = get_sensor_comparison_chart_data( - farm=self.farm, - physical_device_uuid=self.sensor.physical_device_uuid, - range_value="7d", - ) - - self.assertEqual(data["series"][0]["name"], "moisture") - self.assertEqual(data["series"][0]["data"], [41.0, 48.5]) - self.assertEqual(data["currentValue"], 48.5) - self.assertEqual(data["vsLastWeek"], "+18.3%") - self.assertEqual(len(data["categories"]), 2) - - def test_values_list_service_returns_formatted_sensor_items(self): - data = get_sensor_values_list_data( - farm=self.farm, - physical_device_uuid=self.sensor.physical_device_uuid, - range_value="7d", - ) - - self.assertEqual(data["sensors"][0]["title"], "Moisture") - self.assertEqual(data["sensors"][0]["subtitle"], "مقدار فعلی: 48.5%") - self.assertEqual(data["sensors"][0]["trendNumber"], 7.5) - self.assertEqual(data["sensors"][0]["trend"], "positive") - self.assertEqual(data["sensors"][1]["title"], "Temperature") - self.assertEqual(data["sensors"][1]["subtitle"], "مقدار فعلی: 23.2°C") - self.assertEqual(data["sensors"][1]["trend"], "positive") - - def test_radar_chart_service_returns_aligned_labels_and_series(self): - data = get_sensor_radar_chart_data( - farm=self.farm, - physical_device_uuid=self.sensor.physical_device_uuid, - range_value="7d", - ) - - self.assertEqual( - data["labels"], - ["Moisture", "Temperature", "PH", "EC", "Nitrogen", "Potassium"], - ) - self.assertEqual(data["series"][0]["name"], "وضعیت فعلی") - self.assertEqual(data["series"][0]["data"], [48.5, 23.2, 6.8, 1.4, 31.0, 24.0]) - self.assertEqual(data["series"][1]["name"], "بازه ایده آل") - self.assertEqual(data["series"][1]["data"], [60.0, 26.0, 6.5, 1.3, 42.0, 38.0]) - self.assertEqual(len(data["labels"]), len(data["series"][0]["data"])) - self.assertEqual(len(data["labels"]), len(data["series"][1]["data"])) - - -class Sensor7In1ViewTests(Sensor7In1BaseTestCase): - def test_summary_view_returns_sensor_cards(self): - request = self.factory.get(f"/api/sensor-7-in-1/summary/?farm_uuid={self.farm.farm_uuid}") - force_authenticate(request, user=self.user) - - response = Sensor7In1SummaryView.as_view()(request) - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.data["code"], 200) - self.assertEqual(response.data["data"]["sensor"]["sensorCatalogCode"], "sensor-7-in-1") - - def test_summary_view_requires_farm_uuid(self): - request = self.factory.get("/api/sensor-7-in-1/summary/") - force_authenticate(request, user=self.user) - - response = Sensor7In1SummaryView.as_view()(request) - - self.assertEqual(response.status_code, 400) - self.assertEqual(response.data["farm_uuid"][0], "This field is required.") - - def test_sensor_comparison_chart_view_returns_raw_payload(self): - request = self.factory.get( - ( - "/api/sensors/comparison-chart/" - f"?farm_uuid={self.farm.farm_uuid}" - ) - ) - force_authenticate(request, user=self.user) - - response = SensorComparisonChartView.as_view()(request) - - self.assertEqual(response.status_code, 200) - self.assertIn("series", response.data) - self.assertNotIn("code", response.data) - self.assertEqual(response.data["currentValue"], 48.5) - self.assertEqual(response.data["vsLastWeek"], "+18.3%") - - def test_sensor_values_list_view_returns_raw_payload(self): - request = self.factory.get( - ( - "/api/sensors/values-list/" - f"?farm_uuid={self.farm.farm_uuid}" - ) - ) - force_authenticate(request, user=self.user) - - response = SensorValuesListView.as_view()(request) - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.data["sensors"][0]["title"], "Moisture") - self.assertEqual(response.data["sensors"][0]["trendNumber"], 7.5) - self.assertEqual(response.data["sensors"][0]["trend"], "positive") - - def test_sensor_radar_chart_view_returns_raw_payload(self): - request = self.factory.get( - ( - "/api/sensors/radar-chart/" - f"?farm_uuid={self.farm.farm_uuid}" - ) - ) - force_authenticate(request, user=self.user) - - response = SensorRadarChartView.as_view()(request) - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.data["labels"], ["Moisture", "Temperature", "PH", "EC", "Nitrogen", "Potassium"]) - self.assertEqual(response.data["series"][0]["name"], "وضعیت فعلی") - self.assertEqual(response.data["series"][1]["name"], "بازه ایده آل") - self.assertEqual(len(response.data["labels"]), len(response.data["series"][0]["data"])) - - -@override_settings( - USE_EXTERNAL_API_MOCK=True, - CROP_ZONE_CHUNK_AREA_SQM=200000, -) -class Sensor7In1SeedTests(TestCase): - def test_seed_sensor_7_in_1_demo_data_creates_idempotent_sensor_logs(self): - first_result = seed_sensor_7_in_1_demo_data() - second_result = seed_sensor_7_in_1_demo_data() - - sensor = second_result["sensor"] - logs = SensorExternalRequestLog.objects.filter( - farm_uuid=second_result["farm"].farm_uuid, - physical_device_uuid=sensor.physical_device_uuid, - ) - - self.assertTrue(SensorCatalog.objects.filter(code="sensor-7-in-1").exists()) - self.assertEqual(first_result["farm"].id, second_result["farm"].id) - self.assertEqual(first_result["sensor"].id, second_result["sensor"].id) - self.assertEqual(logs.count(), 7) - self.assertEqual(logs.first().payload["soil_moisture"], 52.4) diff --git a/sensor_7_in_1/urls.py b/sensor_7_in_1/urls.py deleted file mode 100644 index d30047a..0000000 --- a/sensor_7_in_1/urls.py +++ /dev/null @@ -1,10 +0,0 @@ -from django.urls import path - -from .views import ( - Sensor7In1SummaryView, -) - - -urlpatterns = [ - path("summary/", Sensor7In1SummaryView.as_view(), name="sensor-7-in-1-summary"), -] diff --git a/sensor_7_in_1/views.py b/sensor_7_in_1/views.py deleted file mode 100644 index 6764064..0000000 --- a/sensor_7_in_1/views.py +++ /dev/null @@ -1,183 +0,0 @@ -from rest_framework import serializers, status -from drf_spectacular.utils import OpenApiParameter, OpenApiTypes, extend_schema -from rest_framework.permissions import IsAuthenticated -from rest_framework.response import Response -from rest_framework.views import APIView - -from config.swagger import code_response, farm_uuid_query_param -from farm_hub.models import FarmHub - -from soil.serializers import SoilComparisonChartSerializer, SoilRadarChartSerializer -from .serializers import ( - Sensor7In1SummarySerializer, - SensorComparisonChartQuerySerializer, - SensorComparisonChartResponseSerializer, - SensorRadarChartQuerySerializer, - SensorRadarChartResponseSerializer, - SensorValuesListQuerySerializer, - SensorValuesListResponseSerializer, -) -from .services import ( - get_sensor_comparison_chart_data, - get_sensor_7_in_1_comparison_chart_data, - get_sensor_7_in_1_radar_chart_data, - get_sensor_7_in_1_summary_data, - get_primary_soil_sensor, - get_sensor_radar_chart_data, - get_sensor_values_list_data, -) - - -class Sensor7In1SummaryView(APIView): - permission_classes = [IsAuthenticated] - required_feature_code = "sensor-7-in-1" - - @staticmethod - def _get_farm(request): - farm_uuid = request.query_params.get("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 - - @staticmethod - def _get_primary_sensor(*, farm): - sensor = get_primary_soil_sensor(farm=farm) - if sensor is None: - raise serializers.ValidationError({"farm_uuid": ["No sensor found for this farm."]}) - return sensor - - @extend_schema( - tags=["Sensor 7 in 1"], - parameters=[ - farm_uuid_query_param(required=True, description="UUID of the farm for sensor 7 in 1 summary.") - ], - responses={200: code_response("Sensor7In1SummaryResponse", data=Sensor7In1SummarySerializer())}, - ) - def get(self, request): - farm = self._get_farm(request) - return Response( - {"code": 200, "msg": "OK", "data": get_sensor_7_in_1_summary_data(farm)}, - status=status.HTTP_200_OK, - ) - - -class Sensor7In1RadarChartView(Sensor7In1SummaryView): - @extend_schema( - tags=["Sensor 7 in 1"], - parameters=[ - farm_uuid_query_param(required=True, description="UUID of the farm for sensor 7 in 1 radar chart.") - ], - responses={200: code_response("Sensor7In1RadarChartResponse", data=SoilRadarChartSerializer())}, - ) - def get(self, request): - farm = self._get_farm(request) - return Response( - {"code": 200, "msg": "OK", "data": get_sensor_7_in_1_radar_chart_data(farm)}, - status=status.HTTP_200_OK, - ) - - -class Sensor7In1ComparisonChartView(Sensor7In1SummaryView): - @extend_schema( - tags=["Sensor 7 in 1"], - parameters=[ - farm_uuid_query_param(required=True, description="UUID of the farm for sensor 7 in 1 comparison chart.") - ], - responses={200: code_response("Sensor7In1ComparisonChartResponse", data=SoilComparisonChartSerializer())}, - ) - def get(self, request): - farm = self._get_farm(request) - return Response( - {"code": 200, "msg": "OK", "data": get_sensor_7_in_1_comparison_chart_data(farm)}, - status=status.HTTP_200_OK, - ) - - -class SensorComparisonChartView(Sensor7In1SummaryView): - @extend_schema( - tags=["Sensor 7 in 1"], - parameters=[ - farm_uuid_query_param(required=True, description="UUID of the farm."), - OpenApiParameter( - name="range", - type=OpenApiTypes.STR, - location=OpenApiParameter.QUERY, - required=False, - description="Chart range, supported values: 7d, 30d. Defaults to 7d.", - ), - ], - responses={200: SensorComparisonChartResponseSerializer}, - ) - def get(self, request): - serializer = SensorComparisonChartQuerySerializer(data=request.query_params) - serializer.is_valid(raise_exception=True) - - farm = self._get_farm(request) - sensor = self._get_primary_sensor(farm=farm) - data = get_sensor_comparison_chart_data( - farm=farm, - physical_device_uuid=sensor.physical_device_uuid, - range_value=serializer.validated_data["range"], - ) - return Response(data, status=status.HTTP_200_OK) - - -class SensorValuesListView(Sensor7In1SummaryView): - @extend_schema( - tags=["Sensor 7 in 1"], - parameters=[ - farm_uuid_query_param(required=True, description="UUID of the farm."), - OpenApiParameter( - name="range", - type=OpenApiTypes.STR, - location=OpenApiParameter.QUERY, - required=False, - description="Values list range, supported values: 1h, 24h, 7d. Defaults to 7d.", - ), - ], - responses={200: SensorValuesListResponseSerializer}, - ) - def get(self, request): - serializer = SensorValuesListQuerySerializer(data=request.query_params) - serializer.is_valid(raise_exception=True) - - farm = self._get_farm(request) - sensor = self._get_primary_sensor(farm=farm) - data = get_sensor_values_list_data( - farm=farm, - physical_device_uuid=sensor.physical_device_uuid, - range_value=serializer.validated_data["range"], - ) - return Response(data, status=status.HTTP_200_OK) - - -class SensorRadarChartView(Sensor7In1SummaryView): - @extend_schema( - tags=["Sensor 7 in 1"], - parameters=[ - farm_uuid_query_param(required=True, description="UUID of the farm."), - OpenApiParameter( - name="range", - type=OpenApiTypes.STR, - location=OpenApiParameter.QUERY, - required=False, - description="Radar chart range, supported values: today, 7d, 30d. Defaults to 7d.", - ), - ], - responses={200: SensorRadarChartResponseSerializer}, - ) - def get(self, request): - serializer = SensorRadarChartQuerySerializer(data=request.query_params) - serializer.is_valid(raise_exception=True) - - farm = self._get_farm(request) - sensor = self._get_primary_sensor(farm=farm) - data = get_sensor_radar_chart_data( - farm=farm, - physical_device_uuid=sensor.physical_device_uuid, - range_value=serializer.validated_data["range"], - ) - return Response(data, status=status.HTTP_200_OK) diff --git a/sensor_catalog/SensorCatalogListView.md b/sensor_catalog/SensorCatalogListView.md deleted file mode 100644 index 3644b0e..0000000 --- a/sensor_catalog/SensorCatalogListView.md +++ /dev/null @@ -1,302 +0,0 @@ -# مستند API کاتالوگ سنسورها - -این فایل API ثبت‌شده در `sensor_catalog/urls.py` را به‌صورت کامل توضیح می‌دهد. - -## فایل route - -فایل route این app: - -`sensor_catalog/urls.py` - -محتوای آن: - -```python -from django.urls import path - -from .views import SensorCatalogListView - -urlpatterns = [ - path("", SensorCatalogListView.as_view(), name="sensor-catalog-list"), -] -``` - -## آدرس نهایی endpoint - -این route در `config/urls.py` این‌طور mount شده است: - -```python -path("api/sensor-catalog/", include("sensor_catalog.urls")) -``` - -پس آدرس نهایی API این است: - -`GET /api/sensor-catalog/` - -## هدف API - -این endpoint برای گرفتن لیست کاتالوگ سنسورها استفاده می‌شود. - -منظور از کاتالوگ سنسور، تعریف مرجع هر نوع سنسور است؛ مثلا: - -- کد سنسور -- نام سنسور -- توضیحات -- فیلدهای خروجی سنسور -- نمونه payload -- منبع تغذیه‌های پشتیبانی‌شده - -این API بیشتر برای frontend یا تنظیمات سیستم مفید است تا بداند چه نوع سنسورهایی در سیستم تعریف شده‌اند و هر سنسور چه ساختاری دارد. - -## View مربوطه - -این endpoint در فایل `sensor_catalog/views.py` پیاده‌سازی شده است: - -```python -class SensorCatalogListView(APIView): - permission_classes = [IsAuthenticated] -``` - -این یعنی: - -- فقط متد `GET` پشتیبانی می‌شود -- کاربر باید authenticated باشد - -## احراز هویت و دسترسی - -این View از: - -```python -permission_classes = [IsAuthenticated] -``` - -استفاده می‌کند. - -در این پروژه به‌صورت پیش‌فرض authentication از طریق JWT انجام می‌شود، چون در `config/settings.py` مقدار زیر تعریف شده: - -```python -"DEFAULT_AUTHENTICATION_CLASSES": [ - "rest_framework_simplejwt.authentication.JWTAuthentication", -] -``` - -پس اگر کاربر توکن معتبر نداشته باشد، این API پاسخ `401 Unauthorized` برمی‌گرداند. - -## رفتار endpoint - -در متد `get` این View: - -```python -def get(self, request): - sensors = SensorCatalog.objects.order_by("code") - data = SensorCatalogSerializer(sensors, many=True).data - return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK) -``` - -این منطق اجرا می‌شود: - -1. همه رکوردهای `SensorCatalog` از دیتابیس خوانده می‌شوند -2. خروجی بر اساس `code` مرتب می‌شود -3. داده‌ها با serializer به JSON تبدیل می‌شوند -4. پاسخ استاندارد با `code` و `msg` و `data` برگردانده می‌شود - -## مدل دیتابیس - -مدل این API در `sensor_catalog/models.py` قرار دارد: - -```python -class SensorCatalog(models.Model): - uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True) - code = models.CharField(max_length=255, unique=True, db_index=True) - name = models.CharField(max_length=255, unique=True, db_index=True) - description = models.TextField(blank=True, default="") - customizable_fields = models.JSONField(default=list, blank=True) - supported_power_sources = models.JSONField(default=list, blank=True) - returned_data_fields = models.JSONField(default=list, blank=True) - sample_payload = models.JSONField(default=dict, blank=True) - is_active = models.BooleanField(default=True) - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) -``` - -### معنی فیلدها - -- `uuid`: شناسه یکتا برای هر کاتالوگ -- `code`: کد یکتا و فنی سنسور -- `name`: نام قابل نمایش سنسور -- `description`: توضیح سنسور -- `customizable_fields`: فیلدهایی که موقع ساخت/پیکربندی سنسور ممکن است قابل تنظیم باشند -- `supported_power_sources`: نوع منبع تغذیه‌های پشتیبانی‌شده -- `returned_data_fields`: فیلدهایی که این سنسور در payload خود برمی‌گرداند -- `sample_payload`: یک نمونه payload برای درک ساختار داده -- `is_active`: فعال یا غیرفعال بودن این کاتالوگ -- `created_at` و `updated_at`: زمان ایجاد و آخرین بروزرسانی - -## Serializer خروجی - -serializer این endpoint در `sensor_catalog/serializers.py` تعریف شده است: - -```python -class SensorCatalogSerializer(serializers.ModelSerializer): - class Meta: - model = SensorCatalog - fields = [ - "uuid", - "code", - "name", - "description", - "customizable_fields", - "supported_power_sources", - "returned_data_fields", - "sample_payload", - "is_active", - ] - read_only_fields = fields -``` - -### نکته مهم - -این serializer فقط این فیلدها را در خروجی برمی‌گرداند: - -- `uuid` -- `code` -- `name` -- `description` -- `customizable_fields` -- `supported_power_sources` -- `returned_data_fields` -- `sample_payload` -- `is_active` - -پس فیلدهای `created_at` و `updated_at` در پاسخ این API نیستند. - -## ورودی API - -این endpoint ورودی body یا query param خاصی ندارد. - -فقط کافی است کاربر authenticated باشد. - -### نمونه درخواست - -```http -GET /api/sensor-catalog/ -Authorization: Bearer -``` - -## خروجی موفق - -نمونه پاسخ موفق: - -```json -{ - "code": 200, - "msg": "success", - "data": [ - { - "uuid": "11111111-1111-1111-1111-111111111111", - "code": "sensor_7_soil_moisture_sensor_v1_2", - "name": "Sensor 7 - Soil Moisture Sensor v1.2", - "description": "Measures only soil moisture using electrical resistance between two metal probes.", - "customizable_fields": [], - "supported_power_sources": ["solar", "direct_power"], - "returned_data_fields": ["soil_moisture", "analog_output", "digital_output"], - "sample_payload": { - "soil_moisture": 42, - "analog_output": 610, - "digital_output": 1 - }, - "is_active": true - }, - { - "uuid": "22222222-2222-2222-2222-222222222222", - "code": "legacy_sensor", - "name": "Legacy Sensor", - "description": "", - "customizable_fields": [], - "supported_power_sources": ["direct_power"], - "returned_data_fields": ["status"], - "sample_payload": { - "status": "offline" - }, - "is_active": false - } - ] -} -``` - -## ترتیب خروجی - -خروجی با این دستور مرتب می‌شود: - -```python -SensorCatalog.objects.order_by("code") -``` - -یعنی لیست همیشه بر اساس `code` به صورت صعودی برگردانده می‌شود. - -## سناریوهای کاربردی - -این API معمولا برای این موارد استفاده می‌شود: - -- ساخت dropdown برای انتخاب نوع سنسور -- نمایش ساختار داده قابل انتظار از یک سنسور -- فهمیدن اینکه هر سنسور چه فیلدهایی برمی‌گرداند -- ساخت فرم‌های داینامیک برای پیکربندی سنسور -- نمایش `sample_payload` در Swagger یا UI مدیریتی - -## وضعیت‌های خطا - -### 401 Unauthorized - -اگر کاربر login نباشد یا توکن معتبر نداشته باشد. - -### 200 با لیست خالی - -اگر هیچ رکوردی در جدول `sensor_catalogs` وجود نداشته باشد، پاسخ موفق است اما `data` خالی خواهد بود: - -```json -{ - "code": 200, - "msg": "success", - "data": [] -} -``` - -## تست موجود - -برای این endpoint تست در فایل `sensor_catalog/tests.py` وجود دارد. - -تست اصلی بررسی می‌کند که: - -- کاربر authenticated بتواند endpoint را صدا بزند -- پاسخ `200` باشد -- همه سنسورهای موجود برگردانده شوند - -نمونه assertion: - -```python -self.assertEqual(response.status_code, 200) -self.assertEqual(response.data["code"], 200) -self.assertEqual(len(response.data["data"]), 2) -``` - -## خلاصه - -API موجود در `sensor_catalog/urls.py` فقط یک endpoint دارد: - -- `GET /api/sensor-catalog/` - -این endpoint: - -- نیاز به احراز هویت دارد -- همه کاتالوگ‌های سنسور را از دیتابیس می‌خواند -- آن‌ها را بر اساس `code` مرتب می‌کند -- اطلاعات ساختاری سنسورها را برای frontend یا پنل مدیریتی برمی‌گرداند - -## فایل‌های مرتبط - -- `sensor_catalog/urls.py` -- `sensor_catalog/views.py` -- `sensor_catalog/serializers.py` -- `sensor_catalog/models.py` -- `sensor_catalog/tests.py` -- `config/urls.py` diff --git a/sensor_catalog/__init__.py b/sensor_catalog/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/sensor_catalog/apps.py b/sensor_catalog/apps.py deleted file mode 100644 index f31c8a0..0000000 --- a/sensor_catalog/apps.py +++ /dev/null @@ -1,7 +0,0 @@ -from django.apps import AppConfig - - -class SensorCatalogConfig(AppConfig): - default_auto_field = "django.db.models.BigAutoField" - name = "sensor_catalog" - verbose_name = "Sensor Catalog" diff --git a/sensor_catalog/management/commands/__init__.py b/sensor_catalog/management/commands/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/sensor_catalog/migrations/0001_initial.py b/sensor_catalog/migrations/0001_initial.py deleted file mode 100644 index 2635466..0000000 --- a/sensor_catalog/migrations/0001_initial.py +++ /dev/null @@ -1,32 +0,0 @@ -# Generated by Django 5.2.12 on 2026-03-20 00:00 - -import uuid -from django.db import migrations, models - - -class Migration(migrations.Migration): - initial = True - - dependencies = [] - - operations = [ - migrations.CreateModel( - name="SensorCatalog", - fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("uuid", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True)), - ("name", models.CharField(db_index=True, max_length=255, unique=True)), - ("description", models.TextField(blank=True, default="")), - ("customizable_fields", models.JSONField(blank=True, default=list)), - ("returned_data_fields", models.JSONField(blank=True, default=list)), - ("sample_payload", models.JSONField(blank=True, default=dict)), - ("is_active", models.BooleanField(default=True)), - ("created_at", models.DateTimeField(auto_now_add=True)), - ("updated_at", models.DateTimeField(auto_now=True)), - ], - options={ - "db_table": "sensor_catalogs", - "ordering": ["name"], - }, - ), - ] diff --git a/sensor_catalog/migrations/0002_sensorcatalog_supported_power_sources.py b/sensor_catalog/migrations/0002_sensorcatalog_supported_power_sources.py deleted file mode 100644 index cc92124..0000000 --- a/sensor_catalog/migrations/0002_sensorcatalog_supported_power_sources.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 5.2.12 on 2026-03-20 01:00 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("sensor_catalog", "0001_initial"), - ] - - operations = [ - migrations.AddField( - model_name="sensorcatalog", - name="supported_power_sources", - field=models.JSONField(blank=True, default=list), - ), - ] diff --git a/sensor_catalog/migrations/0003_sensorcatalog_code.py b/sensor_catalog/migrations/0003_sensorcatalog_code.py deleted file mode 100644 index 92cb92a..0000000 --- a/sensor_catalog/migrations/0003_sensorcatalog_code.py +++ /dev/null @@ -1,44 +0,0 @@ -import re - -from django.db import migrations, models - - -def _to_snake_case(value): - normalized = re.sub(r"[^a-zA-Z0-9]+", "_", (value or "").strip()).strip("_").lower() - return normalized or "sensor" - - -def populate_sensor_codes(apps, schema_editor): - SensorCatalog = apps.get_model("sensor_catalog", "SensorCatalog") - - used_codes = set() - for sensor in SensorCatalog.objects.all().order_by("id"): - base_code = _to_snake_case(sensor.name) - code = base_code - suffix = 2 - while code in used_codes: - code = f"{base_code}_{suffix}" - suffix += 1 - sensor.code = code - sensor.save(update_fields=["code"]) - used_codes.add(code) - - -class Migration(migrations.Migration): - dependencies = [ - ("sensor_catalog", "0002_sensorcatalog_supported_power_sources"), - ] - - operations = [ - migrations.AddField( - model_name="sensorcatalog", - name="code", - field=models.CharField(blank=True, db_index=True, default="", max_length=255), - ), - migrations.RunPython(populate_sensor_codes, migrations.RunPython.noop), - migrations.AlterField( - model_name="sensorcatalog", - name="code", - field=models.CharField(db_index=True, max_length=255, unique=True), - ), - ] diff --git a/sensor_catalog/migrations/0004_alter_sensorcatalog_options.py b/sensor_catalog/migrations/0004_alter_sensorcatalog_options.py deleted file mode 100644 index 3b9df2f..0000000 --- a/sensor_catalog/migrations/0004_alter_sensorcatalog_options.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 5.1.15 on 2026-04-25 21:19 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('sensor_catalog', '0003_sensorcatalog_code'), - ] - - operations = [ - migrations.AlterModelOptions( - name='sensorcatalog', - options={'ordering': ['code']}, - ), - ] diff --git a/sensor_catalog/migrations/__init__.py b/sensor_catalog/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/sensor_catalog/models.py b/sensor_catalog/models.py deleted file mode 100644 index 939f4e3..0000000 --- a/sensor_catalog/models.py +++ /dev/null @@ -1,24 +0,0 @@ -import uuid - -from django.db import models - - -class SensorCatalog(models.Model): - uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True) - code = models.CharField(max_length=255, unique=True, db_index=True) - name = models.CharField(max_length=255, unique=True, db_index=True) - description = models.TextField(blank=True, default="") - customizable_fields = models.JSONField(default=list, blank=True) - supported_power_sources = models.JSONField(default=list, blank=True) - returned_data_fields = models.JSONField(default=list, blank=True) - sample_payload = models.JSONField(default=dict, blank=True) - is_active = models.BooleanField(default=True) - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - - class Meta: - db_table = "sensor_catalogs" - ordering = ["code"] - - def __str__(self): - return self.name diff --git a/sensor_catalog/serializers.py b/sensor_catalog/serializers.py deleted file mode 100644 index ee92677..0000000 --- a/sensor_catalog/serializers.py +++ /dev/null @@ -1,20 +0,0 @@ -from rest_framework import serializers - -from .models import SensorCatalog - - -class SensorCatalogSerializer(serializers.ModelSerializer): - class Meta: - model = SensorCatalog - fields = [ - "uuid", - "code", - "name", - "description", - "customizable_fields", - "supported_power_sources", - "returned_data_fields", - "sample_payload", - "is_active", - ] - read_only_fields = fields diff --git a/sensor_catalog/tests.py b/sensor_catalog/tests.py deleted file mode 100644 index b3fc9b7..0000000 --- a/sensor_catalog/tests.py +++ /dev/null @@ -1,57 +0,0 @@ -from django.contrib.auth import get_user_model -from django.test import TestCase -from rest_framework.test import APIRequestFactory, force_authenticate - -from sensor_catalog.models import SensorCatalog -from sensor_catalog.views import SensorCatalogListView - - -class SensorCatalogListViewTests(TestCase): - def setUp(self): - self.factory = APIRequestFactory() - self.user = get_user_model().objects.create_user( - username="sensor-user", - password="secret123", - email="sensor@example.com", - phone_number="09120000002", - ) - SensorCatalog.objects.update_or_create( - code="sensor_7_soil_moisture_sensor_v1_2", - name="Sensor 7 - Soil Moisture Sensor v1.2", - defaults={ - "description": ( - "Measures only soil moisture using electrical resistance between two metal probes. " - "Provides analog and digital outputs." - ), - "customizable_fields": [], - "supported_power_sources": ["solar", "direct_power"], - "returned_data_fields": ["soil_moisture", "analog_output", "digital_output"], - "sample_payload": {"soil_moisture": 42, "analog_output": 610, "digital_output": 1}, - "is_active": True, - }, - ) - SensorCatalog.objects.update_or_create( - code="legacy_sensor", - name="Legacy Sensor", - defaults={ - "customizable_fields": [], - "supported_power_sources": ["direct_power"], - "returned_data_fields": ["status"], - "sample_payload": {"status": "offline"}, - "is_active": False, - }, - ) - - def test_list_returns_all_existing_sensors(self): - request = self.factory.get("/api/sensor-catalog/") - force_authenticate(request, user=self.user) - - response = SensorCatalogListView.as_view()(request) - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.data["code"], 200) - self.assertEqual(len(response.data["data"]), 2) - self.assertEqual( - {item["code"] for item in response.data["data"]}, - {"sensor_7_soil_moisture_sensor_v1_2", "legacy_sensor"}, - ) diff --git a/sensor_catalog/views.py b/sensor_catalog/views.py deleted file mode 100644 index 68551db..0000000 --- a/sensor_catalog/views.py +++ /dev/null @@ -1,22 +0,0 @@ -from rest_framework import status -from rest_framework.permissions import IsAuthenticated -from rest_framework.response import Response -from rest_framework.views import APIView -from drf_spectacular.utils import extend_schema - -from config.swagger import code_response -from .models import SensorCatalog -from .serializers import SensorCatalogSerializer - - -class SensorCatalogListView(APIView): - permission_classes = [IsAuthenticated] - - @extend_schema( - tags=["Sensor Catalog"], - responses={200: code_response("SensorCatalogListResponse", data=SensorCatalogSerializer(many=True))}, - ) - def get(self, request): - sensors = SensorCatalog.objects.order_by("code") - data = SensorCatalogSerializer(sensors, many=True).data - return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK) diff --git a/sensor_external_api/SensorExternalAPIView.md b/sensor_external_api/SensorExternalAPIView.md deleted file mode 100644 index acc8eef..0000000 --- a/sensor_external_api/SensorExternalAPIView.md +++ /dev/null @@ -1,356 +0,0 @@ -# مستند API دریافت داده سنسور خارجی - -این فایل رفتار endpoint زیر را توضیح می‌دهد: - -`POST /api/sensor-external-api/` - -این API برای دریافت payload از یک سنسور فیزیکی، ثبت آن داخل دیتابیس، ساخت نوتیفیکیشن برای مزرعه، و سپس ارسال همان داده به سرویس AI/Farm Data استفاده می‌شود. - -## هدف API - -این endpoint وقتی صدا زده می‌شود که یک سنسور خارجی داده جدیدی تولید کرده باشد. بک‌اند در این مسیر چند کار پشت سر هم انجام می‌دهد: - -1. اعتبارسنجی API key -2. اعتبارسنجی `uuid` و `payload` -3. پیدا کردن سنسور بر اساس `physical_device_uuid` -4. ذخیره لاگ درخواست در جدول `sensor_external_request_logs` -5. ساخت notification برای مزرعه -6. ارسال داده به سرویس AI در endpoint مربوط به farm data - -## مسیر و View - -این endpoint در فایل `sensor_external_api/urls.py` ثبت شده است: - -```python -path("", SensorExternalAPIView.as_view(), name="sensor-external-api") -``` - -پیاده‌سازی view در فایل `sensor_external_api/views.py` قرار دارد: - -```python -class SensorExternalAPIView(APIView): - authentication_classes = [SensorExternalAPIKeyAuthentication] - permission_classes = [AllowAny] -``` - -## احراز هویت - -این API از هدر `X-API-Key` استفاده می‌کند. - -کلاس احراز هویت: - -`sensor_external_api/authentication.py` - -رفتار آن: - -- اگر `X-API-Key` یا `Authorization` ارسال نشود، پاسخ `401` می‌دهد. -- اگر مقدار کلید اشتباه باشد، پاسخ `401` می‌دهد. -- مقدار مورد انتظار از `SENSOR_EXTERNAL_API_KEY` خوانده می‌شود. - -## ورودی درخواست - -serializer ورودی در فایل `sensor_external_api/serializers.py` تعریف شده است: - -```python -class SensorExternalRequestSerializer(serializers.Serializer): - uuid = serializers.UUIDField() - payload = serializers.JSONField(required=False, default=dict) -``` - -### بدنه نمونه درخواست - -```json -{ - "uuid": "22222222-2222-2222-2222-222222222222", - "payload": { - "moisture_percent": 32.5, - "temperature_c": 21.3, - "ph": 6.7, - "ec_ds_m": 1.1, - "nitrogen_mg_kg": 42, - "phosphorus_mg_kg": 18, - "potassium_mg_kg": 210 - } -} -``` - -نکته: - -- `uuid` در این API همان `physical_device_uuid` سنسور است. -- `payload` به همان شکلی که از سنسور می‌آید ذخیره و forward می‌شود. - -## روند اجرای API - -### 1) اعتبارسنجی request - -در متد `post` ابتدا داده ورودی validate می‌شود: - -```python -serializer = SensorExternalRequestSerializer(data=request.data) -serializer.is_valid(raise_exception=True) -``` - -اگر `uuid` معتبر نباشد یا ساختار body خراب باشد، DRF خطای `400` برمی‌گرداند. - -### 2) ثبت لاگ و ساخت نوتیفیکیشن - -سپس این سرویس صدا زده می‌شود: - -```python -notification = create_sensor_external_notification( - physical_device_uuid=serializer.validated_data["uuid"], - payload=serializer.validated_data.get("payload"), -) -``` - -این تابع در فایل `sensor_external_api/services.py` قرار دارد. - -کارهایی که انجام می‌دهد: - -- سنسور را از جدول `FarmSensor` با `physical_device_uuid` پیدا می‌کند. -- اگر سنسور پیدا نشود، `ValueError("Physical device not found.")` می‌دهد. -- یک رکورد در جدول `sensor_external_request_logs` می‌سازد. -- یک notification برای مزرعه می‌سازد. - -### رکوردی که در دیتابیس ذخیره می‌شود - -مدل ذخیره‌سازی: - -`sensor_external_api/models.py` - -```python -class SensorExternalRequestLog(models.Model): - farm_uuid = models.UUIDField(db_index=True) - sensor_catalog_uuid = models.UUIDField(null=True, blank=True, db_index=True) - physical_device_uuid = models.UUIDField(db_index=True) - payload = models.JSONField(default=dict, blank=True) - created_at = models.DateTimeField(auto_now_add=True) -``` - -یعنی payload خام سنسور برای گزارش‌گیری و استفاده‌های بعدی نگه داشته می‌شود. - -### 3) ارسال داده به سرویس AI / Farm Data - -بعد از ثبت لاگ، این سرویس صدا زده می‌شود: - -```python -forward_sensor_payload_to_farm_data( - physical_device_uuid=serializer.validated_data["uuid"], - payload=serializer.validated_data.get("payload"), -) -``` - -این قسمت مهم‌ترین call خارجی endpoint است. - -## این API چه آدرسی از AI را صدا می‌زند؟ - -سرویس خارجی از طریق `external_api_adapter.request` صدا زده می‌شود: - -```python -response = external_api_request( - "ai", - _get_farm_data_path(), - method="POST", - payload=request_payload, - headers={...}, -) -``` - -### service name - -مقدار service برابر است با: - -`"ai"` - -یعنی این درخواست به سرویسی می‌رود که در تنظیمات به عنوان AI service تعریف شده است. - -### base URL سرویس AI - -در `config/settings.py`: - -```python -"ai": { - "base_url": os.getenv("AI_SERVICE_BASE_URL", "http://ai-web:8000"), - "api_key": os.getenv("AI_SERVICE_API_KEY", ""), - "host_header": os.getenv("AI_SERVICE_HOST_HEADER", "localhost"), -} -``` - -پس base URL به‌صورت پیش‌فرض این است: - -`http://ai-web:8000` - -### path مقصد - -path از این تنظیم خوانده می‌شود: - -```python -FARM_DATA_API_PATH = os.getenv("FARM_DATA_API_PATH", "/api/farm-data/") -``` - -پس path پیش‌فرض این است: - -`/api/farm-data/` - -### آدرس نهایی که صدا زده می‌شود - -در حالت پیش‌فرض، آدرس نهایی به این صورت است: - -`POST http://ai-web:8000/api/farm-data/` - -اگر متغیرهای environment تغییر کرده باشند، این آدرس هم تغییر می‌کند. - -## چرا این آدرس صدا زده می‌شود؟ - -هدف از این call این است که داده سنسور خام فقط در بک‌اند ذخیره نشود، بلکه برای پردازش downstream هم به سرویس AI/Farm Data فرستاده شود. - -این سرویس AI احتمالا برای کارهای زیر استفاده می‌شود: - -- تحلیل داده سنسورها در سطح مزرعه -- ساخت داده تجمیعی farm data -- تغذیه dashboardها و مدل‌های AI -- محاسبه شاخص‌ها یا توصیه‌های بعدی - -خود این endpoint در این پروژه فقط داده را forward می‌کند و پردازش AI داخل همین اپ انجام نمی‌شود. - -## چه payloadی به AI ارسال می‌شود؟ - -قبل از ارسال، بک‌اند این ساختار را می‌سازد: - -```python -request_payload = { - "farm_uuid": str(sensor.farm.farm_uuid), - "farm_boundary": farm_boundary, - "sensor_payload": { - sensor.name or str(sensor.physical_device_uuid): payload, - }, -} -``` - -یعنی payload ارسال‌شده به AI دقیقا body اولیه کاربر نیست، بلکه این wrapper را دارد: - -```json -{ - "farm_uuid": "11111111-1111-1111-1111-111111111111", - "farm_boundary": { - "type": "Polygon", - "coordinates": [[[51.39, 35.7], [51.41, 35.7], [51.41, 35.72], [51.39, 35.72], [51.39, 35.7]]] - }, - "sensor_payload": { - "Soil Sensor 7-in-1": { - "moisture_percent": 32.5, - "temperature_c": 21.3, - "ph": 6.7, - "ec_ds_m": 1.1, - "nitrogen_mg_kg": 42, - "phosphorus_mg_kg": 18, - "potassium_mg_kg": 210 - } - } -} -``` - -## farm_boundary از کجا می‌آید؟ - -سرویس `_get_farm_boundary` این منطق را دارد: - -- اگر `farm.current_crop_area` وجود داشته باشد، از آن استفاده می‌کند. -- اگر وجود نداشته باشد، آخرین crop area مزرعه را برمی‌دارد. -- اگر هیچ boundary وجود نداشته باشد، خطا می‌دهد. -- اگر geometry از نوع `Polygon` نباشد، خطا می‌دهد. - -پس سرویس AI فقط وقتی صدا زده می‌شود که مرز مزرعه معتبر وجود داشته باشد. - -## هدرهایی که به AI ارسال می‌شوند - -در زمان forward کردن، این هدرها ارسال می‌شوند: - -```python -headers={ - "Accept": "application/json", - "Content-Type": "application/json", - "X-API-Key": api_key, - "Authorization": f"Api-Key {api_key}", -} -``` - -`api_key` از این setting می‌آید: - -`FARM_DATA_API_KEY` - -اگر این مقدار ست نشده باشد، پاسخ `503` برمی‌گردد. - -## پاسخ موفق - -اگر همه چیز درست باشد: - -- لاگ ذخیره می‌شود -- notification ساخته می‌شود -- داده به AI forward می‌شود -- پاسخ `201` برمی‌گردد - -نمونه ساختار پاسخ: - -```json -{ - "code": 201, - "msg": "success", - "data": { - "...": "serialized notification object" - } -} -``` - -نکته: - -data خروجی این endpoint نتیجه AI نیست. خروجی، notification ساخته‌شده در سیستم خود بک‌اند است. - -## خطاهای ممکن - -### 401 Unauthorized - -اگر API key ارسال نشود یا اشتباه باشد. - -### 404 Not Found - -اگر `physical_device_uuid` در جدول `FarmSensor` پیدا نشود. - -پاسخ: - -```json -{ - "code": 404, - "msg": "Physical device not found." -} -``` - -### 503 Service Unavailable - -در چند حالت: - -- migration جدول‌ها انجام نشده باشد -- `FARM_DATA_API_KEY` تنظیم نشده باشد -- مرز مزرعه موجود نباشد -- geometry مزرعه `Polygon` نباشد -- سرویس AI در دسترس نباشد -- سرویس AI پاسخ خطای 4xx/5xx بدهد - -نمونه خطا: - -```json -{ - "code": 503, - "msg": "Farm data API request failed: connection error" -} -``` - -## خلاصه رفتاری endpoint - -`POST /api/sensor-external-api/` این کارها را انجام می‌دهد: - -1. داده سنسور را از بیرون می‌گیرد. -2. سنسور را با `physical_device_uuid` پیدا می‌کند. -3. payload را در جدول لاگ ذخیره می‌کند. -4. برای مزرعه notification می‌سازد. -5. داده را به سرویس AI در آدرس پیش‌فرض `POST http://ai-web:8000/api/farm-data/` می‌فرستد. -6. در نهایت نتیجه موفقیت را با notification برمی‌گرداند. diff --git a/sensor_external_api/SensorExternalRequestLogListAPIView.md b/sensor_external_api/SensorExternalRequestLogListAPIView.md deleted file mode 100644 index f6a4234..0000000 --- a/sensor_external_api/SensorExternalRequestLogListAPIView.md +++ /dev/null @@ -1,317 +0,0 @@ -# مستند API لاگ درخواست های سنسور خارجی - -این فایل نحوه کار endpoint زیر را توضیح می دهد: - -`GET /sensor_external_api/logs/` - -مسیر مربوطه در فایل `sensor_external_api/urls.py` به این صورت ثبت شده است: - -```python -path("logs/", SensorExternalRequestLogListAPIView.as_view(), name="sensor-external-api-log-list") -``` - -## هدف API - -این API برای مشاهده لیست لاگ درخواست هایی استفاده می شود که از سنسورهای خارجی برای یک مزرعه مشخص ثبت شده اند. - -هر لاگ شامل اطلاعات زیر است: -- شناسه لاگ -- `farm_uuid` -- `sensor_catalog_uuid` -- `physical_device_uuid` -- `payload` دریافتی از سنسور -- زمان ثبت لاگ -- اطلاعات سنسور مزرعه (`farm_sensor`) -- اطلاعات کاتالوگ سنسور (`sensor_catalog`) - -## کلاس View - -این endpoint در کلاس `SensorExternalRequestLogListAPIView` داخل فایل `sensor_external_api/views.py` پیاده سازی شده است. - -ویژگی های مهم این View: -- فقط متد `GET` را پشتیبانی می کند. -- نیاز به احراز هویت دارد. -- از صفحه بندی استفاده می کند. -- لاگ ها را بر اساس `farm_uuid` فیلتر می کند. - -## احراز هویت و دسترسی - -در این View مقدار زیر تعریف شده است: - -```python -permission_classes = [IsAuthenticated] -``` - -یعنی کاربر باید authenticated باشد تا بتواند این API را صدا بزند. - -نکته مهم: -در تست ها، اگر درخواست بدون اعتبارنامه ارسال شود، پاسخ `401 Unauthorized` برمی گردد. - -## پارامترهای ورودی - -این API پارامترهای query string زیر را دریافت می کند: - -- `farm_uuid` اجباری -- `page` اختیاری، پیش فرض `1` -- `page_size` اختیاری، پیش فرض `20` و حداکثر `100` - -اعتبارسنجی پارامترها توسط `SensorExternalRequestLogQuerySerializer` در فایل `sensor_external_api/serializers.py` انجام می شود: - -```python -class SensorExternalRequestLogQuerySerializer(serializers.Serializer): - farm_uuid = serializers.UUIDField() - page = serializers.IntegerField(required=False, min_value=1, default=1) - page_size = serializers.IntegerField(required=False, min_value=1, max_value=100, default=20) -``` - -### نمونه درخواست - -```http -GET /api/sensor-external-api/logs/?farm_uuid=11111111-1111-1111-1111-111111111111&page=1&page_size=20 -``` - -## روند اجرای API - -### 1) اعتبارسنجی query params -ابتدا `request.query_params` توسط serializer اعتبارسنجی می شود: - -```python -serializer = SensorExternalRequestLogQuerySerializer(data=request.query_params) -serializer.is_valid(raise_exception=True) -``` - -اگر `farm_uuid` معتبر نباشد یا `page_size` خارج از بازه باشد، پاسخ خطای validation از DRF برمی گردد. - -### 2) گرفتن لاگ های مربوط به مزرعه -سپس سرویس `get_sensor_external_request_logs_for_farm` فراخوانی می شود: - -```python -queryset = get_sensor_external_request_logs_for_farm( - farm_uuid=serializer.validated_data["farm_uuid"], -) -``` - -این سرویس در فایل `sensor_external_api/services.py` تعریف شده و لاگ ها را از جدول `sensor_external_request_logs` می خواند: - -```python -SensorExternalRequestLog.objects.filter(farm_uuid=farm_uuid).order_by("-created_at", "-id") -``` - -پس ترتیب خروجی به این صورت است: -- جدیدترین لاگ ها اول نمایش داده می شوند. -- اگر `created_at` برابر باشد، لاگ با `id` بزرگ تر زودتر می آید. - -### 3) مدیریت خطای migration -اگر جدول های لازم هنوز migrate نشده باشند، سرویس خطا را به `ValueError` تبدیل می کند و View این پاسخ را برمی گرداند: - -```json -{ - "code": 503, - "msg": "Required tables are not ready. Run migrations." -} -``` - -با status code برابر با `503 Service Unavailable`. - -### 4) صفحه بندی نتایج -این View از paginator زیر استفاده می کند: - -```python -class SensorExternalRequestLogPagination(PageNumberPagination): - page_size = 20 - page_size_query_param = "page_size" - max_page_size = 100 -``` - -و در View نیز `page_size` از داده معتبرشده serializer روی paginator اعمال می شود: - -```python -paginator = self.pagination_class() -paginator.page_size = serializer.validated_data["page_size"] -page = paginator.paginate_queryset(queryset, request, view=self) -``` - -### 5) ساخت map از سنسورهای مزرعه -برای اینکه هر لاگ همراه با اطلاعات سنسور مزرعه و کاتالوگ سنسور برگردد، این سرویس صدا زده می شود: - -```python -farm_sensor_map = get_farm_sensor_map_for_logs(logs=page) -``` - -این سرویس: -- لاگ های همان page را می گیرد. -- `FarmSensor` های متناظر را از دیتابیس پیدا می کند. -- یک map با کلید زیر می سازد: - -```python -(farm_uuid, sensor_catalog_uuid, physical_device_uuid) -``` - -به کمک این map، serializer می تواند برای هر لاگ اطلاعات تکمیلی را پر کند. - -## ساختار serializer خروجی - -خروجی هر آیتم با `SensorExternalRequestLogSerializer` ساخته می شود. - -فیلدهای اصلی: - -```python -fields = [ - "id", - "farm_uuid", - "sensor_catalog_uuid", - "physical_device_uuid", - "farm_sensor", - "sensor_catalog", - "payload", - "created_at", -] -``` - -### فیلد `farm_sensor` -این فیلد از نوع `SerializerMethodField` است. -اگر سنسور متناظر پیدا شود، اطلاعاتی مثل موارد زیر را برمی گرداند: -- `uuid` -- `sensor_catalog_uuid` -- `physical_device_uuid` -- `name` -- `sensor_type` -- `is_active` -- `specifications` -- `power_source` -- `created_at` -- `updated_at` - -اگر سنسور پیدا نشود، مقدار آن `null` خواهد بود. - -### فیلد `sensor_catalog` -این فیلد هم از نوع `SerializerMethodField` است. -اگر `farm_sensor` و `sensor_catalog` موجود باشند، اطلاعات کاتالوگ سنسور برمی گردد، مثل: -- `uuid` -- `code` -- `name` -- `description` -- `customizable_fields` -- `supported_power_sources` -- `returned_data_fields` -- `sample_payload` -- `is_active` -- `created_at` -- `updated_at` - -اگر داده متناظر وجود نداشته باشد، مقدار آن `null` خواهد بود. - -## مدل لاگ - -لاگ ها در مدل `SensorExternalRequestLog` ذخیره می شوند: - -```python -class SensorExternalRequestLog(models.Model): - farm_uuid = models.UUIDField(db_index=True) - sensor_catalog_uuid = models.UUIDField(null=True, blank=True, db_index=True) - physical_device_uuid = models.UUIDField(db_index=True) - payload = models.JSONField(default=dict, blank=True) - created_at = models.DateTimeField(auto_now_add=True) -``` - -ویژگی مهم مدل: -- جدول دیتابیس: `sensor_external_request_logs` -- ترتیب پیش فرض: `ordering = ["-created_at", "-id"]` - -## نمونه پاسخ موفق - -```json -{ - "code": 200, - "msg": "success", - "count": 2, - "next": "http://example.com/api/sensor-external-api/logs/?farm_uuid=11111111-1111-1111-1111-111111111111&page=2&page_size=1", - "previous": null, - "data": [ - { - "id": 12, - "farm_uuid": "11111111-1111-1111-1111-111111111111", - "sensor_catalog_uuid": "22222222-2222-2222-2222-222222222222", - "physical_device_uuid": "55555555-5555-5555-5555-555555555555", - "farm_sensor": { - "uuid": "99999999-9999-9999-9999-999999999999", - "sensor_catalog_uuid": "22222222-2222-2222-2222-222222222222", - "physical_device_uuid": "55555555-5555-5555-5555-555555555555", - "name": "External device 2", - "sensor_type": "soil_sensor", - "is_active": true, - "specifications": { - "model": "FH-2" - }, - "power_source": { - "type": "solar" - }, - "created_at": "2024-01-01T10:00:00Z", - "updated_at": "2024-01-01T10:00:00Z" - }, - "sensor_catalog": { - "uuid": "22222222-2222-2222-2222-222222222222", - "code": "ext-sensor-log-2", - "name": "External Sensor Log 2", - "description": "Sensor catalog for second log", - "customizable_fields": [], - "supported_power_sources": [], - "returned_data_fields": ["humidity"], - "sample_payload": {}, - "is_active": true, - "created_at": "2024-01-01T10:00:00Z", - "updated_at": "2024-01-01T10:00:00Z" - }, - "payload": { - "temp": 18 - }, - "created_at": "2024-01-02T10:00:00Z" - } - ] -} -``` - -## خطاهای احتمالی - -### 401 Unauthorized -اگر کاربر احراز هویت نشده باشد: - -```json -{ - "detail": "Authentication credentials were not provided." -} -``` - -### 400 Bad Request -اگر پارامترها نامعتبر باشند، مثلا `farm_uuid` فرمت درست نداشته باشد یا `page_size` بیشتر از `100` باشد، خطای validation برگردانده می شود. - -### 503 Service Unavailable -اگر migration های مربوط به جدول ها اجرا نشده باشند: - -```json -{ - "code": 503, - "msg": "Required tables are not ready. Run migrations." -} -``` - -## نکات مهم پیاده سازی - -- این API فقط لاگ های مربوط به یک `farm_uuid` را برمی گرداند. -- پاسخ به صورت paginated است. -- داده های `farm_sensor` و `sensor_catalog` به صورت enrich شده به هر لاگ اضافه می شوند. -- اگر برای یک لاگ، سنسور متناظر در `FarmSensor` پیدا نشود، فیلدهای `farm_sensor` و `sensor_catalog` ممکن است `null` باشند. -- ترتیب نمایش لاگ ها نزولی و از جدیدترین به قدیمی ترین است. - -## فایل های مرتبط - -- `sensor_external_api/urls.py` -- `sensor_external_api/views.py` -- `sensor_external_api/serializers.py` -- `sensor_external_api/services.py` -- `sensor_external_api/models.py` -- `sensor_external_api/tests.py` - -## جمع بندی - -API مربوط به `logs/` برای گزارش گیری و مشاهده تاریخچه درخواست های ورودی از سنسورهای خارجی یک مزرعه استفاده می شود. این endpoint با دریافت `farm_uuid`، لاگ های مرتبط را از دیتابیس می خواند، آن ها را صفحه بندی می کند، اطلاعات سنسور و کاتالوگ را به خروجی اضافه می کند و در نهایت پاسخ استاندارد برمی گرداند. diff --git a/sensor_external_api/__init__.py b/sensor_external_api/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/sensor_external_api/migrations/0001_initial.py b/sensor_external_api/migrations/0001_initial.py deleted file mode 100644 index 30e154b..0000000 --- a/sensor_external_api/migrations/0001_initial.py +++ /dev/null @@ -1,27 +0,0 @@ -# Generated by Django 5.1.15 on 2026-04-04 21:00 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - initial = True - - dependencies = [] - - operations = [ - migrations.CreateModel( - name="SensorExternalRequestLog", - fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("farm_uuid", models.UUIDField(db_index=True)), - ("sensor_catalog_uuid", models.UUIDField(blank=True, db_index=True, null=True)), - ("physical_device_uuid", models.UUIDField(db_index=True)), - ("payload", models.JSONField(blank=True, default=dict)), - ("created_at", models.DateTimeField(auto_now_add=True)), - ], - options={ - "db_table": "sensor_external_request_logs", - "ordering": ["-created_at", "-id"], - }, - ), - ] diff --git a/sensor_external_api/migrations/__init__.py b/sensor_external_api/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/sensor_external_api/models.py b/sensor_external_api/models.py deleted file mode 100644 index 9a91ca1..0000000 --- a/sensor_external_api/models.py +++ /dev/null @@ -1,16 +0,0 @@ -from django.db import models - - -class SensorExternalRequestLog(models.Model): - farm_uuid = models.UUIDField(db_index=True) - sensor_catalog_uuid = models.UUIDField(null=True, blank=True, db_index=True) - physical_device_uuid = models.UUIDField(db_index=True) - payload = models.JSONField(default=dict, blank=True) - created_at = models.DateTimeField(auto_now_add=True) - - class Meta: - db_table = "sensor_external_request_logs" - ordering = ["-created_at", "-id"] - - def __str__(self): - return f"{self.physical_device_uuid}:{self.created_at.isoformat()}" diff --git a/sensor_external_api/services.py b/sensor_external_api/services.py deleted file mode 100644 index c765ee9..0000000 --- a/sensor_external_api/services.py +++ /dev/null @@ -1,290 +0,0 @@ -import logging - -from django.conf import settings -from django.db import OperationalError, ProgrammingError, transaction - -from external_api_adapter import request as external_api_request -from external_api_adapter.exceptions import ExternalAPIRequestError -from farm_hub.models import FarmSensor -from notifications.services import create_notification_for_farm_uuid - -from .models import SensorExternalRequestLog - - -logger = logging.getLogger(__name__) - - -class FarmDataForwardError(Exception): - pass - - -def get_sensor_external_request_logs_for_farm( - *, - farm_uuid, - physical_device_uuid=None, - sensor_type=None, - date_from=None, - date_to=None, -): - try: - queryset = SensorExternalRequestLog.objects.filter(farm_uuid=farm_uuid) - - if physical_device_uuid: - queryset = queryset.filter(physical_device_uuid=physical_device_uuid) - - if sensor_type: - physical_device_uuids = FarmSensor.objects.filter( - farm__farm_uuid=farm_uuid, - sensor_type=sensor_type, - ).values_list("physical_device_uuid", flat=True) - queryset = queryset.filter(physical_device_uuid__in=physical_device_uuids) - - if date_from: - queryset = queryset.filter(created_at__date__gte=date_from) - - if date_to: - queryset = queryset.filter(created_at__date__lte=date_to) - - return queryset.order_by("-created_at", "-id") - except (ProgrammingError, OperationalError) as exc: - raise ValueError("Sensor external API tables are not migrated.") from exc - - -def get_farm_sensor_map_for_logs(*, logs): - try: - logs = list(logs) - if not logs: - return {} - - farm_sensor_queryset = ( - FarmSensor.objects.select_related("farm", "sensor_catalog") - .filter( - farm__farm_uuid__in={log.farm_uuid for log in logs}, - physical_device_uuid__in={log.physical_device_uuid for log in logs}, - ) - .order_by("-created_at", "-id") - ) - - farm_sensor_map = {} - for farm_sensor in farm_sensor_queryset: - exact_key = ( - farm_sensor.farm.farm_uuid, - farm_sensor.sensor_catalog.uuid if farm_sensor.sensor_catalog else None, - farm_sensor.physical_device_uuid, - ) - fallback_key = ( - farm_sensor.farm.farm_uuid, - None, - farm_sensor.physical_device_uuid, - ) - farm_sensor_map.setdefault(exact_key, farm_sensor) - farm_sensor_map.setdefault(fallback_key, farm_sensor) - - return farm_sensor_map - except (ProgrammingError, OperationalError) as exc: - raise ValueError("Sensor external API tables are not migrated.") from exc - - -def get_latest_sensor_external_request_log(*, farm_uuid, sensor_catalog_uuid, physical_device_uuid): - try: - return ( - SensorExternalRequestLog.objects.filter( - farm_uuid=farm_uuid, - sensor_catalog_uuid=sensor_catalog_uuid, - physical_device_uuid=physical_device_uuid, - ) - .order_by("-created_at", "-id") - .first() - ) - except (ProgrammingError, OperationalError) as exc: - raise ValueError("Sensor external API tables are not migrated.") from exc - - -def create_sensor_external_notification(*, physical_device_uuid, payload=None): - payload = payload or {} - logger.warning( - "Sensor external notification start: physical_device_uuid=%s payload_type=%s payload_keys=%s", - physical_device_uuid, - type(payload).__name__, - sorted(payload.keys()) if isinstance(payload, dict) else None, - ) - sensor = ( - FarmSensor.objects.select_related("farm", "farm__current_crop_area", "sensor_catalog") - .filter(physical_device_uuid=physical_device_uuid) - .first() - ) - if sensor is None: - logger.error( - "Sensor external notification failed: physical device not found for uuid=%s", - physical_device_uuid, - ) - raise ValueError("Physical device not found.") - - try: - with transaction.atomic(): - SensorExternalRequestLog.objects.create( - farm_uuid=sensor.farm.farm_uuid, - sensor_catalog_uuid=sensor.sensor_catalog.uuid if sensor.sensor_catalog else None, - physical_device_uuid=sensor.physical_device_uuid, - payload=payload, - ) - notification = create_notification_for_farm_uuid( - farm_uuid=sensor.farm.farm_uuid, - title="Sensor external API request", - message=f"Payload received from device {sensor.physical_device_uuid}.", - level="info", - metadata={ - "farm_uuid": str(sensor.farm.farm_uuid), - "sensor_catalog_uuid": str(sensor.sensor_catalog.uuid) if sensor.sensor_catalog else None, - "physical_device_uuid": str(sensor.physical_device_uuid), - "payload": payload, - }, - ) - logger.warning( - "Sensor external notification created: farm_uuid=%s sensor_catalog_uuid=%s physical_device_uuid=%s", - sensor.farm.farm_uuid, - sensor.sensor_catalog.uuid if sensor.sensor_catalog else None, - sensor.physical_device_uuid, - ) - return notification - except (ProgrammingError, OperationalError) as exc: - logger.exception( - "Sensor external notification failed due to database readiness: physical_device_uuid=%s", - physical_device_uuid, - ) - raise ValueError("Sensor external API tables are not migrated.") from exc - - -def forward_sensor_payload_to_farm_data(*, physical_device_uuid, payload=None): - payload = payload or {} - sensor = ( - FarmSensor.objects.select_related("farm", "farm__current_crop_area", "sensor_catalog") - .filter(physical_device_uuid=physical_device_uuid) - .first() - ) - if sensor is None: - logger.error( - "Farm data forward failed: physical device not found for uuid=%s", - physical_device_uuid, - ) - raise ValueError("Physical device not found.") - - farm_boundary = _get_farm_boundary(sensor=sensor) - api_key = getattr(settings, "FARM_DATA_API_KEY", "") - if not api_key: - logger.error( - "Farm data forward failed: FARM_DATA_API_KEY missing for farm_uuid=%s physical_device_uuid=%s", - sensor.farm.farm_uuid, - physical_device_uuid, - ) - raise FarmDataForwardError("FARM_DATA_API_KEY is not configured.") - - sensor_key = _get_sensor_key(sensor=sensor) - normalized_sensor_payload = _normalize_sensor_payload(sensor_key=sensor_key, sensor_payload=payload) - request_payload = { - "farm_uuid": str(sensor.farm.farm_uuid), - "farm_boundary": farm_boundary, - "sensor_key": sensor_key, - "sensor_payload": normalized_sensor_payload, - } - logger.warning( - "Farm data forward start: farm_uuid=%s physical_device_uuid=%s sensor_key=%s payload_keys=%s boundary_type=%s boundary_points=%s", - sensor.farm.farm_uuid, - physical_device_uuid, - sensor_key, - sorted(normalized_sensor_payload.keys()) if isinstance(normalized_sensor_payload, dict) else None, - farm_boundary.get("type") if isinstance(farm_boundary, dict) else None, - len(farm_boundary.get("coordinates", [[]])[0]) if isinstance(farm_boundary, dict) and farm_boundary.get("coordinates") else None, - ) - - try: - response = external_api_request( - "ai", - _get_farm_data_path(), - method="POST", - payload=request_payload, - headers={ - "Accept": "application/json", - "Content-Type": "application/json", - "X-API-Key": api_key, - "Authorization": f"Api-Key {api_key}", - }, - ) - except ExternalAPIRequestError as exc: - logger.exception( - "Farm data forward request exception: farm_uuid=%s physical_device_uuid=%s sensor_key=%s", - sensor.farm.farm_uuid, - physical_device_uuid, - sensor_key, - ) - raise FarmDataForwardError(f"Farm data API request failed: {exc}") from exc - - if response.status_code >= 400: - response_body = response.data - logger.error( - "Farm data forward rejected: farm_uuid=%s physical_device_uuid=%s sensor_key=%s status_code=%s response=%s", - sensor.farm.farm_uuid, - physical_device_uuid, - sensor_key, - response.status_code, - response_body, - ) - raise FarmDataForwardError( - f"Farm data API returned status {response.status_code}: {response_body}" - ) - - logger.warning( - "Farm data forward success: farm_uuid=%s physical_device_uuid=%s sensor_key=%s status_code=%s", - sensor.farm.farm_uuid, - physical_device_uuid, - sensor_key, - response.status_code, - ) - return request_payload - - -def _get_farm_boundary(*, sensor): - crop_area = sensor.farm.current_crop_area or sensor.farm.crop_areas.order_by("-created_at", "-id").first() - if crop_area is None: - logger.error( - "Farm data forward failed: no farm boundary configured for farm_uuid=%s physical_device_uuid=%s", - sensor.farm.farm_uuid, - sensor.physical_device_uuid, - ) - raise FarmDataForwardError("Farm boundary is not configured for this farm.") - - geometry = crop_area.geometry or {} - if geometry.get("type") == "Feature": - geometry = geometry.get("geometry") or {} - - if geometry.get("type") != "Polygon": - logger.error( - "Farm data forward failed: invalid boundary geometry type=%s for farm_uuid=%s physical_device_uuid=%s", - geometry.get("type"), - sensor.farm.farm_uuid, - sensor.physical_device_uuid, - ) - raise FarmDataForwardError("Farm boundary geometry must be a Polygon.") - - return geometry - - -def _normalize_sensor_payload(*, sensor_key, sensor_payload): - if not sensor_payload: - return {} - if not isinstance(sensor_payload, dict): - raise FarmDataForwardError("`payload` must be a JSON object.") - - if all(isinstance(value, dict) for value in sensor_payload.values()): - return sensor_payload - return {sensor_key: sensor_payload} - - -def _get_sensor_key(*, sensor): - if sensor.sensor_catalog and sensor.sensor_catalog.code: - return sensor.sensor_catalog.code - return "sensor-7-1" - - -def _get_farm_data_path(): - return getattr(settings, "FARM_DATA_API_PATH", "/api/farm-data/") diff --git a/sensor_external_api/tests.py b/sensor_external_api/tests.py deleted file mode 100644 index ec2fc10..0000000 --- a/sensor_external_api/tests.py +++ /dev/null @@ -1,380 +0,0 @@ -from datetime import datetime, timezone as dt_timezone - -from django.contrib.auth import get_user_model -from django.test import TestCase, override_settings -from rest_framework.test import APIRequestFactory -from rest_framework_simplejwt.tokens import AccessToken -from unittest.mock import patch - -from external_api_adapter.adapter import AdapterResponse -from external_api_adapter.exceptions import ExternalAPIRequestError -from crop_zoning.models import CropArea -from farm_hub.models import FarmHub, FarmSensor, FarmType -from notifications.models import FarmNotification -from sensor_catalog.models import SensorCatalog - -from .models import SensorExternalRequestLog -from .services import get_latest_sensor_external_request_log -from .views import SensorExternalAPIView, SensorExternalRequestLogListAPIView - - -@override_settings( - SENSOR_EXTERNAL_API_KEY="12345", - FARM_DATA_API_KEY="farm-data-key", -) -class SensorExternalAPIViewTests(TestCase): - def setUp(self): - self.factory = APIRequestFactory() - self.user = get_user_model().objects.create_user( - username="sensor-external-user", - password="secret123", - email="sensor-external@example.com", - phone_number="09120000015", - ) - self.farm_type = FarmType.objects.create(name="سنسور خارجی") - self.farm = FarmHub.objects.create( - owner=self.user, - farm_type=self.farm_type, - name="Farm External", - ) - self.sensor_catalog = SensorCatalog.objects.create( - code="ext-sensor-v1", - name="External Sensor", - ) - self.crop_area = CropArea.objects.create( - farm=self.farm, - geometry={ - "type": "Polygon", - "coordinates": [ - [ - [51.39, 35.7], - [51.41, 35.7], - [51.41, 35.72], - [51.39, 35.72], - [51.39, 35.7], - ] - ], - }, - points=[ - [51.39, 35.7], - [51.41, 35.7], - [51.41, 35.72], - [51.39, 35.72], - ], - center={"lat": 35.71, "lng": 51.4}, - area_sqm=1000, - area_hectares=0.1, - chunk_area_sqm=1000, - ) - self.farm.current_crop_area = self.crop_area - self.farm.save(update_fields=["current_crop_area"]) - self.sensor = FarmSensor.objects.create( - farm=self.farm, - sensor_catalog=self.sensor_catalog, - physical_device_uuid="11111111-1111-1111-1111-111111111111", - name="sensor-7-1", - sensor_type="weather_station", - ) - - def test_requires_api_key(self): - request = self.factory.post( - "/api/sensor-external-api/", - {"uuid": str(self.sensor.physical_device_uuid), "payload": {"temp": 12}}, - format="json", - ) - - response = SensorExternalAPIView.as_view()(request) - - self.assertEqual(response.status_code, 401) - - @patch("sensor_external_api.services.external_api_request") - def test_creates_notification_and_request_log_for_device_uuid(self, mock_external_api_request): - mock_external_api_request.return_value = AdapterResponse(status_code=201, data={}) - request = self.factory.post( - "/api/sensor-external-api/", - {"uuid": str(self.sensor.physical_device_uuid), "payload": {"temp": 12}}, - format="json", - HTTP_X_API_KEY="12345", - ) - - response = SensorExternalAPIView.as_view()(request) - - self.assertEqual(response.status_code, 201) - self.assertTrue( - FarmNotification.objects.filter( - farm=self.farm, - title="Sensor external API request", - ).exists() - ) - self.assertTrue( - SensorExternalRequestLog.objects.filter( - farm_uuid=self.farm.farm_uuid, - sensor_catalog_uuid=self.sensor_catalog.uuid, - physical_device_uuid=self.sensor.physical_device_uuid, - payload={"temp": 12}, - ).exists() - ) - mock_external_api_request.assert_called_once_with( - "ai", - "/api/farm-data/", - method="POST", - payload={ - "farm_uuid": str(self.farm.farm_uuid), - "farm_boundary": self.crop_area.geometry, - "sensor_key": self.sensor_catalog.code, - "sensor_payload": { - self.sensor_catalog.code: {"temp": 12}, - }, - }, - headers={ - "Accept": "application/json", - "Content-Type": "application/json", - "X-API-Key": "farm-data-key", - "Authorization": "Api-Key farm-data-key", - }, - ) - - def test_returns_404_for_unknown_device_uuid(self): - request = self.factory.post( - "/api/sensor-external-api/", - {"uuid": "22222222-2222-2222-2222-222222222222", "payload": {"temp": 12}}, - format="json", - HTTP_X_API_KEY="12345", - ) - - response = SensorExternalAPIView.as_view()(request) - - self.assertEqual(response.status_code, 404) - - @patch("sensor_external_api.services.external_api_request") - def test_returns_503_when_farm_data_api_is_unavailable(self, mock_external_api_request): - mock_external_api_request.side_effect = ExternalAPIRequestError("connection error") - - request = self.factory.post( - "/api/sensor-external-api/", - {"uuid": str(self.sensor.physical_device_uuid), "payload": {"temp": 12}}, - format="json", - HTTP_X_API_KEY="12345", - ) - - response = SensorExternalAPIView.as_view()(request) - - self.assertEqual(response.status_code, 503) - self.assertEqual(response.data["code"], 503) - self.assertIn("Farm data API request failed", response.data["msg"]) - - -class SensorExternalServiceTests(TestCase): - def test_get_latest_sensor_external_request_log_returns_latest_matching_record(self): - first_log = SensorExternalRequestLog.objects.create( - farm_uuid="11111111-1111-1111-1111-111111111111", - sensor_catalog_uuid="22222222-2222-2222-2222-222222222222", - physical_device_uuid="33333333-3333-3333-3333-333333333333", - payload={"temp": 12}, - ) - latest_log = SensorExternalRequestLog.objects.create( - farm_uuid=first_log.farm_uuid, - sensor_catalog_uuid=first_log.sensor_catalog_uuid, - physical_device_uuid=first_log.physical_device_uuid, - payload={"temp": 18}, - ) - SensorExternalRequestLog.objects.create( - farm_uuid=first_log.farm_uuid, - sensor_catalog_uuid=first_log.sensor_catalog_uuid, - physical_device_uuid="44444444-4444-4444-4444-444444444444", - payload={"temp": 25}, - ) - - log = get_latest_sensor_external_request_log( - farm_uuid=first_log.farm_uuid, - sensor_catalog_uuid=first_log.sensor_catalog_uuid, - physical_device_uuid=first_log.physical_device_uuid, - ) - - self.assertIsNotNone(log) - self.assertEqual(log.id, latest_log.id) - self.assertEqual(log.payload, {"temp": 18}) - - -@override_settings(SENSOR_EXTERNAL_API_KEY="12345") -class SensorExternalRequestLogListAPIViewTests(TestCase): - def setUp(self): - self.factory = APIRequestFactory() - self.user = get_user_model().objects.create_user( - username="sensor-external-log-user", - password="secret123", - email="sensor-external-log@example.com", - phone_number="09120000016", - ) - self.access_token = str(AccessToken.for_user(self.user)) - self.farm_type = FarmType.objects.create(name="لاگ سنسور خارجی") - self.farm = FarmHub.objects.create( - owner=self.user, - farm_type=self.farm_type, - name="Farm Log External", - farm_uuid="11111111-1111-1111-1111-111111111111", - ) - self.farm_uuid = self.farm.farm_uuid - self.other_farm_uuid = "aaaaaaaa-1111-1111-1111-111111111111" - self.first_catalog = SensorCatalog.objects.create( - code="ext-sensor-log-1", - name="External Sensor Log 1", - description="Sensor catalog for first log", - returned_data_fields=["temp"], - ) - self.second_catalog = SensorCatalog.objects.create( - code="ext-sensor-log-2", - name="External Sensor Log 2", - description="Sensor catalog for second log", - returned_data_fields=["humidity"], - ) - self.first_sensor = FarmSensor.objects.create( - farm=self.farm, - sensor_catalog=self.first_catalog, - physical_device_uuid="33333333-3333-3333-3333-333333333333", - name="External device 1", - sensor_type="weather_station", - specifications={"model": "FH-1"}, - power_source={"type": "battery"}, - ) - self.second_sensor = FarmSensor.objects.create( - farm=self.farm, - sensor_catalog=self.second_catalog, - physical_device_uuid="55555555-5555-5555-5555-555555555555", - name="External device 2", - sensor_type="soil_sensor", - specifications={"model": "FH-2"}, - power_source={"type": "solar"}, - ) - - self.first_log = SensorExternalRequestLog.objects.create( - farm_uuid=self.farm_uuid, - sensor_catalog_uuid=self.first_catalog.uuid, - physical_device_uuid=self.first_sensor.physical_device_uuid, - payload={"temp": 12}, - ) - self.second_log = SensorExternalRequestLog.objects.create( - farm_uuid=self.farm_uuid, - sensor_catalog_uuid=self.second_catalog.uuid, - physical_device_uuid=self.second_sensor.physical_device_uuid, - payload={"temp": 18}, - ) - SensorExternalRequestLog.objects.create( - farm_uuid=self.other_farm_uuid, - sensor_catalog_uuid="66666666-6666-6666-6666-666666666666", - physical_device_uuid="77777777-7777-7777-7777-777777777777", - payload={"temp": 24}, - ) - - def test_requires_bearer_token(self): - request = self.factory.get( - f"/api/sensor-external-api/logs/?farm_uuid={self.farm_uuid}&page=1&page_size=20" - ) - - response = SensorExternalRequestLogListAPIView.as_view()(request) - - self.assertEqual(response.status_code, 401) - - def test_requires_page_and_page_size(self): - request = self.factory.get( - f"/api/sensor-external-api/logs/?farm_uuid={self.farm_uuid}", - HTTP_AUTHORIZATION=f"Bearer {self.access_token}", - ) - - response = SensorExternalRequestLogListAPIView.as_view()(request) - - self.assertEqual(response.status_code, 400) - self.assertIn("page", response.data) - self.assertIn("page_size", response.data) - - def test_returns_paginated_logs_for_farm_uuid(self): - request = self.factory.get( - f"/api/sensor-external-api/logs/?farm_uuid={self.farm_uuid}&page=1&page_size=1", - HTTP_AUTHORIZATION=f"Bearer {self.access_token}", - ) - - response = SensorExternalRequestLogListAPIView.as_view()(request) - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.data["code"], 200) - self.assertEqual(response.data["count"], 2) - self.assertEqual(len(response.data["data"]), 1) - self.assertEqual(response.data["data"][0]["id"], self.second_log.id) - self.assertEqual( - response.data["data"][0]["physical_device_uuid"], - str(self.second_log.physical_device_uuid), - ) - self.assertEqual( - response.data["data"][0]["sensor_catalog"]["uuid"], - str(self.second_catalog.uuid), - ) - self.assertEqual( - response.data["data"][0]["sensor_catalog"]["name"], - self.second_catalog.name, - ) - self.assertEqual( - response.data["data"][0]["farm_sensor"]["uuid"], - str(self.second_sensor.uuid), - ) - self.assertEqual( - response.data["data"][0]["farm_sensor"]["physical_device_uuid"], - str(self.second_sensor.physical_device_uuid), - ) - self.assertEqual(response.data["data"][0]["payload"]["temp"], 18) - self.assertIsInstance(response.data["data"][0]["payload"]["temp"], int) - - def test_filters_logs_by_physical_device_uuid(self): - request = self.factory.get( - ( - "/api/sensor-external-api/logs/" - f"?farm_uuid={self.farm_uuid}" - f"&physical_device_uuid={self.first_sensor.physical_device_uuid}" - "&page=1&page_size=20" - ), - HTTP_AUTHORIZATION=f"Bearer {self.access_token}", - ) - - response = SensorExternalRequestLogListAPIView.as_view()(request) - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.data["count"], 1) - self.assertEqual(response.data["data"][0]["id"], self.first_log.id) - - def test_filters_logs_by_sensor_type(self): - request = self.factory.get( - ( - "/api/sensor-external-api/logs/" - f"?farm_uuid={self.farm_uuid}" - "&sensor_type=soil_sensor" - "&page=1&page_size=20" - ), - HTTP_AUTHORIZATION=f"Bearer {self.access_token}", - ) - - response = SensorExternalRequestLogListAPIView.as_view()(request) - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.data["count"], 1) - self.assertEqual(response.data["data"][0]["id"], self.second_log.id) - - def test_filters_logs_by_date_range(self): - older_timestamp = datetime(2025, 5, 1, 10, 0, tzinfo=dt_timezone.utc) - newer_timestamp = datetime(2025, 5, 2, 11, 0, tzinfo=dt_timezone.utc) - SensorExternalRequestLog.objects.filter(id=self.first_log.id).update(created_at=older_timestamp) - SensorExternalRequestLog.objects.filter(id=self.second_log.id).update(created_at=newer_timestamp) - - request = self.factory.get( - ( - "/api/sensor-external-api/logs/" - f"?farm_uuid={self.farm_uuid}" - "&date_from=2025-05-02&date_to=2025-05-02" - "&page=1&page_size=20" - ), - HTTP_AUTHORIZATION=f"Bearer {self.access_token}", - ) - - response = SensorExternalRequestLogListAPIView.as_view()(request) - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.data["count"], 1) - self.assertEqual(response.data["data"][0]["id"], self.second_log.id) diff --git a/sensor_external_api/views.py b/sensor_external_api/views.py deleted file mode 100644 index 3757e7f..0000000 --- a/sensor_external_api/views.py +++ /dev/null @@ -1,197 +0,0 @@ -import logging - -from rest_framework import serializers, status -from rest_framework.pagination import PageNumberPagination -from rest_framework.permissions import AllowAny -from rest_framework.permissions import IsAuthenticated -from rest_framework.response import Response -from rest_framework.views import APIView -from drf_spectacular.utils import OpenApiExample, OpenApiParameter, OpenApiTypes, extend_schema - -from config.swagger import code_response -from notifications.serializers import FarmNotificationSerializer - -from .authentication import SensorExternalAPIKeyAuthentication -from .serializers import ( - SensorExternalRequestLogQuerySerializer, - SensorExternalRequestLogSerializer, - SensorExternalRequestSerializer, -) -from .services import ( - FarmDataForwardError, - create_sensor_external_notification, - forward_sensor_payload_to_farm_data, - get_farm_sensor_map_for_logs, - get_sensor_external_request_logs_for_farm, -) - - -logger = logging.getLogger(__name__) - - -class SensorExternalRequestLogPagination(PageNumberPagination): - page_size = 20 - page_size_query_param = "page_size" - max_page_size = 100 - - -class SensorExternalAPIView(APIView): - authentication_classes = [SensorExternalAPIKeyAuthentication] - permission_classes = [AllowAny] - - @extend_schema( - tags=["Sensor External API"], - request=SensorExternalRequestSerializer, - examples=[ - OpenApiExample( - "Sensor External API Request", - value={ - "uuid": "22222222-2222-2222-2222-222222222222", - "payload": { - "moisture_percent": 32.5, - "temperature_c": 21.3, - "ph": 6.7, - "ec_ds_m": 1.1, - "nitrogen_mg_kg": 42, - "phosphorus_mg_kg": 18, - "potassium_mg_kg": 210, - }, - }, - request_only=True, - ) - ], - parameters=[ - OpenApiParameter( - name="X-API-Key", - type=OpenApiTypes.STR, - location=OpenApiParameter.HEADER, - required=True, - default="12345", - description="API key for sensor external API.", - ) - ], - responses={ - 201: code_response("SensorExternalAPIResponse", data=FarmNotificationSerializer()), - 401: code_response("SensorExternalAPIUnauthorizedResponse"), - 404: code_response("SensorExternalAPIDeviceNotFoundResponse"), - 503: code_response("SensorExternalAPIUnavailableResponse"), - }, - ) - def post(self, request): - serializer = SensorExternalRequestSerializer(data=request.data) - serializer.is_valid(raise_exception=True) - logger.warning( - "Sensor external API POST received: uuid=%s payload_keys=%s", - serializer.validated_data["uuid"], - sorted(serializer.validated_data.get("payload", {}).keys()) - if isinstance(serializer.validated_data.get("payload"), dict) - else None, - ) - - try: - notification = create_sensor_external_notification( - physical_device_uuid=serializer.validated_data["uuid"], - payload=serializer.validated_data.get("payload"), - ) - forward_sensor_payload_to_farm_data( - physical_device_uuid=serializer.validated_data["uuid"], - payload=serializer.validated_data.get("payload"), - ) - except ValueError as exc: - if "not migrated" in str(exc): - logger.exception( - "Sensor external API POST failed due to missing migrations: uuid=%s", - serializer.validated_data["uuid"], - ) - return Response( - {"code": 503, "msg": "Required tables are not ready. Run migrations."}, - status=status.HTTP_503_SERVICE_UNAVAILABLE, - ) - logger.exception( - "Sensor external API POST failed due to missing physical device: uuid=%s", - serializer.validated_data["uuid"], - ) - return Response({"code": 404, "msg": "Physical device not found."}, status=status.HTTP_404_NOT_FOUND) - except FarmDataForwardError as exc: - logger.exception( - "Sensor external API POST failed while forwarding to farm data: uuid=%s", - serializer.validated_data["uuid"], - ) - return Response( - {"code": 503, "msg": str(exc)}, - status=status.HTTP_503_SERVICE_UNAVAILABLE, - ) - - data = FarmNotificationSerializer(notification).data - logger.warning("Sensor external API POST succeeded: uuid=%s", serializer.validated_data["uuid"]) - return Response({"code": 201, "msg": "success", "data": data}, status=status.HTTP_201_CREATED) - - -class SensorExternalRequestLogListAPIView(APIView): - permission_classes = [IsAuthenticated] - - pagination_class = SensorExternalRequestLogPagination - - @extend_schema( - tags=["Sensor External API"], - parameters=[ - OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=True, default="11111111-1111-1111-1111-111111111111"), - OpenApiParameter(name="page", type=OpenApiTypes.INT, location=OpenApiParameter.QUERY, required=True), - OpenApiParameter(name="page_size", type=OpenApiTypes.INT, location=OpenApiParameter.QUERY, required=True), - OpenApiParameter(name="physical_device_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=False), - OpenApiParameter(name="sensor_type", type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, required=False), - OpenApiParameter(name="date_from", type=OpenApiTypes.DATE, location=OpenApiParameter.QUERY, required=False), - OpenApiParameter(name="date_to", type=OpenApiTypes.DATE, location=OpenApiParameter.QUERY, required=False), - ], - responses={ - 200: code_response( - "SensorExternalRequestLogListResponse", - data=SensorExternalRequestLogSerializer(many=True), - extra_fields={ - "count": serializers.IntegerField(), - "next": serializers.CharField(allow_null=True), - "previous": serializers.CharField(allow_null=True), - }, - ), - 401: code_response("SensorExternalRequestLogListUnauthorizedResponse"), - 503: code_response("SensorExternalRequestLogListUnavailableResponse"), - }, - ) - def get(self, request): - serializer = SensorExternalRequestLogQuerySerializer(data=request.query_params) - serializer.is_valid(raise_exception=True) - - try: - queryset = get_sensor_external_request_logs_for_farm( - farm_uuid=serializer.validated_data["farm_uuid"], - physical_device_uuid=serializer.validated_data.get("physical_device_uuid"), - sensor_type=serializer.validated_data.get("sensor_type"), - date_from=serializer.validated_data.get("date_from"), - date_to=serializer.validated_data.get("date_to"), - ) - except ValueError: - return Response( - {"code": 503, "msg": "Required tables are not ready. Run migrations."}, - status=status.HTTP_503_SERVICE_UNAVAILABLE, - ) - - paginator = self.pagination_class() - paginator.page_size = serializer.validated_data["page_size"] - page = paginator.paginate_queryset(queryset, request, view=self) - farm_sensor_map = get_farm_sensor_map_for_logs(logs=page) - data = SensorExternalRequestLogSerializer( - page, - many=True, - context={"farm_sensor_map": farm_sensor_map}, - ).data - return Response( - { - "code": 200, - "msg": "success", - "count": paginator.page.paginator.count, - "next": paginator.get_next_link(), - "previous": paginator.get_previous_link(), - "data": data, - }, - status=status.HTTP_200_OK, - )