This commit is contained in:
2026-04-02 23:25:39 +03:30
parent 881f8efa4d
commit bd0d04256c
84 changed files with 2725 additions and 856 deletions
+36
View File
@@ -0,0 +1,36 @@
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("farm_hub", "0002_seed_default_catalog"),
]
operations = [
migrations.CreateModel(
name="FarmDashboardConfig",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("disabled_card_ids", models.JSONField(blank=True, default=list)),
("row_order", models.JSONField(default=list)),
("enable_drag_reorder", models.BooleanField(default=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"farm",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="dashboard_config",
to="farm_hub.farmhub",
),
),
],
options={
"db_table": "farm_dashboard_configs",
"ordering": ["-updated_at", "-id"],
},
),
]
View File
+23
View File
@@ -0,0 +1,23 @@
from django.db import models
from farm_hub.models import FarmHub
class FarmDashboardConfig(models.Model):
farm = models.OneToOneField(
FarmHub,
on_delete=models.CASCADE,
related_name="dashboard_config",
)
disabled_card_ids = models.JSONField(default=list, blank=True)
row_order = models.JSONField(default=list, blank=True)
enable_drag_reorder = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "farm_dashboard_configs"
ordering = ["-updated_at", "-id"]
def __str__(self):
return f"Dashboard config for {self.farm.name}"
File diff suppressed because one or more lines are too long
+3 -1
View File
@@ -4,6 +4,7 @@ from .mock_data import VALID_CARD_IDS, VALID_ROW_IDS
class FarmDashboardConfigSerializer(serializers.Serializer):
farm_uuid = serializers.UUIDField(read_only=True)
disabled_card_ids = serializers.ListField(
child=serializers.CharField(),
allow_empty=True,
@@ -40,6 +41,7 @@ class FarmDashboardConfigSerializer(serializers.Serializer):
class FarmDashboardConfigPatchSerializer(FarmDashboardConfigSerializer):
farm_uuid = serializers.UUIDField(required=True)
disabled_card_ids = serializers.ListField(
child=serializers.CharField(),
allow_empty=True,
@@ -54,6 +56,6 @@ class FarmDashboardConfigPatchSerializer(FarmDashboardConfigSerializer):
def validate(self, attrs):
attrs = super().validate(attrs)
if not attrs:
if set(attrs.keys()) == {"farm_uuid"}:
raise serializers.ValidationError("At least one config field must be provided.")
return attrs
+100 -14
View File
@@ -1,52 +1,105 @@
from copy import deepcopy
from unittest.mock import patch
from django.test import SimpleTestCase
from rest_framework.test import APIRequestFactory
from django.contrib.auth import get_user_model
from django.test import TestCase
from rest_framework.test import APIRequestFactory, force_authenticate
from .mock_data import DEFAULT_CONFIG, reset_config
from .views import FarmDashboardConfigView
from farm_hub.models import FarmHub, FarmType
from .mock_data import DEFAULT_CONFIG
from .models import FarmDashboardConfig
from .views import FarmDashboardCardsView, FarmDashboardConfigView
class FarmDashboardConfigViewTests(SimpleTestCase):
class DashboardBaseTestCase(TestCase):
def setUp(self):
self.factory = APIRequestFactory()
reset_config()
self.user = get_user_model().objects.create_user(
username="farmer",
password="secret123",
email="farmer@example.com",
phone_number="09120000000",
)
self.other_user = get_user_model().objects.create_user(
username="other-farmer",
password="secret123",
email="other@example.com",
phone_number="09120000001",
)
self.farm_type = FarmType.objects.create(name="زراعی")
self.farm = FarmHub.objects.create(owner=self.user, farm_type=self.farm_type, name="Farm 1")
self.other_farm = FarmHub.objects.create(owner=self.other_user, farm_type=self.farm_type, name="Farm 2")
def tearDown(self):
reset_config()
def test_get_returns_canonical_config(self):
request = self.factory.get("/api/farm-dashboard-config/")
class FarmDashboardConfigViewTests(DashboardBaseTestCase):
def test_get_returns_default_config_and_persists_it(self):
request = self.factory.get(f"/api/farm-dashboard-config/?farm_uuid={self.farm.farm_uuid}")
force_authenticate(request, user=self.user)
response = FarmDashboardConfigView.as_view()(request)
expected = deepcopy(DEFAULT_CONFIG)
expected["farm_uuid"] = str(self.farm.farm_uuid)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["code"], 200)
self.assertEqual(response.data["msg"], "OK")
self.assertEqual(response.data["data"], DEFAULT_CONFIG)
self.assertEqual(response.data["data"], expected)
self.assertTrue(FarmDashboardConfig.objects.filter(farm=self.farm).exists())
def test_get_requires_farm_uuid(self):
request = self.factory.get("/api/farm-dashboard-config/")
force_authenticate(request, user=self.user)
response = FarmDashboardConfigView.as_view()(request)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.data["farm_uuid"][0], "This field is required.")
def test_get_rejects_foreign_farm_uuid(self):
request = self.factory.get(f"/api/farm-dashboard-config/?farm_uuid={self.other_farm.farm_uuid}")
force_authenticate(request, user=self.user)
response = FarmDashboardConfigView.as_view()(request)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.data["farm_uuid"][0], "Farm not found.")
def test_patch_partial_update_returns_full_final_config(self):
request = self.factory.patch(
"/api/farm-dashboard-config/",
{"disabled_card_ids": ["farmWeatherCard"]},
{
"farm_uuid": str(self.farm.farm_uuid),
"disabled_card_ids": ["farmWeatherCard"],
},
format="json",
)
force_authenticate(request, user=self.user)
response = FarmDashboardConfigView.as_view()(request)
expected = deepcopy(DEFAULT_CONFIG)
expected["farm_uuid"] = str(self.farm.farm_uuid)
expected["disabled_card_ids"] = ["farmWeatherCard"]
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["data"], expected)
self.assertEqual(
FarmDashboardConfig.objects.get(farm=self.farm).disabled_card_ids,
["farmWeatherCard"],
)
def test_patch_only_drag_flag_still_returns_full_config(self):
request = self.factory.patch(
"/api/farm-dashboard-config/",
{"enable_drag_reorder": False},
{
"farm_uuid": str(self.farm.farm_uuid),
"enable_drag_reorder": False,
},
format="json",
)
force_authenticate(request, user=self.user)
response = FarmDashboardConfigView.as_view()(request)
expected = deepcopy(DEFAULT_CONFIG)
expected["farm_uuid"] = str(self.farm.farm_uuid)
expected["enable_drag_reorder"] = False
self.assertEqual(response.status_code, 200)
@@ -57,10 +110,43 @@ class FarmDashboardConfigViewTests(SimpleTestCase):
def test_patch_rejects_invalid_row_order(self):
request = self.factory.patch(
"/api/farm-dashboard-config/",
{"row_order": ["overviewKpis"]},
{
"farm_uuid": str(self.farm.farm_uuid),
"row_order": ["overviewKpis"],
},
format="json",
)
force_authenticate(request, user=self.user)
response = FarmDashboardConfigView.as_view()(request)
self.assertEqual(response.status_code, 400)
self.assertIn("row_order", response.data)
class FarmDashboardCardsViewTests(DashboardBaseTestCase):
@patch("dashboard.views.external_api_request")
def test_get_forwards_farm_uuid_to_external_api(self, mock_external_api_request):
mock_external_api_request.return_value.data = {"status": "success", "data": {}}
mock_external_api_request.return_value.status_code = 200
request = self.factory.get(f"/api/farm-dashboard/?farm_uuid={self.farm.farm_uuid}")
force_authenticate(request, user=self.user)
response = FarmDashboardCardsView.as_view()(request)
self.assertEqual(response.status_code, 200)
mock_external_api_request.assert_called_once_with(
"ai",
"/dashboard-data/status",
method="GET",
query={"farm_uuid": str(self.farm.farm_uuid)},
)
def test_get_requires_farm_uuid(self):
request = self.factory.get("/api/farm-dashboard/")
force_authenticate(request, user=self.user)
response = FarmDashboardCardsView.as_view()(request)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.data["farm_uuid"][0], "This field is required.")
+85 -20
View File
@@ -2,21 +2,59 @@
Farm Dashboard API views.
"""
from rest_framework import status
from rest_framework import serializers
from rest_framework.permissions import AllowAny
from rest_framework import serializers, status
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from drf_spectacular.utils import extend_schema, extend_schema_view
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, extend_schema, extend_schema_view
from config.swagger import code_response
from .mock_data import get_config, update_config
from external_api_adapter import request as external_api_request
from farm_hub.models import FarmHub
from .mock_data import DEFAULT_CONFIG
from .models import FarmDashboardConfig
from .serializers import FarmDashboardConfigPatchSerializer, FarmDashboardConfigSerializer
class FarmAccessMixin:
@staticmethod
def _get_farm(request, farm_uuid):
if not farm_uuid:
raise serializers.ValidationError({"farm_uuid": ["This field is required."]})
try:
return FarmHub.objects.get(farm_uuid=farm_uuid, owner=request.user)
except FarmHub.DoesNotExist as exc:
raise serializers.ValidationError({"farm_uuid": ["Farm not found."]}) from exc
@staticmethod
def _get_or_create_dashboard_config(farm):
config, _created = FarmDashboardConfig.objects.get_or_create(
farm=farm,
defaults={
"disabled_card_ids": DEFAULT_CONFIG["disabled_card_ids"],
"row_order": DEFAULT_CONFIG["row_order"],
"enable_drag_reorder": DEFAULT_CONFIG["enable_drag_reorder"],
},
)
return config
@staticmethod
def _serialize_config(config):
return {
"farm_uuid": str(config.farm.farm_uuid),
"disabled_card_ids": config.disabled_card_ids,
"row_order": config.row_order,
"enable_drag_reorder": config.enable_drag_reorder,
}
@extend_schema_view(
get=extend_schema(
tags=["Farm Dashboard"],
parameters=[
OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=True),
],
responses={200: code_response("FarmDashboardConfigGetResponse", data=FarmDashboardConfigSerializer())},
),
patch=extend_schema(
@@ -25,25 +63,43 @@ from .serializers import FarmDashboardConfigPatchSerializer, FarmDashboardConfig
responses={200: code_response("FarmDashboardConfigPatchResponse", data=FarmDashboardConfigSerializer())},
),
)
class FarmDashboardConfigView(APIView):
class FarmDashboardConfigView(FarmAccessMixin, APIView):
"""
Farm dashboard config endpoints.
GET returns the current config.
PATCH accepts partial updates and returns the full final config.
GET/PATCH are persisted in DB per farm.
"""
permission_classes = [AllowAny]
permission_classes = [IsAuthenticated]
def get(self, request):
config = get_config()
return Response({"code": 200, "msg": "OK", "data": config}, status=status.HTTP_200_OK)
farm = self._get_farm(request, request.query_params.get("farm_uuid"))
config = self._get_or_create_dashboard_config(farm)
return Response(
{"code": 200, "msg": "OK", "data": self._serialize_config(config)},
status=status.HTTP_200_OK,
)
def patch(self, request):
serializer = FarmDashboardConfigPatchSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
config = update_config(serializer.validated_data)
response_serializer = FarmDashboardConfigSerializer(config)
farm = self._get_farm(request, serializer.validated_data["farm_uuid"])
config = self._get_or_create_dashboard_config(farm)
update_fields = ["updated_at"]
if "disabled_card_ids" in serializer.validated_data:
config.disabled_card_ids = serializer.validated_data["disabled_card_ids"]
update_fields.append("disabled_card_ids")
if "row_order" in serializer.validated_data:
config.row_order = serializer.validated_data["row_order"]
update_fields.append("row_order")
if "enable_drag_reorder" in serializer.validated_data:
config.enable_drag_reorder = serializer.validated_data["enable_drag_reorder"]
update_fields.append("enable_drag_reorder")
config.save(update_fields=update_fields)
return Response(
{"code": 200, "msg": "OK", "data": response_serializer.data},
{"code": 200, "msg": "OK", "data": self._serialize_config(config)},
status=status.HTTP_200_OK,
)
@@ -51,17 +107,26 @@ class FarmDashboardConfigView(APIView):
@extend_schema_view(
get=extend_schema(
tags=["Farm Dashboard"],
parameters=[
OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=True),
],
responses={200: code_response("FarmDashboardCardsResponse", data=serializers.JSONField())},
),
)
class FarmDashboardCardsView(APIView):
class FarmDashboardCardsView(FarmAccessMixin, APIView):
"""
Farm dashboard cards endpoint: GET.
Returns unified response with all 15 card payloads.
No database. Static mock data only.
Requires farm_uuid and forwards it to the external AI service.
"""
def get(self, request):
from external_api_adapter import request as external_api_request
adapter_response = external_api_request("ai", "/dashboard-data/status", method="GET")
permission_classes = [IsAuthenticated]
def get(self, request):
farm = self._get_farm(request, request.query_params.get("farm_uuid"))
adapter_response = external_api_request(
"ai",
"/dashboard-data/status",
method="GET",
query={"farm_uuid": str(farm.farm_uuid)},
)
return Response(adapter_response.data, status=adapter_response.status_code)