UPDATE
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
# Django
|
# Django
|
||||||
SECRET_KEY=your-secret-key-change-in-production
|
SECRET_KEY=your-secret-key-change-in-production
|
||||||
DEBUG=1
|
DEBUG=1
|
||||||
|
DOCKER_VERSION=develop
|
||||||
ALLOWED_HOSTS=node.crop-logic.ir,crop-logic.ir,localhost,127.0.0.1,0.0.0.0
|
ALLOWED_HOSTS=node.crop-logic.ir,crop-logic.ir,localhost,127.0.0.1,0.0.0.0
|
||||||
|
|
||||||
# Database (MySQL)
|
# Database (MySQL)
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
# تغییرات سطح دسترسی برای فرانت
|
|
||||||
|
|
||||||
این سند توضیح میدهد فرانت چطور از سیستم `access_control` استفاده کند، چه `feature code` هایی وجود دارند، و plan `gold` دقیقاً چه دسترسیهایی میدهد.
|
این سند قرارداد جدید endpoint پروفایل دسترسی را توضیح میدهد.
|
||||||
|
|
||||||
|
نکته مهم:
|
||||||
|
|
||||||
|
- فرانت فقط باید از `matched_rules` استفاده کند
|
||||||
|
- منبع حقیقت برای فرانت پاسخ API است؛ نه `access_control/apps.py`
|
||||||
|
- `subscription_plan` ممکن است از plan پیشفرض موثر پر شود، حتی اگر روی خود فارم قبلاً `null` بوده باشد
|
||||||
|
|
||||||
## 1) API پروفایل دسترسی مزرعه
|
## 1) API پروفایل دسترسی مزرعه
|
||||||
|
|
||||||
برای گرفتن دسترسی موثر هر مزرعه از این endpoint استفاده کنید:
|
|
||||||
|
|
||||||
- `GET /api/access-control/farms/{farm_uuid}/profile/`
|
- `GET /api/access-control/farms/{farm_uuid}/profile/`
|
||||||
- نیازمند `Authorization: Bearer <access_token>`
|
- نیازمند `Authorization: Bearer <access_token>`
|
||||||
|
|
||||||
@@ -22,48 +25,12 @@
|
|||||||
"code": "gold",
|
"code": "gold",
|
||||||
"name": "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": [
|
"matched_rules": [
|
||||||
{
|
{
|
||||||
"code": "gold-full-access",
|
"code": "gold-full-access",
|
||||||
"name": "Gold Full Access",
|
"name": "Gold Full Access",
|
||||||
"effect": "allow",
|
"effect": "allow",
|
||||||
"priority": 10
|
"priority": 10
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "sensor-7-page-access",
|
|
||||||
"name": "Sensor 7 Page Access",
|
|
||||||
"effect": "allow",
|
|
||||||
"priority": 20
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"resolved_from_profile": true
|
"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`
|
- plan پیشفرض فارمهای جدید: `gold`
|
||||||
- اگر فرانت هنگام ساخت فارم `subscription_plan_uuid` نفرستد، backend بهصورت پیشفرض plan `gold` را ست میکند.
|
- اگر روی بعضی فارمهای قدیمی `subscription_plan` خالی باشد، backend plan موثر پیشفرض را برمیگرداند
|
||||||
- بنابراین در اکثر سناریوهای فعلی، کاربر جدید بعد از ساخت فارم باید دسترسیهای Gold را داشته باشد.
|
|
||||||
|
|
||||||
## 3) feature code ها
|
## 4) قرارداد جدید فرانت
|
||||||
|
|
||||||
این `code` ها کلید اصلی برای بررسی دسترسی در فرانت هستند:
|
- دیگر روی `features[code]` یا `groups` چیزی ننویسید
|
||||||
|
- منطق فرانت باید بر اساس `matched_rules` نوشته شود
|
||||||
|
- اگر backend بعداً شکل `matched_rules` را گسترش دهد، فرانت باید همچنان روی همین فیلد تکیه کند
|
||||||
|
|
||||||
- `dashboards` : سکشن داشبوردها
|
## 5) گارد API
|
||||||
- `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` : صفحه/داشبورد گلخانه
|
|
||||||
|
|
||||||
## 4) دسترسیهای plan `gold`
|
گاردهای backend همچنان بر اساس access control کار میکنند. اگر کاربر به APIای دسترسی نداشته باشد، پاسخ:
|
||||||
|
|
||||||
اگر `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 برای مزرعه فعال نباشد، پاسخ:
|
|
||||||
|
|
||||||
- `403 Forbidden`
|
- `403 Forbidden`
|
||||||
|
|
||||||
@@ -151,44 +68,3 @@
|
|||||||
"detail": "Access to feature `greenhouse-dashboard` is denied."
|
"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
@@ -1,5 +1,6 @@
|
|||||||
GOLD_PLAN_CODE = "gold"
|
GOLD_PLAN_CODE = "gold"
|
||||||
SENSOR_7_NAME = "Sensor 7 - Soil Moisture Sensor v1.2"
|
SENSOR_7_NAME = "Sensor 7 - Soil Moisture Sensor v1.2"
|
||||||
|
SENSOR_7_CODE = "sensor_7_soil_moisture_sensor_v1_2"
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_SUBSCRIPTION_PLANS = [
|
DEFAULT_SUBSCRIPTION_PLANS = [
|
||||||
@@ -13,21 +14,21 @@ DEFAULT_SUBSCRIPTION_PLANS = [
|
|||||||
|
|
||||||
|
|
||||||
DEFAULT_ACCESS_FEATURES = [
|
DEFAULT_ACCESS_FEATURES = [
|
||||||
{"code": "dashboards", "name": "داشبوردها", "feature_type": "section"},
|
{"code": "dashboards", "name": "داشبوردها", "feature_type": "section", "default_enabled": True},
|
||||||
{"code": "data-section", "name": "بخش داده ها", "feature_type": "section"},
|
{"code": "data-section", "name": "بخش داده ها", "feature_type": "section", "default_enabled": True},
|
||||||
{"code": "water-data", "name": "دیتاهای آب", "feature_type": "page"},
|
{"code": "water-data", "name": "دیتاهای آب", "feature_type": "page", "default_enabled": True},
|
||||||
{"code": "soil-information", "name": "اطلاعات خاک", "feature_type": "page"},
|
{"code": "soil-information", "name": "اطلاعات خاک", "feature_type": "page", "default_enabled": True},
|
||||||
{"code": "crop-zoning", "name": "زون بندی کشت", "feature_type": "page"},
|
{"code": "crop-zoning", "name": "زون بندی کشت", "feature_type": "page", "default_enabled": True},
|
||||||
{"code": "simulator", "name": "شبیه ساز", "feature_type": "section"},
|
{"code": "simulator", "name": "شبیه ساز", "feature_type": "section", "default_enabled": True},
|
||||||
{"code": "plant-growth-simulator", "name": "شبیه ساز رشد گیاه", "feature_type": "page"},
|
{"code": "plant-growth-simulator", "name": "شبیه ساز رشد گیاه", "feature_type": "page", "default_enabled": True},
|
||||||
{"code": "recommendations", "name": "توصیه ها", "feature_type": "section"},
|
{"code": "recommendations", "name": "توصیه ها", "feature_type": "section", "default_enabled": True},
|
||||||
{"code": "irrigation-recommendation", "name": "توصیه آبیاری", "feature_type": "page"},
|
{"code": "irrigation-recommendation", "name": "توصیه آبیاری", "feature_type": "page", "default_enabled": True},
|
||||||
{"code": "fertilization-recommendation", "name": "توصیه کوددهی", "feature_type": "page"},
|
{"code": "fertilization-recommendation", "name": "توصیه کوددهی", "feature_type": "page", "default_enabled": True},
|
||||||
{"code": "smart-assistant", "name": "دستیار هوشمند", "feature_type": "section"},
|
{"code": "smart-assistant", "name": "دستیار هوشمند", "feature_type": "section", "default_enabled": True},
|
||||||
{"code": "farm-ai-assistant", "name": "دستیار هوشمند مزرعه", "feature_type": "page"},
|
{"code": "farm-ai-assistant", "name": "دستیار هوشمند مزرعه", "feature_type": "page", "default_enabled": True},
|
||||||
{"code": "pest-detection", "name": "تشخیص آفات گیاهی", "feature_type": "page"},
|
{"code": "pest-detection", "name": "تشخیص آفات گیاهی", "feature_type": "page", "default_enabled": True},
|
||||||
{"code": "sensor-page", "name": "صفحه سنسور", "feature_type": "page"},
|
{"code": "sensor-page", "name": "صفحه سنسور", "feature_type": "page", "default_enabled": True},
|
||||||
{"code": "greenhouse-dashboard", "name": "Greenhouse Dashboard", "feature_type": "page"},
|
{"code": "greenhouse-dashboard", "name": "Greenhouse Dashboard", "feature_type": "page", "default_enabled": True},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -64,6 +65,7 @@ DEFAULT_ACCESS_RULES = [
|
|||||||
"priority": 20,
|
"priority": 20,
|
||||||
"features": ["sensor-page"],
|
"features": ["sensor-page"],
|
||||||
"sensor_catalogs": [SENSOR_7_NAME],
|
"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),
|
||||||
|
]
|
||||||
@@ -12,8 +12,6 @@ class SubscriptionPlanSerializer(serializers.ModelSerializer):
|
|||||||
class FarmAccessProfileSerializer(serializers.Serializer):
|
class FarmAccessProfileSerializer(serializers.Serializer):
|
||||||
farm_uuid = serializers.UUIDField()
|
farm_uuid = serializers.UUIDField()
|
||||||
subscription_plan = SubscriptionPlanSerializer(allow_null=True)
|
subscription_plan = SubscriptionPlanSerializer(allow_null=True)
|
||||||
features = serializers.DictField()
|
|
||||||
groups = serializers.DictField()
|
|
||||||
matched_rules = serializers.ListField()
|
matched_rules = serializers.ListField()
|
||||||
resolved_from_profile = serializers.BooleanField()
|
resolved_from_profile = serializers.BooleanField()
|
||||||
|
|
||||||
@@ -31,4 +29,3 @@ class FarmAccessProfileCacheSerializer(serializers.ModelSerializer):
|
|||||||
"created_at",
|
"created_at",
|
||||||
"updated_at",
|
"updated_at",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,31 @@
|
|||||||
from django.utils import timezone
|
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):
|
def _manager_id_set(manager):
|
||||||
return {obj.id for obj in manager.all()}
|
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):
|
def rule_matches_farm(rule, farm, product_ids=None, sensor_catalog_ids=None):
|
||||||
if not rule.is_active:
|
if not rule.is_active:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
subscription_plan_ids = _manager_id_set(rule.subscription_plans)
|
subscription_plan_ids = _manager_id_set(rule.subscription_plans)
|
||||||
if subscription_plan_ids:
|
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
|
return False
|
||||||
|
|
||||||
farm_type_ids = _manager_id_set(rule.farm_types)
|
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):
|
if not sensor_catalog_ids or sensor_catalog_rule_ids.isdisjoint(sensor_catalog_ids):
|
||||||
return False
|
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()
|
sensor_catalog_rule_names = set(rule.metadata.get("sensor_catalog_names", [])) if isinstance(rule.metadata, dict) else set()
|
||||||
if sensor_catalog_rule_names:
|
if sensor_catalog_rule_names:
|
||||||
farm_sensor_catalog_names = set(
|
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):
|
def build_farm_access_profile(farm):
|
||||||
|
subscription_plan = get_effective_subscription_plan(farm)
|
||||||
features = AccessFeature.objects.all().order_by("feature_type", "code")
|
features = AccessFeature.objects.all().order_by("feature_type", "code")
|
||||||
resolved = {
|
resolved = {
|
||||||
feature.code: {
|
feature.code: {
|
||||||
@@ -112,11 +133,11 @@ def build_farm_access_profile(farm):
|
|||||||
return {
|
return {
|
||||||
"farm_uuid": str(farm.farm_uuid),
|
"farm_uuid": str(farm.farm_uuid),
|
||||||
"subscription_plan": {
|
"subscription_plan": {
|
||||||
"uuid": str(farm.subscription_plan.uuid),
|
"uuid": str(subscription_plan.uuid),
|
||||||
"code": farm.subscription_plan.code,
|
"code": subscription_plan.code,
|
||||||
"name": farm.subscription_plan.name,
|
"name": subscription_plan.name,
|
||||||
}
|
}
|
||||||
if farm.subscription_plan_id
|
if subscription_plan is not None
|
||||||
else None,
|
else None,
|
||||||
"features": profile.cached_features,
|
"features": profile.cached_features,
|
||||||
"groups": profile.cached_groups,
|
"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):
|
def is_feature_enabled_for_farm(farm, feature_code):
|
||||||
profile = getattr(farm, "access_profile", None)
|
profile = getattr(farm, "access_profile", None)
|
||||||
if profile and isinstance(profile.cached_features, dict):
|
if profile and isinstance(profile.cached_features, dict):
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from farm_hub.models import FarmHub
|
|||||||
|
|
||||||
from .models import SubscriptionPlan
|
from .models import SubscriptionPlan
|
||||||
from .serializers import FarmAccessProfileSerializer, SubscriptionPlanSerializer
|
from .serializers import FarmAccessProfileSerializer, SubscriptionPlanSerializer
|
||||||
from .services import build_farm_access_profile
|
from .services import build_farm_access_profile_response
|
||||||
|
|
||||||
|
|
||||||
class AccessControlBaseView(APIView):
|
class AccessControlBaseView(APIView):
|
||||||
@@ -52,6 +52,5 @@ class FarmAccessProfileView(AccessControlBaseView):
|
|||||||
if farm is None:
|
if farm is None:
|
||||||
return Response({"code": 404, "msg": "Farm not found."}, status=status.HTTP_404_NOT_FOUND)
|
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)
|
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 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):
|
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):
|
def handle(self, *args, **options):
|
||||||
try:
|
try:
|
||||||
user, created = seed_admin_user()
|
farm, created = seed_admin_farm()
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise CommandError(str(exc)) from exc
|
raise CommandError(str(exc)) from exc
|
||||||
|
|
||||||
action = "created" if created else "updated"
|
action = "created" if created else "updated"
|
||||||
|
user = farm.owner
|
||||||
self.stdout.write(
|
self.stdout.write(
|
||||||
self.style.SUCCESS(
|
self.style.SUCCESS(
|
||||||
f"Admin user {action}: username={user.username}, email={user.email}, "
|
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}"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ services:
|
|||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
|
DOCKER_VERSION: ${DOCKER_VERSION:-production}
|
||||||
DB_HOST: db
|
DB_HOST: db
|
||||||
CELERY_BROKER_URL: ${CELERY_BROKER_URL:-redis://redis:6379/0}
|
CELERY_BROKER_URL: ${CELERY_BROKER_URL:-redis://redis:6379/0}
|
||||||
CELERY_RESULT_BACKEND: ${CELERY_RESULT_BACKEND:-redis://redis:6379/0}
|
CELERY_RESULT_BACKEND: ${CELERY_RESULT_BACKEND:-redis://redis:6379/0}
|
||||||
@@ -60,6 +61,7 @@ services:
|
|||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
|
DOCKER_VERSION: ${DOCKER_VERSION:-production}
|
||||||
DB_HOST: db
|
DB_HOST: db
|
||||||
CELERY_BROKER_URL: ${CELERY_BROKER_URL:-redis://redis:6379/0}
|
CELERY_BROKER_URL: ${CELERY_BROKER_URL:-redis://redis:6379/0}
|
||||||
CELERY_RESULT_BACKEND: ${CELERY_RESULT_BACKEND:-redis://redis:6379/0}
|
CELERY_RESULT_BACKEND: ${CELERY_RESULT_BACKEND:-redis://redis:6379/0}
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ services:
|
|||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
|
DOCKER_VERSION: ${DOCKER_VERSION:-develop}
|
||||||
DB_HOST: db
|
DB_HOST: db
|
||||||
CELERY_BROKER_URL: redis://redis:6379/0
|
CELERY_BROKER_URL: redis://redis:6379/0
|
||||||
CELERY_RESULT_BACKEND: redis://redis:6379/0
|
CELERY_RESULT_BACKEND: redis://redis:6379/0
|
||||||
@@ -90,6 +91,7 @@ services:
|
|||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
|
DOCKER_VERSION: ${DOCKER_VERSION:-develop}
|
||||||
DB_HOST: db
|
DB_HOST: db
|
||||||
CELERY_BROKER_URL: redis://redis:6379/0
|
CELERY_BROKER_URL: redis://redis:6379/0
|
||||||
CELERY_RESULT_BACKEND: redis://redis:6379/0
|
CELERY_RESULT_BACKEND: redis://redis:6379/0
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ if [ "${SKIP_MIGRATE}" != "1" ]; then
|
|||||||
echo "Running migrations..."
|
echo "Running migrations..."
|
||||||
python manage.py migrate --noinput --fake-initial
|
python manage.py migrate --noinput --fake-initial
|
||||||
echo "Migrations done."
|
echo "Migrations done."
|
||||||
|
if [ "${DOCKER_VERSION}" = "develop" ]; then
|
||||||
|
echo "Running develop seeders..."
|
||||||
|
python manage.py seed_admin_farm
|
||||||
|
echo "Develop seeders done."
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
echo "Starting command: $*"
|
echo "Starting command: $*"
|
||||||
exec "$@"
|
exec "$@"
|
||||||
|
|||||||
+9
-24
@@ -3,6 +3,7 @@ import uuid
|
|||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
|
|
||||||
from account.seeds import seed_admin_user
|
from account.seeds import seed_admin_user
|
||||||
|
from sensor_catalog.management import seed_sensor_catalog
|
||||||
from sensor_catalog.models import SensorCatalog
|
from sensor_catalog.models import SensorCatalog
|
||||||
|
|
||||||
from .catalog import CATALOG_SEED_DATA
|
from .catalog import CATALOG_SEED_DATA
|
||||||
@@ -16,32 +17,15 @@ ADMIN_FARM_DATA = {
|
|||||||
"is_active": True,
|
"is_active": True,
|
||||||
"sensors": [
|
"sensors": [
|
||||||
{
|
{
|
||||||
"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-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",
|
|
||||||
"physical_device_uuid": uuid.UUID("22222222-2222-2222-2222-222222222222"),
|
"physical_device_uuid": uuid.UUID("22222222-2222-2222-2222-222222222222"),
|
||||||
"name": "Soil Probe 1",
|
"name": "Soil Probe 1",
|
||||||
"sensor_type": "soil_probe",
|
"sensor_type": "soil_probe",
|
||||||
"is_active": True,
|
"is_active": True,
|
||||||
"specifications": {
|
"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]
|
return FarmType.objects.get(name=default_farm_type_name), created_products[:2]
|
||||||
|
|
||||||
|
|
||||||
def _get_sensor_catalog_by_name(name):
|
def _get_sensor_catalog_by_code(code):
|
||||||
return SensorCatalog.objects.filter(name=name).first()
|
return SensorCatalog.objects.filter(code=code).first()
|
||||||
|
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def seed_admin_farm():
|
def seed_admin_farm():
|
||||||
|
seed_sensor_catalog()
|
||||||
owner, _ = seed_admin_user()
|
owner, _ = seed_admin_user()
|
||||||
farm_type, products = _get_default_catalog()
|
farm_type, products = _get_default_catalog()
|
||||||
farm, created = FarmHub.objects.update_or_create(
|
farm, created = FarmHub.objects.update_or_create(
|
||||||
@@ -103,8 +88,8 @@ def seed_admin_farm():
|
|||||||
sensors = []
|
sensors = []
|
||||||
for sensor_data in ADMIN_FARM_DATA["sensors"]:
|
for sensor_data in ADMIN_FARM_DATA["sensors"]:
|
||||||
sensor_data = sensor_data.copy()
|
sensor_data = sensor_data.copy()
|
||||||
sensor_catalog_name = sensor_data.pop("sensor_catalog_name", None)
|
sensor_catalog_code = sensor_data.pop("sensor_catalog_code", None)
|
||||||
sensor_data["sensor_catalog"] = _get_sensor_catalog_by_name(sensor_catalog_name) if sensor_catalog_name else 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))
|
sensors.append(farm.sensors.model(farm=farm, **sensor_data))
|
||||||
farm.sensors.bulk_create(sensors)
|
farm.sensors.bulk_create(sensors)
|
||||||
if created:
|
if created:
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from rest_framework import serializers
|
|||||||
from access_control.models import SubscriptionPlan
|
from access_control.models import SubscriptionPlan
|
||||||
from access_control.serializers import SubscriptionPlanSerializer
|
from access_control.serializers import SubscriptionPlanSerializer
|
||||||
from access_control.catalog import GOLD_PLAN_CODE
|
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 .models import FarmHub, FarmSensor, FarmType, Product
|
||||||
from sensor_catalog.models import SensorCatalog
|
from sensor_catalog.models import SensorCatalog
|
||||||
@@ -58,7 +59,7 @@ class FarmSensorSerializer(serializers.ModelSerializer):
|
|||||||
class FarmHubSerializer(serializers.ModelSerializer):
|
class FarmHubSerializer(serializers.ModelSerializer):
|
||||||
last_updated = serializers.DateTimeField(source="updated_at", read_only=True)
|
last_updated = serializers.DateTimeField(source="updated_at", read_only=True)
|
||||||
farm_type = FarmTypeSerializer(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)
|
products = ProductSerializer(many=True, read_only=True)
|
||||||
sensors = FarmSensorSerializer(many=True, read_only=True)
|
sensors = FarmSensorSerializer(many=True, read_only=True)
|
||||||
area_uuid = serializers.UUIDField(source="current_crop_area.uuid", 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"]
|
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):
|
class FarmSensorWriteSerializer(serializers.ModelSerializer):
|
||||||
sensor_catalog_uuid = serializers.UUIDField(write_only=True, required=False)
|
sensor_catalog_uuid = serializers.UUIDField(write_only=True, required=False)
|
||||||
|
|||||||
+48
-16
@@ -7,6 +7,7 @@ from access_control.services import build_farm_access_profile
|
|||||||
from access_control.views import FarmAccessProfileView
|
from access_control.views import FarmAccessProfileView
|
||||||
from crop_zoning.models import CropArea
|
from crop_zoning.models import CropArea
|
||||||
from farm_hub.models import FarmHub, FarmType, Product
|
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.seeds import seed_admin_farm
|
||||||
from farm_hub.views import FarmListCreateView, FarmTypeListView, FarmTypeProductsView
|
from farm_hub.views import FarmListCreateView, FarmTypeListView, FarmTypeProductsView
|
||||||
from sensor_catalog.models import SensorCatalog
|
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.wheat, _ = Product.objects.get_or_create(farm_type=self.farm_type, name="گندم")
|
||||||
self.plan = SubscriptionPlan.objects.create(code="gold", name="Gold")
|
self.plan = SubscriptionPlan.objects.create(code="gold", name="Gold")
|
||||||
self.weather_station, _ = SensorCatalog.objects.get_or_create(
|
self.weather_station, _ = SensorCatalog.objects.get_or_create(
|
||||||
|
code="sensor_7_soil_moisture_sensor_v1_2",
|
||||||
name="Sensor 7 - Soil Moisture Sensor v1.2",
|
name="Sensor 7 - Soil Moisture Sensor v1.2",
|
||||||
defaults={"supported_power_sources": ["solar", "direct_power"]},
|
defaults={"supported_power_sources": ["solar", "direct_power"]},
|
||||||
)
|
)
|
||||||
@@ -159,23 +161,16 @@ class FarmListCreateViewTests(TestCase):
|
|||||||
)
|
)
|
||||||
class FarmSeedTests(TestCase):
|
class FarmSeedTests(TestCase):
|
||||||
def test_seed_admin_farm_dispatches_crop_logic_flow_on_create(self):
|
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()
|
farm, created = seed_admin_farm()
|
||||||
|
|
||||||
self.assertTrue(created)
|
self.assertTrue(created)
|
||||||
self.assertEqual(farm.farm_uuid.hex, "11111111111111111111111111111111")
|
self.assertEqual(farm.farm_uuid.hex, "11111111111111111111111111111111")
|
||||||
self.assertEqual(CropArea.objects.count(), 1)
|
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.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):
|
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()
|
first_farm, first_created = seed_admin_farm()
|
||||||
second_farm, second_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.plan = SubscriptionPlan.objects.create(code="starter", name="Starter")
|
||||||
self.farm_type = FarmType.objects.create(name="گلخانه ای")
|
self.farm_type = FarmType.objects.create(name="گلخانه ای")
|
||||||
self.product = Product.objects.create(farm_type=self.farm_type, 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(
|
self.farm = FarmHub.objects.create(
|
||||||
owner=self.user,
|
owner=self.user,
|
||||||
farm_type=self.farm_type,
|
farm_type=self.farm_type,
|
||||||
@@ -314,24 +309,61 @@ class FarmAccessProfileTests(TestCase):
|
|||||||
response = FarmAccessProfileView.as_view()(request, farm_uuid=self.farm.farm_uuid)
|
response = FarmAccessProfileView.as_view()(request, farm_uuid=self.farm.farm_uuid)
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertEqual(response.data["data"]["groups"]["pages"]["greenhouse-dashboard"]["enabled"], True)
|
self.assertNotIn("features", response.data["data"])
|
||||||
self.assertEqual(response.data["data"]["groups"]["widgets"]["sensor-analytics"]["enabled"], True)
|
self.assertNotIn("groups", response.data["data"])
|
||||||
|
self.assertEqual(len(response.data["data"]["matched_rules"]), 3)
|
||||||
self.assertTrue(FarmAccessProfile.objects.filter(farm=self.farm).exists())
|
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(
|
sensor_page = AccessFeature.objects.create(
|
||||||
code="sensor-page",
|
code="sensor-page",
|
||||||
name="Sensor Page",
|
name="Sensor Page",
|
||||||
feature_type=AccessFeature.PAGE,
|
feature_type=AccessFeature.PAGE,
|
||||||
)
|
)
|
||||||
sensor_rule = AccessRule.objects.create(
|
sensor_rule = AccessRule.objects.create(
|
||||||
code="sensor-page-by-name",
|
code="sensor-page-by-code",
|
||||||
name="Sensor Page By Name",
|
name="Sensor Page By Code",
|
||||||
priority=40,
|
priority=40,
|
||||||
metadata={"sensor_catalog_names": [self.sensor_catalog.name]},
|
metadata={"sensor_catalog_codes": [self.sensor_catalog.code]},
|
||||||
)
|
)
|
||||||
sensor_rule.features.add(sensor_page)
|
sensor_rule.features.add(sensor_page)
|
||||||
|
|
||||||
profile = build_farm_access_profile(self.farm)
|
profile = build_farm_access_profile(self.farm)
|
||||||
|
|
||||||
self.assertTrue(profile["features"]["sensor-page"]["enabled"])
|
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")
|
||||||
|
|||||||
@@ -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 django.core.management.base import BaseCommand
|
||||||
|
|
||||||
from sensor_catalog.models import SensorCatalog
|
from sensor_catalog.management import seed_sensor_catalog
|
||||||
|
|
||||||
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
help = "Seed sensor catalog data."
|
help = "Seed sensor catalog data."
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
created_count = 0
|
results, created_count, updated_count = seed_sensor_catalog()
|
||||||
updated_count = 0
|
for sensor, created in results:
|
||||||
|
|
||||||
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"],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if created:
|
if created:
|
||||||
created_count += 1
|
|
||||||
self.stdout.write(self.style.SUCCESS(f"Created sensor catalog item: {sensor.name}"))
|
self.stdout.write(self.style.SUCCESS(f"Created sensor catalog item: {sensor.name}"))
|
||||||
else:
|
else:
|
||||||
updated_count += 1
|
|
||||||
self.stdout.write(self.style.WARNING(f"Updated sensor catalog item: {sensor.name}"))
|
self.stdout.write(self.style.WARNING(f"Updated sensor catalog item: {sensor.name}"))
|
||||||
|
|
||||||
self.stdout.write(
|
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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -5,6 +5,7 @@ from django.db import models
|
|||||||
|
|
||||||
class SensorCatalog(models.Model):
|
class SensorCatalog(models.Model):
|
||||||
uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True)
|
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)
|
name = models.CharField(max_length=255, unique=True, db_index=True)
|
||||||
description = models.TextField(blank=True, default="")
|
description = models.TextField(blank=True, default="")
|
||||||
customizable_fields = models.JSONField(default=list, blank=True)
|
customizable_fields = models.JSONField(default=list, blank=True)
|
||||||
@@ -17,7 +18,7 @@ class SensorCatalog(models.Model):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
db_table = "sensor_catalogs"
|
db_table = "sensor_catalogs"
|
||||||
ordering = ["name"]
|
ordering = ["code"]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ class SensorCatalogSerializer(serializers.ModelSerializer):
|
|||||||
model = SensorCatalog
|
model = SensorCatalog
|
||||||
fields = [
|
fields = [
|
||||||
"uuid",
|
"uuid",
|
||||||
|
"code",
|
||||||
"name",
|
"name",
|
||||||
"description",
|
"description",
|
||||||
"customizable_fields",
|
"customizable_fields",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ class SensorCatalogListViewTests(TestCase):
|
|||||||
phone_number="09120000002",
|
phone_number="09120000002",
|
||||||
)
|
)
|
||||||
SensorCatalog.objects.update_or_create(
|
SensorCatalog.objects.update_or_create(
|
||||||
|
code="sensor_7_soil_moisture_sensor_v1_2",
|
||||||
name="Sensor 7 - Soil Moisture Sensor v1.2",
|
name="Sensor 7 - Soil Moisture Sensor v1.2",
|
||||||
defaults={
|
defaults={
|
||||||
"description": (
|
"description": (
|
||||||
@@ -30,6 +31,7 @@ class SensorCatalogListViewTests(TestCase):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
SensorCatalog.objects.update_or_create(
|
SensorCatalog.objects.update_or_create(
|
||||||
|
code="legacy_sensor",
|
||||||
name="Legacy Sensor",
|
name="Legacy Sensor",
|
||||||
defaults={
|
defaults={
|
||||||
"customizable_fields": [],
|
"customizable_fields": [],
|
||||||
@@ -50,6 +52,6 @@ class SensorCatalogListViewTests(TestCase):
|
|||||||
self.assertEqual(response.data["code"], 200)
|
self.assertEqual(response.data["code"], 200)
|
||||||
self.assertEqual(len(response.data["data"]), 2)
|
self.assertEqual(len(response.data["data"]), 2)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
{item["name"] for item in response.data["data"]},
|
{item["code"] for item in response.data["data"]},
|
||||||
{"Sensor 7 - Soil Moisture Sensor v1.2", "Legacy Sensor"},
|
{"sensor_7_soil_moisture_sensor_v1_2", "legacy_sensor"},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -17,6 +17,6 @@ class SensorCatalogListView(APIView):
|
|||||||
responses={200: code_response("SensorCatalogListResponse", data=SensorCatalogSerializer(many=True))},
|
responses={200: code_response("SensorCatalogListResponse", data=SensorCatalogSerializer(many=True))},
|
||||||
)
|
)
|
||||||
def get(self, request):
|
def get(self, request):
|
||||||
sensors = SensorCatalog.objects.order_by("name")
|
sensors = SensorCatalog.objects.order_by("code")
|
||||||
data = SensorCatalogSerializer(sensors, many=True).data
|
data = SensorCatalogSerializer(sensors, many=True).data
|
||||||
return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK)
|
return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK)
|
||||||
|
|||||||
Reference in New Issue
Block a user