UPDATE
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
@@ -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),
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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}"
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -8,6 +8,7 @@ class SensorCatalogSerializer(serializers.ModelSerializer):
|
||||
model = SensorCatalog
|
||||
fields = [
|
||||
"uuid",
|
||||
"code",
|
||||
"name",
|
||||
"description",
|
||||
"customizable_fields",
|
||||
|
||||
@@ -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"},
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user