From f40364470bc97c07492f4eecb201dc3785782ff6 Mon Sep 17 00:00:00 2001 From: Mohammad Sajad Pourajam Date: Wed, 1 Apr 2026 18:38:05 +0330 Subject: [PATCH] UPDATE --- crop_zoning/CROP_ZONING_CODE_LOGIC.md | 883 ++++++++++++++++++++++++ crop_zoning/CROP_ZONING_FRONTEND_API.md | 62 +- crop_zoning/services.py | 49 +- crop_zoning/tests.py | 38 + crop_zoning/views.py | 21 +- 5 files changed, 1047 insertions(+), 6 deletions(-) create mode 100644 crop_zoning/CROP_ZONING_CODE_LOGIC.md diff --git a/crop_zoning/CROP_ZONING_CODE_LOGIC.md b/crop_zoning/CROP_ZONING_CODE_LOGIC.md new file mode 100644 index 0000000..efb85f1 --- /dev/null +++ b/crop_zoning/CROP_ZONING_CODE_LOGIC.md @@ -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 تمرکز دارند. + diff --git a/crop_zoning/CROP_ZONING_FRONTEND_API.md b/crop_zoning/CROP_ZONING_FRONTEND_API.md index 4d726ce..3c69f78 100644 --- a/crop_zoning/CROP_ZONING_FRONTEND_API.md +++ b/crop_zoning/CROP_ZONING_FRONTEND_API.md @@ -51,19 +51,22 @@ Content-Type: application/json ## 1) Get Area ```http -GET /api/crop-zoning/area/?sensor_uuid= +GET /api/crop-zoning/area/?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,11 +148,42 @@ GET /api/crop-zoning/area/?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=&page=1&page_size=10 +``` + +#### صفحه سوم با 25 زون در هر صفحه + +```http +GET /api/crop-zoning/area/?sensor_uuid=&page=3&page_size=25 +``` + ### فیلدهای مهم `zones` - `zoneId`: شناسه نمایشی زون، مثل `zone-0` @@ -169,6 +203,16 @@ GET /api/crop-zoning/area/?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= } ``` +#### وقتی `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 diff --git a/crop_zoning/services.py b/crop_zoning/services.py index 7d3484f..891b1be 100644 --- a/crop_zoning/services.py +++ b/crop_zoning/services.py @@ -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, + }, } diff --git a/crop_zoning/tests.py b/crop_zoning/tests.py index d80b0e8..771f14f 100644 --- a/crop_zoning/tests.py +++ b/crop_zoning/tests.py @@ -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) diff --git a/crop_zoning/views.py b/crop_zoning/views.py index 09e8bd6..3aa2e92 100644 --- a/crop_zoning/views.py +++ b/crop_zoning/views.py @@ -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):