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
|
||||
|
||||
```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
|
||||
|
||||
- `sensor_uuid`: اجباری، UUID سنسور
|
||||
- `page`: اختیاری، شماره صفحه زونها. پیشفرض `1`
|
||||
- `page_size`: اختیاری، تعداد زون در هر صفحه. پیشفرض `10`
|
||||
|
||||
### کاربرد
|
||||
|
||||
- گرفتن آخرین area مربوط به سنسور
|
||||
- ساخت area و zoneها در صورت نبود داده
|
||||
- دریافت وضعیت task
|
||||
- دریافت لیست کامل `zones` برای نمایش روی نقشه
|
||||
- دریافت لیست `zones` به صورت صفحهبندیشده برای نمایش روی نقشه
|
||||
- دریافت اطلاعات pagination برای ساخت pager یا infinite loading در فرانت
|
||||
|
||||
### نمونه پاسخ موفق
|
||||
|
||||
@@ -145,9 +148,40 @@ GET /api/crop-zoning/area/?sensor_uuid=<sensor_uuid>
|
||||
"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`
|
||||
@@ -169,6 +203,16 @@ GET /api/crop-zoning/area/?sensor_uuid=<sensor_uuid>
|
||||
- `soilQualityLayer`: داده layer کیفیت خاک
|
||||
- `cultivationRiskLayer`: داده layer ریسک کشت
|
||||
|
||||
### فیلدهای مهم `pagination`
|
||||
|
||||
- `page`: شماره صفحه فعلی
|
||||
- `page_size`: تعداد زون در هر صفحه
|
||||
- `total_pages`: تعداد کل صفحهها
|
||||
- `total_zones`: تعداد کل زونهای area
|
||||
- `returned_zones`: تعداد زونهای برگشتی در response فعلی
|
||||
- `has_next`: آیا صفحه بعدی وجود دارد یا نه
|
||||
- `has_previous`: آیا صفحه قبلی وجود دارد یا نه
|
||||
|
||||
### خطاها
|
||||
|
||||
#### وقتی `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
|
||||
|
||||
+47
-2
@@ -29,6 +29,7 @@ from .models import (
|
||||
EARTH_RADIUS_METERS = 6378137.0
|
||||
PRODUCT_DEFAULTS = PRODUCTS_RESPONSE_DATA["products"]
|
||||
DEFAULT_CELL_SIDE_KM = 0.15
|
||||
DEFAULT_ZONE_PAGE_SIZE = 10
|
||||
RULE_BASED_ALGORITHM = "rule_based_v1"
|
||||
RULE_BASED_PRODUCTS = {
|
||||
"wheat": {
|
||||
@@ -107,6 +108,29 @@ def get_chunk_area_sqm(cell_side_km=None):
|
||||
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():
|
||||
return deepcopy(AREA_RESPONSE_DATA["area"])
|
||||
|
||||
@@ -920,7 +944,7 @@ def _zones_queryset(zone_ids=None):
|
||||
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()
|
||||
if area:
|
||||
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)
|
||||
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)
|
||||
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:
|
||||
task_status = "FAILURE"
|
||||
@@ -995,6 +1022,15 @@ def get_latest_area_payload(area=None):
|
||||
},
|
||||
"area": area.geometry,
|
||||
"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 {
|
||||
"task": {
|
||||
@@ -1010,6 +1046,15 @@ def get_latest_area_payload(area=None):
|
||||
},
|
||||
"area": get_default_area_feature(),
|
||||
"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):
|
||||
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):
|
||||
request = self.factory.get("/api/crop-zoning/area/")
|
||||
response = AreaView.as_view()(request)
|
||||
@@ -158,6 +163,39 @@ class AreaViewTests(TestCase):
|
||||
self.assertIn("crop", 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")
|
||||
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)
|
||||
|
||||
+20
-1
@@ -17,6 +17,7 @@ from .services import (
|
||||
get_products_payload,
|
||||
get_soil_quality_payload,
|
||||
get_water_need_payload,
|
||||
get_zone_page_request_params,
|
||||
get_zone_details_payload,
|
||||
)
|
||||
|
||||
@@ -31,6 +32,20 @@ class AreaView(APIView):
|
||||
location=OpenApiParameter.QUERY,
|
||||
required=True,
|
||||
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={
|
||||
@@ -42,13 +57,17 @@ class AreaView(APIView):
|
||||
def get(self, request):
|
||||
sensor_uuid = request.query_params.get("sensor_uuid")
|
||||
try:
|
||||
page, page_size = get_zone_page_request_params(request.query_params)
|
||||
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)
|
||||
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):
|
||||
|
||||
Reference in New Issue
Block a user