diff --git a/.env.example b/.env.example index e69e019..bcd0d5d 100644 --- a/.env.example +++ b/.env.example @@ -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) diff --git a/ACCESS_CONTROL_FRONTEND_CHANGES_FA.md b/ACCESS_CONTROL_FRONTEND_CHANGES_FA.md index ab801db..f4d3181 100644 --- a/ACCESS_CONTROL_FRONTEND_CHANGES_FA.md +++ b/ACCESS_CONTROL_FRONTEND_CHANGES_FA.md @@ -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 ` @@ -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 diff --git a/access_control/catalog.py b/access_control/catalog.py index 1e3af4b..1060faf 100644 --- a/access_control/catalog.py +++ b/access_control/catalog.py @@ -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]}, }, ] diff --git a/access_control/migrations/0004_enable_default_feature_access.py b/access_control/migrations/0004_enable_default_feature_access.py new file mode 100644 index 0000000..ef7eea2 --- /dev/null +++ b/access_control/migrations/0004_enable_default_feature_access.py @@ -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), + ] diff --git a/access_control/migrations/0005_backfill_farm_subscription_plans.py b/access_control/migrations/0005_backfill_farm_subscription_plans.py new file mode 100644 index 0000000..f30c2ab --- /dev/null +++ b/access_control/migrations/0005_backfill_farm_subscription_plans.py @@ -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), + ] diff --git a/access_control/serializers.py b/access_control/serializers.py index 085941e..d0dbc6f 100644 --- a/access_control/serializers.py +++ b/access_control/serializers.py @@ -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", ] - diff --git a/access_control/services.py b/access_control/services.py index f3a5127..4e06891 100644 --- a/access_control/services.py +++ b/access_control/services.py @@ -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): diff --git a/access_control/views.py b/access_control/views.py index e20d90f..a060c52 100644 --- a/access_control/views.py +++ b/access_control/views.py @@ -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) - diff --git a/account/management/commands/seed_admin_user.py b/account/management/commands/seed_admin_user.py index 4841025..1be19ca 100644 --- a/account/management/commands/seed_admin_user.py +++ b/account/management/commands/seed_admin_user.py @@ -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}" ) ) diff --git a/docker-compose-prod.yaml b/docker-compose-prod.yaml index 821c0dd..257e9cd 100644 --- a/docker-compose-prod.yaml +++ b/docker-compose-prod.yaml @@ -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} diff --git a/docker-compose.yaml b/docker-compose.yaml index c66a9b2..e8a9643 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -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 diff --git a/entrypoint.sh b/entrypoint.sh index 5eaa21d..9094d79 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -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 "$@" diff --git a/farm_hub/seeds.py b/farm_hub/seeds.py index df2b538..4ea2325 100644 --- a/farm_hub/seeds.py +++ b/farm_hub/seeds.py @@ -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: diff --git a/farm_hub/serializers.py b/farm_hub/serializers.py index d0d4d0b..e236c64 100644 --- a/farm_hub/serializers.py +++ b/farm_hub/serializers.py @@ -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) diff --git a/farm_hub/tests.py b/farm_hub/tests.py index 29037db..1558a14 100644 --- a/farm_hub/tests.py +++ b/farm_hub/tests.py @@ -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") diff --git a/sensor_catalog/management/__init__.py b/sensor_catalog/management/__init__.py index e69de29..6e1f0ab 100644 --- a/sensor_catalog/management/__init__.py +++ b/sensor_catalog/management/__init__.py @@ -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 diff --git a/sensor_catalog/management/commands/seed_sensor_catalog.py b/sensor_catalog/management/commands/seed_sensor_catalog.py index b2ce61f..beb9ad6 100644 --- a/sensor_catalog/management/commands/seed_sensor_catalog.py +++ b/sensor_catalog/management/commands/seed_sensor_catalog.py @@ -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( diff --git a/sensor_catalog/migrations/0003_sensorcatalog_code.py b/sensor_catalog/migrations/0003_sensorcatalog_code.py new file mode 100644 index 0000000..92cb92a --- /dev/null +++ b/sensor_catalog/migrations/0003_sensorcatalog_code.py @@ -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), + ), + ] diff --git a/sensor_catalog/models.py b/sensor_catalog/models.py index f5bae9c..939f4e3 100644 --- a/sensor_catalog/models.py +++ b/sensor_catalog/models.py @@ -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 diff --git a/sensor_catalog/serializers.py b/sensor_catalog/serializers.py index 0ae2272..ee92677 100644 --- a/sensor_catalog/serializers.py +++ b/sensor_catalog/serializers.py @@ -8,6 +8,7 @@ class SensorCatalogSerializer(serializers.ModelSerializer): model = SensorCatalog fields = [ "uuid", + "code", "name", "description", "customizable_fields", diff --git a/sensor_catalog/tests.py b/sensor_catalog/tests.py index a576919..b3fc9b7 100644 --- a/sensor_catalog/tests.py +++ b/sensor_catalog/tests.py @@ -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"}, ) diff --git a/sensor_catalog/views.py b/sensor_catalog/views.py index d22123e..68551db 100644 --- a/sensor_catalog/views.py +++ b/sensor_catalog/views.py @@ -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)