This commit is contained in:
2026-04-04 01:16:16 +03:30
parent ecb42c6895
commit 6d5ece1f5d
22 changed files with 311 additions and 261 deletions
+1
View File
@@ -1,6 +1,7 @@
# Django
SECRET_KEY=your-secret-key-change-in-production
DEBUG=1
DOCKER_VERSION=develop
ALLOWED_HOSTS=node.crop-logic.ir,crop-logic.ir,localhost,127.0.0.1,0.0.0.0
# Database (MySQL)
+21 -145
View File
@@ -1,11 +1,14 @@
# تغییرات سطح دسترسی برای فرانت
این سند توضیح می‌دهد فرانت چطور از سیستم `access_control` استفاده کند، چه `feature code` هایی وجود دارند، و plan `gold` دقیقاً چه دسترسی‌هایی می‌دهد.
این سند قرارداد جدید endpoint پروفایل دسترسی را توضیح می‌دهد.
نکته مهم:
- فرانت فقط باید از `matched_rules` استفاده کند
- منبع حقیقت برای فرانت پاسخ API است؛ نه `access_control/apps.py`
- `subscription_plan` ممکن است از plan پیش‌فرض موثر پر شود، حتی اگر روی خود فارم قبلاً `null` بوده باشد
## 1) API پروفایل دسترسی مزرعه
برای گرفتن دسترسی موثر هر مزرعه از این endpoint استفاده کنید:
- `GET /api/access-control/farms/{farm_uuid}/profile/`
- نیازمند `Authorization: Bearer <access_token>`
@@ -22,48 +25,12 @@
"code": "gold",
"name": "Gold"
},
"features": {
"greenhouse-dashboard": {
"enabled": true,
"type": "page",
"name": "Greenhouse Dashboard",
"description": "",
"metadata": {},
"source": "gold-full-access"
},
"sensor-page": {
"enabled": true,
"type": "page",
"name": "صفحه سنسور",
"description": "",
"metadata": {},
"source": "sensor-7-page-access"
}
},
"groups": {
"pages": {
"greenhouse-dashboard": {
"enabled": true,
"type": "page",
"name": "Greenhouse Dashboard",
"description": "",
"metadata": {},
"source": "gold-full-access"
}
}
},
"matched_rules": [
{
"code": "gold-full-access",
"name": "Gold Full Access",
"effect": "allow",
"priority": 10
},
{
"code": "sensor-7-page-access",
"name": "Sensor 7 Page Access",
"effect": "allow",
"priority": 20
}
],
"resolved_from_profile": true
@@ -71,76 +38,26 @@
}
```
## 2) رفتار پیش‌فرض plan
## 2) معنی `matched_rules`
- `matched_rules` لیست ruleهایی است که برای آن مزرعه match شده‌اند
- هر آیتم فعلاً شامل `code` و `name` و `effect` و `priority` است
- اگر ruleای match نشود، داخل این لیست نمی‌آید
## 3) رفتار پیش‌فرض plan
- plan پیش‌فرض فارم‌های جدید: `gold`
- اگر فرانت هنگام ساخت فارم `subscription_plan_uuid` نفرستد، backend به‌صورت پیش‌فرض plan `gold` را ست می‌کند.
- بنابراین در اکثر سناریوهای فعلی، کاربر جدید بعد از ساخت فارم باید دسترسی‌های Gold را داشته باشد.
- اگر روی بعضی فارم‌های قدیمی `subscription_plan` خالی باشد، backend plan موثر پیش‌فرض را برمی‌گرداند
## 3) feature code ها
## 4) قرارداد جدید فرانت
این `code` ها کلید اصلی برای بررسی دسترسی در فرانت هستند:
- دیگر روی `features[code]` یا `groups` چیزی ننویسید
- منطق فرانت باید بر اساس `matched_rules` نوشته شود
- اگر backend بعداً شکل `matched_rules` را گسترش دهد، فرانت باید همچنان روی همین فیلد تکیه کند
- `dashboards` : سکشن داشبوردها
- `data-section` : سکشن بخش داده‌ها
- `water-data` : صفحه دیتاهای آب
- `soil-information` : صفحه اطلاعات خاک
- `crop-zoning` : صفحه زون‌بندی کشت
- `simulator` : سکشن شبیه‌ساز
- `plant-growth-simulator` : صفحه شبیه‌ساز رشد گیاه
- `recommendations` : سکشن توصیه‌ها
- `irrigation-recommendation` : صفحه توصیه آبیاری
- `fertilization-recommendation` : صفحه توصیه کوددهی
- `smart-assistant` : سکشن دستیار هوشمند
- `farm-ai-assistant` : صفحه دستیار هوشمند مزرعه
- `pest-detection` : صفحه تشخیص آفات گیاهی
- `sensor-page` : صفحه سنسور
- `greenhouse-dashboard` : صفحه/داشبورد گلخانه
## 5) گارد API
## 4) دسترسی‌های plan `gold`
اگر `subscription_plan.code === "gold"` باشد، این featureها باید فعال باشند:
- `dashboards`
- `data-section`
- `water-data`
- `soil-information`
- `crop-zoning`
- `simulator`
- `plant-growth-simulator`
- `recommendations`
- `irrigation-recommendation`
- `fertilization-recommendation`
- `smart-assistant`
- `farm-ai-assistant`
- `pest-detection`
- `greenhouse-dashboard`
## 5) قانون سنسور
یک rule جدا هم برای سنسور تعریف شده است:
- rule code: `sensor-7-page-access`
- شرط: مزرعه سنسور `Sensor 7 - Soil Moisture Sensor v1.2` را داشته باشد
- نتیجه: feature `sensor-page` فعال می‌شود
پس برای نمایش صفحه سنسور بهتر است فقط این را چک کنید:
- `features["sensor-page"]?.enabled === true`
و لازم نیست منطق نام سنسور را داخل فرانت تکرار کنید.
## 6) source و matched_rules یعنی چه؟
- `features[code].source` نشان می‌دهد این دسترسی از کدام rule آمده است.
- `matched_rules` لیست ruleهایی است که روی این مزرعه match شده‌اند.
- برای UI معمولی معمولاً فقط `enabled` کافی است.
- برای debug یا پنل ادمین، `source` و `matched_rules` مفید هستند.
## 7) گارد دسترسی روی APIها
علاوه بر `IsAuthenticated`، گارد مبتنی بر feature هم اضافه شده است.
اگر API نیازمند یک feature خاص باشد و آن feature برای مزرعه فعال نباشد، پاسخ:
گاردهای backend همچنان بر اساس access control کار می‌کنند. اگر کاربر به APIای دسترسی نداشته باشد، پاسخ:
- `403 Forbidden`
@@ -151,44 +68,3 @@
"detail": "Access to feature `greenhouse-dashboard` is denied."
}
```
## 8) APIهایی که فعلاً به feature وصل شده‌اند
فعلاً این endpointها نیازمند feature `greenhouse-dashboard` هستند:
- `GET /api/farm-dashboard/`
- `GET /api/farm-dashboard-config/`
- `PATCH /api/farm-dashboard-config/`
نکته:
- در این endpointها باید `farm_uuid` معتبر و متعلق به همان کاربر ارسال شود.
## 9) پیشنهاد پیاده‌سازی در فرانت
- بعد از انتخاب مزرعه، یک‌بار `access profile` را fetch کنید.
- نمایش منوها، سکشن‌ها و صفحه‌ها را با `features[feature_code].enabled` کنترل کنید.
- اگر کاربر مستقیم وارد صفحه شد و backend `403` داد، صفحه `عدم دسترسی` یا fallback مناسب نمایش دهید.
- در سوئیچ مزرعه، access profile را دوباره بگیرید.
- منبع نهایی حقیقت همیشه backend است، نه فقط hide/show در UI.
## 10) الگوی پیشنهادی در کد فرانت
نمونه:
```ts
const canAccess = (profile: any, featureCode: string) => {
return profile?.features?.[featureCode]?.enabled === true;
};
const canShowDashboard = canAccess(profile, "greenhouse-dashboard");
const canShowSensorPage = canAccess(profile, "sensor-page");
const canShowPestDetection = canAccess(profile, "pest-detection");
```
## 11) جمع‌بندی قرارداد فرانت
- کلید پایدار بررسی: `feature code`
- منبع معتبر: `GET /api/access-control/farms/{farm_uuid}/profile/`
- تصمیم UI: بر اساس `features[code].enabled`
- تصمیم نهایی backend: بر اساس ruleهای access control
+18 -16
View File
@@ -1,5 +1,6 @@
GOLD_PLAN_CODE = "gold"
SENSOR_7_NAME = "Sensor 7 - Soil Moisture Sensor v1.2"
SENSOR_7_CODE = "sensor_7_soil_moisture_sensor_v1_2"
DEFAULT_SUBSCRIPTION_PLANS = [
@@ -13,21 +14,21 @@ DEFAULT_SUBSCRIPTION_PLANS = [
DEFAULT_ACCESS_FEATURES = [
{"code": "dashboards", "name": "داشبوردها", "feature_type": "section"},
{"code": "data-section", "name": "بخش داده ها", "feature_type": "section"},
{"code": "water-data", "name": "دیتاهای آب", "feature_type": "page"},
{"code": "soil-information", "name": "اطلاعات خاک", "feature_type": "page"},
{"code": "crop-zoning", "name": "زون بندی کشت", "feature_type": "page"},
{"code": "simulator", "name": "شبیه ساز", "feature_type": "section"},
{"code": "plant-growth-simulator", "name": "شبیه ساز رشد گیاه", "feature_type": "page"},
{"code": "recommendations", "name": "توصیه ها", "feature_type": "section"},
{"code": "irrigation-recommendation", "name": "توصیه آبیاری", "feature_type": "page"},
{"code": "fertilization-recommendation", "name": "توصیه کوددهی", "feature_type": "page"},
{"code": "smart-assistant", "name": "دستیار هوشمند", "feature_type": "section"},
{"code": "farm-ai-assistant", "name": "دستیار هوشمند مزرعه", "feature_type": "page"},
{"code": "pest-detection", "name": "تشخیص آفات گیاهی", "feature_type": "page"},
{"code": "sensor-page", "name": "صفحه سنسور", "feature_type": "page"},
{"code": "greenhouse-dashboard", "name": "Greenhouse Dashboard", "feature_type": "page"},
{"code": "dashboards", "name": "داشبوردها", "feature_type": "section", "default_enabled": True},
{"code": "data-section", "name": "بخش داده ها", "feature_type": "section", "default_enabled": True},
{"code": "water-data", "name": "دیتاهای آب", "feature_type": "page", "default_enabled": True},
{"code": "soil-information", "name": "اطلاعات خاک", "feature_type": "page", "default_enabled": True},
{"code": "crop-zoning", "name": "زون بندی کشت", "feature_type": "page", "default_enabled": True},
{"code": "simulator", "name": "شبیه ساز", "feature_type": "section", "default_enabled": True},
{"code": "plant-growth-simulator", "name": "شبیه ساز رشد گیاه", "feature_type": "page", "default_enabled": True},
{"code": "recommendations", "name": "توصیه ها", "feature_type": "section", "default_enabled": True},
{"code": "irrigation-recommendation", "name": "توصیه آبیاری", "feature_type": "page", "default_enabled": True},
{"code": "fertilization-recommendation", "name": "توصیه کوددهی", "feature_type": "page", "default_enabled": True},
{"code": "smart-assistant", "name": "دستیار هوشمند", "feature_type": "section", "default_enabled": True},
{"code": "farm-ai-assistant", "name": "دستیار هوشمند مزرعه", "feature_type": "page", "default_enabled": True},
{"code": "pest-detection", "name": "تشخیص آفات گیاهی", "feature_type": "page", "default_enabled": True},
{"code": "sensor-page", "name": "صفحه سنسور", "feature_type": "page", "default_enabled": True},
{"code": "greenhouse-dashboard", "name": "Greenhouse Dashboard", "feature_type": "page", "default_enabled": True},
]
@@ -64,6 +65,7 @@ DEFAULT_ACCESS_RULES = [
"priority": 20,
"features": ["sensor-page"],
"sensor_catalogs": [SENSOR_7_NAME],
"metadata": {"sensor_catalog_names": [SENSOR_7_NAME]},
"sensor_catalog_codes": [SENSOR_7_CODE],
"metadata": {"sensor_catalog_codes": [SENSOR_7_CODE]},
},
]
@@ -0,0 +1,20 @@
from django.db import migrations
def enable_default_feature_access(apps, schema_editor):
AccessFeature = apps.get_model("access_control", "AccessFeature")
from access_control.catalog import DEFAULT_ACCESS_FEATURES
default_enabled_codes = [item["code"] for item in DEFAULT_ACCESS_FEATURES if item.get("default_enabled", False)]
AccessFeature.objects.filter(code__in=default_enabled_codes).update(default_enabled=True)
class Migration(migrations.Migration):
dependencies = [
("access_control", "0003_seed_default_access_rules"),
]
operations = [
migrations.RunPython(enable_default_feature_access, migrations.RunPython.noop),
]
@@ -0,0 +1,26 @@
from django.db import migrations
def backfill_farm_subscription_plans(apps, schema_editor):
SubscriptionPlan = apps.get_model("access_control", "SubscriptionPlan")
FarmHub = apps.get_model("farm_hub", "FarmHub")
default_plan = (
SubscriptionPlan.objects.filter(is_active=True, metadata__is_default=True).order_by("name").first()
or SubscriptionPlan.objects.filter(code="gold", is_active=True).first()
)
if default_plan is None:
return
FarmHub.objects.filter(subscription_plan__isnull=True).update(subscription_plan=default_plan)
class Migration(migrations.Migration):
dependencies = [
("access_control", "0004_enable_default_feature_access"),
("farm_hub", "0007_farmhub_subscription_plan"),
]
operations = [
migrations.RunPython(backfill_farm_subscription_plans, migrations.RunPython.noop),
]
-3
View File
@@ -12,8 +12,6 @@ class SubscriptionPlanSerializer(serializers.ModelSerializer):
class FarmAccessProfileSerializer(serializers.Serializer):
farm_uuid = serializers.UUIDField()
subscription_plan = SubscriptionPlanSerializer(allow_null=True)
features = serializers.DictField()
groups = serializers.DictField()
matched_rules = serializers.ListField()
resolved_from_profile = serializers.BooleanField()
@@ -31,4 +29,3 @@ class FarmAccessProfileCacheSerializer(serializers.ModelSerializer):
"created_at",
"updated_at",
]
+37 -6
View File
@@ -1,19 +1,31 @@
from django.utils import timezone
from .models import AccessFeature, AccessRule, FarmAccessProfile
from .catalog import GOLD_PLAN_CODE
from .models import AccessFeature, AccessRule, FarmAccessProfile, SubscriptionPlan
def _manager_id_set(manager):
return {obj.id for obj in manager.all()}
def get_effective_subscription_plan(farm):
if getattr(farm, "subscription_plan_id", None):
return farm.subscription_plan
return (
SubscriptionPlan.objects.filter(is_active=True, metadata__is_default=True).order_by("name").first()
or SubscriptionPlan.objects.filter(code=GOLD_PLAN_CODE, is_active=True).first()
)
def rule_matches_farm(rule, farm, product_ids=None, sensor_catalog_ids=None):
if not rule.is_active:
return False
subscription_plan_ids = _manager_id_set(rule.subscription_plans)
if subscription_plan_ids:
if farm.subscription_plan_id is None or farm.subscription_plan_id not in subscription_plan_ids:
subscription_plan = get_effective_subscription_plan(farm)
if subscription_plan is None or subscription_plan.id not in subscription_plan_ids:
return False
farm_type_ids = _manager_id_set(rule.farm_types)
@@ -36,6 +48,14 @@ def rule_matches_farm(rule, farm, product_ids=None, sensor_catalog_ids=None):
if not sensor_catalog_ids or sensor_catalog_rule_ids.isdisjoint(sensor_catalog_ids):
return False
sensor_catalog_rule_codes = set(rule.metadata.get("sensor_catalog_codes", [])) if isinstance(rule.metadata, dict) else set()
if sensor_catalog_rule_codes:
farm_sensor_catalog_codes = set(
farm.sensors.exclude(sensor_catalog__code__isnull=True).values_list("sensor_catalog__code", flat=True)
)
if not farm_sensor_catalog_codes or sensor_catalog_rule_codes.isdisjoint(farm_sensor_catalog_codes):
return False
sensor_catalog_rule_names = set(rule.metadata.get("sensor_catalog_names", [])) if isinstance(rule.metadata, dict) else set()
if sensor_catalog_rule_names:
farm_sensor_catalog_names = set(
@@ -48,6 +68,7 @@ def rule_matches_farm(rule, farm, product_ids=None, sensor_catalog_ids=None):
def build_farm_access_profile(farm):
subscription_plan = get_effective_subscription_plan(farm)
features = AccessFeature.objects.all().order_by("feature_type", "code")
resolved = {
feature.code: {
@@ -112,11 +133,11 @@ def build_farm_access_profile(farm):
return {
"farm_uuid": str(farm.farm_uuid),
"subscription_plan": {
"uuid": str(farm.subscription_plan.uuid),
"code": farm.subscription_plan.code,
"name": farm.subscription_plan.name,
"uuid": str(subscription_plan.uuid),
"code": subscription_plan.code,
"name": subscription_plan.name,
}
if farm.subscription_plan_id
if subscription_plan is not None
else None,
"features": profile.cached_features,
"groups": profile.cached_groups,
@@ -125,6 +146,16 @@ def build_farm_access_profile(farm):
}
def build_farm_access_profile_response(farm):
profile_data = build_farm_access_profile(farm)
return {
"farm_uuid": profile_data["farm_uuid"],
"subscription_plan": profile_data["subscription_plan"],
"matched_rules": profile_data["matched_rules"],
"resolved_from_profile": profile_data["resolved_from_profile"],
}
def is_feature_enabled_for_farm(farm, feature_code):
profile = getattr(farm, "access_profile", None)
if profile and isinstance(profile.cached_features, dict):
+2 -3
View File
@@ -9,7 +9,7 @@ from farm_hub.models import FarmHub
from .models import SubscriptionPlan
from .serializers import FarmAccessProfileSerializer, SubscriptionPlanSerializer
from .services import build_farm_access_profile
from .services import build_farm_access_profile_response
class AccessControlBaseView(APIView):
@@ -52,6 +52,5 @@ class FarmAccessProfileView(AccessControlBaseView):
if farm is None:
return Response({"code": 404, "msg": "Farm not found."}, status=status.HTTP_404_NOT_FOUND)
data = build_farm_access_profile(farm)
data = build_farm_access_profile_response(farm)
return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK)
@@ -1,21 +1,24 @@
from django.core.management.base import BaseCommand, CommandError
from account.seeds import ADMIN_USER_DATA, seed_admin_user
from account.seeds import ADMIN_USER_DATA
from farm_hub.seeds import seed_admin_farm
class Command(BaseCommand):
help = "Create or update the default admin user."
help = "Create or update the default admin user through the admin farm seeder."
def handle(self, *args, **options):
try:
user, created = seed_admin_user()
farm, created = seed_admin_farm()
except ValueError as exc:
raise CommandError(str(exc)) from exc
action = "created" if created else "updated"
user = farm.owner
self.stdout.write(
self.style.SUCCESS(
f"Admin user {action}: username={user.username}, email={user.email}, "
f"phone_number={user.phone_number}, password={ADMIN_USER_DATA['password']}"
f"phone_number={user.phone_number}, password={ADMIN_USER_DATA['password']}, "
f"farm_uuid={farm.farm_uuid}"
)
)
+2
View File
@@ -39,6 +39,7 @@ services:
env_file:
- .env
environment:
DOCKER_VERSION: ${DOCKER_VERSION:-production}
DB_HOST: db
CELERY_BROKER_URL: ${CELERY_BROKER_URL:-redis://redis:6379/0}
CELERY_RESULT_BACKEND: ${CELERY_RESULT_BACKEND:-redis://redis:6379/0}
@@ -60,6 +61,7 @@ services:
env_file:
- .env
environment:
DOCKER_VERSION: ${DOCKER_VERSION:-production}
DB_HOST: db
CELERY_BROKER_URL: ${CELERY_BROKER_URL:-redis://redis:6379/0}
CELERY_RESULT_BACKEND: ${CELERY_RESULT_BACKEND:-redis://redis:6379/0}
+2
View File
@@ -62,6 +62,7 @@ services:
env_file:
- .env
environment:
DOCKER_VERSION: ${DOCKER_VERSION:-develop}
DB_HOST: db
CELERY_BROKER_URL: redis://redis:6379/0
CELERY_RESULT_BACKEND: redis://redis:6379/0
@@ -90,6 +91,7 @@ services:
env_file:
- .env
environment:
DOCKER_VERSION: ${DOCKER_VERSION:-develop}
DB_HOST: db
CELERY_BROKER_URL: redis://redis:6379/0
CELERY_RESULT_BACKEND: redis://redis:6379/0
+5
View File
@@ -4,6 +4,11 @@ if [ "${SKIP_MIGRATE}" != "1" ]; then
echo "Running migrations..."
python manage.py migrate --noinput --fake-initial
echo "Migrations done."
if [ "${DOCKER_VERSION}" = "develop" ]; then
echo "Running develop seeders..."
python manage.py seed_admin_farm
echo "Develop seeders done."
fi
fi
echo "Starting command: $*"
exec "$@"
+9 -24
View File
@@ -3,6 +3,7 @@ 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 .catalog import CATALOG_SEED_DATA
@@ -16,32 +17,15 @@ ADMIN_FARM_DATA = {
"is_active": True,
"sensors": [
{
"sensor_catalog_name": "Sensor 7 - Soil Moisture Sensor v1.2",
"physical_device_uuid": uuid.UUID("22222222-2222-2222-2222-222222222221"),
"name": "Station 1",
"sensor_type": "weather_station",
"is_active": True,
"specifications": {
"model": "CL-SENSE-PRO-X",
"firmware": "2.4.1",
"manufacturer": "CropLogic",
},
"power_source": {
"type": "hybrid",
"battery": {"capacity_mah": 12000, "voltage": 12},
"solar": {"panel_watt": 40, "controller": "MPPT"},
},
},
{
"sensor_catalog_name": "Sensor 7 - Soil Moisture Sensor v1.2",
"sensor_catalog_code": "sensor_7_soil_moisture_sensor_v1_2",
"physical_device_uuid": uuid.UUID("22222222-2222-2222-2222-222222222222"),
"name": "Soil Probe 1",
"sensor_type": "soil_probe",
"is_active": True,
"specifications": {
"capabilities": ["soil_moisture", "soil_temperature", "ph", "ec"],
"capabilities": ["soil_moisture", "analog_output", "digital_output"],
},
"power_source": {"type": "battery", "backup": "solar"},
"power_source": {"type": "solar"},
},
],
}
@@ -81,12 +65,13 @@ def _get_default_catalog():
return FarmType.objects.get(name=default_farm_type_name), created_products[:2]
def _get_sensor_catalog_by_name(name):
return SensorCatalog.objects.filter(name=name).first()
def _get_sensor_catalog_by_code(code):
return SensorCatalog.objects.filter(code=code).first()
@transaction.atomic
def seed_admin_farm():
seed_sensor_catalog()
owner, _ = seed_admin_user()
farm_type, products = _get_default_catalog()
farm, created = FarmHub.objects.update_or_create(
@@ -103,8 +88,8 @@ def seed_admin_farm():
sensors = []
for sensor_data in ADMIN_FARM_DATA["sensors"]:
sensor_data = sensor_data.copy()
sensor_catalog_name = sensor_data.pop("sensor_catalog_name", None)
sensor_data["sensor_catalog"] = _get_sensor_catalog_by_name(sensor_catalog_name) if sensor_catalog_name else None
sensor_catalog_code = sensor_data.pop("sensor_catalog_code", None)
sensor_data["sensor_catalog"] = _get_sensor_catalog_by_code(sensor_catalog_code) if sensor_catalog_code else None
sensors.append(farm.sensors.model(farm=farm, **sensor_data))
farm.sensors.bulk_create(sensors)
if created:
+8 -1
View File
@@ -2,6 +2,7 @@ from rest_framework import serializers
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 .models import FarmHub, FarmSensor, FarmType, Product
from sensor_catalog.models import SensorCatalog
@@ -58,7 +59,7 @@ class FarmSensorSerializer(serializers.ModelSerializer):
class FarmHubSerializer(serializers.ModelSerializer):
last_updated = serializers.DateTimeField(source="updated_at", read_only=True)
farm_type = FarmTypeSerializer(read_only=True)
subscription_plan = SubscriptionPlanSerializer(read_only=True)
subscription_plan = serializers.SerializerMethodField()
products = ProductSerializer(many=True, read_only=True)
sensors = FarmSensorSerializer(many=True, read_only=True)
area_uuid = serializers.UUIDField(source="current_crop_area.uuid", read_only=True)
@@ -78,6 +79,12 @@ class FarmHubSerializer(serializers.ModelSerializer):
]
read_only_fields = ["farm_uuid", "last_updated"]
def get_subscription_plan(self, obj):
subscription_plan = get_effective_subscription_plan(obj)
if subscription_plan is None:
return None
return SubscriptionPlanSerializer(subscription_plan, context=self.context).data
class FarmSensorWriteSerializer(serializers.ModelSerializer):
sensor_catalog_uuid = serializers.UUIDField(write_only=True, required=False)
+48 -16
View File
@@ -7,6 +7,7 @@ from access_control.services import build_farm_access_profile
from access_control.views import FarmAccessProfileView
from crop_zoning.models import CropArea
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 FarmListCreateView, FarmTypeListView, FarmTypeProductsView
from sensor_catalog.models import SensorCatalog
@@ -46,6 +47,7 @@ class FarmListCreateViewTests(TestCase):
self.wheat, _ = Product.objects.get_or_create(farm_type=self.farm_type, name="گندم")
self.plan = SubscriptionPlan.objects.create(code="gold", name="Gold")
self.weather_station, _ = SensorCatalog.objects.get_or_create(
code="sensor_7_soil_moisture_sensor_v1_2",
name="Sensor 7 - Soil Moisture Sensor v1.2",
defaults={"supported_power_sources": ["solar", "direct_power"]},
)
@@ -159,23 +161,16 @@ class FarmListCreateViewTests(TestCase):
)
class FarmSeedTests(TestCase):
def test_seed_admin_farm_dispatches_crop_logic_flow_on_create(self):
SensorCatalog.objects.get_or_create(
name="Sensor 7 - Soil Moisture Sensor v1.2",
defaults={"supported_power_sources": ["solar", "direct_power"]},
)
farm, created = seed_admin_farm()
self.assertTrue(created)
self.assertEqual(farm.farm_uuid.hex, "11111111111111111111111111111111")
self.assertEqual(CropArea.objects.count(), 1)
self.assertEqual(farm.sensors.count(), 2)
self.assertEqual(farm.sensors.count(), 1)
self.assertIsNotNone(farm.sensors.first().physical_device_uuid)
self.assertTrue(SensorCatalog.objects.filter(code="sensor_7_soil_moisture_sensor_v1_2").exists())
def test_seed_admin_farm_does_not_dispatch_twice_for_existing_seed(self):
SensorCatalog.objects.get_or_create(
name="Sensor 7 - Soil Moisture Sensor v1.2",
defaults={"supported_power_sources": ["solar", "direct_power"]},
)
first_farm, first_created = seed_admin_farm()
second_farm, second_created = seed_admin_farm()
@@ -252,7 +247,7 @@ class FarmAccessProfileTests(TestCase):
self.plan = SubscriptionPlan.objects.create(code="starter", name="Starter")
self.farm_type = FarmType.objects.create(name="گلخانه ای")
self.product = Product.objects.create(farm_type=self.farm_type, name="خیار")
self.sensor_catalog = SensorCatalog.objects.create(name="Climate Sensor")
self.sensor_catalog = SensorCatalog.objects.create(code="climate_sensor", name="Climate Sensor")
self.farm = FarmHub.objects.create(
owner=self.user,
farm_type=self.farm_type,
@@ -314,24 +309,61 @@ class FarmAccessProfileTests(TestCase):
response = FarmAccessProfileView.as_view()(request, farm_uuid=self.farm.farm_uuid)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["data"]["groups"]["pages"]["greenhouse-dashboard"]["enabled"], True)
self.assertEqual(response.data["data"]["groups"]["widgets"]["sensor-analytics"]["enabled"], True)
self.assertNotIn("features", response.data["data"])
self.assertNotIn("groups", response.data["data"])
self.assertEqual(len(response.data["data"]["matched_rules"]), 3)
self.assertTrue(FarmAccessProfile.objects.filter(farm=self.farm).exists())
def test_sensor_rule_can_match_by_metadata_sensor_name(self):
def test_sensor_rule_can_match_by_metadata_sensor_code(self):
sensor_page = AccessFeature.objects.create(
code="sensor-page",
name="Sensor Page",
feature_type=AccessFeature.PAGE,
)
sensor_rule = AccessRule.objects.create(
code="sensor-page-by-name",
name="Sensor Page By Name",
code="sensor-page-by-code",
name="Sensor Page By Code",
priority=40,
metadata={"sensor_catalog_names": [self.sensor_catalog.name]},
metadata={"sensor_catalog_codes": [self.sensor_catalog.code]},
)
sensor_rule.features.add(sensor_page)
profile = build_farm_access_profile(self.farm)
self.assertTrue(profile["features"]["sensor-page"]["enabled"])
def test_build_farm_access_profile_falls_back_to_default_plan(self):
default_plan = SubscriptionPlan.objects.create(code="gold", name="Gold", metadata={"is_default": True})
fallback_farm = FarmHub.objects.create(
owner=self.user,
farm_type=self.farm_type,
subscription_plan=None,
name="Fallback Plan Farm",
)
fallback_farm.products.add(self.product)
fallback_feature = AccessFeature.objects.create(
code="fallback-dashboard",
name="Fallback Dashboard",
feature_type=AccessFeature.PAGE,
)
fallback_rule = AccessRule.objects.create(code="gold-fallback-rule", name="Gold Fallback Rule", priority=5)
fallback_rule.features.add(fallback_feature)
fallback_rule.subscription_plans.add(default_plan)
profile = build_farm_access_profile(fallback_farm)
self.assertEqual(profile["subscription_plan"]["code"], "gold")
self.assertTrue(profile["features"]["fallback-dashboard"]["enabled"])
def test_farm_serializer_returns_default_plan_when_model_plan_is_null(self):
SubscriptionPlan.objects.create(code="gold", name="Gold", metadata={"is_default": True})
fallback_farm = FarmHub.objects.create(
owner=self.user,
farm_type=self.farm_type,
subscription_plan=None,
name="Serializer Fallback Farm",
)
payload = FarmHubSerializer(fallback_farm).data
self.assertEqual(payload["subscription_plan"]["code"], "gold")
+50
View File
@@ -0,0 +1,50 @@
from sensor_catalog.models import SensorCatalog
SENSOR_CATALOG_ITEMS = [
{
"code": "sensor_7_soil_moisture_sensor_v1_2",
"name": "Sensor 7 - Soil Moisture Sensor v1.2",
"description": (
"This sensor is typically the YL-69 or FC-28 soil moisture sensor. "
"It measures only soil moisture and provides analog and digital outputs. "
"It does not report soil temperature, pH, or nutrients."
),
"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,
}
]
def seed_sensor_catalog():
created_count = 0
updated_count = 0
results = []
for item in SENSOR_CATALOG_ITEMS:
sensor, created = SensorCatalog.objects.update_or_create(
code=item["code"],
defaults={
"name": item["name"],
"description": item["description"],
"customizable_fields": item["customizable_fields"],
"supported_power_sources": item["supported_power_sources"],
"returned_data_fields": item["returned_data_fields"],
"sample_payload": item["sample_payload"],
"is_active": item["is_active"],
},
)
results.append((sensor, created))
if created:
created_count += 1
else:
updated_count += 1
return results, created_count, updated_count
@@ -1,53 +1,17 @@
from django.core.management.base import BaseCommand
from sensor_catalog.models import SensorCatalog
SENSOR_CATALOG_ITEMS = [
{
"name": "Sensor 7 - Soil Moisture Sensor v1.2",
"description": (
"This sensor is typically the YL-69 or FC-28 soil moisture sensor. "
"It measures only soil moisture and provides analog and digital outputs. "
"It does not report soil temperature, pH, or nutrients."
),
"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,
}
]
from sensor_catalog.management import seed_sensor_catalog
class Command(BaseCommand):
help = "Seed sensor catalog data."
def handle(self, *args, **options):
created_count = 0
updated_count = 0
for item in SENSOR_CATALOG_ITEMS:
sensor, created = SensorCatalog.objects.update_or_create(
name=item["name"],
defaults={
"description": item["description"],
"customizable_fields": item["customizable_fields"],
"supported_power_sources": item["supported_power_sources"],
"returned_data_fields": item["returned_data_fields"],
"sample_payload": item["sample_payload"],
"is_active": item["is_active"],
},
)
results, created_count, updated_count = seed_sensor_catalog()
for sensor, created in results:
if created:
created_count += 1
self.stdout.write(self.style.SUCCESS(f"Created sensor catalog item: {sensor.name}"))
else:
updated_count += 1
self.stdout.write(self.style.WARNING(f"Updated sensor catalog item: {sensor.name}"))
self.stdout.write(
@@ -0,0 +1,44 @@
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),
),
]
+2 -1
View File
@@ -5,6 +5,7 @@ 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)
@@ -17,7 +18,7 @@ class SensorCatalog(models.Model):
class Meta:
db_table = "sensor_catalogs"
ordering = ["name"]
ordering = ["code"]
def __str__(self):
return self.name
+1
View File
@@ -8,6 +8,7 @@ class SensorCatalogSerializer(serializers.ModelSerializer):
model = SensorCatalog
fields = [
"uuid",
"code",
"name",
"description",
"customizable_fields",
+4 -2
View File
@@ -16,6 +16,7 @@ class SensorCatalogListViewTests(TestCase):
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": (
@@ -30,6 +31,7 @@ class SensorCatalogListViewTests(TestCase):
},
)
SensorCatalog.objects.update_or_create(
code="legacy_sensor",
name="Legacy Sensor",
defaults={
"customizable_fields": [],
@@ -50,6 +52,6 @@ class SensorCatalogListViewTests(TestCase):
self.assertEqual(response.data["code"], 200)
self.assertEqual(len(response.data["data"]), 2)
self.assertEqual(
{item["name"] for item in response.data["data"]},
{"Sensor 7 - Soil Moisture Sensor v1.2", "Legacy Sensor"},
{item["code"] for item in response.data["data"]},
{"sensor_7_soil_moisture_sensor_v1_2", "legacy_sensor"},
)
+1 -1
View File
@@ -17,6 +17,6 @@ class SensorCatalogListView(APIView):
responses={200: code_response("SensorCatalogListResponse", data=SensorCatalogSerializer(many=True))},
)
def get(self, request):
sensors = SensorCatalog.objects.order_by("name")
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)