UPDATE
This commit is contained in:
@@ -0,0 +1,883 @@
|
|||||||
|
# Crop Zoning Code Logic
|
||||||
|
|
||||||
|
این فایل یک توضیح کامل و شفاف از منطق سه فایل زیر است:
|
||||||
|
|
||||||
|
- `crop_zoning/views.py`
|
||||||
|
- `crop_zoning/services.py`
|
||||||
|
- `crop_zoning/tests.py`
|
||||||
|
|
||||||
|
هدف این داکیومنت این است که بدون نیاز به خواندن مستقیم کد، بتوان فهمید هر endpoint چه میکند، دادهها چگونه ساخته میشوند، taskها چگونه مدیریت میشوند، و تستها چه رفتارهایی را پوشش میدهند.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## تصویر کلی ماژول
|
||||||
|
|
||||||
|
ماژول `crop_zoning` برای این ساخته شده که:
|
||||||
|
|
||||||
|
1. یک polygon مربوط به زمین را دریافت یا پیدا کند.
|
||||||
|
2. آن را به چند zone مربعی تقسیم کند.
|
||||||
|
3. برای هر zone داده اولیه تولید کند.
|
||||||
|
4. برای هر zone یک task پردازش جداگانه ثبت کند.
|
||||||
|
5. خروجی مناسب برای فرانت برگرداند تا هم وضعیت پردازش را بداند و هم zoneها را روی نقشه نمایش دهد.
|
||||||
|
|
||||||
|
این ماژول دو نوع داده برای zoneها دارد:
|
||||||
|
|
||||||
|
- داده اولیه و rule-based که سریع ساخته میشود و برای خالی نبودن UI استفاده میشود.
|
||||||
|
- داده تحلیلی که بعدا از طریق task و داده خاک تکمیل میشود.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## منطق `crop_zoning/views.py`
|
||||||
|
|
||||||
|
فایل `views.py` فقط لایه HTTP است.
|
||||||
|
یعنی کار اصلی را خودش انجام نمیدهد، بلکه:
|
||||||
|
|
||||||
|
- ورودی request را میخواند
|
||||||
|
- آن را validate میکند یا به serviceها میسپارد
|
||||||
|
- خروجی مناسب را به صورت JSON response برمیگرداند
|
||||||
|
|
||||||
|
### 1) `AreaView`
|
||||||
|
|
||||||
|
این مهمترین endpoint ماژول است.
|
||||||
|
|
||||||
|
### کار این view
|
||||||
|
|
||||||
|
- `sensor_uuid` را از query params میگیرد.
|
||||||
|
- `page` و `page_size` را هم از query params میگیرد.
|
||||||
|
- از service میخواهد مطمئن شود برای این sensor یک area معتبر و zoneهای آن وجود دارند.
|
||||||
|
- اگر zoneها وجود نداشته باشند، ساخته میشوند.
|
||||||
|
- اگر taskهای پردازش لازم باشند، dispatch میشوند.
|
||||||
|
- در نهایت خروجی area + zoneهای همان صفحه + اطلاعات pagination را برمیگرداند.
|
||||||
|
|
||||||
|
### ورودیهای `AreaView`
|
||||||
|
|
||||||
|
- `sensor_uuid`: اجباری
|
||||||
|
- `page`: اختیاری، پیشفرض `1`
|
||||||
|
- `page_size`: اختیاری، پیشفرض `10`
|
||||||
|
|
||||||
|
### خروجی `AreaView`
|
||||||
|
|
||||||
|
خروجی سه بخش مهم دارد:
|
||||||
|
|
||||||
|
- `task`: وضعیت پردازش کل area
|
||||||
|
- `area`: polygon اصلی زمین
|
||||||
|
- `zones`: فقط zoneهای مربوط به همان صفحه
|
||||||
|
- `pagination`: اطلاعات صفحهبندی zoneها
|
||||||
|
|
||||||
|
### مدیریت خطا در `AreaView`
|
||||||
|
|
||||||
|
اگر هر کدام از این موارد رخ بدهد، خطای `400` داده میشود:
|
||||||
|
|
||||||
|
- `sensor_uuid` ارسال نشده باشد
|
||||||
|
- `sensor_uuid` معتبر نباشد یا sensor پیدا نشود
|
||||||
|
- `page` نامعتبر باشد
|
||||||
|
- `page_size` نامعتبر باشد
|
||||||
|
|
||||||
|
اگر تنظیمات سمت سرور مشکل داشته باشند، خطای `500` داده میشود.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2) `ProductsView`
|
||||||
|
|
||||||
|
این endpoint لیست محصولات قابل کشت را برمیگرداند.
|
||||||
|
|
||||||
|
### کار این view
|
||||||
|
|
||||||
|
- از service میخواهد محصولات پیشفرض داخل دیتابیس sync شوند.
|
||||||
|
- سپس لیست محصولات را به فرمت مناسب فرانت برمیگرداند.
|
||||||
|
|
||||||
|
این view ساده است و منطق تحلیلی ندارد.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3) `ZonesInitialView`
|
||||||
|
|
||||||
|
این view برای ساخت zoneها از روی یک polygon ورودی استفاده میشود.
|
||||||
|
|
||||||
|
### کار این view
|
||||||
|
|
||||||
|
- polygon را از یکی از این کلیدها میگیرد:
|
||||||
|
- `area`
|
||||||
|
- `area_geojson`
|
||||||
|
- `boundary`
|
||||||
|
- اگر هیچکدام نباشد، از area پیشفرض mock استفاده میکند.
|
||||||
|
- در صورت ارسال، `cell_side_km` را هم میگیرد.
|
||||||
|
- service را صدا میزند تا area و zoneها ساخته شوند.
|
||||||
|
- response اولیه zoneها را برمیگرداند.
|
||||||
|
|
||||||
|
### تفاوت با `AreaView`
|
||||||
|
|
||||||
|
- `AreaView` بر اساس `sensor_uuid` کار میکند و وضعیت taskها را هم برمیگرداند.
|
||||||
|
- `ZonesInitialView` بیشتر برای ساخت اولیه zoneها از روی polygon مناسب است.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4) `ZonesWaterNeedView`
|
||||||
|
|
||||||
|
این view لایه نیاز آبی zoneها را برمیگرداند.
|
||||||
|
|
||||||
|
### کار این view
|
||||||
|
|
||||||
|
- از request، `zoneIds` را میگیرد.
|
||||||
|
- service را صدا میزند.
|
||||||
|
- برای هر zone، level و value و color مربوط به آب را برمیگرداند.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5) `ZonesSoilQualityView`
|
||||||
|
|
||||||
|
این view لایه کیفیت خاک zoneها را برمیگرداند.
|
||||||
|
|
||||||
|
### خروجی اصلی
|
||||||
|
|
||||||
|
برای هر zone:
|
||||||
|
|
||||||
|
- `level`
|
||||||
|
- `score`
|
||||||
|
- `color`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6) `ZonesCultivationRiskView`
|
||||||
|
|
||||||
|
این view لایه ریسک کشت zoneها را برمیگرداند.
|
||||||
|
|
||||||
|
### خروجی اصلی
|
||||||
|
|
||||||
|
برای هر zone:
|
||||||
|
|
||||||
|
- `level`
|
||||||
|
- `color`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7) `ZoneDetailsView`
|
||||||
|
|
||||||
|
این endpoint جزئیات یک zone را برمیگرداند.
|
||||||
|
|
||||||
|
### کار این view
|
||||||
|
|
||||||
|
- `zone_id` را از URL میگیرد.
|
||||||
|
- جزئیات recommendation آن zone را از service میخواند.
|
||||||
|
- اگر zone پیدا نشود، `404` برمیگرداند.
|
||||||
|
|
||||||
|
### خروجی اصلی
|
||||||
|
|
||||||
|
- crop پیشنهادی
|
||||||
|
- درصد تطابق
|
||||||
|
- نیاز آبی
|
||||||
|
- سود تخمینی
|
||||||
|
- reason
|
||||||
|
- criteria
|
||||||
|
- مساحت zone
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## منطق `crop_zoning/services.py`
|
||||||
|
|
||||||
|
این فایل قلب اصلی ماژول است.
|
||||||
|
بیشتر منطق واقعی اینجا پیادهسازی شده.
|
||||||
|
|
||||||
|
برای فهم بهتر، این فایل را میتوان به 8 بخش تقسیم کرد.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## بخش 1: تنظیمات و utilityهای اولیه
|
||||||
|
|
||||||
|
### ثابتها
|
||||||
|
|
||||||
|
چند constant اصلی در ابتدای فایل تعریف شدهاند:
|
||||||
|
|
||||||
|
- `DEFAULT_CELL_SIDE_KM`: اندازه پیشفرض ضلع هر zone
|
||||||
|
- `DEFAULT_ZONE_PAGE_SIZE`: تعداد پیشفرض zoneها در هر صفحه response
|
||||||
|
- `RULE_BASED_ALGORITHM`: نام الگوریتم rule-based
|
||||||
|
- `RULE_BASED_PRODUCTS`: داده اولیه محصولات و اطلاعات نمایشی آنها
|
||||||
|
|
||||||
|
### `get_default_cell_side_km()`
|
||||||
|
|
||||||
|
این تابع اندازه پیشفرض ضلع هر zone را مشخص میکند.
|
||||||
|
|
||||||
|
اولویتها:
|
||||||
|
|
||||||
|
1. اگر `CROP_ZONE_CELL_SIDE_KM` در settings وجود داشته باشد، همان استفاده میشود.
|
||||||
|
2. اگر نبود، از `CROP_ZONE_CHUNK_AREA_SQM` استفاده میکند و از روی آن ضلع مربع را حساب میکند.
|
||||||
|
3. اگر هیچکدام نباشند، از `DEFAULT_CELL_SIDE_KM` استفاده میشود.
|
||||||
|
|
||||||
|
### `get_task_stale_seconds()`
|
||||||
|
|
||||||
|
این تابع مشخص میکند بعد از چند ثانیه یک task ممکن است stale محسوب شود.
|
||||||
|
یعنی اگر task گیر کرده باشد، دوباره dispatch شود.
|
||||||
|
|
||||||
|
### `get_cell_side_km(cell_side_km=None)`
|
||||||
|
|
||||||
|
اگر کاربر اندازه zone را داده باشد، آن را validate میکند.
|
||||||
|
اگر نداده باشد، مقدار پیشفرض را برمیگرداند.
|
||||||
|
|
||||||
|
### `get_chunk_area_sqm(cell_side_km=None)`
|
||||||
|
|
||||||
|
مساحت zone را از روی ضلع آن حساب میکند:
|
||||||
|
|
||||||
|
- ضلع بر حسب کیلومتر دریافت میشود
|
||||||
|
- به متر تبدیل میشود
|
||||||
|
- مربع آن به عنوان مساحت zone برگردانده میشود
|
||||||
|
|
||||||
|
### `parse_positive_int(...)`
|
||||||
|
|
||||||
|
برای validate کردن پارامترهای عددی مثبت استفاده میشود.
|
||||||
|
الان برای `page` و `page_size` استفاده میشود.
|
||||||
|
|
||||||
|
### `get_zone_page_request_params(query_params)`
|
||||||
|
|
||||||
|
این تابع پارامترهای pagination را از query params میگیرد:
|
||||||
|
|
||||||
|
- `page`
|
||||||
|
- `page_size`
|
||||||
|
|
||||||
|
اگر ارسال نشده باشند، از default استفاده میکند.
|
||||||
|
اگر نامعتبر باشند، `ValueError` میدهد.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## بخش 2: آمادهسازی polygon و محاسبات هندسی
|
||||||
|
|
||||||
|
این بخش مسئول کار با GeoJSON و polygon است.
|
||||||
|
|
||||||
|
### `get_default_area_feature()`
|
||||||
|
|
||||||
|
یک area پیشفرض از داده mock برمیگرداند.
|
||||||
|
|
||||||
|
### `normalize_area_feature(area_feature)`
|
||||||
|
|
||||||
|
این تابع ورودی area را normalize میکند تا همیشه ساختار `Feature` داشته باشد.
|
||||||
|
|
||||||
|
### کارهای این تابع
|
||||||
|
|
||||||
|
- بررسی میکند ورودی null نباشد
|
||||||
|
- بررسی میکند ورودی dict باشد
|
||||||
|
- اگر ورودی از نوع `Feature` نباشد، آن را به `Feature` تبدیل میکند
|
||||||
|
- بررسی میکند geometry از نوع `Polygon` باشد
|
||||||
|
- بررسی میکند polygon حداقل 4 نقطه داشته باشد
|
||||||
|
|
||||||
|
### `get_polygon_ring(area_feature)`
|
||||||
|
|
||||||
|
حلقه اصلی polygon را استخراج میکند.
|
||||||
|
|
||||||
|
### `polygon_area_sqm(ring)`
|
||||||
|
|
||||||
|
مساحت polygon را به متر مربع حساب میکند.
|
||||||
|
برای این کار نقاط جغرافیایی را به مختصات مسطح تقریبی تبدیل میکند و فرمول shoelace را اجرا میکند.
|
||||||
|
|
||||||
|
### `normalize_points(ring)`
|
||||||
|
|
||||||
|
اگر آخر polygon با نقطه اول بسته شده باشد، نقطه تکراری آخر را حذف میکند.
|
||||||
|
|
||||||
|
### `calculate_center(points)`
|
||||||
|
|
||||||
|
مرکز تقریبی polygon یا مربع را از میانگین نقاط حساب میکند.
|
||||||
|
|
||||||
|
### `get_bbox(points)`
|
||||||
|
|
||||||
|
کمینه و بیشینه طول و عرض جغرافیایی را برمیگرداند تا محدوده کلی polygon مشخص شود.
|
||||||
|
|
||||||
|
### `meters_to_latitude_delta(meters)` و `meters_to_longitude_delta(meters, latitude)`
|
||||||
|
|
||||||
|
این دو تابع فاصله متر را به اختلاف latitude و longitude تبدیل میکنند.
|
||||||
|
برای ساخت grid مربعی از این دو تابع استفاده میشود.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## بخش 3: تشخیص برخورد polygon و cell
|
||||||
|
|
||||||
|
این بخش مشخص میکند که آیا یک مربع grid واقعا با polygon زمین برخورد دارد یا نه.
|
||||||
|
|
||||||
|
### `point_in_polygon(point, polygon_points)`
|
||||||
|
|
||||||
|
چک میکند یک نقطه داخل polygon هست یا نه.
|
||||||
|
|
||||||
|
### `_orientation`, `_on_segment`, `segments_intersect`
|
||||||
|
|
||||||
|
این توابع utilityهای هندسی برای تشخیص برخورد دو خط هستند.
|
||||||
|
|
||||||
|
### `rectangle_contains_point(point, cell_points)`
|
||||||
|
|
||||||
|
چک میکند یک نقطه داخل مربع cell قرار دارد یا نه.
|
||||||
|
|
||||||
|
### `polygon_intersects_cell(polygon_points, cell_points)`
|
||||||
|
|
||||||
|
این مهمترین تابع تقاطع است.
|
||||||
|
اگر یکی از شرایط زیر برقرار باشد، cell معتبر در نظر گرفته میشود:
|
||||||
|
|
||||||
|
- مرکز cell داخل polygon باشد
|
||||||
|
- یکی از گوشههای cell داخل polygon باشد
|
||||||
|
- یکی از نقاط polygon داخل cell باشد
|
||||||
|
- یکی از اضلاع polygon با اضلاع cell برخورد داشته باشد
|
||||||
|
|
||||||
|
نتیجه: فقط مربعهایی zone میشوند که واقعا با زمین همپوشانی داشته باشند.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## بخش 4: ساخت zoneها از روی area
|
||||||
|
|
||||||
|
### `build_square_points(...)`
|
||||||
|
|
||||||
|
چهار گوشه یک مربع را از روی مرزهای آن میسازد.
|
||||||
|
|
||||||
|
### `build_zone_square(area_points, center, zone_area_sqm)`
|
||||||
|
|
||||||
|
اگر area خیلی کوچک باشد یا zoneی تولید نشود، یک مربع fallback حول center area ساخته میشود.
|
||||||
|
|
||||||
|
### `split_area_into_zones(area_feature, cell_side_km=None)`
|
||||||
|
|
||||||
|
این تابع مهمترین بخش ساخت zoneها است.
|
||||||
|
|
||||||
|
### مراحل اجرای آن
|
||||||
|
|
||||||
|
1. polygon area را میگیرد.
|
||||||
|
2. center و bbox و total area را حساب میکند.
|
||||||
|
3. اندازه ضلع zone را مشخص میکند.
|
||||||
|
4. روی bbox یک grid مربعی میسازد.
|
||||||
|
5. هر cell را با `polygon_intersects_cell` بررسی میکند.
|
||||||
|
6. اگر cell با polygon تقاطع داشته باشد، یک zone جدید میسازد.
|
||||||
|
7. برای هر zone این دادهها تولید میشود:
|
||||||
|
- `zone_id`
|
||||||
|
- `geometry`
|
||||||
|
- `points`
|
||||||
|
- `center`
|
||||||
|
- `area_sqm`
|
||||||
|
- `area_hectares`
|
||||||
|
- `sequence`
|
||||||
|
8. اگر هیچ zoneی ساخته نشود، یک zone fallback میسازد.
|
||||||
|
9. در نهایت area summary و لیست zoneها را برمیگرداند.
|
||||||
|
|
||||||
|
### نکته مهم
|
||||||
|
|
||||||
|
در این پروژه zoneها grid-based هستند، نه بر اساس تقسیم واقعی shape زمین.
|
||||||
|
یعنی ابتدا grid مربعی ساخته میشود و بعد فقط مربعهایی که با زمین برخورد دارند نگه داشته میشوند.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## بخش 5: تولید recommendation و لایههای تحلیلی
|
||||||
|
|
||||||
|
این بخش داده پیشنهادی هر zone را تولید میکند.
|
||||||
|
|
||||||
|
### `build_rule_based_zone_metrics(index, coords)`
|
||||||
|
|
||||||
|
این تابع بدون نیاز به API خارجی، برای هر zone یک recommendation اولیه میسازد.
|
||||||
|
|
||||||
|
### هدف آن
|
||||||
|
|
||||||
|
وقتی zone تازه ساخته میشود، فرانت از همان ابتدا داده خالی نداشته باشد.
|
||||||
|
|
||||||
|
### خروجی آن
|
||||||
|
|
||||||
|
- `recommended_crop`
|
||||||
|
- `match_percent`
|
||||||
|
- `water_need_level`
|
||||||
|
- `water_need_value`
|
||||||
|
- `soil_quality_score`
|
||||||
|
- `soil_level`
|
||||||
|
- `cultivation_risk_level`
|
||||||
|
- `estimated_profit`
|
||||||
|
- `reason`
|
||||||
|
- `criteria`
|
||||||
|
|
||||||
|
این دادهها از روی مختصات zone و `sequence` به صورت deterministic ساخته میشوند.
|
||||||
|
|
||||||
|
### `build_initial_zone_payload(zone)`
|
||||||
|
|
||||||
|
خروجی سبک و اولیه برای endpoint ساخت zoneها تولید میکند.
|
||||||
|
|
||||||
|
### `build_area_zone_payload(zone)`
|
||||||
|
|
||||||
|
خروجی کاملتر برای `AreaView` تولید میکند و این بخشها را شامل میشود:
|
||||||
|
|
||||||
|
- geometry
|
||||||
|
- center
|
||||||
|
- area
|
||||||
|
- processing status
|
||||||
|
- crop recommendation
|
||||||
|
- water layer
|
||||||
|
- soil layer
|
||||||
|
- risk layer
|
||||||
|
|
||||||
|
### `persist_zone_analysis_metrics(zone, metrics)`
|
||||||
|
|
||||||
|
metrics را داخل مدلهای مختلف ذخیره میکند:
|
||||||
|
|
||||||
|
- recommendation
|
||||||
|
- criteria
|
||||||
|
- water need layer
|
||||||
|
- soil quality layer
|
||||||
|
- cultivation risk layer
|
||||||
|
|
||||||
|
### `ensure_rule_based_zone_data(zone, force=False)`
|
||||||
|
|
||||||
|
اگر zone هنوز recommendation نداشته باشد، با rule-based data آن را پر میکند.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## بخش 6: تحلیل خاک واقعی و ذخیره نتیجه
|
||||||
|
|
||||||
|
### `_get_level_color_map(...)`
|
||||||
|
|
||||||
|
رنگ هر level را برای سه لایه water / soil / risk برمیگرداند.
|
||||||
|
|
||||||
|
### `_pick_level(...)`
|
||||||
|
|
||||||
|
از روی score مشخص میکند level برابر `low` یا `medium` یا `high` است.
|
||||||
|
|
||||||
|
### `_format_range(...)`
|
||||||
|
|
||||||
|
برای ساخت رشتههایی مثل `3000-4000 m³/ha` استفاده میشود.
|
||||||
|
|
||||||
|
### `_derive_analysis_metrics(depths)`
|
||||||
|
|
||||||
|
این تابع از داده depthهای خاک، recommendation نهایی را میسازد.
|
||||||
|
|
||||||
|
### ورودی آن
|
||||||
|
|
||||||
|
آرایهای از depthها که از سرویس خارجی خاک میآید.
|
||||||
|
|
||||||
|
### محاسبات مهم آن
|
||||||
|
|
||||||
|
از میانگین این فیلدها استفاده میکند:
|
||||||
|
|
||||||
|
- `phh2o`
|
||||||
|
- `soc`
|
||||||
|
- `clay`
|
||||||
|
- `nitrogen`
|
||||||
|
- `wv0033`
|
||||||
|
|
||||||
|
بعد از اینها محاسبه میشود:
|
||||||
|
|
||||||
|
- کیفیت خاک
|
||||||
|
- نیاز آبی
|
||||||
|
- ریسک کشت
|
||||||
|
- محصول پیشنهادی
|
||||||
|
- درصد تطابق
|
||||||
|
- reason
|
||||||
|
- criteria
|
||||||
|
|
||||||
|
### `fetch_soil_data_for_zone(zone)`
|
||||||
|
|
||||||
|
برای یک zone به سرویس خارجی AI درخواست میزند و داده خاک میگیرد.
|
||||||
|
|
||||||
|
### payload ارسالی
|
||||||
|
|
||||||
|
- longitude
|
||||||
|
- latitude
|
||||||
|
- geometry zone
|
||||||
|
- center
|
||||||
|
- area
|
||||||
|
|
||||||
|
### `analyze_and_store_zone_soil_data(zone_id)`
|
||||||
|
|
||||||
|
این تابع منطق اصلی پردازش هر zone در worker است.
|
||||||
|
|
||||||
|
### مراحل آن
|
||||||
|
|
||||||
|
1. zone از دیتابیس خوانده میشود.
|
||||||
|
2. اگر قبلا کامل شده باشد، دوباره کاری نمیکند.
|
||||||
|
3. status روی `processing` میرود.
|
||||||
|
4. از API خارجی داده خاک میگیرد.
|
||||||
|
5. depthها را استخراج میکند.
|
||||||
|
6. recommendation واقعیتر را از روی خاک میسازد.
|
||||||
|
7. نتیجه را داخل مدلهای analysis و recommendation ذخیره میکند.
|
||||||
|
8. status را `completed` میکند.
|
||||||
|
9. اگر هر خطایی رخ دهد، status روی `failed` میرود و متن خطا ذخیره میشود.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## بخش 7: مدیریت taskهای zone
|
||||||
|
|
||||||
|
چون هر zone جداگانه پردازش میشود، باید taskها مدیریت شوند.
|
||||||
|
|
||||||
|
### `_get_stale_zone_ids(zones)`
|
||||||
|
|
||||||
|
این تابع zoneهایی را پیدا میکند که task آنها stale شده است.
|
||||||
|
|
||||||
|
### چه zoneهایی stale محسوب میشوند؟
|
||||||
|
|
||||||
|
- zone کامل نشده باشد
|
||||||
|
- task_id داشته باشد
|
||||||
|
- task خیلی قدیمی شده باشد
|
||||||
|
- یا task_id آن با task یک zone completed مشترک باشد
|
||||||
|
- یا state task در celery یکی از stateهای نامعتبر برای ادامه باشد
|
||||||
|
|
||||||
|
### `dispatch_zone_processing_tasks(crop_area_id=None, zone_ids=None, force=False)`
|
||||||
|
|
||||||
|
این تابع برای zoneهای انتخابشده task celery ثبت میکند.
|
||||||
|
|
||||||
|
### رفتار آن
|
||||||
|
|
||||||
|
- zoneهای completed را رد میکند
|
||||||
|
- اگر zone pending/processing باشد و task_id معتبر داشته باشد، دوباره dispatch نمیکند مگر `force=True`
|
||||||
|
- برای هر zone یک task جدا ثبت میکند
|
||||||
|
- اگر celery broker در دسترس نباشد، باز هم یک `task_id` محلی تولید میکند
|
||||||
|
- متن خطا را در `processing_error` ذخیره میکند
|
||||||
|
|
||||||
|
### اهمیت این طراحی
|
||||||
|
|
||||||
|
این باعث میشود:
|
||||||
|
|
||||||
|
- هر zone مستقل پردازش شود
|
||||||
|
- fail شدن یک zone بقیه را متوقف نکند
|
||||||
|
- فرانت بتواند وضعیت هر zone را جدا ببیند
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## بخش 8: ساخت area، بازیابی area و ساخت payload response
|
||||||
|
|
||||||
|
### `create_missing_zones_for_area(crop_area)`
|
||||||
|
|
||||||
|
اگر area در دیتابیس وجود داشته باشد ولی zoneهایش از بین رفته باشند یا ساخته نشده باشند، دوباره از روی geometry آن zoneها را میسازد.
|
||||||
|
|
||||||
|
### `get_sensor_for_uuid(sensor_uuid)`
|
||||||
|
|
||||||
|
اعتبارسنجی میکند که:
|
||||||
|
|
||||||
|
- `sensor_uuid` ارسال شده باشد
|
||||||
|
- sensor واقعا در دیتابیس وجود داشته باشد
|
||||||
|
|
||||||
|
### `ensure_latest_area_ready_for_processing(sensor_uuid, area_feature=None)`
|
||||||
|
|
||||||
|
این یکی از مهمترین توابع کل فایل است.
|
||||||
|
|
||||||
|
### منطق آن
|
||||||
|
|
||||||
|
1. sensor را پیدا میکند.
|
||||||
|
2. آخرین area مربوط به آن sensor را میگیرد.
|
||||||
|
3. اگر area وجود نداشته باشد:
|
||||||
|
- area پیشفرض یا area ورودی را میگیرد
|
||||||
|
- area و zoneها را میسازد
|
||||||
|
- taskها را dispatch میکند
|
||||||
|
4. اگر area وجود داشته باشد:
|
||||||
|
- مطمئن میشود zoneها وجود دارند
|
||||||
|
- برای هر zone، rule-based data را در صورت نبود ایجاد میکند
|
||||||
|
- zoneهای stale را پیدا میکند
|
||||||
|
- zoneهای لازم را دوباره dispatch میکند
|
||||||
|
- area تازه از دیتابیس خوانده میشود و برگردانده میشود
|
||||||
|
|
||||||
|
### نتیجه این تابع
|
||||||
|
|
||||||
|
وقتی `AreaView` این تابع را صدا میزند، همیشه یک area آماده برای نمایش و پردازش دارد.
|
||||||
|
|
||||||
|
### `create_zones_and_dispatch(area_feature, cell_side_km=None, sensor=None)`
|
||||||
|
|
||||||
|
این تابع area جدید را میسازد.
|
||||||
|
|
||||||
|
### مراحل آن
|
||||||
|
|
||||||
|
1. productها sync میشوند.
|
||||||
|
2. area normalize میشود.
|
||||||
|
3. area به zoneها تقسیم میشود.
|
||||||
|
4. داخل transaction:
|
||||||
|
- یک `CropArea` ساخته میشود
|
||||||
|
- همه `CropZone`ها bulk create میشوند
|
||||||
|
5. zoneها دوباره از دیتابیس خوانده میشوند
|
||||||
|
6. rule-based data برای هر zone ساخته میشود
|
||||||
|
7. taskهای پردازش dispatch میشوند
|
||||||
|
8. area و zones برگردانده میشوند
|
||||||
|
|
||||||
|
### `_zones_queryset(zone_ids=None)`
|
||||||
|
|
||||||
|
یک queryset آماده برمیگرداند که relationهای لازم را از قبل load میکند:
|
||||||
|
|
||||||
|
- recommendation
|
||||||
|
- product
|
||||||
|
- criteria
|
||||||
|
- water layer
|
||||||
|
- soil layer
|
||||||
|
- risk layer
|
||||||
|
|
||||||
|
این باعث میشود responseسازی سریعتر و با query کمتر انجام شود.
|
||||||
|
|
||||||
|
### `get_latest_area_payload(area=None, page=1, page_size=DEFAULT_ZONE_PAGE_SIZE)`
|
||||||
|
|
||||||
|
این تابع خروجی نهایی `AreaView` را میسازد.
|
||||||
|
|
||||||
|
### کارهای این تابع
|
||||||
|
|
||||||
|
1. area را پیدا میکند.
|
||||||
|
2. وضعیت همه zoneها را میخواند.
|
||||||
|
3. تعداد completed / pending / processing / failed را حساب میکند.
|
||||||
|
4. `task.status` را تعیین میکند.
|
||||||
|
5. `stage` و `stage_label` را تعیین میکند.
|
||||||
|
6. درصد پیشرفت را حساب میکند.
|
||||||
|
7. zoneهای همان صفحه را با slicing برمیدارد.
|
||||||
|
8. `pagination` را میسازد.
|
||||||
|
9. payload نهایی را برمیگرداند.
|
||||||
|
|
||||||
|
### منطق `task.status`
|
||||||
|
|
||||||
|
- اگر zone failed داشته باشیم: `FAILURE`
|
||||||
|
- اگر همه complete باشند: `SUCCESS`
|
||||||
|
- اگر بخشی complete یا processing باشند: `PROCESSING`
|
||||||
|
- در غیر این صورت: `PENDING`
|
||||||
|
|
||||||
|
### منطق pagination
|
||||||
|
|
||||||
|
- `page` و `page_size` از request گرفته میشوند
|
||||||
|
- `total_pages` از تقسیم تعداد کل zoneها بر `page_size` محاسبه میشود
|
||||||
|
- فقط zoneهای همان بازه برگردانده میشوند
|
||||||
|
- اطلاعات page فعلی، تعداد صفحات و وجود صفحه قبل/بعد در body قرار میگیرد
|
||||||
|
|
||||||
|
### `get_initial_zones_payload(crop_area)`
|
||||||
|
|
||||||
|
payload سادهتر برای endpoint اولیه zoneها میسازد.
|
||||||
|
|
||||||
|
### `get_water_need_payload(zone_ids=None)`
|
||||||
|
|
||||||
|
خروجی لایه نیاز آبی را برمیگرداند.
|
||||||
|
|
||||||
|
### `get_soil_quality_payload(zone_ids=None)`
|
||||||
|
|
||||||
|
خروجی لایه کیفیت خاک را برمیگرداند.
|
||||||
|
|
||||||
|
### `get_cultivation_risk_payload(zone_ids=None)`
|
||||||
|
|
||||||
|
خروجی لایه ریسک کشت را برمیگرداند.
|
||||||
|
|
||||||
|
### `get_zone_details_payload(zone_id)`
|
||||||
|
|
||||||
|
خروجی دیتیل یک zone را میسازد.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## جریان کامل اجرای `GET /api/crop-zoning/area/`
|
||||||
|
|
||||||
|
اگر بخواهیم کل flow را از ابتدا تا انتها خیلی ساده توضیح بدهیم:
|
||||||
|
|
||||||
|
1. فرانت `sensor_uuid` و احتمالا `page` و `page_size` را میفرستد.
|
||||||
|
2. `AreaView` پارامترها را میخواند.
|
||||||
|
3. `ensure_latest_area_ready_for_processing` اجرا میشود.
|
||||||
|
4. اگر area وجود نداشته باشد، area و zoneها ساخته میشوند.
|
||||||
|
5. اگر zoneها ناقص باشند، کامل میشوند.
|
||||||
|
6. اگر recommendation اولیه نباشد، ساخته میشود.
|
||||||
|
7. اگر taskهای لازم وجود نداشته باشند یا stale باشند، dispatch میشوند.
|
||||||
|
8. `get_latest_area_payload` اجرا میشود.
|
||||||
|
9. وضعیت کلی task و zoneهای صفحه فعلی ساخته میشود.
|
||||||
|
10. response نهایی به فرانت برمیگردد.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## منطق `crop_zoning/tests.py`
|
||||||
|
|
||||||
|
این فایل تست رفتار کلیدی API را پوشش میدهد.
|
||||||
|
|
||||||
|
تستها با `Django TestCase` و `APIRequestFactory` نوشته شدهاند.
|
||||||
|
|
||||||
|
### تنظیمات مشترک تستها
|
||||||
|
|
||||||
|
در تستها از این تنظیمات استفاده شده:
|
||||||
|
|
||||||
|
- `USE_EXTERNAL_API_MOCK=True`
|
||||||
|
- `CROP_ZONE_CHUNK_AREA_SQM=200000`
|
||||||
|
|
||||||
|
هدف این است که:
|
||||||
|
|
||||||
|
- وابستگی به API خارجی واقعی حذف شود
|
||||||
|
- zoneها با اندازه مشخص و قابل پیشبینی ساخته شوند
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## کلاس `ZonesInitialViewTests`
|
||||||
|
|
||||||
|
### `test_post_accepts_area_geojson_alias`
|
||||||
|
|
||||||
|
این تست بررسی میکند که اگر polygon با کلید `area_geojson` ارسال شود:
|
||||||
|
|
||||||
|
- endpoint آن را قبول کند
|
||||||
|
- پاسخ `200` بدهد
|
||||||
|
- zone ساخته شود
|
||||||
|
- تعداد zoneهای خروجی با `zone_count` یکسان باشد
|
||||||
|
|
||||||
|
این تست در عمل alias بودن `area_geojson` را validate میکند.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## کلاس `AreaViewTests`
|
||||||
|
|
||||||
|
این کلاس رفتارهای اصلی `AreaView` را تست میکند.
|
||||||
|
|
||||||
|
### `setUp`
|
||||||
|
|
||||||
|
در شروع هر تست:
|
||||||
|
|
||||||
|
- یک user ساخته میشود
|
||||||
|
- یک sensor برای آن user ساخته میشود
|
||||||
|
- `APIRequestFactory` آماده میشود
|
||||||
|
|
||||||
|
### `_create_area(...)`
|
||||||
|
|
||||||
|
یک helper برای ساخت سریع `CropArea` در تستها است.
|
||||||
|
|
||||||
|
### `_request()`
|
||||||
|
|
||||||
|
یک request استاندارد برای `AreaView` با `sensor_uuid` معتبر میسازد.
|
||||||
|
|
||||||
|
### `_request_with_pagination(page, page_size)`
|
||||||
|
|
||||||
|
یک request برای تست pagination میسازد.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### تستهای اصلی `AreaView`
|
||||||
|
|
||||||
|
#### `test_get_requires_sensor_uuid`
|
||||||
|
|
||||||
|
بررسی میکند اگر `sensor_uuid` ارسال نشود، پاسخ `400` برگردد.
|
||||||
|
|
||||||
|
#### `test_get_returns_pending_task_status_until_all_zones_complete`
|
||||||
|
|
||||||
|
بررسی میکند اگر zoneها pending و processing باشند:
|
||||||
|
|
||||||
|
- status کلی `PROCESSING` باشد
|
||||||
|
- area برگردد
|
||||||
|
- zoneها در response باشند
|
||||||
|
- فیلد `processing_status` برای zone موجود باشد
|
||||||
|
|
||||||
|
#### `test_get_returns_area_when_all_tasks_complete`
|
||||||
|
|
||||||
|
بررسی میکند وقتی همه zoneها complete باشند:
|
||||||
|
|
||||||
|
- status کلی `SUCCESS` باشد
|
||||||
|
- zoneها برگردند
|
||||||
|
- فیلدهای recommendation و layerها موجود باشند
|
||||||
|
|
||||||
|
#### `test_get_returns_paginated_zones`
|
||||||
|
|
||||||
|
تست جدید pagination است.
|
||||||
|
|
||||||
|
بررسی میکند که:
|
||||||
|
|
||||||
|
- با `page=2` و `page_size=1`
|
||||||
|
- فقط zone دوم برگردد
|
||||||
|
- اطلاعات pagination درست باشد
|
||||||
|
- `total_pages`, `has_next`, `has_previous` درست باشند
|
||||||
|
|
||||||
|
#### `test_get_rejects_invalid_pagination_params`
|
||||||
|
|
||||||
|
بررسی میکند اگر `page=0` باشد:
|
||||||
|
|
||||||
|
- پاسخ `400` بدهد
|
||||||
|
- پیام خطا مناسب برگردد
|
||||||
|
|
||||||
|
#### `test_get_dispatches_zone_task_when_task_id_is_missing`
|
||||||
|
|
||||||
|
با mock کردن `dispatch_zone_processing_tasks` بررسی میکند که:
|
||||||
|
|
||||||
|
- اگر zone task_id نداشته باشد
|
||||||
|
- در زمان فراخوانی `AreaView`
|
||||||
|
- dispatch انجام شود
|
||||||
|
|
||||||
|
#### `test_get_creates_area_when_sensor_has_no_data`
|
||||||
|
|
||||||
|
با mock کردن `create_zones_and_dispatch` بررسی میکند که:
|
||||||
|
|
||||||
|
- اگر sensor هنوز area نداشته باشد
|
||||||
|
- سیستم area جدید بسازد
|
||||||
|
- همان sensor را به service پاس بدهد
|
||||||
|
|
||||||
|
#### `test_each_zone_gets_its_own_task`
|
||||||
|
|
||||||
|
بررسی میکند برای دو zone جدا:
|
||||||
|
|
||||||
|
- دو task مستقل ایجاد شود
|
||||||
|
- هر zone task_id جدا داشته باشد
|
||||||
|
|
||||||
|
این تست خیلی مهم است چون تایید میکند taskها shared نیستند و per-zone هستند.
|
||||||
|
|
||||||
|
#### `test_get_generates_local_task_id_when_broker_is_unavailable`
|
||||||
|
|
||||||
|
با mock کردن celery و ایجاد `OperationalError` بررسی میکند که:
|
||||||
|
|
||||||
|
- حتی وقتی broker در دسترس نیست
|
||||||
|
- سیستم task_id محلی بسازد
|
||||||
|
- response خراب نشود
|
||||||
|
- وضعیت کلی درست بماند
|
||||||
|
|
||||||
|
#### `test_get_stores_task_id_and_reuses_it_on_next_request`
|
||||||
|
|
||||||
|
بررسی میکند:
|
||||||
|
|
||||||
|
- وقتی اولین request task_id را ثبت کرد
|
||||||
|
- request بعدی دوباره task تازه نسازد
|
||||||
|
- همان task_id قبلی reuse شود
|
||||||
|
|
||||||
|
این تست جلوی dispatch تکراری را میگیرد.
|
||||||
|
|
||||||
|
#### `test_get_redispatches_pending_zone_when_shared_task_already_completed`
|
||||||
|
|
||||||
|
این تست سناریوی قدیمی یا خراب را پوشش میدهد.
|
||||||
|
|
||||||
|
سناریو:
|
||||||
|
|
||||||
|
- یک zone completed شده
|
||||||
|
- zone دیگر pending مانده
|
||||||
|
- هر دو task_id یکسان دارند
|
||||||
|
|
||||||
|
در این حالت سیستم باید zone stale را دوباره dispatch کند.
|
||||||
|
|
||||||
|
این تست نشان میدهد منطق stale detection واقعا کار میکند.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## جمعبندی معماری
|
||||||
|
|
||||||
|
اگر خیلی خلاصه بخواهیم نقش هر فایل را بگوییم:
|
||||||
|
|
||||||
|
### `views.py`
|
||||||
|
|
||||||
|
لایه HTTP است.
|
||||||
|
|
||||||
|
- request را میگیرد
|
||||||
|
- service مناسب را صدا میزند
|
||||||
|
- response برمیگرداند
|
||||||
|
|
||||||
|
### `services.py`
|
||||||
|
|
||||||
|
لایه business logic است.
|
||||||
|
|
||||||
|
- area را validate میکند
|
||||||
|
- polygon را به zone تبدیل میکند
|
||||||
|
- recommendation اولیه و نهایی میسازد
|
||||||
|
- taskها را مدیریت میکند
|
||||||
|
- payload response را میسازد
|
||||||
|
|
||||||
|
### `tests.py`
|
||||||
|
|
||||||
|
لایه اطمینان از رفتار سیستم است.
|
||||||
|
|
||||||
|
- ساخت area
|
||||||
|
- ساخت zone
|
||||||
|
- status task
|
||||||
|
- dispatch task
|
||||||
|
- stale task
|
||||||
|
- pagination
|
||||||
|
- خطاهای ورودی
|
||||||
|
|
||||||
|
را تست میکند.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## نکات مهم برای فرانت
|
||||||
|
|
||||||
|
- endpoint `area` الان pagination دارد و `zones` همیشه همه zoneها را برنمیگرداند.
|
||||||
|
- تعداد کل zoneها از `task.total_zones` یا `pagination.total_zones` قابل خواندن است.
|
||||||
|
- تعداد کل صفحهها از `pagination.total_pages` قابل خواندن است.
|
||||||
|
- برای نمایش progress باید از `task.progress_percent` و `task.status` استفاده شود.
|
||||||
|
- `task.status` وضعیت کلی area است، نه وضعیت تکتک zoneها.
|
||||||
|
- وضعیت هر zone داخل `processing_status` قرار دارد.
|
||||||
|
- در صورت نیاز به جزئیات بیشتر برای یک zone باید `ZoneDetailsView` صدا زده شود.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## نکات مهم برای بکاند
|
||||||
|
|
||||||
|
- منطق grid سازی و پردازش zoneها تقریبا کامل داخل `services.py` متمرکز شده است.
|
||||||
|
- `AreaView` عمدا thin نگه داشته شده تا business logic وارد view نشود.
|
||||||
|
- rule-based data نقش fallback سریع برای UI را دارد.
|
||||||
|
- data واقعیتر بعدا با taskهای تحلیل خاک جایگزین یا تکمیل میشود.
|
||||||
|
- تستها بیشتر روی پایداری flow پردازش و task dispatch تمرکز دارند.
|
||||||
|
|
||||||
@@ -51,19 +51,22 @@ Content-Type: application/json
|
|||||||
## 1) Get Area
|
## 1) Get Area
|
||||||
|
|
||||||
```http
|
```http
|
||||||
GET /api/crop-zoning/area/?sensor_uuid=<sensor_uuid>
|
GET /api/crop-zoning/area/?sensor_uuid=<sensor_uuid>&page=1&page_size=10
|
||||||
```
|
```
|
||||||
|
|
||||||
### Query Params
|
### Query Params
|
||||||
|
|
||||||
- `sensor_uuid`: اجباری، UUID سنسور
|
- `sensor_uuid`: اجباری، UUID سنسور
|
||||||
|
- `page`: اختیاری، شماره صفحه زونها. پیشفرض `1`
|
||||||
|
- `page_size`: اختیاری، تعداد زون در هر صفحه. پیشفرض `10`
|
||||||
|
|
||||||
### کاربرد
|
### کاربرد
|
||||||
|
|
||||||
- گرفتن آخرین area مربوط به سنسور
|
- گرفتن آخرین area مربوط به سنسور
|
||||||
- ساخت area و zoneها در صورت نبود داده
|
- ساخت area و zoneها در صورت نبود داده
|
||||||
- دریافت وضعیت task
|
- دریافت وضعیت task
|
||||||
- دریافت لیست کامل `zones` برای نمایش روی نقشه
|
- دریافت لیست `zones` به صورت صفحهبندیشده برای نمایش روی نقشه
|
||||||
|
- دریافت اطلاعات pagination برای ساخت pager یا infinite loading در فرانت
|
||||||
|
|
||||||
### نمونه پاسخ موفق
|
### نمونه پاسخ موفق
|
||||||
|
|
||||||
@@ -145,9 +148,40 @@ GET /api/crop-zoning/area/?sensor_uuid=<sensor_uuid>
|
|||||||
"color": "#22c55e"
|
"color": "#22c55e"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"pagination": {
|
||||||
|
"page": 1,
|
||||||
|
"page_size": 10,
|
||||||
|
"total_pages": 37,
|
||||||
|
"total_zones": 364,
|
||||||
|
"returned_zones": 10,
|
||||||
|
"has_next": true,
|
||||||
|
"has_previous": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### رفتار pagination
|
||||||
|
|
||||||
|
- `zones` فقط شامل زونهای همان صفحهای است که در query param فرستاده شده
|
||||||
|
- `task.total_zones` تعداد کل زونهای area را نشان میدهد، نه فقط زونهای همان صفحه
|
||||||
|
- `pagination.total_pages` تعداد کل صفحهها را برای فرانت مشخص میکند
|
||||||
|
- `pagination.returned_zones` تعداد آیتمهای برگشتی در همان response را نشان میدهد
|
||||||
|
- اگر `page` بزرگتر از `total_pages` باشد، response خطا نمیدهد و فقط `zones` خالی برمیگردد
|
||||||
|
|
||||||
|
### مثالها
|
||||||
|
|
||||||
|
#### صفحه اول با 10 زون در هر صفحه
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/crop-zoning/area/?sensor_uuid=<sensor_uuid>&page=1&page_size=10
|
||||||
|
```
|
||||||
|
|
||||||
|
#### صفحه سوم با 25 زون در هر صفحه
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/crop-zoning/area/?sensor_uuid=<sensor_uuid>&page=3&page_size=25
|
||||||
```
|
```
|
||||||
|
|
||||||
### فیلدهای مهم `zones`
|
### فیلدهای مهم `zones`
|
||||||
@@ -169,6 +203,16 @@ GET /api/crop-zoning/area/?sensor_uuid=<sensor_uuid>
|
|||||||
- `soilQualityLayer`: داده layer کیفیت خاک
|
- `soilQualityLayer`: داده layer کیفیت خاک
|
||||||
- `cultivationRiskLayer`: داده layer ریسک کشت
|
- `cultivationRiskLayer`: داده layer ریسک کشت
|
||||||
|
|
||||||
|
### فیلدهای مهم `pagination`
|
||||||
|
|
||||||
|
- `page`: شماره صفحه فعلی
|
||||||
|
- `page_size`: تعداد زون در هر صفحه
|
||||||
|
- `total_pages`: تعداد کل صفحهها
|
||||||
|
- `total_zones`: تعداد کل زونهای area
|
||||||
|
- `returned_zones`: تعداد زونهای برگشتی در response فعلی
|
||||||
|
- `has_next`: آیا صفحه بعدی وجود دارد یا نه
|
||||||
|
- `has_previous`: آیا صفحه قبلی وجود دارد یا نه
|
||||||
|
|
||||||
### خطاها
|
### خطاها
|
||||||
|
|
||||||
#### وقتی `sensor_uuid` ارسال نشود
|
#### وقتی `sensor_uuid` ارسال نشود
|
||||||
@@ -189,6 +233,18 @@ GET /api/crop-zoning/area/?sensor_uuid=<sensor_uuid>
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### وقتی `page` یا `page_size` نامعتبر باشد
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "error",
|
||||||
|
"message": "page must be a positive integer."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- همین رفتار برای `page_size` هم وجود دارد و پیام خطا به صورت
|
||||||
|
`page_size must be a positive integer.` برمیگردد.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2) Get Products
|
## 2) Get Products
|
||||||
|
|||||||
+47
-2
@@ -29,6 +29,7 @@ from .models import (
|
|||||||
EARTH_RADIUS_METERS = 6378137.0
|
EARTH_RADIUS_METERS = 6378137.0
|
||||||
PRODUCT_DEFAULTS = PRODUCTS_RESPONSE_DATA["products"]
|
PRODUCT_DEFAULTS = PRODUCTS_RESPONSE_DATA["products"]
|
||||||
DEFAULT_CELL_SIDE_KM = 0.15
|
DEFAULT_CELL_SIDE_KM = 0.15
|
||||||
|
DEFAULT_ZONE_PAGE_SIZE = 10
|
||||||
RULE_BASED_ALGORITHM = "rule_based_v1"
|
RULE_BASED_ALGORITHM = "rule_based_v1"
|
||||||
RULE_BASED_PRODUCTS = {
|
RULE_BASED_PRODUCTS = {
|
||||||
"wheat": {
|
"wheat": {
|
||||||
@@ -107,6 +108,29 @@ def get_chunk_area_sqm(cell_side_km=None):
|
|||||||
return (resolved_cell_side_km * 1000.0) ** 2
|
return (resolved_cell_side_km * 1000.0) ** 2
|
||||||
|
|
||||||
|
|
||||||
|
def parse_positive_int(value, field_name, default=None):
|
||||||
|
if value in {None, ""}:
|
||||||
|
if default is None:
|
||||||
|
raise ValueError(f"{field_name} must be a positive integer.")
|
||||||
|
return default
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed_value = int(value)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise ValueError(f"{field_name} must be a positive integer.") from exc
|
||||||
|
|
||||||
|
if parsed_value <= 0:
|
||||||
|
raise ValueError(f"{field_name} must be a positive integer.")
|
||||||
|
return parsed_value
|
||||||
|
|
||||||
|
|
||||||
|
def get_zone_page_request_params(query_params):
|
||||||
|
return (
|
||||||
|
parse_positive_int(query_params.get("page"), "page", default=1),
|
||||||
|
parse_positive_int(query_params.get("page_size"), "page_size", default=DEFAULT_ZONE_PAGE_SIZE),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_default_area_feature():
|
def get_default_area_feature():
|
||||||
return deepcopy(AREA_RESPONSE_DATA["area"])
|
return deepcopy(AREA_RESPONSE_DATA["area"])
|
||||||
|
|
||||||
@@ -920,7 +944,7 @@ def _zones_queryset(zone_ids=None):
|
|||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
|
|
||||||
def get_latest_area_payload(area=None):
|
def get_latest_area_payload(area=None, page=1, page_size=DEFAULT_ZONE_PAGE_SIZE):
|
||||||
area = area or CropArea.objects.order_by("-created_at", "-id").first()
|
area = area or CropArea.objects.order_by("-created_at", "-id").first()
|
||||||
if area:
|
if area:
|
||||||
status_zones = list(area.zones.only("zone_id", "task_id", "processing_status", "processing_error"))
|
status_zones = list(area.zones.only("zone_id", "task_id", "processing_status", "processing_error"))
|
||||||
@@ -929,7 +953,10 @@ def get_latest_area_payload(area=None):
|
|||||||
processing_zones = sum(1 for zone in status_zones if zone.processing_status == CropZone.STATUS_PROCESSING)
|
processing_zones = sum(1 for zone in status_zones if zone.processing_status == CropZone.STATUS_PROCESSING)
|
||||||
failed_zones = sum(1 for zone in status_zones if zone.processing_status == CropZone.STATUS_FAILED)
|
failed_zones = sum(1 for zone in status_zones if zone.processing_status == CropZone.STATUS_FAILED)
|
||||||
pending_zones = sum(1 for zone in status_zones if zone.processing_status == CropZone.STATUS_PENDING)
|
pending_zones = sum(1 for zone in status_zones if zone.processing_status == CropZone.STATUS_PENDING)
|
||||||
zones = _zones_queryset().filter(crop_area=area)
|
total_pages = math.ceil(total_zones / page_size) if total_zones else 0
|
||||||
|
start_index = (page - 1) * page_size
|
||||||
|
end_index = start_index + page_size
|
||||||
|
zones = list(_zones_queryset().filter(crop_area=area)[start_index:end_index])
|
||||||
|
|
||||||
if failed_zones:
|
if failed_zones:
|
||||||
task_status = "FAILURE"
|
task_status = "FAILURE"
|
||||||
@@ -995,6 +1022,15 @@ def get_latest_area_payload(area=None):
|
|||||||
},
|
},
|
||||||
"area": area.geometry,
|
"area": area.geometry,
|
||||||
"zones": [build_area_zone_payload(zone) for zone in zones],
|
"zones": [build_area_zone_payload(zone) for zone in zones],
|
||||||
|
"pagination": {
|
||||||
|
"page": page,
|
||||||
|
"page_size": page_size,
|
||||||
|
"total_pages": total_pages,
|
||||||
|
"total_zones": total_zones,
|
||||||
|
"returned_zones": len(zones),
|
||||||
|
"has_next": page < total_pages,
|
||||||
|
"has_previous": page > 1 and total_pages > 0,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
"task": {
|
"task": {
|
||||||
@@ -1010,6 +1046,15 @@ def get_latest_area_payload(area=None):
|
|||||||
},
|
},
|
||||||
"area": get_default_area_feature(),
|
"area": get_default_area_feature(),
|
||||||
"zones": [],
|
"zones": [],
|
||||||
|
"pagination": {
|
||||||
|
"page": page,
|
||||||
|
"page_size": page_size,
|
||||||
|
"total_pages": 0,
|
||||||
|
"total_zones": 0,
|
||||||
|
"returned_zones": 0,
|
||||||
|
"has_next": False,
|
||||||
|
"has_previous": False,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -88,6 +88,11 @@ class AreaViewTests(TestCase):
|
|||||||
def _request(self):
|
def _request(self):
|
||||||
return self.factory.get(f"/api/crop-zoning/area/?sensor_uuid={self.sensor.uuid_sensor}")
|
return self.factory.get(f"/api/crop-zoning/area/?sensor_uuid={self.sensor.uuid_sensor}")
|
||||||
|
|
||||||
|
def _request_with_pagination(self, page=1, page_size=10):
|
||||||
|
return self.factory.get(
|
||||||
|
f"/api/crop-zoning/area/?sensor_uuid={self.sensor.uuid_sensor}&page={page}&page_size={page_size}"
|
||||||
|
)
|
||||||
|
|
||||||
def test_get_requires_sensor_uuid(self):
|
def test_get_requires_sensor_uuid(self):
|
||||||
request = self.factory.get("/api/crop-zoning/area/")
|
request = self.factory.get("/api/crop-zoning/area/")
|
||||||
response = AreaView.as_view()(request)
|
response = AreaView.as_view()(request)
|
||||||
@@ -158,6 +163,39 @@ class AreaViewTests(TestCase):
|
|||||||
self.assertIn("crop", response.data["data"]["zones"][0])
|
self.assertIn("crop", response.data["data"]["zones"][0])
|
||||||
self.assertIn("waterNeedLayer", response.data["data"]["zones"][0])
|
self.assertIn("waterNeedLayer", response.data["data"]["zones"][0])
|
||||||
|
|
||||||
|
def test_get_returns_paginated_zones(self):
|
||||||
|
crop_area = self._create_area(zone_count=3, area_sqm=300000, area_hectares=30)
|
||||||
|
for sequence in range(3):
|
||||||
|
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=100000,
|
||||||
|
area_hectares=10,
|
||||||
|
sequence=sequence,
|
||||||
|
processing_status=CropZone.STATUS_COMPLETED,
|
||||||
|
task_id=f"celery-task-{sequence}",
|
||||||
|
)
|
||||||
|
|
||||||
|
response = AreaView.as_view()(self._request_with_pagination(page=2, page_size=1))
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(len(response.data["data"]["zones"]), 1)
|
||||||
|
self.assertEqual(response.data["data"]["zones"][0]["zoneId"], "zone-1")
|
||||||
|
self.assertEqual(response.data["data"]["pagination"]["page"], 2)
|
||||||
|
self.assertEqual(response.data["data"]["pagination"]["page_size"], 1)
|
||||||
|
self.assertEqual(response.data["data"]["pagination"]["total_pages"], 3)
|
||||||
|
self.assertTrue(response.data["data"]["pagination"]["has_next"])
|
||||||
|
self.assertTrue(response.data["data"]["pagination"]["has_previous"])
|
||||||
|
|
||||||
|
def test_get_rejects_invalid_pagination_params(self):
|
||||||
|
response = AreaView.as_view()(self._request_with_pagination(page=0, page_size=10))
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
self.assertEqual(response.data["message"], "page must be a positive integer.")
|
||||||
|
|
||||||
@patch("crop_zoning.services.dispatch_zone_processing_tasks")
|
@patch("crop_zoning.services.dispatch_zone_processing_tasks")
|
||||||
def test_get_dispatches_zone_task_when_task_id_is_missing(self, mock_dispatch):
|
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)
|
crop_area = self._create_area(zone_count=1, area_sqm=200000, area_hectares=20)
|
||||||
|
|||||||
+20
-1
@@ -17,6 +17,7 @@ from .services import (
|
|||||||
get_products_payload,
|
get_products_payload,
|
||||||
get_soil_quality_payload,
|
get_soil_quality_payload,
|
||||||
get_water_need_payload,
|
get_water_need_payload,
|
||||||
|
get_zone_page_request_params,
|
||||||
get_zone_details_payload,
|
get_zone_details_payload,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -31,6 +32,20 @@ class AreaView(APIView):
|
|||||||
location=OpenApiParameter.QUERY,
|
location=OpenApiParameter.QUERY,
|
||||||
required=True,
|
required=True,
|
||||||
description="UUID سنسور ارسالی کاربر برای گرفتن یا ساخت task فعال همان سنسور.",
|
description="UUID سنسور ارسالی کاربر برای گرفتن یا ساخت task فعال همان سنسور.",
|
||||||
|
),
|
||||||
|
OpenApiParameter(
|
||||||
|
name="page",
|
||||||
|
type=OpenApiTypes.INT,
|
||||||
|
location=OpenApiParameter.QUERY,
|
||||||
|
required=False,
|
||||||
|
description="شماره صفحه زونها. مقدار پیشفرض 1 است.",
|
||||||
|
),
|
||||||
|
OpenApiParameter(
|
||||||
|
name="page_size",
|
||||||
|
type=OpenApiTypes.INT,
|
||||||
|
location=OpenApiParameter.QUERY,
|
||||||
|
required=False,
|
||||||
|
description="تعداد زون در هر صفحه. مقدار پیشفرض 10 است.",
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
responses={
|
responses={
|
||||||
@@ -42,13 +57,17 @@ class AreaView(APIView):
|
|||||||
def get(self, request):
|
def get(self, request):
|
||||||
sensor_uuid = request.query_params.get("sensor_uuid")
|
sensor_uuid = request.query_params.get("sensor_uuid")
|
||||||
try:
|
try:
|
||||||
|
page, page_size = get_zone_page_request_params(request.query_params)
|
||||||
crop_area = ensure_latest_area_ready_for_processing(sensor_uuid=sensor_uuid)
|
crop_area = ensure_latest_area_ready_for_processing(sensor_uuid=sensor_uuid)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
return Response({"status": "error", "message": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
return Response({"status": "error", "message": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
except ImproperlyConfigured as exc:
|
except ImproperlyConfigured as exc:
|
||||||
return Response({"status": "error", "message": str(exc)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
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)
|
return Response(
|
||||||
|
{"status": "success", "data": get_latest_area_payload(crop_area, page=page, page_size=page_size)},
|
||||||
|
status=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ProductsView(APIView):
|
class ProductsView(APIView):
|
||||||
|
|||||||
Reference in New Issue
Block a user