diff --git a/Dockerfile b/Dockerfile index a951b15..0ca88a3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,13 +30,12 @@ COPY requirements.txt . # Python mirrors RUN pip config --user set global.index-url https://package-mirror.liara.ir/repository/pypi/simple && \ - pip config --user set global.extra-index-url https://mirror.cdn.ir/repository/pypi/simple && \ pip config --user set global.extra-index-url https://mirror2.chabokan.net/pypi/simple && \ pip config --user set global.trusted-host package-mirror.liara.ir && \ - pip config --user set global.trusted-host mirror.cdn.ir && \ + pip config --user set global.trusted-host mirror2.chabokan.net && \ pip config --user set global.trusted-host mirror-pypi.runflare.com -RUN pip install -r requirements.txt +RUN pip install -r requirements.txt COPY entrypoint.sh /app/entrypoint.sh COPY . . diff --git a/crop_zoning/apps.py b/crop_zoning/apps.py index 4147c50..aeffd26 100644 --- a/crop_zoning/apps.py +++ b/crop_zoning/apps.py @@ -5,3 +5,6 @@ class CropZoningConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "crop_zoning" verbose_name = "Crop Zoning" + + def ready(self): + from . import tasks # noqa: F401 diff --git a/crop_zoning/migrations/0004_croparea_sensor.py b/crop_zoning/migrations/0004_croparea_sensor.py new file mode 100644 index 0000000..83f49e5 --- /dev/null +++ b/crop_zoning/migrations/0004_croparea_sensor.py @@ -0,0 +1,23 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("sensor_hub", "0001_initial"), + ("crop_zoning", "0003_zone_processing_and_analysis"), + ] + + operations = [ + migrations.AddField( + model_name="croparea", + name="sensor", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="crop_areas", + to="sensor_hub.sensor", + ), + ), + ] diff --git a/crop_zoning/models.py b/crop_zoning/models.py index beafc02..9435dc1 100644 --- a/crop_zoning/models.py +++ b/crop_zoning/models.py @@ -1,10 +1,19 @@ import uuid from django.db import models +from sensor_hub.models import Sensor class CropArea(models.Model): uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True) + sensor = models.ForeignKey( + Sensor, + on_delete=models.CASCADE, + related_name="crop_areas", + null=True, + blank=True, + db_index=True, + ) geometry = models.JSONField(default=dict) points = models.JSONField(default=list) center = models.JSONField(default=dict) @@ -216,4 +225,3 @@ class CropZoneAnalysis(models.Model): db_table = "crop_zone_analyses" ordering = ["crop_zone_id"] - diff --git a/crop_zoning/postman/crop_zoning.json b/crop_zoning/postman/crop_zoning.json index eb4f42b..940e20a 100644 --- a/crop_zoning/postman/crop_zoning.json +++ b/crop_zoning/postman/crop_zoning.json @@ -1 +1 @@ -{"info":{"name":"Crop Zoning","schema":"https://schema.getpostman.com/json/collection/v2.1.0/collection.json","description":"Crop Zoning API. GET area. GET products. POST zones/initial (crops). POST zones/water-need, soil-quality, cultivation-risk (layer data). GET zones/:zoneId/details (detail panel)."},"item":[{"name":"Get area (GET)","request":{"method":"GET","header":[{"key":"Content-Type","value":"application/json"}],"url":"{{baseUrl}}/api/crop-zoning/area/","description":"Returns fixed land area GeoJSON polygon for map. User cannot draw/edit."},"response":[{"name":"Success","status":"OK","code":200,"body":"{\n \"status\": \"success\",\n \"data\": {\n \"area\": {\n \"type\": \"Feature\",\n \"properties\": {},\n \"geometry\": {\n \"type\": \"Polygon\",\n \"coordinates\": [[[51.38, 35.68], [51.405, 35.672], [51.41, 35.695], [51.385, 35.71], [51.365, 35.688], [51.38, 35.68]]]\n }\n }\n }\n}"}]},{"name":"Get products (GET)","request":{"method":"GET","header":[{"key":"Content-Type","value":"application/json"}],"url":"{{baseUrl}}/api/crop-zoning/products/","description":"Returns static list of cultivable products (id, label, color) for Legend and zone detail panel."},"response":[{"name":"Success","status":"OK","code":200,"body":"{\n \"status\": \"success\",\n \"data\": {\n \"products\": [\n {\"id\": \"wheat\", \"label\": \"گندم\", \"color\": \"#6bcb77\"},\n {\"id\": \"canola\", \"label\": \"کلزا\", \"color\": \"#ffd93d\"},\n {\"id\": \"saffron\", \"label\": \"زعفران\", \"color\": \"#9b59b6\"}\n ]\n }\n}"}]},{"name":"Zones initial (POST)","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n \"zones\": {\n \"type\": \"FeatureCollection\",\n \"features\": [\n {\n \"type\": \"Feature\",\n \"geometry\": {\n \"type\": \"Polygon\",\n \"coordinates\": [[[51.38, 35.68], [51.3815, 35.68], [51.3815, 35.6815], [51.38, 35.6815], [51.38, 35.68]]]\n },\n \"properties\": {\"index\": 0}\n },\n {\n \"type\": \"Feature\",\n \"geometry\": {\n \"type\": \"Polygon\",\n \"coordinates\": [[[51.3815, 35.68], [51.383, 35.68], [51.383, 35.6815], [51.3815, 35.6815], [51.3815, 35.68]]]\n },\n \"properties\": {\"index\": 1}\n },\n {\n \"type\": \"Feature\",\n \"geometry\": {\n \"type\": \"Polygon\",\n \"coordinates\": [[[51.38, 35.6815], [51.3815, 35.6815], [51.3815, 35.683], [51.38, 35.683], [51.38, 35.6815]]]\n },\n \"properties\": {\"index\": 2}\n }\n ]\n },\n \"products\": [\"wheat\", \"canola\", \"saffron\"]\n}"},"url":"{{baseUrl}}/api/crop-zoning/zones/initial/","description":"Body: zones (FeatureCollection of grid squares), optional products. Returns initial data for map and hover/tooltip (no reason, criteria)."},"response":[{"name":"Success","status":"OK","code":200,"body":"{\n \"status\": \"success\",\n \"data\": {\n \"total_area_hectares\": 23.45,\n \"total_area_sqm\": 234500,\n \"zone_count\": 3,\n \"zones\": [\n {\n \"zoneId\": \"zone-0\",\n \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.38, 35.68], [51.3815, 35.68], [51.3815, 35.6815], [51.38, 35.6815], [51.38, 35.68]]]},\n \"crop\": \"wheat\",\n \"matchPercent\": 85,\n \"waterNeed\": \"۴۵۰۰-۵۵۰۰ m³/ha\",\n \"estimatedProfit\": \"۱۵-۲۵ میلیون/هکتار\"\n },\n {\n \"zoneId\": \"zone-1\",\n \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.3815, 35.68], [51.383, 35.68], [51.383, 35.6815], [51.3815, 35.6815], [51.3815, 35.68]]]},\n \"crop\": \"canola\",\n \"matchPercent\": 78,\n \"waterNeed\": \"۵۰۰۰-۶۰۰۰ m³/ha\",\n \"estimatedProfit\": \"۲۰-۳۵ میلیون/هکتار\"\n },\n {\n \"zoneId\": \"zone-2\",\n \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.38, 35.6815], [51.3815, 35.6815], [51.3815, 35.683], [51.38, 35.683], [51.38, 35.6815]]]},\n \"crop\": \"saffron\",\n \"matchPercent\": 92,\n \"waterNeed\": \"۳۰۰۰-۴۰۰۰ m³/ha\",\n \"estimatedProfit\": \"۵۰-۱۵۰ میلیون/هکتار\"\n }\n ]\n }\n}"}]},{"name":"Zones water-need (POST)","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n \"zones\": {\n \"type\": \"FeatureCollection\",\n \"features\": [\n {\"type\": \"Feature\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.38, 35.68], [51.3815, 35.68], [51.3815, 35.6815], [51.38, 35.6815], [51.38, 35.68]]]}, \"properties\": {\"index\": 0}},\n {\"type\": \"Feature\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.3815, 35.68], [51.383, 35.68], [51.383, 35.6815], [51.3815, 35.6815], [51.3815, 35.68]]]}, \"properties\": {\"index\": 1}},\n {\"type\": \"Feature\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.38, 35.6815], [51.3815, 35.6815], [51.3815, 35.683], [51.38, 35.683], [51.38, 35.6815]]]}, \"properties\": {\"index\": 2}}\n ]\n }\n}"},"url":"{{baseUrl}}/api/crop-zoning/zones/water-need/","description":"Returns water need per zone for water need layer (level, value, color)."},"response":[{"name":"Success","status":"OK","code":200,"body":"{\n \"status\": \"success\",\n \"data\": {\n \"zones\": [\n {\"zoneId\": \"zone-0\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.38, 35.68], [51.3815, 35.68], [51.3815, 35.6815], [51.38, 35.6815], [51.38, 35.68]]]}, \"level\": \"medium\", \"value\": \"۴۵۰۰-۵۵۰۰ m³/ha\", \"color\": \"#0ea5e9\"},\n {\"zoneId\": \"zone-1\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.3815, 35.68], [51.383, 35.68], [51.383, 35.6815], [51.3815, 35.6815], [51.3815, 35.68]]]}, \"level\": \"high\", \"value\": \"۵۰۰۰-۶۰۰۰ m³/ha\", \"color\": \"#0369a1\"},\n {\"zoneId\": \"zone-2\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.38, 35.6815], [51.3815, 35.6815], [51.3815, 35.683], [51.38, 35.683], [51.38, 35.6815]]]}, \"level\": \"low\", \"value\": \"۳۰۰۰-۴۰۰۰ m³/ha\", \"color\": \"#7dd3fc\"}\n ]\n }\n}"}]},{"name":"Zones soil-quality (POST)","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n \"zones\": {\n \"type\": \"FeatureCollection\",\n \"features\": [\n {\"type\": \"Feature\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.38, 35.68], [51.3815, 35.68], [51.3815, 35.6815], [51.38, 35.6815], [51.38, 35.68]]]}, \"properties\": {\"index\": 0}},\n {\"type\": \"Feature\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.3815, 35.68], [51.383, 35.68], [51.383, 35.6815], [51.3815, 35.6815], [51.3815, 35.68]]]}, \"properties\": {\"index\": 1}},\n {\"type\": \"Feature\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.38, 35.6815], [51.3815, 35.6815], [51.3815, 35.683], [51.38, 35.683], [51.38, 35.6815]]]}, \"properties\": {\"index\": 2}}\n ]\n }\n}"},"url":"{{baseUrl}}/api/crop-zoning/zones/soil-quality/","description":"Returns soil quality per zone for soil quality layer (level, score, color)."},"response":[{"name":"Success","status":"OK","code":200,"body":"{\n \"status\": \"success\",\n \"data\": {\n \"zones\": [\n {\"zoneId\": \"zone-0\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.38, 35.68], [51.3815, 35.68], [51.3815, 35.6815], [51.38, 35.6815], [51.38, 35.68]]]}, \"level\": \"high\", \"score\": 88, \"color\": \"#22c55e\"},\n {\"zoneId\": \"zone-1\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.3815, 35.68], [51.383, 35.68], [51.383, 35.6815], [51.3815, 35.6815], [51.3815, 35.68]]]}, \"level\": \"medium\", \"score\": 62, \"color\": \"#eab308\"},\n {\"zoneId\": \"zone-2\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.38, 35.6815], [51.3815, 35.6815], [51.3815, 35.683], [51.38, 35.683], [51.38, 35.6815]]]}, \"level\": \"high\", \"score\": 95, \"color\": \"#22c55e\"}\n ]\n }\n}"}]},{"name":"Zones cultivation-risk (POST)","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n \"zones\": {\n \"type\": \"FeatureCollection\",\n \"features\": [\n {\"type\": \"Feature\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.38, 35.68], [51.3815, 35.68], [51.3815, 35.6815], [51.38, 35.6815], [51.38, 35.68]]]}, \"properties\": {\"index\": 0}},\n {\"type\": \"Feature\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.3815, 35.68], [51.383, 35.68], [51.383, 35.6815], [51.3815, 35.6815], [51.3815, 35.68]]]}, \"properties\": {\"index\": 1}},\n {\"type\": \"Feature\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.38, 35.6815], [51.3815, 35.6815], [51.3815, 35.683], [51.38, 35.683], [51.38, 35.6815]]]}, \"properties\": {\"index\": 2}}\n ]\n }\n}"},"url":"{{baseUrl}}/api/crop-zoning/zones/cultivation-risk/","description":"Returns cultivation risk per zone for risk layer (level, color)."},"response":[{"name":"Success","status":"OK","code":200,"body":"{\n \"status\": \"success\",\n \"data\": {\n \"zones\": [\n {\"zoneId\": \"zone-0\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.38, 35.68], [51.3815, 35.68], [51.3815, 35.6815], [51.38, 35.6815], [51.38, 35.68]]]}, \"level\": \"low\", \"color\": \"#22c55e\"},\n {\"zoneId\": \"zone-1\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.3815, 35.68], [51.383, 35.68], [51.383, 35.6815], [51.3815, 35.6815], [51.3815, 35.68]]]}, \"level\": \"medium\", \"color\": \"#f59e0b\"},\n {\"zoneId\": \"zone-2\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.38, 35.6815], [51.3815, 35.6815], [51.3815, 35.683], [51.38, 35.683], [51.38, 35.6815]]]}, \"level\": \"low\", \"color\": \"#22c55e\"}\n ]\n }\n}"}]},{"name":"Zone details (GET)","request":{"method":"GET","header":[{"key":"Content-Type","value":"application/json"}],"url":"{{baseUrl}}/api/crop-zoning/zones/zone-0/details/","description":"Returns detail data for one zone (reason, criteria, area_hectares) for detail panel and radar chart."},"response":[{"name":"Success","status":"OK","code":200,"body":"{\n \"status\": \"success\",\n \"data\": {\n \"zoneId\": \"zone-0\",\n \"crop\": \"wheat\",\n \"matchPercent\": 85,\n \"waterNeed\": \"۴۵۰۰-۵۵۰۰ m³/ha\",\n \"estimatedProfit\": \"۱۵-۲۵ میلیون/هکتار\",\n \"reason\": \"دمای مناسب، خاک حاصلخیز، دسترسی به آب کافی\",\n \"criteria\": [{\"name\": \"دما\", \"value\": 82}, {\"name\": \"بارش\", \"value\": 75}, {\"name\": \"خاک\", \"value\": 88}, {\"name\": \"آب\", \"value\": 70}],\n \"area_hectares\": 2.25\n }\n}"}]},{"name":"Zone details zone-2 (GET)","request":{"method":"GET","header":[{"key":"Content-Type","value":"application/json"}],"url":"{{baseUrl}}/api/crop-zoning/zones/zone-2/details/","description":"Returns detail data for zone-2 (saffron)."},"response":[{"name":"Success","status":"OK","code":200,"body":"{\n \"status\": \"success\",\n \"data\": {\n \"zoneId\": \"zone-2\",\n \"crop\": \"saffron\",\n \"matchPercent\": 92,\n \"waterNeed\": \"۳۰۰۰-۴۰۰۰ m³/ha\",\n \"estimatedProfit\": \"۵۰-۱۵۰ میلیون/هکتار\",\n \"reason\": \"ارتفاع و آب و هوای خشک مناسب، پتانسیل سود بالا\",\n \"criteria\": [{\"name\": \"دما\", \"value\": 90}, {\"name\": \"بارش\", \"value\": 65}, {\"name\": \"خاک\", \"value\": 95}, {\"name\": \"آب\", \"value\": 85}],\n \"area_hectares\": 2.25\n }\n}"}]}],"variable":[{"key":"baseUrl","value":"http://localhost:8000"}]} +{"info":{"name":"Crop Zoning","schema":"https://schema.getpostman.com/json/collection/v2.1.0/collection.json","description":"Crop Zoning API. GET area. GET products. POST zones/initial (crops). POST zones/water-need, soil-quality, cultivation-risk (layer data). GET zones/:zoneId/details (detail panel)."},"item":[{"name":"Get area (GET)","request":{"method":"GET","header":[{"key":"Content-Type","value":"application/json"}],"url":"{{baseUrl}}/api/crop-zoning/area/?sensor_uuid={{sensorUuid}}","description":"Returns task status and area for the requested sensor. If the sensor has no crop-zoning data yet, it creates data and dispatches a Celery task. Only one active task is allowed per sensor."},"response":[{"name":"Success","status":"OK","code":200,"body":"{\n \"status\": \"success\",\n \"data\": {\n \"area\": {\n \"type\": \"Feature\",\n \"properties\": {},\n \"geometry\": {\n \"type\": \"Polygon\",\n \"coordinates\": [[[51.38, 35.68], [51.405, 35.672], [51.41, 35.695], [51.385, 35.71], [51.365, 35.688], [51.38, 35.68]]]\n }\n }\n }\n}"}]},{"name":"Get products (GET)","request":{"method":"GET","header":[{"key":"Content-Type","value":"application/json"}],"url":"{{baseUrl}}/api/crop-zoning/products/","description":"Returns static list of cultivable products (id, label, color) for Legend and zone detail panel."},"response":[{"name":"Success","status":"OK","code":200,"body":"{\n \"status\": \"success\",\n \"data\": {\n \"products\": [\n {\"id\": \"wheat\", \"label\": \"گندم\", \"color\": \"#6bcb77\"},\n {\"id\": \"canola\", \"label\": \"کلزا\", \"color\": \"#ffd93d\"},\n {\"id\": \"saffron\", \"label\": \"زعفران\", \"color\": \"#9b59b6\"}\n ]\n }\n}"}]},{"name":"Zones initial (POST)","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n \"zones\": {\n \"type\": \"FeatureCollection\",\n \"features\": [\n {\n \"type\": \"Feature\",\n \"geometry\": {\n \"type\": \"Polygon\",\n \"coordinates\": [[[51.38, 35.68], [51.3815, 35.68], [51.3815, 35.6815], [51.38, 35.6815], [51.38, 35.68]]]\n },\n \"properties\": {\"index\": 0}\n },\n {\n \"type\": \"Feature\",\n \"geometry\": {\n \"type\": \"Polygon\",\n \"coordinates\": [[[51.3815, 35.68], [51.383, 35.68], [51.383, 35.6815], [51.3815, 35.6815], [51.3815, 35.68]]]\n },\n \"properties\": {\"index\": 1}\n },\n {\n \"type\": \"Feature\",\n \"geometry\": {\n \"type\": \"Polygon\",\n \"coordinates\": [[[51.38, 35.6815], [51.3815, 35.6815], [51.3815, 35.683], [51.38, 35.683], [51.38, 35.6815]]]\n },\n \"properties\": {\"index\": 2}\n }\n ]\n },\n \"products\": [\"wheat\", \"canola\", \"saffron\"]\n}"},"url":"{{baseUrl}}/api/crop-zoning/zones/initial/","description":"Body: zones (FeatureCollection of grid squares), optional products. Returns initial data for map and hover/tooltip (no reason, criteria)."},"response":[{"name":"Success","status":"OK","code":200,"body":"{\n \"status\": \"success\",\n \"data\": {\n \"total_area_hectares\": 23.45,\n \"total_area_sqm\": 234500,\n \"zone_count\": 3,\n \"zones\": [\n {\n \"zoneId\": \"zone-0\",\n \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.38, 35.68], [51.3815, 35.68], [51.3815, 35.6815], [51.38, 35.6815], [51.38, 35.68]]]},\n \"crop\": \"wheat\",\n \"matchPercent\": 85,\n \"waterNeed\": \"۴۵۰۰-۵۵۰۰ m³/ha\",\n \"estimatedProfit\": \"۱۵-۲۵ میلیون/هکتار\"\n },\n {\n \"zoneId\": \"zone-1\",\n \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.3815, 35.68], [51.383, 35.68], [51.383, 35.6815], [51.3815, 35.6815], [51.3815, 35.68]]]},\n \"crop\": \"canola\",\n \"matchPercent\": 78,\n \"waterNeed\": \"۵۰۰۰-۶۰۰۰ m³/ha\",\n \"estimatedProfit\": \"۲۰-۳۵ میلیون/هکتار\"\n },\n {\n \"zoneId\": \"zone-2\",\n \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.38, 35.6815], [51.3815, 35.6815], [51.3815, 35.683], [51.38, 35.683], [51.38, 35.6815]]]},\n \"crop\": \"saffron\",\n \"matchPercent\": 92,\n \"waterNeed\": \"۳۰۰۰-۴۰۰۰ m³/ha\",\n \"estimatedProfit\": \"۵۰-۱۵۰ میلیون/هکتار\"\n }\n ]\n }\n}"}]},{"name":"Zones water-need (POST)","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n \"zones\": {\n \"type\": \"FeatureCollection\",\n \"features\": [\n {\"type\": \"Feature\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.38, 35.68], [51.3815, 35.68], [51.3815, 35.6815], [51.38, 35.6815], [51.38, 35.68]]]}, \"properties\": {\"index\": 0}},\n {\"type\": \"Feature\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.3815, 35.68], [51.383, 35.68], [51.383, 35.6815], [51.3815, 35.6815], [51.3815, 35.68]]]}, \"properties\": {\"index\": 1}},\n {\"type\": \"Feature\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.38, 35.6815], [51.3815, 35.6815], [51.3815, 35.683], [51.38, 35.683], [51.38, 35.6815]]]}, \"properties\": {\"index\": 2}}\n ]\n }\n}"},"url":"{{baseUrl}}/api/crop-zoning/zones/water-need/","description":"Returns water need per zone for water need layer (level, value, color)."},"response":[{"name":"Success","status":"OK","code":200,"body":"{\n \"status\": \"success\",\n \"data\": {\n \"zones\": [\n {\"zoneId\": \"zone-0\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.38, 35.68], [51.3815, 35.68], [51.3815, 35.6815], [51.38, 35.6815], [51.38, 35.68]]]}, \"level\": \"medium\", \"value\": \"۴۵۰۰-۵۵۰۰ m³/ha\", \"color\": \"#0ea5e9\"},\n {\"zoneId\": \"zone-1\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.3815, 35.68], [51.383, 35.68], [51.383, 35.6815], [51.3815, 35.6815], [51.3815, 35.68]]]}, \"level\": \"high\", \"value\": \"۵۰۰۰-۶۰۰۰ m³/ha\", \"color\": \"#0369a1\"},\n {\"zoneId\": \"zone-2\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.38, 35.6815], [51.3815, 35.6815], [51.3815, 35.683], [51.38, 35.683], [51.38, 35.6815]]]}, \"level\": \"low\", \"value\": \"۳۰۰۰-۴۰۰۰ m³/ha\", \"color\": \"#7dd3fc\"}\n ]\n }\n}"}]},{"name":"Zones soil-quality (POST)","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n \"zones\": {\n \"type\": \"FeatureCollection\",\n \"features\": [\n {\"type\": \"Feature\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.38, 35.68], [51.3815, 35.68], [51.3815, 35.6815], [51.38, 35.6815], [51.38, 35.68]]]}, \"properties\": {\"index\": 0}},\n {\"type\": \"Feature\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.3815, 35.68], [51.383, 35.68], [51.383, 35.6815], [51.3815, 35.6815], [51.3815, 35.68]]]}, \"properties\": {\"index\": 1}},\n {\"type\": \"Feature\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.38, 35.6815], [51.3815, 35.6815], [51.3815, 35.683], [51.38, 35.683], [51.38, 35.6815]]]}, \"properties\": {\"index\": 2}}\n ]\n }\n}"},"url":"{{baseUrl}}/api/crop-zoning/zones/soil-quality/","description":"Returns soil quality per zone for soil quality layer (level, score, color)."},"response":[{"name":"Success","status":"OK","code":200,"body":"{\n \"status\": \"success\",\n \"data\": {\n \"zones\": [\n {\"zoneId\": \"zone-0\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.38, 35.68], [51.3815, 35.68], [51.3815, 35.6815], [51.38, 35.6815], [51.38, 35.68]]]}, \"level\": \"high\", \"score\": 88, \"color\": \"#22c55e\"},\n {\"zoneId\": \"zone-1\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.3815, 35.68], [51.383, 35.68], [51.383, 35.6815], [51.3815, 35.6815], [51.3815, 35.68]]]}, \"level\": \"medium\", \"score\": 62, \"color\": \"#eab308\"},\n {\"zoneId\": \"zone-2\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.38, 35.6815], [51.3815, 35.6815], [51.3815, 35.683], [51.38, 35.683], [51.38, 35.6815]]]}, \"level\": \"high\", \"score\": 95, \"color\": \"#22c55e\"}\n ]\n }\n}"}]},{"name":"Zones cultivation-risk (POST)","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n \"zones\": {\n \"type\": \"FeatureCollection\",\n \"features\": [\n {\"type\": \"Feature\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.38, 35.68], [51.3815, 35.68], [51.3815, 35.6815], [51.38, 35.6815], [51.38, 35.68]]]}, \"properties\": {\"index\": 0}},\n {\"type\": \"Feature\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.3815, 35.68], [51.383, 35.68], [51.383, 35.6815], [51.3815, 35.6815], [51.3815, 35.68]]]}, \"properties\": {\"index\": 1}},\n {\"type\": \"Feature\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.38, 35.6815], [51.3815, 35.6815], [51.3815, 35.683], [51.38, 35.683], [51.38, 35.6815]]]}, \"properties\": {\"index\": 2}}\n ]\n }\n}"},"url":"{{baseUrl}}/api/crop-zoning/zones/cultivation-risk/","description":"Returns cultivation risk per zone for risk layer (level, color)."},"response":[{"name":"Success","status":"OK","code":200,"body":"{\n \"status\": \"success\",\n \"data\": {\n \"zones\": [\n {\"zoneId\": \"zone-0\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.38, 35.68], [51.3815, 35.68], [51.3815, 35.6815], [51.38, 35.6815], [51.38, 35.68]]]}, \"level\": \"low\", \"color\": \"#22c55e\"},\n {\"zoneId\": \"zone-1\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.3815, 35.68], [51.383, 35.68], [51.383, 35.6815], [51.3815, 35.6815], [51.3815, 35.68]]]}, \"level\": \"medium\", \"color\": \"#f59e0b\"},\n {\"zoneId\": \"zone-2\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.38, 35.6815], [51.3815, 35.6815], [51.3815, 35.683], [51.38, 35.683], [51.38, 35.6815]]]}, \"level\": \"low\", \"color\": \"#22c55e\"}\n ]\n }\n}"}]},{"name":"Zone details (GET)","request":{"method":"GET","header":[{"key":"Content-Type","value":"application/json"}],"url":"{{baseUrl}}/api/crop-zoning/zones/zone-0/details/","description":"Returns detail data for one zone (reason, criteria, area_hectares) for detail panel and radar chart."},"response":[{"name":"Success","status":"OK","code":200,"body":"{\n \"status\": \"success\",\n \"data\": {\n \"zoneId\": \"zone-0\",\n \"crop\": \"wheat\",\n \"matchPercent\": 85,\n \"waterNeed\": \"۴۵۰۰-۵۵۰۰ m³/ha\",\n \"estimatedProfit\": \"۱۵-۲۵ میلیون/هکتار\",\n \"reason\": \"دمای مناسب، خاک حاصلخیز، دسترسی به آب کافی\",\n \"criteria\": [{\"name\": \"دما\", \"value\": 82}, {\"name\": \"بارش\", \"value\": 75}, {\"name\": \"خاک\", \"value\": 88}, {\"name\": \"آب\", \"value\": 70}],\n \"area_hectares\": 2.25\n }\n}"}]},{"name":"Zone details zone-2 (GET)","request":{"method":"GET","header":[{"key":"Content-Type","value":"application/json"}],"url":"{{baseUrl}}/api/crop-zoning/zones/zone-2/details/","description":"Returns detail data for zone-2 (saffron)."},"response":[{"name":"Success","status":"OK","code":200,"body":"{\n \"status\": \"success\",\n \"data\": {\n \"zoneId\": \"zone-2\",\n \"crop\": \"saffron\",\n \"matchPercent\": 92,\n \"waterNeed\": \"۳۰۰۰-۴۰۰۰ m³/ha\",\n \"estimatedProfit\": \"۵۰-۱۵۰ میلیون/هکتار\",\n \"reason\": \"ارتفاع و آب و هوای خشک مناسب، پتانسیل سود بالا\",\n \"criteria\": [{\"name\": \"دما\", \"value\": 90}, {\"name\": \"بارش\", \"value\": 65}, {\"name\": \"خاک\", \"value\": 95}, {\"name\": \"آب\", \"value\": 85}],\n \"area_hectares\": 2.25\n }\n}"}]}],"variable":[{"key":"baseUrl","value":"http://localhost:8000"},{"key":"sensorUuid","value":"550e8400-e29b-41d4-a716-446655440000"}]} diff --git a/crop_zoning/services.py b/crop_zoning/services.py index 84e21ca..4339e50 100644 --- a/crop_zoning/services.py +++ b/crop_zoning/services.py @@ -3,8 +3,10 @@ from copy import deepcopy from decimal import Decimal from django.conf import settings +from kombu.exceptions import OperationalError from django.db import transaction from django.db.models import Prefetch +from sensor_hub.models import Sensor from external_api_adapter.adapter import request as external_request @@ -23,17 +25,68 @@ from .models import ( EARTH_RADIUS_METERS = 6378137.0 PRODUCT_DEFAULTS = PRODUCTS_RESPONSE_DATA["products"] +DEFAULT_CELL_SIDE_KM = 0.15 +RULE_BASED_ALGORITHM = "rule_based_v1" +RULE_BASED_PRODUCTS = { + "wheat": { + "water_need": "۴۵۰۰-۵۵۰۰ m³/ha", + "water_need_level": "medium", + "estimated_profit": "۱۵-۲۵ میلیون/هکتار", + "reason": "دمای مناسب، خاک حاصلخیز، دسترسی به آب کافی", + }, + "canola": { + "water_need": "۵۰۰۰-۶۰۰۰ m³/ha", + "water_need_level": "high", + "estimated_profit": "۲۰-۳۵ میلیون/هکتار", + "reason": "پایداری بهتر در برابر نوسان دما و پتانسیل سود اقتصادی مناسب", + }, + "saffron": { + "water_need": "۳۰۰۰-۴۰۰۰ m³/ha", + "water_need_level": "low", + "estimated_profit": "۵۰-۱۵۰ میلیون/هکتار", + "reason": "اقلیم خشک‌تر و نیاز آبی کمتر این زون برای زعفران مناسب‌تر است", + }, +} +RULE_BASED_CROP_IDS = tuple(RULE_BASED_PRODUCTS.keys()) -def get_chunk_area_sqm(): +def get_default_cell_side_km(): + raw_value = getattr(settings, "CROP_ZONE_CELL_SIDE_KM", None) + try: + cell_side_km = float(raw_value) + except (TypeError, ValueError): + cell_side_km = 0 + if cell_side_km > 0: + return cell_side_km + raw_value = getattr(settings, "CROP_ZONE_CHUNK_AREA_SQM", 0) try: chunk_area = float(raw_value) except (TypeError, ValueError): chunk_area = 0 - if chunk_area <= 0: - raise ValueError("CROP_ZONE_CHUNK_AREA_SQM must be a positive number.") - return chunk_area + if chunk_area > 0: + return math.sqrt(chunk_area) / 1000.0 + + return DEFAULT_CELL_SIDE_KM + + +def get_cell_side_km(cell_side_km=None): + if cell_side_km is None or cell_side_km == "": + resolved_value = get_default_cell_side_km() + else: + try: + resolved_value = float(cell_side_km) + except (TypeError, ValueError) as exc: + raise ValueError("cell_side_km must be a positive number.") from exc + + if resolved_value <= 0: + raise ValueError("cell_side_km must be a positive number.") + return resolved_value + + +def get_chunk_area_sqm(cell_side_km=None): + resolved_cell_side_km = get_cell_side_km(cell_side_km) + return (resolved_cell_side_km * 1000.0) ** 2 def get_default_area_feature(): @@ -138,69 +191,206 @@ def calculate_center(points): } +def get_bbox(points): + longitudes = [point[0] for point in points] + latitudes = [point[1] for point in points] + return { + "min_lng": min(longitudes), + "max_lng": max(longitudes), + "min_lat": min(latitudes), + "max_lat": max(latitudes), + } + + +def meters_to_latitude_delta(meters): + return meters / 111320.0 + + +def meters_to_longitude_delta(meters, latitude): + longitude_factor = 111320.0 * math.cos(math.radians(latitude)) + if abs(longitude_factor) < 1e-9: + longitude_factor = 1.0 + return meters / longitude_factor + + +def point_in_polygon(point, polygon_points): + x, y = point + inside = False + point_count = len(polygon_points) + if point_count < 3: + return False + + for index in range(point_count): + x1, y1 = polygon_points[index] + x2, y2 = polygon_points[(index + 1) % point_count] + intersects = ((y1 > y) != (y2 > y)) and ( + x < ((x2 - x1) * (y - y1) / ((y2 - y1) or 1e-12)) + x1 + ) + if intersects: + inside = not inside + + return inside + + +def _orientation(point_a, point_b, point_c): + value = ((point_b[1] - point_a[1]) * (point_c[0] - point_b[0])) - ( + (point_b[0] - point_a[0]) * (point_c[1] - point_b[1]) + ) + if abs(value) < 1e-12: + return 0 + return 1 if value > 0 else 2 + + +def _on_segment(point_a, point_b, point_c): + return ( + min(point_a[0], point_c[0]) - 1e-12 <= point_b[0] <= max(point_a[0], point_c[0]) + 1e-12 + and min(point_a[1], point_c[1]) - 1e-12 <= point_b[1] <= max(point_a[1], point_c[1]) + 1e-12 + ) + + +def segments_intersect(point_a, point_b, point_c, point_d): + orientation_1 = _orientation(point_a, point_b, point_c) + orientation_2 = _orientation(point_a, point_b, point_d) + orientation_3 = _orientation(point_c, point_d, point_a) + orientation_4 = _orientation(point_c, point_d, point_b) + + if orientation_1 != orientation_2 and orientation_3 != orientation_4: + return True + + if orientation_1 == 0 and _on_segment(point_a, point_c, point_b): + return True + if orientation_2 == 0 and _on_segment(point_a, point_d, point_b): + return True + if orientation_3 == 0 and _on_segment(point_c, point_a, point_d): + return True + if orientation_4 == 0 and _on_segment(point_c, point_b, point_d): + return True + + return False + + +def rectangle_contains_point(point, cell_points): + min_lng = min(vertex[0] for vertex in cell_points) + max_lng = max(vertex[0] for vertex in cell_points) + min_lat = min(vertex[1] for vertex in cell_points) + max_lat = max(vertex[1] for vertex in cell_points) + return min_lng <= point[0] <= max_lng and min_lat <= point[1] <= max_lat + + +def polygon_intersects_cell(polygon_points, cell_points): + cell_center = calculate_center(cell_points) + if point_in_polygon([cell_center["longitude"], cell_center["latitude"]], polygon_points): + return True + + if any(point_in_polygon(point, polygon_points) for point in cell_points): + return True + + if any(rectangle_contains_point(point, cell_points) for point in polygon_points): + return True + + polygon_edges = list(zip(polygon_points, polygon_points[1:] + polygon_points[:1])) + cell_edges = list(zip(cell_points, cell_points[1:] + cell_points[:1])) + return any( + segments_intersect(start_a, end_a, start_b, end_b) + for start_a, end_a in polygon_edges + for start_b, end_b in cell_edges + ) + + +def build_square_points(left_lng, bottom_lat, right_lng, top_lat): + return [ + [round(left_lng, 8), round(bottom_lat, 8)], + [round(right_lng, 8), round(bottom_lat, 8)], + [round(right_lng, 8), round(top_lat, 8)], + [round(left_lng, 8), round(top_lat, 8)], + ] + + def build_zone_square(area_points, center, zone_area_sqm): if len(area_points) < 4: return area_points width = math.sqrt(max(zone_area_sqm, 1)) half_width = width / 2.0 + delta_lat = meters_to_latitude_delta(half_width) + delta_lng = meters_to_longitude_delta(half_width, center["latitude"]) - latitude_factor = 111320.0 - longitude_factor = 111320.0 * math.cos(math.radians(center["latitude"])) - if longitude_factor == 0: - longitude_factor = 1.0 - - delta_lat = half_width / latitude_factor - delta_lng = half_width / longitude_factor - - return [ - [round(center["longitude"] - delta_lng, 8), round(center["latitude"] - delta_lat, 8)], - [round(center["longitude"] + delta_lng, 8), round(center["latitude"] - delta_lat, 8)], - [round(center["longitude"] + delta_lng, 8), round(center["latitude"] + delta_lat, 8)], - [round(center["longitude"] - delta_lng, 8), round(center["latitude"] + delta_lat, 8)], - ] + return build_square_points( + center["longitude"] - delta_lng, + center["latitude"] - delta_lat, + center["longitude"] + delta_lng, + center["latitude"] + delta_lat, + ) -def split_area_into_zones(area_feature): +def split_area_into_zones(area_feature, cell_side_km=None): area_ring = get_polygon_ring(area_feature) area_points = normalize_points(area_ring) area_center = calculate_center(area_points) total_area_sqm = polygon_area_sqm(area_ring) - chunk_area_sqm = get_chunk_area_sqm() - zone_count = max(1, math.ceil(total_area_sqm / chunk_area_sqm)) + resolved_cell_side_km = get_cell_side_km(cell_side_km) + chunk_area_sqm = get_chunk_area_sqm(resolved_cell_side_km) + cell_side_meters = resolved_cell_side_km * 1000.0 + bbox = get_bbox(area_points) + latitude_step = meters_to_latitude_delta(cell_side_meters) zones = [] - remaining_area = total_area_sqm - base_longitude = area_center["longitude"] - base_latitude = area_center["latitude"] + sequence = 0 + current_lat = bbox["min_lat"] - for sequence in range(zone_count): - zone_area_sqm = min(chunk_area_sqm, remaining_area) if sequence < zone_count - 1 else remaining_area - if zone_area_sqm <= 0: - zone_area_sqm = min(chunk_area_sqm, total_area_sqm) + while current_lat < bbox["max_lat"] - 1e-12: + next_lat = current_lat + latitude_step + row_center_lat = current_lat + (latitude_step / 2.0) + longitude_step = meters_to_longitude_delta(cell_side_meters, row_center_lat) + current_lng = bbox["min_lng"] - shift = (sequence - ((zone_count - 1) / 2)) * 0.0003 - zone_center = { - "longitude": round(base_longitude + shift, 8), - "latitude": round(base_latitude, 8), - } - zone_points = build_zone_square(area_points, zone_center, zone_area_sqm) + while current_lng < bbox["max_lng"] - 1e-12: + next_lng = current_lng + longitude_step + zone_points = build_square_points(current_lng, current_lat, next_lng, next_lat) + + if polygon_intersects_cell(area_points, zone_points): + zone_geometry = { + "type": "Polygon", + "coordinates": [[*zone_points, zone_points[0]]], + } + zone_area_sqm = polygon_area_sqm(zone_geometry["coordinates"][0]) + zones.append( + { + "zone_id": f"zone-{sequence}", + "geometry": zone_geometry, + "points": zone_points, + "center": calculate_center(zone_points), + "area_sqm": round(zone_area_sqm, 2), + "area_hectares": round(zone_area_sqm / 10000, 4), + "sequence": sequence, + } + ) + sequence += 1 + + current_lng = next_lng + + current_lat = next_lat + + if not zones: + zone_points = build_zone_square(area_points, area_center, max(total_area_sqm, chunk_area_sqm)) zone_geometry = { "type": "Polygon", "coordinates": [[*zone_points, zone_points[0]]], } + zone_area_sqm = polygon_area_sqm(zone_geometry["coordinates"][0]) zones.append( { - "zone_id": f"zone-{sequence}", + "zone_id": "zone-0", "geometry": zone_geometry, "points": zone_points, - "center": zone_center, - "area_sqm": zone_area_sqm, - "area_hectares": zone_area_sqm / 10000, - "sequence": sequence, + "center": area_center, + "area_sqm": round(zone_area_sqm, 2), + "area_hectares": round(zone_area_sqm / 10000, 4), + "sequence": 0, } ) - remaining_area = max(0.0, remaining_area - zone_area_sqm) + + zone_count = len(zones) area_geometry = { "type": "Feature", @@ -213,6 +403,7 @@ def split_area_into_zones(area_feature): "center": area_center, "area_sqm": round(total_area_sqm, 2), "area_hectares": round(total_area_sqm / 10000, 4), + "cell_side_km": round(resolved_cell_side_km, 4), } ) @@ -224,12 +415,50 @@ def split_area_into_zones(area_feature): "area_sqm": total_area_sqm, "area_hectares": total_area_sqm / 10000, "chunk_area_sqm": chunk_area_sqm, + "cell_side_km": resolved_cell_side_km, "zone_count": zone_count, }, "zones": zones, } +def build_rule_based_zone_metrics(index, coords): + if coords: + first_longitude, first_latitude = coords[0] + else: + first_longitude, first_latitude = (0.0, 0.0) + + seed = int((index * 7) + math.floor(first_latitude * 100) + math.floor(first_longitude * 100)) + crop_id = RULE_BASED_CROP_IDS[abs(seed) % len(RULE_BASED_CROP_IDS)] + crop_metadata = RULE_BASED_PRODUCTS[crop_id] + + match_percent = 60 + (abs(seed) % 35) + criteria = [ + {"name": "دما", "value": 55 + (abs(seed + 11) % 40)}, + {"name": "بارش", "value": 55 + (abs(seed + 17) % 40)}, + {"name": "خاک", "value": 55 + (abs(seed + 23) % 40)}, + {"name": "آب", "value": 55 + (abs(seed + 29) % 40)}, + ] + soil_quality_score = criteria[2]["value"] + soil_level = _pick_level(soil_quality_score, 65, 85) + cultivation_risk_score = max(1, min(100, round(100 - match_percent + ((abs(seed) % 9) - 4)))) + cultivation_risk_level = "low" if cultivation_risk_score <= 30 else "medium" if cultivation_risk_score <= 60 else "high" + + return { + "soil_quality_score": soil_quality_score, + "soil_level": soil_level, + "water_need_level": crop_metadata["water_need_level"], + "water_need_value": crop_metadata["water_need"], + "cultivation_risk_level": cultivation_risk_level, + "recommended_crop": crop_id, + "match_percent": match_percent, + "estimated_profit": crop_metadata["estimated_profit"], + "reason": crop_metadata["reason"], + "criteria": criteria, + "algorithm": RULE_BASED_ALGORITHM, + } + + def build_initial_zone_payload(zone): recommendation = getattr(zone, "recommendation", None) return { @@ -242,6 +471,67 @@ def build_initial_zone_payload(zone): } +def persist_zone_analysis_metrics(zone, metrics): + ensure_products_exist() + product = CropProduct.objects.get(product_id=metrics["recommended_crop"]) + recommendation, _ = CropZoneRecommendation.objects.update_or_create( + crop_zone=zone, + defaults={ + "product": product, + "match_percent": metrics["match_percent"], + "water_need": metrics["water_need_value"], + "estimated_profit": metrics["estimated_profit"], + "reason": metrics["reason"], + }, + ) + CropZoneCriteria.objects.filter(recommendation=recommendation).delete() + CropZoneCriteria.objects.bulk_create( + [ + CropZoneCriteria( + recommendation=recommendation, + name=item["name"], + value=item["value"], + sequence=index, + ) + for index, item in enumerate(metrics["criteria"]) + ] + ) + CropZoneWaterNeedLayer.objects.update_or_create( + crop_zone=zone, + defaults={ + "level": metrics["water_need_level"], + "value": metrics["water_need_value"], + "color": _get_level_color_map("water", metrics["water_need_level"]), + }, + ) + CropZoneSoilQualityLayer.objects.update_or_create( + crop_zone=zone, + defaults={ + "level": metrics["soil_level"], + "score": metrics["soil_quality_score"], + "color": _get_level_color_map("soil", metrics["soil_level"]), + }, + ) + CropZoneCultivationRiskLayer.objects.update_or_create( + crop_zone=zone, + defaults={ + "level": metrics["cultivation_risk_level"], + "color": _get_level_color_map("risk", metrics["cultivation_risk_level"]), + }, + ) + return recommendation + + +def ensure_rule_based_zone_data(zone, force=False): + has_recommendation = CropZoneRecommendation.objects.filter(crop_zone=zone).exists() + if has_recommendation and not force: + return zone + + metrics = build_rule_based_zone_metrics(zone.sequence, zone.points) + persist_zone_analysis_metrics(zone, metrics) + return zone + + def _get_level_color_map(layer_name, level): mappings = { "water": {"low": "#7dd3fc", "medium": "#0ea5e9", "high": "#0369a1"}, @@ -371,51 +661,7 @@ def analyze_and_store_zone_soil_data(zone_id): "depths": depths, }, ) - recommendation, _ = CropZoneRecommendation.objects.update_or_create( - crop_zone=zone, - defaults={ - "product": product, - "match_percent": metrics["match_percent"], - "water_need": metrics["water_need_value"], - "estimated_profit": metrics["estimated_profit"], - "reason": metrics["reason"], - }, - ) - CropZoneCriteria.objects.filter(recommendation=recommendation).delete() - CropZoneCriteria.objects.bulk_create( - [ - CropZoneCriteria( - recommendation=recommendation, - name=item["name"], - value=item["value"], - sequence=index, - ) - for index, item in enumerate(metrics["criteria"]) - ] - ) - CropZoneWaterNeedLayer.objects.update_or_create( - crop_zone=zone, - defaults={ - "level": metrics["water_need_level"], - "value": metrics["water_need_value"], - "color": _get_level_color_map("water", metrics["water_need_level"]), - }, - ) - CropZoneSoilQualityLayer.objects.update_or_create( - crop_zone=zone, - defaults={ - "level": metrics["soil_level"], - "score": metrics["soil_quality_score"], - "color": _get_level_color_map("soil", metrics["soil_level"]), - }, - ) - CropZoneCultivationRiskLayer.objects.update_or_create( - crop_zone=zone, - defaults={ - "level": metrics["cultivation_risk_level"], - "color": _get_level_color_map("risk", metrics["cultivation_risk_level"]), - }, - ) + persist_zone_analysis_metrics(zone, metrics) zone.processing_status = CropZone.STATUS_COMPLETED zone.processing_error = "" zone.save(update_fields=["processing_status", "processing_error", "updated_at"]) @@ -428,28 +674,130 @@ def analyze_and_store_zone_soil_data(zone_id): return zone -def dispatch_zone_processing_tasks(crop_area_id): +def dispatch_zone_processing_tasks(crop_area_id=None, zone_ids=None): from .tasks import process_zone_soil_data - zones = list(CropZone.objects.filter(crop_area_id=crop_area_id).only("id")) + queryset = CropZone.objects.select_related("crop_area").all() + if crop_area_id is not None: + queryset = queryset.filter(crop_area_id=crop_area_id) + if zone_ids is not None: + queryset = queryset.filter(id__in=zone_ids) + + zones = list(queryset.only("id", "task_id", "processing_status", "crop_area__sensor_id")) + sensor_task_ids = {} for zone in zones: - task_identifier = "" + sensor_id = zone.crop_area.sensor_id + existing_task_id = sensor_task_ids.get(sensor_id) or zone.task_id + if existing_task_id and zone.processing_status in {CropZone.STATUS_PENDING, CropZone.STATUS_PROCESSING}: + sensor_task_ids[sensor_id] = existing_task_id + if zone.task_id != existing_task_id: + CropZone.objects.filter(id=zone.id).update(task_id=existing_task_id) + continue + try: async_result = process_zone_soil_data.delay(zone.id) - task_identifier = getattr(async_result, "id", "") or "" - except Exception: - analyze_and_store_zone_soil_data(zone_id=zone.id) - CropZone.objects.filter(id=zone.id).update(task_id=task_identifier) + task_identifier = getattr(async_result, "id", "") or str(uuid.uuid4()) + processing_error = "" + except OperationalError as exc: + task_identifier = str(uuid.uuid4()) + processing_error = f"Celery broker unavailable: {exc}" + except Exception as exc: + task_identifier = str(uuid.uuid4()) + processing_error = f"Celery dispatch failed: {exc}" + + update_fields = {"task_id": task_identifier} + if zone.processing_status == CropZone.STATUS_FAILED: + update_fields["processing_status"] = CropZone.STATUS_PENDING + if processing_error: + update_fields["processing_error"] = processing_error + elif zone.processing_status == CropZone.STATUS_FAILED: + update_fields["processing_error"] = "" + CropZone.objects.filter(id=zone.id).update(**update_fields) + if sensor_id and task_identifier: + sensor_task_ids[sensor_id] = task_identifier -def create_zones_and_dispatch(area_feature): +def create_missing_zones_for_area(crop_area): + if crop_area.zones.exists(): + return list(crop_area.zones.order_by("sequence", "id")) + + area_feature = normalize_area_feature(crop_area.geometry) + zoning_result = split_area_into_zones( + area_feature, + cell_side_km=math.sqrt(max(crop_area.chunk_area_sqm, 1)) / 1000.0, + ) + zones = CropZone.objects.bulk_create( + [ + CropZone( + crop_area=crop_area, + zone_id=zone["zone_id"], + geometry=zone["geometry"], + points=zone["points"], + center=zone["center"], + area_sqm=round(zone["area_sqm"], 2), + area_hectares=round(zone["area_hectares"], 4), + sequence=zone["sequence"], + ) + for zone in zoning_result["zones"] + ] + ) + crop_area.zone_count = len(zones) + crop_area.save(update_fields=["zone_count", "updated_at"]) + return list(crop_area.zones.order_by("sequence", "id")) + + +def get_sensor_for_uuid(sensor_uuid): + if not sensor_uuid: + raise ValueError("sensor_uuid is required.") + try: + return Sensor.objects.get(uuid_sensor=sensor_uuid) + except Sensor.DoesNotExist as exc: + raise ValueError("Sensor not found.") from exc + + +def ensure_latest_area_ready_for_processing(sensor_uuid, area_feature=None): + sensor = get_sensor_for_uuid(sensor_uuid) + latest_area = CropArea.objects.filter(sensor=sensor).order_by("-created_at", "-id").first() + if latest_area is None: + latest_area, _ = create_zones_and_dispatch(area_feature or get_default_area_feature(), sensor=sensor) + return latest_area + + zones = create_missing_zones_for_area(latest_area) + for zone in zones: + ensure_rule_based_zone_data(zone) + + active_task_id = next((zone.task_id for zone in zones if zone.task_id and zone.processing_status in {CropZone.STATUS_PENDING, CropZone.STATUS_PROCESSING}), "") + zones_to_dispatch = [] + for zone in zones: + if zone.processing_status == CropZone.STATUS_COMPLETED: + continue + if active_task_id: + if not zone.task_id: + CropZone.objects.filter(id=zone.id).update(task_id=active_task_id) + continue + if zone.processing_status == CropZone.STATUS_PROCESSING and zone.task_id: + active_task_id = zone.task_id + continue + if zone.processing_status == CropZone.STATUS_PENDING and zone.task_id: + active_task_id = zone.task_id + continue + zones_to_dispatch.append(zone.id) + + if zones_to_dispatch: + dispatch_zone_processing_tasks(zone_ids=zones_to_dispatch) + + return CropArea.objects.get(id=latest_area.id) + + +def create_zones_and_dispatch(area_feature, cell_side_km=None, sensor=None): ensure_products_exist() area_feature = normalize_area_feature(area_feature) - zoning_result = split_area_into_zones(area_feature) + zoning_result = split_area_into_zones(area_feature, cell_side_km=cell_side_km) area_data = zoning_result["area"] with transaction.atomic(): crop_area = CropArea.objects.create( + sensor=sensor, geometry=area_data["geometry"], points=area_data["points"], center=area_data["center"], @@ -475,6 +823,9 @@ def create_zones_and_dispatch(area_feature): ) crop_area.refresh_from_db() + zones = list(crop_area.zones.order_by("sequence", "id")) + for zone in zones: + ensure_rule_based_zone_data(zone) dispatch_zone_processing_tasks(crop_area.id) return crop_area, zones @@ -493,11 +844,62 @@ def _zones_queryset(zone_ids=None): return queryset -def get_latest_area_payload(): - area = CropArea.objects.order_by("-created_at", "-id").first() +def get_latest_area_payload(area=None): + area = area or CropArea.objects.order_by("-created_at", "-id").first() if area: - return {"area": area.geometry} - return {"area": get_default_area_feature()} + zones = list(area.zones.only("zone_id", "task_id", "processing_status", "processing_error")) + total_zones = len(zones) + completed_zones = sum(1 for zone in zones if zone.processing_status == CropZone.STATUS_COMPLETED) + processing_zones = sum(1 for zone in zones if zone.processing_status == CropZone.STATUS_PROCESSING) + failed_zones = sum(1 for zone in zones if zone.processing_status == CropZone.STATUS_FAILED) + pending_zones = sum(1 for zone in zones if zone.processing_status == CropZone.STATUS_PENDING) + + if failed_zones: + task_status = "FAILURE" + elif total_zones and completed_zones == total_zones: + task_status = "SUCCESS" + elif processing_zones or completed_zones: + task_status = "PROCESSING" + else: + task_status = "PENDING" + + return { + "task": { + "status": task_status, + "area_uuid": str(area.uuid), + "total_zones": total_zones, + "completed_zones": completed_zones, + "processing_zones": processing_zones, + "pending_zones": pending_zones, + "failed_zones": failed_zones, + "task_ids": [zone.task_id for zone in zones if zone.task_id], + "failed_zone_errors": [ + { + "zoneId": zone.zone_id, + "error": zone.processing_error, + } + for zone in zones + if zone.processing_status == CropZone.STATUS_FAILED and zone.processing_error + ], + "cell_side_km": round(math.sqrt(max(area.chunk_area_sqm, 1)) / 1000.0, 4), + }, + "area": area.geometry, + } + return { + "task": { + "status": "IDLE", + "area_uuid": "", + "total_zones": 0, + "completed_zones": 0, + "processing_zones": 0, + "pending_zones": 0, + "failed_zones": 0, + "task_ids": [], + "failed_zone_errors": [], + "cell_side_km": round(get_default_cell_side_km(), 4), + }, + "area": get_default_area_feature(), + } def get_initial_zones_payload(crop_area): diff --git a/crop_zoning/tests.py b/crop_zoning/tests.py index 702403d..a9856c7 100644 --- a/crop_zoning/tests.py +++ b/crop_zoning/tests.py @@ -1,7 +1,14 @@ +from unittest.mock import patch + +from kombu.exceptions import OperationalError + +from django.contrib.auth import get_user_model from django.test import TestCase, override_settings from rest_framework.test import APIRequestFactory -from crop_zoning.views import ZonesInitialView +from crop_zoning.models import CropArea, CropZone +from crop_zoning.views import AreaView, ZonesInitialView +from sensor_hub.models import Sensor AREA_GEOJSON = { @@ -45,3 +52,234 @@ class ZonesInitialViewTests(TestCase): response.data["data"]["zone_count"], len(response.data["data"]["zones"]), ) + + +@override_settings( + USE_EXTERNAL_API_MOCK=True, + CROP_ZONE_CHUNK_AREA_SQM=200000, +) +class AreaViewTests(TestCase): + def setUp(self): + self.factory = APIRequestFactory() + self.user = get_user_model().objects.create_user( + username="farmer", + password="secret123", + email="farmer@example.com", + phone_number="09120000000", + ) + self.sensor = Sensor.objects.create(owner=self.user, name="sensor-1") + + def _create_area(self, **kwargs): + defaults = { + "sensor": self.sensor, + "geometry": AREA_GEOJSON, + "points": AREA_GEOJSON["geometry"]["coordinates"][0][:-1], + "center": {"longitude": 51.40874867, "latitude": 35.69575533}, + "area_sqm": 300000, + "area_hectares": 30, + "chunk_area_sqm": 200000, + "zone_count": 2, + } + defaults.update(kwargs) + return CropArea.objects.create(**defaults) + + def _request(self): + return self.factory.get(f"/api/crop-zoning/area/?sensor_uuid={self.sensor.uuid_sensor}") + + def test_get_requires_sensor_uuid(self): + request = self.factory.get("/api/crop-zoning/area/") + response = AreaView.as_view()(request) + self.assertEqual(response.status_code, 400) + self.assertEqual(response.data["message"], "sensor_uuid is required.") + + def test_get_returns_pending_task_status_until_all_zones_complete(self): + crop_area = self._create_area() + CropZone.objects.create( + crop_area=crop_area, + zone_id="zone-0", + geometry=AREA_GEOJSON["geometry"], + points=AREA_GEOJSON["geometry"]["coordinates"][0][:-1], + center={"longitude": 51.4087, "latitude": 35.6957}, + area_sqm=200000, + area_hectares=20, + sequence=0, + processing_status=CropZone.STATUS_PENDING, + task_id="celery-task-1", + ) + CropZone.objects.create( + crop_area=crop_area, + zone_id="zone-1", + geometry=AREA_GEOJSON["geometry"], + points=AREA_GEOJSON["geometry"]["coordinates"][0][:-1], + center={"longitude": 51.4088, "latitude": 35.6958}, + area_sqm=100000, + area_hectares=10, + sequence=1, + processing_status=CropZone.STATUS_PROCESSING, + task_id="celery-task-1", + ) + + response = AreaView.as_view()(self._request()) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["status"], "success") + self.assertEqual(response.data["data"]["task"]["status"], "PROCESSING") + self.assertEqual(response.data["data"]["task"]["total_zones"], 2) + self.assertEqual(response.data["data"]["area"], AREA_GEOJSON) + + def test_get_returns_area_when_all_tasks_complete(self): + crop_area = self._create_area() + for sequence in range(2): + CropZone.objects.create( + crop_area=crop_area, + zone_id=f"zone-{sequence}", + geometry=AREA_GEOJSON["geometry"], + points=AREA_GEOJSON["geometry"]["coordinates"][0][:-1], + center={"longitude": 51.4087 + (sequence * 0.0001), "latitude": 35.6957}, + area_sqm=150000, + area_hectares=15, + sequence=sequence, + processing_status=CropZone.STATUS_COMPLETED, + task_id="celery-task-1", + ) + + response = AreaView.as_view()(self._request()) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["data"]["task"]["status"], "SUCCESS") + self.assertEqual(response.data["data"]["area"], AREA_GEOJSON) + + @patch("crop_zoning.services.dispatch_zone_processing_tasks") + def test_get_dispatches_zone_task_when_task_id_is_missing(self, mock_dispatch): + crop_area = self._create_area(zone_count=1, area_sqm=200000, area_hectares=20) + CropZone.objects.create( + crop_area=crop_area, + zone_id="zone-0", + geometry=AREA_GEOJSON["geometry"], + points=AREA_GEOJSON["geometry"]["coordinates"][0][:-1], + center={"longitude": 51.4087, "latitude": 35.6957}, + area_sqm=200000, + area_hectares=20, + sequence=0, + processing_status=CropZone.STATUS_PENDING, + task_id="", + ) + + response = AreaView.as_view()(self._request()) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["status"], "success") + mock_dispatch.assert_called_once() + + @patch("crop_zoning.services.create_zones_and_dispatch") + def test_get_creates_area_when_sensor_has_no_data(self, mock_create): + created_area = self._create_area(zone_count=0) + mock_create.return_value = (created_area, []) + + response = AreaView.as_view()(self._request()) + + self.assertEqual(response.status_code, 200) + mock_create.assert_called_once() + self.assertEqual(mock_create.call_args.kwargs["sensor"], self.sensor) + + @patch("crop_zoning.tasks.process_zone_soil_data.delay") + def test_only_one_active_task_is_created_per_sensor(self, mock_delay): + crop_area = self._create_area() + zone0 = CropZone.objects.create( + crop_area=crop_area, + zone_id="zone-0", + geometry=AREA_GEOJSON["geometry"], + points=AREA_GEOJSON["geometry"]["coordinates"][0][:-1], + center={"longitude": 51.4087, "latitude": 35.6957}, + area_sqm=150000, + area_hectares=15, + sequence=0, + processing_status=CropZone.STATUS_PENDING, + task_id="", + ) + zone1 = CropZone.objects.create( + crop_area=crop_area, + zone_id="zone-1", + geometry=AREA_GEOJSON["geometry"], + points=AREA_GEOJSON["geometry"]["coordinates"][0][:-1], + center={"longitude": 51.4088, "latitude": 35.6958}, + area_sqm=150000, + area_hectares=15, + sequence=1, + processing_status=CropZone.STATUS_PENDING, + task_id="", + ) + + class Result: + id = "shared-task-id" + + mock_delay.return_value = Result() + + response = AreaView.as_view()(self._request()) + + self.assertEqual(response.status_code, 200) + self.assertEqual(mock_delay.call_count, 1) + zone0.refresh_from_db() + zone1.refresh_from_db() + self.assertEqual(zone0.task_id, "shared-task-id") + self.assertEqual(zone1.task_id, "shared-task-id") + + @patch("crop_zoning.tasks.process_zone_soil_data.delay", side_effect=OperationalError("redis down")) + def test_get_generates_local_task_id_when_broker_is_unavailable(self, mock_delay): + crop_area = self._create_area(zone_count=1, area_sqm=200000, area_hectares=20) + zone = CropZone.objects.create( + crop_area=crop_area, + zone_id="zone-0", + geometry=AREA_GEOJSON["geometry"], + points=AREA_GEOJSON["geometry"]["coordinates"][0][:-1], + center={"longitude": 51.4087, "latitude": 35.6957}, + area_sqm=200000, + area_hectares=20, + sequence=0, + processing_status=CropZone.STATUS_PENDING, + task_id="", + ) + + response = AreaView.as_view()(self._request()) + + self.assertEqual(response.status_code, 200) + zone.refresh_from_db() + self.assertTrue(zone.task_id) + self.assertEqual(response.data["data"]["task"]["task_ids"], [zone.task_id]) + self.assertEqual(response.data["data"]["task"]["status"], "PENDING") + self.assertIn("Celery broker unavailable", zone.processing_error) + + @patch("crop_zoning.tasks.process_zone_soil_data.delay") + def test_get_stores_task_id_and_reuses_it_on_next_request(self, mock_delay): + crop_area = self._create_area(zone_count=1, area_sqm=200000, area_hectares=20) + zone = CropZone.objects.create( + crop_area=crop_area, + zone_id="zone-0", + geometry=AREA_GEOJSON["geometry"], + points=AREA_GEOJSON["geometry"]["coordinates"][0][:-1], + center={"longitude": 51.4087, "latitude": 35.6957}, + area_sqm=200000, + area_hectares=20, + sequence=0, + processing_status=CropZone.STATUS_PENDING, + task_id="", + ) + + class Result: + id = "persisted-task-id" + + mock_delay.return_value = Result() + + first_response = AreaView.as_view()(self._request()) + self.assertEqual(first_response.status_code, 200) + zone.refresh_from_db() + self.assertEqual(zone.task_id, "persisted-task-id") + self.assertEqual(first_response.data["data"]["task"]["task_ids"], ["persisted-task-id"]) + self.assertEqual(mock_delay.call_count, 1) + + second_response = AreaView.as_view()(self._request()) + self.assertEqual(second_response.status_code, 200) + self.assertEqual(second_response.data["data"]["task"]["task_ids"], ["persisted-task-id"]) + self.assertEqual(second_response.data["data"]["task"]["status"], "PENDING") + self.assertEqual(mock_delay.call_count, 1) + diff --git a/crop_zoning/views.py b/crop_zoning/views.py index 0801644..09e8bd6 100644 --- a/crop_zoning/views.py +++ b/crop_zoning/views.py @@ -4,11 +4,12 @@ from rest_framework import serializers, status from rest_framework.response import Response from rest_framework.views import APIView from drf_spectacular.types import OpenApiTypes -from drf_spectacular.utils import extend_schema +from drf_spectacular.utils import OpenApiParameter, extend_schema from config.swagger import status_response from .services import ( create_zones_and_dispatch, + ensure_latest_area_ready_for_processing, get_cultivation_risk_payload, get_default_area_feature, get_initial_zones_payload, @@ -23,10 +24,31 @@ from .services import ( class AreaView(APIView): @extend_schema( tags=["Crop Zoning"], - responses={200: status_response("CropZoningAreaResponse", data=serializers.JSONField())}, + parameters=[ + OpenApiParameter( + name="sensor_uuid", + type=OpenApiTypes.UUID, + location=OpenApiParameter.QUERY, + required=True, + description="UUID سنسور ارسالی کاربر برای گرفتن یا ساخت task فعال همان سنسور.", + ) + ], + responses={ + 200: status_response("CropZoningAreaResponse", data=serializers.JSONField()), + 400: status_response("CropZoningAreaValidationError", data=serializers.JSONField()), + 500: status_response("CropZoningAreaServerError", data=serializers.JSONField()), + }, ) def get(self, request): - return Response({"status": "success", "data": get_latest_area_payload()}, status=status.HTTP_200_OK) + sensor_uuid = request.query_params.get("sensor_uuid") + try: + crop_area = ensure_latest_area_ready_for_processing(sensor_uuid=sensor_uuid) + except ValueError as exc: + return Response({"status": "error", "message": str(exc)}, status=status.HTTP_400_BAD_REQUEST) + except ImproperlyConfigured as exc: + return Response({"status": "error", "message": str(exc)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + return Response({"status": "success", "data": get_latest_area_payload(crop_area)}, status=status.HTTP_200_OK) class ProductsView(APIView): @@ -45,10 +67,16 @@ class ZonesInitialView(APIView): responses={200: status_response("CropZoningZonesInitialResponse", data=serializers.JSONField())}, ) def post(self, request): - area_feature = request.data.get("area") or request.data.get("area_geojson") or get_default_area_feature() + area_feature = ( + request.data.get("area") + or request.data.get("area_geojson") + or request.data.get("boundary") + or get_default_area_feature() + ) + cell_side_km = request.data.get("cell_side_km") try: - crop_area, _zones = create_zones_and_dispatch(area_feature) + crop_area, _zones = create_zones_and_dispatch(area_feature, cell_side_km=cell_side_km) except ValueError as exc: return Response({"status": "error", "message": str(exc)}, status=status.HTTP_400_BAD_REQUEST) except ImproperlyConfigured as exc: diff --git a/docker-compose.yaml b/docker-compose.yaml index 3f1b90f..d71cb4a 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,46 +1,106 @@ -# Development: volumes mount source so code updates apply without rebuild services: db: - image: mysql:8.0 + image: docker.iranserver.com/mysql:8 + container_name: ai-db environment: - MYSQL_DATABASE: ${DB_NAME:-croplogic} - MYSQL_USER: ${DB_USER:-croplogic} + MYSQL_DATABASE: ${DB_NAME:-ai} + MYSQL_USER: ${DB_USER:-ai} MYSQL_PASSWORD: ${DB_PASSWORD:-changeme} MYSQL_ROOT_PASSWORD: ${DB_PASSWORD:-changeme} volumes: - - mysql_data:/var/lib/mysql + - ai_mysql_data:/var/lib/mysql healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_PASSWORD:-changeme}"] interval: 5s timeout: 5s retries: 5 - # phpmyadmin: - # image: docker.iranserver.com/phpmyadmin - # environment: - # PMA_HOST: db - # PMA_PORT: 3306 - # UPLOAD_LIMIT: 64M - # ports: - # - "8081:80" - # depends_on: - # db: - # condition: service_healthy + phpmyadmin: + image: docker-mirror.liara.ir/phpmyadmin:latest + container_name: ai-phpmyadmin + environment: + PMA_HOST: db + PMA_PORT: 3306 + UPLOAD_LIMIT: 64M + ports: + - "8082:80" + depends_on: + db: + condition: service_healthy + + redis: + image: redis:7-alpine + container_name: ai-redis + ports: + - "6380:6379" + + qdrant: + image: qdrant/qdrant:latest + container_name: ai-qdrant + ports: + - "6333:6333" + - "6334:6334" + volumes: + - qdrant_data:/qdrant/storage + restart: unless-stopped web: - build: . - command: python manage.py runserver 0.0.0.0:8000 + build: + context: . + args: + APT_MIRROR: mirror2.chabokan.net + PIP_INDEX_URL: https://package-mirror.liara.ir/repository/pypi/simple + PIP_EXTRA_INDEX_URL: https://mirror2.chabokan.net/pypi/simple + PYTHON_MIRROR: mirror2.chabokan.net + container_name: ai-web + command: ["python", "manage.py", "runserver", "0.0.0.0:8000"] volumes: - .:/app + - ./logs:/app/logs ports: - "8000:8000" env_file: - .env environment: DB_HOST: db + CELERY_BROKER_URL: redis://redis:6379/0 + CELERY_RESULT_BACKEND: redis://redis:6379/0 + QDRANT_HOST: qdrant + QDRANT_PORT: 6333 depends_on: db: condition: service_healthy + redis: + condition: service_started + qdrant: + condition: service_started + + celery: + build: + context: . + args: + APT_MIRROR: mirror2.chabokan.net + PIP_INDEX_URL: https://package-mirror.liara.ir/repository/pypi/simple + PIP_EXTRA_INDEX_URL: https://mirror2.chabokan.net/pypi/simple + PYTHON_MIRROR: mirror2.chabokan.net + container_name: ai-celery + command: celery -A config worker -l info + volumes: + - .:/app + - ./logs:/app/logs + env_file: + - .env + environment: + DB_HOST: db + CELERY_BROKER_URL: redis://redis:6379/0 + CELERY_RESULT_BACKEND: redis://redis:6379/0 + SKIP_MIGRATE: "1" + depends_on: + db: + condition: service_healthy + redis: + condition: service_started volumes: - mysql_data: + ai_mysql_data: + qdrant_data: diff --git a/sensor_hub/seeds.py b/sensor_hub/seeds.py index 218e03d..b2af338 100644 --- a/sensor_hub/seeds.py +++ b/sensor_hub/seeds.py @@ -5,6 +5,7 @@ from django.db import transaction from account.seeds import seed_admin_user from .models import Sensor +from .services import dispatch_sensor_zoning ADMIN_SENSOR_UUID = uuid.UUID("11111111-1111-1111-1111-111111111111") @@ -74,6 +75,22 @@ ADMIN_SENSOR_DATA = { }, } +ADMIN_SENSOR_AREA_GEOJSON = { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [51.418934, 35.706815], + [51.423054, 35.691062], + [51.384258, 35.689389], + [51.418934, 35.706815], + ] + ], + }, +} + @transaction.atomic def seed_admin_sensor(): @@ -89,4 +106,6 @@ def seed_admin_sensor(): "customized_sensors": ADMIN_SENSOR_DATA["customized_sensors"], }, ) + if created: + dispatch_sensor_zoning(ADMIN_SENSOR_AREA_GEOJSON) return sensor, created diff --git a/sensor_hub/serializers.py b/sensor_hub/serializers.py index e3c3732..434bad3 100644 --- a/sensor_hub/serializers.py +++ b/sensor_hub/serializers.py @@ -1,6 +1,5 @@ from rest_framework import serializers - from .models import Sensor @@ -51,6 +50,14 @@ class SensorCreateSerializer(serializers.ModelSerializer): return value + def create(self, validated_data): + validated_data.pop("area_geojson", None) + return super().create(validated_data) + + def update(self, instance, validated_data): + validated_data.pop("area_geojson", None) + return super().update(instance, validated_data) + class SensorToggleSerializer(serializers.Serializer): uuid_sensor = serializers.UUIDField() diff --git a/sensor_hub/services.py b/sensor_hub/services.py index fc5bc12..258297e 100644 --- a/sensor_hub/services.py +++ b/sensor_hub/services.py @@ -3,6 +3,11 @@ from django.db import transaction from crop_zoning.services import create_zones_and_dispatch, get_initial_zones_payload, normalize_area_feature +def dispatch_sensor_zoning(area_feature, sensor): + crop_area, _zones = create_zones_and_dispatch(normalize_area_feature(area_feature), sensor=sensor) + return get_initial_zones_payload(crop_area) + + def create_sensor_with_zoning(serializer, owner): area_feature = serializer.validated_data.pop("area_geojson", None) @@ -11,7 +16,6 @@ def create_sensor_with_zoning(serializer, owner): zoning_payload = None if area_feature is not None: - crop_area, _zones = create_zones_and_dispatch(normalize_area_feature(area_feature)) - zoning_payload = get_initial_zones_payload(crop_area) + zoning_payload = dispatch_sensor_zoning(area_feature, sensor) return sensor, zoning_payload diff --git a/sensor_hub/tests.py b/sensor_hub/tests.py index 0a829f3..6507f9d 100644 --- a/sensor_hub/tests.py +++ b/sensor_hub/tests.py @@ -3,6 +3,7 @@ from django.test import TestCase, override_settings from rest_framework.test import APIRequestFactory, force_authenticate from crop_zoning.models import CropArea +from sensor_hub.seeds import seed_admin_sensor from sensor_hub.views import SensorListCreateView @@ -63,3 +64,25 @@ class SensorListCreateViewTests(TestCase): CropArea.objects.get().zone_count, ) self.assertEqual(CropArea.objects.count(), 1) + + +@override_settings( + USE_EXTERNAL_API_MOCK=True, + CROP_ZONE_CHUNK_AREA_SQM=200000, +) +class SensorSeedTests(TestCase): + def test_seed_admin_sensor_dispatches_crop_logic_flow_on_create(self): + sensor, created = seed_admin_sensor() + + self.assertTrue(created) + self.assertEqual(sensor.uuid_sensor.hex, "11111111111111111111111111111111") + self.assertEqual(CropArea.objects.count(), 1) + + def test_seed_admin_sensor_does_not_dispatch_twice_for_existing_seed(self): + first_sensor, first_created = seed_admin_sensor() + second_sensor, second_created = seed_admin_sensor() + + self.assertTrue(first_created) + self.assertFalse(second_created) + self.assertEqual(first_sensor.id, second_sensor.id) + self.assertEqual(CropArea.objects.count(), 1)