From 8d73b1703cc3ec8bd536f49ee58d4068eca2c3f1 Mon Sep 17 00:00:00 2001 From: Mohammad Sajad Pourajam Date: Sat, 2 May 2026 04:49:29 +0330 Subject: [PATCH] UPDATE --- celerybeat-schedule | Bin 16384 -> 16384 bytes config/settings.py | 2 + config/urls.py | 2 + farmer_calendar/FARMER_CALENDAR_API.md | 392 ++++++++++++++ farmer_calendar/__init__.py | 0 farmer_calendar/apps.py | 7 + farmer_calendar/enums.py | 41 ++ farmer_calendar/migrations/0001_initial.py | 67 +++ .../0002_add_zone_and_todo_fields.py | 92 ++++ farmer_calendar/migrations/__init__.py | 0 farmer_calendar/models.py | 84 +++ farmer_calendar/serializers.py | 119 ++++ farmer_calendar/tests.py | 167 ++++++ farmer_calendar/urls.py | 9 + farmer_calendar/views.py | 164 ++++++ farmer_todos/FARMER_TODOS_API.md | 507 ++++++++++++++++++ farmer_todos/__init__.py | 0 farmer_todos/apps.py | 7 + farmer_todos/migrations/0001_initial.py | 85 +++ .../0002_merge_todos_into_calendar.py | 104 ++++ farmer_todos/migrations/__init__.py | 0 farmer_todos/models.py | 10 + farmer_todos/serializers.py | 178 ++++++ farmer_todos/tests.py | 238 ++++++++ farmer_todos/urls.py | 17 + farmer_todos/views.py | 242 +++++++++ 26 files changed, 2534 insertions(+) create mode 100644 farmer_calendar/FARMER_CALENDAR_API.md create mode 100644 farmer_calendar/__init__.py create mode 100644 farmer_calendar/apps.py create mode 100644 farmer_calendar/enums.py create mode 100644 farmer_calendar/migrations/0001_initial.py create mode 100644 farmer_calendar/migrations/0002_add_zone_and_todo_fields.py create mode 100644 farmer_calendar/migrations/__init__.py create mode 100644 farmer_calendar/models.py create mode 100644 farmer_calendar/serializers.py create mode 100644 farmer_calendar/tests.py create mode 100644 farmer_calendar/urls.py create mode 100644 farmer_calendar/views.py create mode 100644 farmer_todos/FARMER_TODOS_API.md create mode 100644 farmer_todos/__init__.py create mode 100644 farmer_todos/apps.py create mode 100644 farmer_todos/migrations/0001_initial.py create mode 100644 farmer_todos/migrations/0002_merge_todos_into_calendar.py create mode 100644 farmer_todos/migrations/__init__.py create mode 100644 farmer_todos/models.py create mode 100644 farmer_todos/serializers.py create mode 100644 farmer_todos/tests.py create mode 100644 farmer_todos/urls.py create mode 100644 farmer_todos/views.py diff --git a/celerybeat-schedule b/celerybeat-schedule index 84ef724fb31d97830c344b8f9162950b57b01d86..ab42b15d2f50f83aed694d4bed505cb9867da4d2 100644 GIT binary patch delta 29 kcmZo@U~Fh$+|X*w!OFzQz`&?}Z}LZjKt|im_l*^K0D!Crwg3PC delta 29 kcmZo@U~Fh$+|X*w!6L`Nz`&SjH2I@JAfx`~`^Jhq0DTS!Pyhe` diff --git a/config/settings.py b/config/settings.py index 18191ee..4ae7048 100644 --- a/config/settings.py +++ b/config/settings.py @@ -57,6 +57,8 @@ INSTALLED_APPS = [ "farm_ai_assistant", "notifications.apps.NotificationsConfig", "plants.apps.PlantsConfig", + "farmer_calendar.apps.FarmerCalendarConfig", + "farmer_todos.apps.FarmerTodosConfig", "external_api_adapter.apps.ExternalApiAdapterConfig", "sensor_external_api.apps.SensorExternalApiConfig", "rest_framework", diff --git a/config/urls.py b/config/urls.py index 0ed4d0b..5ae6c85 100644 --- a/config/urls.py +++ b/config/urls.py @@ -36,6 +36,8 @@ urlpatterns = [ path("api/notifications/", include("notifications.urls")), path("api/farm-alerts/", include("farm_alerts.urls")), path("api/plants/", include("plants.urls")), + path("api/events/", include("farmer_calendar.urls")), + path("api/farmer-todos/", include("farmer_todos.urls")), path("api/sensor-external-api/", include("sensor_external_api.urls")), ] diff --git a/farmer_calendar/FARMER_CALENDAR_API.md b/farmer_calendar/FARMER_CALENDAR_API.md new file mode 100644 index 0000000..c8adbb6 --- /dev/null +++ b/farmer_calendar/FARMER_CALENDAR_API.md @@ -0,0 +1,392 @@ +# Farmer Calendar API + +این فایل مستندات کامل APIهای اپ `farmer_calendar` را توضیح می‌دهد. + +## Base Path + +تمام endpointهای این اپ با این prefix در دسترس هستند: + +```text +/api/events/ +``` + +## Authentication + +همه endpointهای این اپ نیاز به احراز هویت دارند. + +- Permission: `IsAuthenticated` +- Authentication: بر اساس تنظیمات DRF پروژه، معمولاً JWT + +هدر معمول: + +```text +Authorization: Bearer +``` + +## Data Model + +موجودیت اصلی در این اپ `FarmerCalendarEvent` است. + +فیلدهای مهم: + +- `id`: شناسه عمومی event از نوع UUID +- `title`: عنوان event +- `description`: توضیحات +- `deadline`: مهلت به صورت timestamp عددی +- `start`: زمان شروع event +- `end`: زمان پایان event +- `extendedProps`: داده‌های اضافه به صورت object +- `tags`: لیست tagها + +## Tag Rules + +در نسخه فعلی، `tags` از دیتابیس خوانده نمی‌شوند و فقط از enum داخلی پروژه مجاز هستند. + +نمونه tagهای مجاز: + +- `آبیاری` +- `آفت` +- `فوری` +- `روزانه` +- `ثبت دستی` +- `بازدید` +- `کوددهی` +- `سمپاشی` +- `برداشت` + +اگر tag خارج از enum ارسال شود، request با خطای validation رد می‌شود. + +## Priority + +در مدل مشترک event/todo، اولویت به صورت enum تعریف شده است. + +مقادیر مجاز: + +- `زیاد` +- `متوسط` +- `کم` + +یا در بعضی serializerهای مرتبط، ورودی انگلیسی: + +- `high` +- `medium` +- `low` + +## Endpoints + +### 1) List Events + +```http +GET /api/events/ +``` + +#### Query Params + +- `start`: فیلتر از این datetime به بعد +- `end`: فیلتر تا این datetime +- `farm_uuid`: اگر کاربر چند مزرعه داشته باشد، برای محدود کردن نتایج به یک مزرعه + +#### Behavior + +- فقط eventهای متعلق به farmهای کاربر login شده را برمی‌گرداند +- اگر `start` ارسال شود، eventهایی برمی‌گردند که `end >= start` +- اگر `end` ارسال شود، eventهایی برمی‌گردند که `start <= end` +- خروجی بر اساس `start` و بعد `created_at` مرتب می‌شود + +#### Sample Request + +```http +GET /api/events/?farm_uuid=&start=2025-02-24T00:00:00Z&end=2025-02-25T00:00:00Z +``` + +#### Sample Response + +```json +{ + "events": [ + { + "id": "4be7c204-6fd8-4aa4-a5f4-7f0e9ceaa111", + "title": "آبیاری بلوک شمالی", + "description": "کنترل فشار و مدت زمان آبیاری", + "deadline": 1734942600, + "tags": ["آبیاری", "فوری"], + "start": "2025-02-24T06:30:00Z", + "end": "2025-02-24T08:00:00Z", + "extendedProps": { + "source": "manual" + } + } + ], + "meta": { + "total": 1 + } +} +``` + +--- + +### 2) Create Event + +```http +POST /api/events/ +``` + +#### Request Body + +- `title`: اجباری، string +- `description`: اختیاری، string +- `deadline`: اختیاری، integer +- `tags`: اختیاری، array از tagهای enum +- `start`: اجباری، datetime +- `end`: اجباری، datetime +- `extendedProps`: اختیاری، object +- `farm_uuid`: اختیاری، اما اگر کاربر چند farm داشته باشد اجباری می‌شود + +#### Validation Rules + +- `title` نباید خالی باشد +- `extendedProps` باید object باشد +- `end` نباید از `start` کوچک‌تر باشد +- `tags` فقط باید از enum مجاز باشند +- اگر کاربر چند farm داشته باشد و `farm_uuid` نفرستد، خطا برمی‌گردد + +#### Sample Request + +```json +{ + "farm_uuid": "6b7ce8a8-13ec-4a6e-9118-7c298fd2a111", + "title": "بازدید آفت در گلخانه", + "description": "بررسی وضعیت برگ ها و ثبت گزارش", + "deadline": 1734971400, + "tags": ["آفت", "فوری"], + "start": "2025-02-24T14:00:00Z", + "end": "2025-02-24T15:00:00Z", + "extendedProps": { + "source": "manual" + } +} +``` + +#### Sample Success Response + +```json +{ + "event": { + "id": "7aa97f9f-bc4c-49f1-858f-11f3f433a111", + "title": "بازدید آفت در گلخانه", + "description": "بررسی وضعیت برگ ها و ثبت گزارش", + "deadline": 1734971400, + "tags": ["آفت", "فوری"], + "start": "2025-02-24T14:00:00Z", + "end": "2025-02-24T15:00:00Z", + "extendedProps": { + "source": "manual" + } + } +} +``` + +#### Sample Validation Error + +```json +{ + "code": "EVENT_VALIDATION_ERROR", + "message": "title cannot be empty", + "details": { + "title": ["title cannot be empty"] + } +} +``` + +--- + +### 3) Get Event Detail + +```http +GET /api/events// +``` + +#### Path Param + +- `event_uuid`: شناسه UUID رویداد + +#### Behavior + +- فقط اگر event متعلق به کاربر باشد برگردانده می‌شود +- اگر وجود نداشته باشد یا متعلق به کاربر دیگری باشد، `404` می‌دهد + +#### Sample Response + +```json +{ + "event": { + "id": "4be7c204-6fd8-4aa4-a5f4-7f0e9ceaa111", + "title": "آبیاری بلوک شمالی", + "description": "کنترل فشار و مدت زمان آبیاری", + "deadline": 1734942600, + "tags": ["آبیاری"], + "start": "2025-02-24T06:30:00Z", + "end": "2025-02-24T08:00:00Z", + "extendedProps": {} + } +} +``` + +#### Sample Not Found Response + +```json +{ + "code": "EVENT_NOT_FOUND", + "message": "Event not found." +} +``` + +--- + +### 4) Update Event + +```http +PUT /api/events// +``` + +#### Request Body + +ساختار body مثل create است. + +#### Important Notes + +- این endpoint در حال حاضر update کامل انجام می‌دهد، نه partial +- اگر `farm_uuid` ارسال شود، نباید با farm فعلی event فرق داشته باشد +- اگر `tags` ارسال شوند، tagهای قبلی با همان لیست جدید جایگزین می‌شوند + +#### Sample Request + +```json +{ + "title": "آبیاری بلوک شمالی", + "description": "اولویت بالا", + "deadline": 1734942600, + "tags": ["آبیاری", "فوری"], + "start": "2025-02-24T15:00:00Z", + "end": "2025-02-24T16:00:00Z", + "extendedProps": {} +} +``` + +#### Sample Response + +```json +{ + "event": { + "id": "4be7c204-6fd8-4aa4-a5f4-7f0e9ceaa111", + "title": "آبیاری بلوک شمالی", + "description": "اولویت بالا", + "deadline": 1734942600, + "tags": ["آبیاری", "فوری"], + "start": "2025-02-24T15:00:00Z", + "end": "2025-02-24T16:00:00Z", + "extendedProps": {} + } +} +``` + +--- + +### 5) Delete Event + +```http +DELETE /api/events// +``` + +#### Sample Response + +```json +{ + "success": true +} +``` + +--- + +### 6) List Available Tags + +```http +GET /api/events/tags/ +``` + +#### Query Params + +- `farm_uuid`: اختیاری؛ در نسخه فعلی فقط validate می‌شود ولی لیست tagها از enum داخلی برمی‌گردد + +#### Behavior + +- tagها از enum کد برمی‌گردند +- این endpoint دیگر به داده‌های tag در دیتابیس وابسته نیست + +#### Sample Response + +```json +{ + "tags": [ + { + "id": "tag_irrigation", + "label": "آبیاری", + "value": "آبیاری" + }, + { + "id": "tag_pest", + "label": "آفت", + "value": "آفت" + }, + { + "id": "tag_urgent", + "label": "فوری", + "value": "فوری" + } + ], + "meta": { + "total": 9 + } +} +``` + +## Error Format + +فرمت خطاهای validation به این شکل است: + +```json +{ + "code": "EVENT_VALIDATION_ERROR", + "message": "error message", + "details": {} +} +``` + +و خطای پیدا نشدن: + +```json +{ + "code": "EVENT_NOT_FOUND", + "message": "Event not found." +} +``` + +## Farm Resolution Rules + +رفتار `farm_uuid` در این اپ: + +- اگر کاربر فقط یک farm داشته باشد، در create می‌تواند `farm_uuid` نفرستد +- اگر کاربر چند farm داشته باشد، در create باید `farm_uuid` بفرستد +- اگر `farm_uuid` نامعتبر باشد، validation error برمی‌گردد +- در update، `farm_uuid` نباید farm event را تغییر دهد + +## Implementation Notes + +- فایل routeها: `farmer_calendar/urls.py` +- فایل viewها: `farmer_calendar/views.py` +- فایل serializerها: `farmer_calendar/serializers.py` +- enumهای tag و priority: `farmer_calendar/enums.py` + +## Related Note + +در ساختار فعلی پروژه، `farmer_calendar` و `farmer_todos` روی یک مدل مشترک سوار شده‌اند، ولی endpointهای این فایل فقط مربوط به مسیرهای `farmer_calendar` هستند. diff --git a/farmer_calendar/__init__.py b/farmer_calendar/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/farmer_calendar/apps.py b/farmer_calendar/apps.py new file mode 100644 index 0000000..32f203f --- /dev/null +++ b/farmer_calendar/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class FarmerCalendarConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "farmer_calendar" + verbose_name = "Farmer Calendar" diff --git a/farmer_calendar/enums.py b/farmer_calendar/enums.py new file mode 100644 index 0000000..709b22b --- /dev/null +++ b/farmer_calendar/enums.py @@ -0,0 +1,41 @@ +from django.db import models + + +class FarmerPriority(models.TextChoices): + HIGH = "زیاد", "High" + MEDIUM = "متوسط", "Medium" + LOW = "کم", "Low" + + +class FarmerTag(models.TextChoices): + IRRIGATION = "آبیاری", "آبیاری" + PEST = "آفت", "آفت" + URGENT = "فوری", "فوری" + DAILY = "روزانه", "روزانه" + MANUAL = "ثبت دستی", "ثبت دستی" + VISIT = "بازدید", "بازدید" + FERTILIZATION = "کوددهی", "کوددهی" + SPRAYING = "سمپاشی", "سمپاشی" + HARVEST = "برداشت", "برداشت" + + +FARMER_TAG_CHOICES = [(tag.value, tag.label) for tag in FarmerTag] +FARMER_TAG_VALUES = {tag.value for tag in FarmerTag} +FARMER_TAG_ITEMS = [ + { + "id": f"tag_{tag.name.lower()}", + "label": tag.label, + "value": tag.value, + } + for tag in FarmerTag +] + + +PRIORITY_INPUT_MAP = { + "high": FarmerPriority.HIGH, + "medium": FarmerPriority.MEDIUM, + "low": FarmerPriority.LOW, + FarmerPriority.HIGH.value: FarmerPriority.HIGH, + FarmerPriority.MEDIUM.value: FarmerPriority.MEDIUM, + FarmerPriority.LOW.value: FarmerPriority.LOW, +} diff --git a/farmer_calendar/migrations/0001_initial.py b/farmer_calendar/migrations/0001_initial.py new file mode 100644 index 0000000..f0593b4 --- /dev/null +++ b/farmer_calendar/migrations/0001_initial.py @@ -0,0 +1,67 @@ +# Generated by Django 5.2.5 on 2025-02-24 00:00 + +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("farm_hub", "0009_farmhub_irrigation_method_fields"), + ] + + operations = [ + migrations.CreateModel( + name="FarmerCalendarTag", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("uuid", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True)), + ("label", models.CharField(max_length=100)), + ("value", models.CharField(max_length=100)), + ("is_active", models.BooleanField(default=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "farm", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="calendar_tags", to="farm_hub.farmhub"), + ), + ], + options={ + "db_table": "farmer_calendar_tags", + "ordering": ["label"], + }, + ), + migrations.CreateModel( + name="FarmerCalendarEvent", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("uuid", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True)), + ("title", models.CharField(max_length=255)), + ("description", models.TextField(blank=True, default="")), + ("deadline", models.BigIntegerField(blank=True, null=True)), + ("start", models.DateTimeField()), + ("end", models.DateTimeField()), + ("extended_props", models.JSONField(blank=True, default=dict)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "farm", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="calendar_events", to="farm_hub.farmhub"), + ), + ( + "tags", + models.ManyToManyField(blank=True, related_name="events", to="farmer_calendar.farmercalendartag"), + ), + ], + options={ + "db_table": "farmer_calendar_events", + "ordering": ["start", "created_at"], + }, + ), + migrations.AddConstraint( + model_name="farmercalendartag", + constraint=models.UniqueConstraint(fields=("farm", "value"), name="uniq_farmer_calendar_tag_per_farm"), + ), + ] diff --git a/farmer_calendar/migrations/0002_add_zone_and_todo_fields.py b/farmer_calendar/migrations/0002_add_zone_and_todo_fields.py new file mode 100644 index 0000000..89b0528 --- /dev/null +++ b/farmer_calendar/migrations/0002_add_zone_and_todo_fields.py @@ -0,0 +1,92 @@ +import uuid + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("farmer_calendar", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="FarmerCalendarZone", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("uuid", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True)), + ("label", models.CharField(max_length=255)), + ("value", models.CharField(max_length=255)), + ("is_active", models.BooleanField(default=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "farm", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="calendar_zones", to="farm_hub.farmhub"), + ), + ], + options={ + "db_table": "farmer_calendar_zones", + "ordering": ["label"], + }, + ), + migrations.AddField( + model_name="farmercalendarevent", + name="deadline", + field=models.BigIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name="farmercalendarevent", + name="priority", + field=models.CharField( + blank=True, + choices=[("زیاد", "High"), ("متوسط", "Medium"), ("کم", "Low")], + max_length=16, + null=True, + ), + ), + migrations.AddField( + model_name="farmercalendarevent", + name="scheduled_date", + field=models.DateField(blank=True, null=True), + ), + migrations.AddField( + model_name="farmercalendarevent", + name="status", + field=models.CharField(choices=[("open", "Open"), ("done", "Done")], default="open", max_length=16), + ), + migrations.AddField( + model_name="farmercalendarevent", + name="time", + field=models.TimeField(blank=True, null=True), + ), + migrations.AddField( + model_name="farmercalendarevent", + name="zone", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="events", + to="farmer_calendar.farmercalendarzone", + ), + ), + migrations.AlterField( + model_name="farmercalendarevent", + name="end", + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AlterField( + model_name="farmercalendarevent", + name="start", + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AlterModelOptions( + name="farmercalendarevent", + options={"db_table": "farmer_calendar_events", "ordering": ["scheduled_date", "start", "time", "created_at"]}, + ), + migrations.AddConstraint( + model_name="farmercalendarzone", + constraint=models.UniqueConstraint(fields=("farm", "value"), name="uniq_farmer_calendar_zone_per_farm"), + ), + ] diff --git a/farmer_calendar/migrations/__init__.py b/farmer_calendar/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/farmer_calendar/models.py b/farmer_calendar/models.py new file mode 100644 index 0000000..9b4dfbd --- /dev/null +++ b/farmer_calendar/models.py @@ -0,0 +1,84 @@ +import uuid as uuid_lib + +from django.db import models + +from farm_hub.models import FarmHub +from .enums import FarmerPriority + + +class FarmerCalendarZone(models.Model): + uuid = models.UUIDField(default=uuid_lib.uuid4, unique=True, editable=False, db_index=True) + farm = models.ForeignKey(FarmHub, on_delete=models.CASCADE, related_name="calendar_zones") + label = models.CharField(max_length=255) + value = models.CharField(max_length=255) + is_active = models.BooleanField(default=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "farmer_calendar_zones" + ordering = ["label"] + constraints = [ + models.UniqueConstraint(fields=["farm", "value"], name="uniq_farmer_calendar_zone_per_farm"), + ] + + def __str__(self): + return self.label + + +class FarmerCalendarTag(models.Model): + uuid = models.UUIDField(default=uuid_lib.uuid4, unique=True, editable=False, db_index=True) + farm = models.ForeignKey(FarmHub, on_delete=models.CASCADE, related_name="calendar_tags") + label = models.CharField(max_length=100) + value = models.CharField(max_length=100) + is_active = models.BooleanField(default=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "farmer_calendar_tags" + ordering = ["label"] + constraints = [ + models.UniqueConstraint(fields=["farm", "value"], name="uniq_farmer_calendar_tag_per_farm"), + ] + + def __str__(self): + return self.label + + +class FarmerCalendarEvent(models.Model): + PRIORITY_HIGH = FarmerPriority.HIGH + PRIORITY_MEDIUM = FarmerPriority.MEDIUM + PRIORITY_LOW = FarmerPriority.LOW + PRIORITY_CHOICES = FarmerPriority.choices + + STATUS_OPEN = "open" + STATUS_DONE = "done" + STATUS_CHOICES = [ + (STATUS_OPEN, "Open"), + (STATUS_DONE, "Done"), + ] + + uuid = models.UUIDField(default=uuid_lib.uuid4, unique=True, editable=False, db_index=True) + farm = models.ForeignKey(FarmHub, on_delete=models.CASCADE, related_name="calendar_events") + zone = models.ForeignKey(FarmerCalendarZone, on_delete=models.PROTECT, related_name="events", null=True, blank=True) + title = models.CharField(max_length=255) + description = models.TextField(blank=True, default="") + deadline = models.BigIntegerField(null=True, blank=True) + scheduled_date = models.DateField(null=True, blank=True) + time = models.TimeField(null=True, blank=True) + start = models.DateTimeField(null=True, blank=True) + end = models.DateTimeField(null=True, blank=True) + priority = models.CharField(max_length=16, choices=PRIORITY_CHOICES, null=True, blank=True) + status = models.CharField(max_length=16, choices=STATUS_CHOICES, default=STATUS_OPEN) + extended_props = models.JSONField(default=dict, blank=True) + tags = models.ManyToManyField(FarmerCalendarTag, related_name="events", blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "farmer_calendar_events" + ordering = ["scheduled_date", "start", "time", "created_at"] + + def __str__(self): + return self.title diff --git a/farmer_calendar/serializers.py b/farmer_calendar/serializers.py new file mode 100644 index 0000000..e044157 --- /dev/null +++ b/farmer_calendar/serializers.py @@ -0,0 +1,119 @@ +from rest_framework import serializers + +from .enums import FARMER_TAG_ITEMS, FARMER_TAG_VALUES +from .models import FarmerCalendarEvent + + +class FarmerCalendarEventResponseSerializer(serializers.ModelSerializer): + id = serializers.UUIDField(source="uuid", read_only=True) + tags = serializers.SerializerMethodField() + extendedProps = serializers.SerializerMethodField() + + class Meta: + model = FarmerCalendarEvent + fields = [ + "id", + "title", + "description", + "deadline", + "tags", + "start", + "end", + "extendedProps", + ] + + def get_tags(self, obj): + raw_tags = obj.extended_props.get("tags", []) + return [tag for tag in raw_tags if tag in FARMER_TAG_VALUES] + + def get_extendedProps(self, obj): + extended_props = dict(obj.extended_props or {}) + extended_props.pop("tags", None) + return extended_props + + +class FarmerCalendarEventWriteSerializer(serializers.Serializer): + title = serializers.CharField(max_length=255) + description = serializers.CharField(required=False, allow_blank=True, default="") + deadline = serializers.IntegerField(required=False, allow_null=True) + tags = serializers.ListField( + child=serializers.CharField(max_length=100), + required=False, + default=list, + allow_empty=True, + ) + start = serializers.DateTimeField() + end = serializers.DateTimeField() + extendedProps = serializers.JSONField(required=False, default=dict) + farm_uuid = serializers.UUIDField(required=False, write_only=True) + + def validate_title(self, value): + value = value.strip() + if not value: + raise serializers.ValidationError("title cannot be empty") + return value + + def validate_tags(self, value): + normalized = [] + for tag in value: + cleaned = tag.strip() + if cleaned: + if cleaned not in FARMER_TAG_VALUES: + raise serializers.ValidationError(f"tag `{cleaned}` is not valid") + normalized.append(cleaned) + return normalized + + def validate_extendedProps(self, value): + if not isinstance(value, dict): + raise serializers.ValidationError("extendedProps must be an object") + return value + + def validate(self, attrs): + if attrs["end"] < attrs["start"]: + raise serializers.ValidationError({"end": "end cannot be before start"}) + return attrs + + def create(self, validated_data): + tags = validated_data.pop("tags", []) + validated_data.pop("farm_uuid", None) + extended_props = validated_data.pop("extendedProps", {}) + extended_props["tags"] = tags + validated_data["extended_props"] = extended_props + event = FarmerCalendarEvent.objects.create(**validated_data) + return event + + def update(self, instance, validated_data): + tags = validated_data.pop("tags", None) + validated_data.pop("farm_uuid", None) + if "extendedProps" in validated_data: + validated_data["extended_props"] = validated_data.pop("extendedProps") + if tags is not None: + extended_props = dict(instance.extended_props or {}) + if "extended_props" in validated_data: + extended_props.update(validated_data["extended_props"] or {}) + extended_props["tags"] = tags + validated_data["extended_props"] = extended_props + + for attr, value in validated_data.items(): + setattr(instance, attr, value) + instance.save() + return instance + + +class FarmerCalendarListQuerySerializer(serializers.Serializer): + start = serializers.DateTimeField(required=False) + end = serializers.DateTimeField(required=False) + farm_uuid = serializers.UUIDField(required=False) + + def validate(self, attrs): + start = attrs.get("start") + end = attrs.get("end") + if start and end and end < start: + raise serializers.ValidationError({"end": "end cannot be before start"}) + return attrs + + +class FarmerCalendarTagIdSerializer(serializers.Serializer): + id = serializers.CharField() + label = serializers.CharField() + value = serializers.CharField() diff --git a/farmer_calendar/tests.py b/farmer_calendar/tests.py new file mode 100644 index 0000000..867e627 --- /dev/null +++ b/farmer_calendar/tests.py @@ -0,0 +1,167 @@ +from datetime import datetime, timezone + +from django.contrib.auth import get_user_model +from django.test import TestCase +from rest_framework.test import APIRequestFactory, force_authenticate + +from access_control.models import SubscriptionPlan +from farm_hub.models import FarmHub, FarmType + +from .models import FarmerCalendarEvent +from .views import EventDetailView, EventListCreateView, EventTagListView + + +class FarmerCalendarViewTests(TestCase): + def setUp(self): + self.factory = APIRequestFactory() + self.user = get_user_model().objects.create_user( + username="calendar-user", + password="secret123", + email="calendar@example.com", + phone_number="09121111111", + ) + self.other_user = get_user_model().objects.create_user( + username="calendar-other", + password="secret123", + email="calendar-other@example.com", + phone_number="09122222222", + ) + self.plan = SubscriptionPlan.objects.create(code="calendar-plan", name="Calendar Plan") + self.farm_type = FarmType.objects.create(name="گلخانه") + self.farm = FarmHub.objects.create( + owner=self.user, + farm_type=self.farm_type, + subscription_plan=self.plan, + name="Greenhouse A", + ) + self.other_farm = FarmHub.objects.create( + owner=self.other_user, + farm_type=self.farm_type, + subscription_plan=self.plan, + name="Greenhouse B", + ) + self.event = FarmerCalendarEvent.objects.create( + farm=self.farm, + title="آبیاری بلوک شمالی", + description="کنترل فشار و مدت زمان آبیاری", + deadline=1734942600, + start=datetime(2025, 2, 24, 6, 30, tzinfo=timezone.utc), + end=datetime(2025, 2, 24, 8, 0, tzinfo=timezone.utc), + extended_props={"tags": ["آبیاری"]}, + ) + + def test_list_events_returns_expected_shape(self): + request = self.factory.get(f"/api/events/?farm_uuid={self.farm.farm_uuid}") + force_authenticate(request, user=self.user) + + response = EventListCreateView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["meta"]["total"], 1) + self.assertEqual(response.data["events"][0]["title"], "آبیاری بلوک شمالی") + self.assertEqual(response.data["events"][0]["tags"], ["آبیاری"]) + self.assertIn("T06:30:00Z", response.data["events"][0]["start"]) + + def test_create_event_creates_tags_and_event(self): + request = self.factory.post( + "/api/events/", + { + "farm_uuid": str(self.farm.farm_uuid), + "title": "بازدید آفت در گلخانه", + "description": "بررسی وضعیت برگ ها و ثبت گزارش", + "deadline": 1734971400, + "tags": ["آفت", "فوری"], + "start": "2025-02-24T14:00:00Z", + "end": "2025-02-24T15:00:00Z", + "extendedProps": {"source": "manual"}, + }, + format="json", + ) + force_authenticate(request, user=self.user) + + response = EventListCreateView.as_view()(request) + + self.assertEqual(response.status_code, 201) + self.assertEqual(response.data["event"]["tags"], ["آفت", "فوری"]) + self.assertEqual(response.data["event"]["extendedProps"], {"source": "manual"}) + self.assertEqual(FarmerCalendarEvent.objects.filter(farm=self.farm).count(), 2) + self.assertEqual(response.data["event"]["tags"], ["آفت", "فوری"]) + + def test_update_event_supports_drag_and_resize_payload(self): + request = self.factory.put( + f"/api/events/{self.event.uuid}/", + { + "title": self.event.title, + "description": "اولویت بالا", + "deadline": self.event.deadline, + "tags": ["آبیاری", "فوری"], + "start": "2025-02-24T15:00:00Z", + "end": "2025-02-24T16:00:00Z", + "extendedProps": {}, + }, + format="json", + ) + force_authenticate(request, user=self.user) + + response = EventDetailView.as_view()(request, event_id=self.event.uuid) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["event"]["description"], "اولویت بالا") + self.assertIn("T15:00:00Z", response.data["event"]["start"]) + self.assertEqual(response.data["event"]["tags"], ["آبیاری", "فوری"]) + + def test_delete_event_returns_success(self): + request = self.factory.delete(f"/api/events/{self.event.uuid}/") + force_authenticate(request, user=self.user) + + response = EventDetailView.as_view()(request, event_id=self.event.uuid) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data, {"success": True}) + self.assertFalse(FarmerCalendarEvent.objects.filter(pk=self.event.pk).exists()) + + def test_tags_endpoint_returns_separate_list(self): + request = self.factory.get(f"/api/events/tags/?farm_uuid={self.farm.farm_uuid}") + force_authenticate(request, user=self.user) + + response = EventTagListView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["meta"]["total"], 1) + self.assertEqual(response.data["tags"][0]["label"], "آبیاری") + self.assertEqual(response.data["tags"][0]["value"], "آبیاری") + + def test_validation_error_returns_message_and_details(self): + request = self.factory.post( + "/api/events/", + { + "farm_uuid": str(self.farm.farm_uuid), + "title": "", + "start": "2025-02-24T15:00:00Z", + "end": "2025-02-24T14:00:00Z", + }, + format="json", + ) + force_authenticate(request, user=self.user) + + response = EventListCreateView.as_view()(request) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.data["code"], "EVENT_VALIDATION_ERROR") + self.assertIn("message", response.data) + self.assertIn("details", response.data) + + def test_detail_rejects_foreign_event(self): + foreign_event = FarmerCalendarEvent.objects.create( + farm=self.other_farm, + title="foreign", + start=datetime(2025, 2, 24, 6, 30, tzinfo=timezone.utc), + end=datetime(2025, 2, 24, 8, 0, tzinfo=timezone.utc), + ) + request = self.factory.get(f"/api/events/{foreign_event.uuid}/") + force_authenticate(request, user=self.user) + + response = EventDetailView.as_view()(request, event_id=foreign_event.uuid) + + self.assertEqual(response.status_code, 404) + self.assertEqual(response.data["message"], "Event not found.") diff --git a/farmer_calendar/urls.py b/farmer_calendar/urls.py new file mode 100644 index 0000000..9fef6a8 --- /dev/null +++ b/farmer_calendar/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from .views import EventDetailView, EventListCreateView, EventTagListView + +urlpatterns = [ + path("tags/", EventTagListView.as_view(), name="farmer-calendar-tag-list"), + path("/", EventDetailView.as_view(), name="farmer-calendar-detail"), + path("", EventListCreateView.as_view(), name="farmer-calendar-list-create"), +] diff --git a/farmer_calendar/views.py b/farmer_calendar/views.py new file mode 100644 index 0000000..96159ec --- /dev/null +++ b/farmer_calendar/views.py @@ -0,0 +1,164 @@ +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, extend_schema +from rest_framework.exceptions import NotFound +from rest_framework import serializers, status +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from farm_hub.models import FarmHub + +from .enums import FARMER_TAG_ITEMS +from .models import FarmerCalendarEvent +from .serializers import ( + FarmerCalendarEventResponseSerializer, + FarmerCalendarEventWriteSerializer, + FarmerCalendarListQuerySerializer, + FarmerCalendarTagIdSerializer, +) + + +class FarmerCalendarBaseView(APIView): + permission_classes = [IsAuthenticated] + + @staticmethod + def error_response(message, details=None, code="EVENT_VALIDATION_ERROR", status_code=status.HTTP_400_BAD_REQUEST): + payload = { + "code": code, + "message": message, + } + if details is not None: + payload["details"] = details + return Response(payload, status=status_code) + + def handle_exception(self, exc): + if isinstance(exc, serializers.ValidationError): + details = exc.detail + message = "Invalid event payload" + if isinstance(details, dict): + first_value = next(iter(details.values()), None) + if isinstance(first_value, list) and first_value: + message = str(first_value[0]) + elif first_value: + message = str(first_value) + elif isinstance(details, list) and details: + message = str(details[0]) + return self.error_response(message=message, details=details) + if isinstance(exc, NotFound): + return self.error_response( + message=str(exc.detail), + code="EVENT_NOT_FOUND", + status_code=status.HTTP_404_NOT_FOUND, + ) + return super().handle_exception(exc) + + def _get_user_farms(self, request): + return FarmHub.objects.filter(owner=request.user).order_by("id") + + def _resolve_farm(self, request, farm_uuid=None, required=False): + farms = self._get_user_farms(request) + if farm_uuid: + try: + return farms.get(farm_uuid=farm_uuid) + except FarmHub.DoesNotExist as exc: + raise serializers.ValidationError({"farm_uuid": ["Farm not found."]}) from exc + + if required: + farm_count = farms.count() + if farm_count == 1: + return farms.first() + if farm_count == 0: + raise serializers.ValidationError({"farm_uuid": ["No farm found for this user."]}) + raise serializers.ValidationError({"farm_uuid": ["farm_uuid is required when multiple farms exist."]}) + return None + + def _get_event(self, request, event_id): + queryset = FarmerCalendarEvent.objects.select_related("farm").prefetch_related("tags") + try: + return queryset.get(uuid=event_id, farm__owner=request.user) + except FarmerCalendarEvent.DoesNotExist as exc: + raise NotFound("Event not found.") from exc + + +class EventListCreateView(FarmerCalendarBaseView): + @extend_schema( + tags=["Farmer Calendar"], + parameters=[ + OpenApiParameter(name="start", type=OpenApiTypes.DATETIME, location=OpenApiParameter.QUERY, required=False), + OpenApiParameter(name="end", type=OpenApiTypes.DATETIME, location=OpenApiParameter.QUERY, required=False), + OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=False), + ], + ) + def get(self, request): + query_serializer = FarmerCalendarListQuerySerializer(data=request.query_params) + query_serializer.is_valid(raise_exception=True) + + queryset = FarmerCalendarEvent.objects.filter(farm__owner=request.user).prefetch_related("tags") + farm = self._resolve_farm(request, query_serializer.validated_data.get("farm_uuid"), required=False) + if farm is not None: + queryset = queryset.filter(farm=farm) + + start = query_serializer.validated_data.get("start") + end = query_serializer.validated_data.get("end") + if start: + queryset = queryset.filter(end__gte=start) + if end: + queryset = queryset.filter(start__lte=end) + + events = queryset.order_by("start", "created_at") + data = FarmerCalendarEventResponseSerializer(events, many=True).data + return Response({"events": data, "meta": {"total": len(data)}}, status=status.HTTP_200_OK) + + @extend_schema(tags=["Farmer Calendar"]) + def post(self, request): + serializer = FarmerCalendarEventWriteSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + farm = self._resolve_farm(request, serializer.validated_data.get("farm_uuid"), required=True) + event = serializer.save(farm=farm) + data = FarmerCalendarEventResponseSerializer(event).data + return Response({"event": data}, status=status.HTTP_201_CREATED) + + +class EventTagListView(FarmerCalendarBaseView): + @extend_schema( + tags=["Farmer Calendar"], + parameters=[ + OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=False), + ], + ) + def get(self, request): + self._resolve_farm(request, request.query_params.get("farm_uuid"), required=False) + data = FarmerCalendarTagIdSerializer(FARMER_TAG_ITEMS, many=True).data + return Response({"tags": data, "meta": {"total": len(data)}}, status=status.HTTP_200_OK) + + +class EventDetailView(FarmerCalendarBaseView): + @extend_schema(tags=["Farmer Calendar"]) + def get(self, request, event_id): + event = self._get_event(request, event_id) + data = FarmerCalendarEventResponseSerializer(event).data + return Response({"event": data}, status=status.HTTP_200_OK) + + @extend_schema(tags=["Farmer Calendar"]) + def put(self, request, event_id): + event = self._get_event(request, event_id) + serializer = FarmerCalendarEventWriteSerializer(event, data=request.data) + serializer.is_valid(raise_exception=True) + + requested_farm_uuid = serializer.validated_data.get("farm_uuid") + if requested_farm_uuid and str(event.farm.farm_uuid) != str(requested_farm_uuid): + return self.error_response( + message="farm_uuid cannot change an existing event", + details={"farm_uuid": ["farm_uuid cannot change an existing event"]}, + ) + + event = serializer.save() + data = FarmerCalendarEventResponseSerializer(event).data + return Response({"event": data}, status=status.HTTP_200_OK) + + @extend_schema(tags=["Farmer Calendar"]) + def delete(self, request, event_id): + event = self._get_event(request, event_id) + event.delete() + return Response({"success": True}, status=status.HTTP_200_OK) diff --git a/farmer_todos/FARMER_TODOS_API.md b/farmer_todos/FARMER_TODOS_API.md new file mode 100644 index 0000000..3a4ca8e --- /dev/null +++ b/farmer_todos/FARMER_TODOS_API.md @@ -0,0 +1,507 @@ +# Farmer Todos API + +این فایل مستندات کامل APIهای اپ `farmer_todos` را توضیح می‌دهد. + +## Base Path + +تمام endpointهای این اپ با این prefix در دسترس هستند: + +```text +/api/farmer-todos/ +``` + +## Authentication + +همه endpointهای این اپ نیاز به احراز هویت دارند. + +- Permission: `IsAuthenticated` +- Authentication: بر اساس تنظیمات DRF پروژه، معمولاً JWT + +هدر معمول: + +```text +Authorization: Bearer +``` + +## Overview + +در ساختار فعلی پروژه، `farmer_todos` و `farmer_calendar` روی یک مدل مشترک سوار هستند، اما این اپ APIهای مخصوص todo را با فرمت مناسب frontend برمی‌گرداند. + +موجودیت اصلی: + +- `FarmerTodoTask` + +فیلدهای مهم response: + +- `id`: شناسه عمومی task از نوع UUID +- `title`: عنوان کار +- `zone`: نام ناحیه یا بخش +- `scheduledDate`: تاریخ انجام +- `time`: ساعت انجام +- `priority`: اولویت +- `note`: توضیح task +- `tags`: لیست tagها +- `status`: وضعیت + +## Enums + +### Priority + +اولویت‌ها enum-based هستند و از دیتابیس خوانده نمی‌شوند. + +مقادیر نهایی ذخیره‌شده: + +- `زیاد` +- `متوسط` +- `کم` + +ورودی‌های قابل قبول: + +- `high` +- `medium` +- `low` +- `زیاد` +- `متوسط` +- `کم` + +### Tags + +`tags` هم enum-based هستند و از دیتابیس خوانده نمی‌شوند. + +نمونه tagهای مجاز: + +- `آبیاری` +- `آفت` +- `فوری` +- `روزانه` +- `ثبت دستی` +- `بازدید` +- `کوددهی` +- `سمپاشی` +- `برداشت` + +اگر tag خارج از enum ارسال شود، request با validation error رد می‌شود. + +### Status + +مقادیر مجاز: + +- `open` +- `done` + +## Endpoints + +### 1) List Tasks + +```http +GET /api/farmer-todos/ +``` + +#### Query Params + +- `status`: فیلتر بر اساس وضعیت +- `priority`: فیلتر بر اساس اولویت +- `date`: فیلتر دقیق روی تاریخ +- `from`: فیلتر از این تاریخ به بعد +- `to`: فیلتر تا این تاریخ +- `zone`: فیلتر بر اساس zone +- `search`: جستجو در `title` و `note` +- `farm_uuid`: محدود کردن نتایج به یک مزرعه + +#### Behavior + +- فقط taskهای متعلق به farmهای کاربر login شده را برمی‌گرداند +- `priority` ورودی مثل `high` به مقدار داخلی مثل `زیاد` normalize می‌شود +- `search` در `title` و `description` مدل جستجو می‌کند +- خروجی بر اساس `scheduled_date` و `time` و `created_at` مرتب می‌شود + +#### Sample Request + +```http +GET /api/farmer-todos/?farm_uuid=&priority=high&status=open&search=رطوبت +``` + +#### Sample Response + +```json +{ + "tasks": [ + { + "id": "11111111-1111-1111-1111-111111111111", + "title": "بررسی رطوبت ردیف شمالی", + "zone": "قطعه گندم - شمال مزرعه", + "scheduledDate": "2025-02-24", + "time": "06:30", + "priority": "زیاد", + "note": "اگر رطوبت کمتر از 28٪ بود، آبیاری دوباره بررسی شود.", + "tags": ["آبیاری"], + "status": "open" + } + ], + "meta": { + "total": 1 + } +} +``` + +--- + +### 2) Create Task + +```http +POST /api/farmer-todos/ +``` + +#### Request Body + +- `title`: اجباری، string +- `zone`: اجباری، string +- `scheduledDate`: اجباری، date با فرمت `YYYY-MM-DD` +- `time`: اجباری، time با فرمت `HH:MM` +- `priority`: اجباری +- `note`: اختیاری، string +- `tags`: اختیاری، array از tagهای enum +- `status`: اختیاری، `open` یا `done` +- `farm_uuid`: اختیاری، اما اگر کاربر چند farm داشته باشد اجباری می‌شود + +#### Validation Rules + +- `title` نباید خالی باشد +- `zone` نباید خالی باشد +- `priority` باید از enum مجاز باشد +- `tags` باید فقط از enum مجاز باشند +- در create اگر fieldهای اصلی نباشند خطای validation برمی‌گردد + +fieldهای اجباری: + +- `title` +- `zone` +- `scheduledDate` +- `time` +- `priority` + +#### Sample Request + +```json +{ + "farm_uuid": "6b7ce8a8-13ec-4a6e-9118-7c298fd2a111", + "title": "بازدید پمپ جنوب", + "zone": "انبار مرکزی", + "scheduledDate": "2025-02-24", + "time": "07:00", + "priority": "medium", + "note": "بعد از ثبت انجام، مورد غیرعادی را یادداشت کن.", + "tags": ["روزانه", "ثبت دستی"], + "status": "open" +} +``` + +#### Sample Success Response + +```json +{ + "task": { + "id": "7aa97f9f-bc4c-49f1-858f-11f3f433a111", + "title": "بازدید پمپ جنوب", + "zone": "انبار مرکزی", + "scheduledDate": "2025-02-24", + "time": "07:00", + "priority": "متوسط", + "note": "بعد از ثبت انجام، مورد غیرعادی را یادداشت کن.", + "tags": ["روزانه", "ثبت دستی"], + "status": "open" + } +} +``` + +#### Sample Validation Error + +```json +{ + "code": "TASK_VALIDATION_ERROR", + "message": "priority must be one of زیاد, متوسط, کم, high, medium, low", + "details": { + "priority": [ + "priority must be one of زیاد, متوسط, کم, high, medium, low" + ] + } +} +``` + +--- + +### 3) Get Task Detail + +```http +GET /api/farmer-todos// +``` + +#### Path Param + +- `task_uuid`: شناسه UUID تسک + +#### Behavior + +- فقط اگر task متعلق به کاربر باشد برگردانده می‌شود +- اگر وجود نداشته باشد یا متعلق به کاربر دیگری باشد، `404` می‌دهد + +#### Sample Response + +```json +{ + "task": { + "id": "11111111-1111-1111-1111-111111111111", + "title": "بررسی رطوبت ردیف شمالی", + "zone": "قطعه گندم - شمال مزرعه", + "scheduledDate": "2025-02-24", + "time": "06:30", + "priority": "زیاد", + "note": "اگر رطوبت کمتر از 28٪ بود، آبیاری دوباره بررسی شود.", + "tags": ["آبیاری"], + "status": "open" + } +} +``` + +#### Sample Not Found Response + +```json +{ + "code": "TASK_NOT_FOUND", + "message": "Task not found." +} +``` + +--- + +### 4) Update Task + +```http +PUT /api/farmer-todos// +``` + +#### Behavior + +- این endpoint از `partial=True` استفاده می‌کند، پس می‌توانی فقط بخشی از فیلدها را بفرستی +- اگر `farm_uuid` ارسال شود، نباید farm فعلی task را تغییر دهد +- اگر `tags` ارسال شوند، لیست tagهای task با لیست جدید جایگزین می‌شود +- اگر `zone` ارسال شود، zone جدید resolve یا ساخته می‌شود + +#### Sample Request + +```json +{ + "status": "done" +} +``` + +یا: + +```json +{ + "priority": "high", + "tags": ["فوری", "بازدید"], + "note": "این کار باید امروز نهایی شود." +} +``` + +#### Sample Response + +```json +{ + "task": { + "id": "11111111-1111-1111-1111-111111111111", + "title": "بررسی رطوبت ردیف شمالی", + "zone": "قطعه گندم - شمال مزرعه", + "scheduledDate": "2025-02-24", + "time": "06:30", + "priority": "زیاد", + "note": "اگر رطوبت کمتر از 28٪ بود، آبیاری دوباره بررسی شود.", + "tags": ["آبیاری"], + "status": "done" + } +} +``` + +--- + +### 5) Delete Task + +```http +DELETE /api/farmer-todos// +``` + +#### Sample Response + +```json +{ + "success": true +} +``` + +--- + +### 6) List Zones + +```http +GET /api/farmer-todos/zones/ +``` + +#### Query Params + +- `farm_uuid`: اختیاری + +#### Behavior + +- zoneها از دیتابیس خوانده می‌شوند +- اگر `farm_uuid` ارسال شود، zoneها به همان farm محدود می‌شوند +- اگر `farm_uuid` ارسال نشود، zoneهای تکراری بین farmهای کاربر deduplicate می‌شوند + +#### Sample Response + +```json +{ + "zones": [ + { + "id": "zone_gndm-shmal-mzrh", + "label": "قطعه گندم - شمال مزرعه", + "value": "قطعه گندم - شمال مزرعه" + } + ], + "meta": { + "total": 1 + } +} +``` + +--- + +### 7) List Tags + +```http +GET /api/farmer-todos/tags/ +``` + +#### Query Params + +- `farm_uuid`: اختیاری؛ در نسخه فعلی فقط validate می‌شود + +#### Behavior + +- tagها از enum داخلی کد برمی‌گردند +- این endpoint دیگر از جدول tag چیزی نمی‌خواند + +#### Sample Response + +```json +{ + "tags": [ + { + "id": "tag_irrigation", + "label": "آبیاری", + "value": "آبیاری" + }, + { + "id": "tag_pest", + "label": "آفت", + "value": "آفت" + }, + { + "id": "tag_urgent", + "label": "فوری", + "value": "فوری" + } + ], + "meta": { + "total": 9 + } +} +``` + +--- + +### 8) Summary + +```http +GET /api/farmer-todos/summary/ +``` + +#### Query Params + +- `farm_uuid`: اختیاری + +#### Response Fields + +- `todayTasksCount`: تعداد taskهای امروز +- `completedCount`: تعداد taskهای انجام‌شده +- `urgentCount`: تعداد taskهای باز با priority بالا +- `progressValue`: درصد پیشرفت +- `nextTask`: نزدیک‌ترین task باز + +#### Behavior + +- `progressValue` از نسبت `completedCount / totalCount` محاسبه می‌شود +- `nextTask` اولین task باز از امروز به بعد است + +#### Sample Response + +```json +{ + "todayTasksCount": 2, + "completedCount": 1, + "urgentCount": 2, + "progressValue": 50, + "nextTask": { + "id": "11111111-1111-1111-1111-111111111111", + "title": "بررسی رطوبت ردیف شمالی", + "zone": "قطعه گندم - شمال مزرعه", + "scheduledDate": "2025-02-24", + "time": "06:30", + "priority": "زیاد", + "note": "اگر رطوبت کمتر از 28٪ بود، آبیاری دوباره بررسی شود.", + "tags": ["آبیاری"], + "status": "open" + } +} +``` + +## Error Format + +خطاهای validation: + +```json +{ + "code": "TASK_VALIDATION_ERROR", + "message": "error message", + "details": {} +} +``` + +خطای پیدا نشدن: + +```json +{ + "code": "TASK_NOT_FOUND", + "message": "Task not found." +} +``` + +## Farm Resolution Rules + +رفتار `farm_uuid`: + +- اگر کاربر فقط یک farm داشته باشد، در create می‌تواند `farm_uuid` نفرستد +- اگر کاربر چند farm داشته باشد، در create باید `farm_uuid` بفرستد +- اگر `farm_uuid` نامعتبر باشد، validation error برمی‌گردد +- در update، `farm_uuid` نباید farm task را تغییر دهد + +## Notes + +- فایل routeها: `farmer_todos/urls.py` +- فایل viewها: `farmer_todos/views.py` +- فایل serializerها: `farmer_todos/serializers.py` +- enumهای مشترک: `farmer_calendar/enums.py` + +## Related Note + +در ساختار فعلی پروژه، `farmer_todos` از مدل مشترک با `farmer_calendar` استفاده می‌کند، ولی response و endpointهای این فایل مخصوص todo هستند. diff --git a/farmer_todos/__init__.py b/farmer_todos/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/farmer_todos/apps.py b/farmer_todos/apps.py new file mode 100644 index 0000000..0a2439d --- /dev/null +++ b/farmer_todos/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class FarmerTodosConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "farmer_todos" + verbose_name = "Farmer Todos" diff --git a/farmer_todos/migrations/0001_initial.py b/farmer_todos/migrations/0001_initial.py new file mode 100644 index 0000000..58ad51c --- /dev/null +++ b/farmer_todos/migrations/0001_initial.py @@ -0,0 +1,85 @@ +# Generated by Django 5.2.5 on 2025-02-24 00:00 + +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("farm_hub", "0009_farmhub_irrigation_method_fields"), + ] + + operations = [ + migrations.CreateModel( + name="FarmerTodoZone", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("uuid", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True)), + ("label", models.CharField(max_length=255)), + ("value", models.CharField(max_length=255)), + ("is_active", models.BooleanField(default=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "farm", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="todo_zones", to="farm_hub.farmhub"), + ), + ], + options={"db_table": "farmer_todo_zones", "ordering": ["label"]}, + ), + migrations.CreateModel( + name="FarmerTodoTag", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("uuid", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True)), + ("label", models.CharField(max_length=100)), + ("value", models.CharField(max_length=100)), + ("is_active", models.BooleanField(default=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "farm", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="todo_tags", to="farm_hub.farmhub"), + ), + ], + options={"db_table": "farmer_todo_tags", "ordering": ["label"]}, + ), + migrations.CreateModel( + name="FarmerTodoTask", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("title", models.CharField(max_length=255)), + ("scheduled_date", models.DateField()), + ("time", models.TimeField()), + ("priority", models.CharField(choices=[("زیاد", "High"), ("متوسط", "Medium"), ("کم", "Low")], max_length=16)), + ("note", models.TextField(blank=True, default="")), + ("status", models.CharField(choices=[("open", "Open"), ("done", "Done")], default="open", max_length=16)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "farm", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="todo_tasks", to="farm_hub.farmhub"), + ), + ( + "tags", + models.ManyToManyField(blank=True, related_name="tasks", to="farmer_todos.farmertodotag"), + ), + ( + "zone", + models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name="tasks", to="farmer_todos.farmertodozone"), + ), + ], + options={"db_table": "farmer_todo_tasks", "ordering": ["scheduled_date", "time", "created_at"]}, + ), + migrations.AddConstraint( + model_name="farmertodozone", + constraint=models.UniqueConstraint(fields=("farm", "value"), name="uniq_farmer_todo_zone_per_farm"), + ), + migrations.AddConstraint( + model_name="farmertodotag", + constraint=models.UniqueConstraint(fields=("farm", "value"), name="uniq_farmer_todo_tag_per_farm"), + ), + ] diff --git a/farmer_todos/migrations/0002_merge_todos_into_calendar.py b/farmer_todos/migrations/0002_merge_todos_into_calendar.py new file mode 100644 index 0000000..169d8b6 --- /dev/null +++ b/farmer_todos/migrations/0002_merge_todos_into_calendar.py @@ -0,0 +1,104 @@ +from django.db import migrations + + +def forwards(apps, schema_editor): + TodoZone = apps.get_model("farmer_todos", "FarmerTodoZone") + TodoTag = apps.get_model("farmer_todos", "FarmerTodoTag") + TodoTask = apps.get_model("farmer_todos", "FarmerTodoTask") + CalendarZone = apps.get_model("farmer_calendar", "FarmerCalendarZone") + CalendarTag = apps.get_model("farmer_calendar", "FarmerCalendarTag") + CalendarEvent = apps.get_model("farmer_calendar", "FarmerCalendarEvent") + + zone_map = {} + for todo_zone in TodoZone.objects.all().iterator(): + calendar_zone, _ = CalendarZone.objects.get_or_create( + farm_id=todo_zone.farm_id, + value=todo_zone.value, + defaults={ + "uuid": todo_zone.uuid, + "label": todo_zone.label, + "is_active": todo_zone.is_active, + "created_at": todo_zone.created_at, + "updated_at": todo_zone.updated_at, + }, + ) + updated = False + if calendar_zone.label != todo_zone.label: + calendar_zone.label = todo_zone.label + updated = True + if calendar_zone.is_active != todo_zone.is_active: + calendar_zone.is_active = todo_zone.is_active + updated = True + if updated: + calendar_zone.save(update_fields=["label", "is_active", "updated_at"]) + zone_map[todo_zone.id] = calendar_zone + + tag_map = {} + for todo_tag in TodoTag.objects.all().iterator(): + calendar_tag, _ = CalendarTag.objects.get_or_create( + farm_id=todo_tag.farm_id, + value=todo_tag.value, + defaults={ + "uuid": todo_tag.uuid, + "label": todo_tag.label, + "is_active": todo_tag.is_active, + "created_at": todo_tag.created_at, + "updated_at": todo_tag.updated_at, + }, + ) + updated = False + if calendar_tag.label != todo_tag.label: + calendar_tag.label = todo_tag.label + updated = True + if calendar_tag.is_active != todo_tag.is_active: + calendar_tag.is_active = todo_tag.is_active + updated = True + if updated: + calendar_tag.save(update_fields=["label", "is_active", "updated_at"]) + tag_map[todo_tag.id] = calendar_tag + + through_model = TodoTask.tags.through + task_tags = {} + for relation in through_model.objects.all().iterator(): + task_tags.setdefault(relation.farmertodotask_id, []).append(relation.farmertodotag_id) + + for todo_task in TodoTask.objects.all().iterator(): + calendar_event, created = CalendarEvent.objects.get_or_create( + farm_id=todo_task.farm_id, + title=todo_task.title, + scheduled_date=todo_task.scheduled_date, + time=todo_task.time, + defaults={ + "zone": zone_map.get(todo_task.zone_id), + "description": todo_task.note, + "priority": todo_task.priority, + "status": todo_task.status, + "created_at": todo_task.created_at, + "updated_at": todo_task.updated_at, + }, + ) + if not created: + calendar_event.zone = zone_map.get(todo_task.zone_id) + calendar_event.description = todo_task.note + calendar_event.priority = todo_task.priority + calendar_event.status = todo_task.status + calendar_event.save(update_fields=["zone", "description", "priority", "status", "updated_at"]) + + calendar_tags = [tag_map[tag_id] for tag_id in task_tags.get(todo_task.id, []) if tag_id in tag_map] + if calendar_tags: + calendar_event.tags.set(calendar_tags) + + +def backwards(apps, schema_editor): + pass + + +class Migration(migrations.Migration): + dependencies = [ + ("farmer_calendar", "0001_initial"), + ("farmer_todos", "0001_initial"), + ] + + operations = [ + migrations.RunPython(forwards, backwards), + ] diff --git a/farmer_todos/migrations/__init__.py b/farmer_todos/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/farmer_todos/models.py b/farmer_todos/models.py new file mode 100644 index 0000000..7cae9bd --- /dev/null +++ b/farmer_todos/models.py @@ -0,0 +1,10 @@ +from farmer_calendar.models import ( + FarmerCalendarEvent, + FarmerCalendarTag, + FarmerCalendarZone, +) + + +FarmerTodoZone = FarmerCalendarZone +FarmerTodoTag = FarmerCalendarTag +FarmerTodoTask = FarmerCalendarEvent diff --git a/farmer_todos/serializers.py b/farmer_todos/serializers.py new file mode 100644 index 0000000..167409b --- /dev/null +++ b/farmer_todos/serializers.py @@ -0,0 +1,178 @@ +from rest_framework import serializers + +from farmer_calendar.enums import FARMER_TAG_VALUES, PRIORITY_INPUT_MAP +from farmer_calendar.models import FarmerCalendarZone + +from .models import FarmerTodoTask + + +class FarmerTodoTaskResponseSerializer(serializers.ModelSerializer): + id = serializers.UUIDField(source="uuid", read_only=True) + zone = serializers.CharField(source="zone.value", read_only=True, allow_null=True) + scheduledDate = serializers.DateField(source="scheduled_date", format="%Y-%m-%d", read_only=True) + time = serializers.TimeField(format="%H:%M", read_only=True) + note = serializers.CharField(source="description", read_only=True) + tags = serializers.SerializerMethodField() + + class Meta: + model = FarmerTodoTask + fields = [ + "id", + "title", + "zone", + "scheduledDate", + "time", + "priority", + "note", + "tags", + "status", + ] + + def get_tags(self, obj): + raw_tags = obj.extended_props.get("tags", []) + return [tag for tag in raw_tags if tag in FARMER_TAG_VALUES] + + +class FarmerTodoChoiceSerializer(serializers.Serializer): + id = serializers.CharField() + label = serializers.CharField() + value = serializers.CharField() + + +class FarmerTodoZoneSerializer(FarmerTodoChoiceSerializer): + prefix = "zone_" + + +class FarmerTodoTagSerializer(FarmerTodoChoiceSerializer): + pass + + +class FarmerTodoTaskWriteSerializer(serializers.Serializer): + title = serializers.CharField(max_length=255, required=False) + zone = serializers.CharField(max_length=255, required=False) + scheduledDate = serializers.DateField(required=False, format="%Y-%m-%d", input_formats=["%Y-%m-%d"]) + time = serializers.TimeField(required=False, format="%H:%M", input_formats=["%H:%M"]) + priority = serializers.CharField(required=False) + note = serializers.CharField(required=False, allow_blank=True, default="") + tags = serializers.ListField( + child=serializers.CharField(max_length=100), + required=False, + default=list, + allow_empty=True, + ) + status = serializers.ChoiceField(choices=[FarmerTodoTask.STATUS_OPEN, FarmerTodoTask.STATUS_DONE], required=False) + farm_uuid = serializers.UUIDField(required=False, write_only=True) + + def validate_title(self, value): + value = value.strip() + if not value: + raise serializers.ValidationError("title cannot be empty") + return value + + def validate_zone(self, value): + value = value.strip() + if not value: + raise serializers.ValidationError("zone cannot be empty") + return value + + def validate_priority(self, value): + normalized = PRIORITY_INPUT_MAP.get(value.strip().lower(), PRIORITY_INPUT_MAP.get(value.strip())) + if normalized is None: + raise serializers.ValidationError("priority must be one of زیاد, متوسط, کم, high, medium, low") + return normalized + + def validate_tags(self, value): + normalized = [] + for tag in value: + cleaned = tag.strip() + if cleaned: + if cleaned not in FARMER_TAG_VALUES: + raise serializers.ValidationError(f"tag `{cleaned}` is not valid") + normalized.append(cleaned) + return normalized + + def validate(self, attrs): + if not self.partial: + required_fields = ["title", "zone", "scheduledDate", "time", "priority"] + errors = {} + for field in required_fields: + if field not in attrs: + errors[field] = [f"{field} is required"] + if errors: + raise serializers.ValidationError(errors) + return attrs + + @staticmethod + def _sync_zone(task, zone_value): + zone, _ = FarmerCalendarZone.objects.get_or_create( + farm=task.farm, + value=zone_value, + defaults={"label": zone_value}, + ) + if zone.label != zone_value: + zone.label = zone_value + zone.save(update_fields=["label", "updated_at"]) + task.zone = zone + + def create(self, validated_data): + zone_value = validated_data.pop("zone") + tags = validated_data.pop("tags", []) + validated_data.pop("farm_uuid", None) + validated_data["scheduled_date"] = validated_data.pop("scheduledDate") + validated_data["description"] = validated_data.pop("note", "") + validated_data["extended_props"] = {"tags": tags} + farm = validated_data["farm"] + zone, _ = FarmerCalendarZone.objects.get_or_create( + farm=farm, + value=zone_value, + defaults={"label": zone_value}, + ) + if zone.label != zone_value: + zone.label = zone_value + zone.save(update_fields=["label", "updated_at"]) + task = FarmerTodoTask.objects.create(zone=zone, **validated_data) + return task + + def update(self, instance, validated_data): + zone_value = validated_data.pop("zone", None) + tags = validated_data.pop("tags", None) + validated_data.pop("farm_uuid", None) + if "scheduledDate" in validated_data: + validated_data["scheduled_date"] = validated_data.pop("scheduledDate") + if "note" in validated_data: + validated_data["description"] = validated_data.pop("note") + if tags is not None: + extended_props = dict(instance.extended_props or {}) + extended_props["tags"] = tags + validated_data["extended_props"] = extended_props + + for attr, value in validated_data.items(): + setattr(instance, attr, value) + if zone_value is not None: + self._sync_zone(instance, zone_value) + instance.save() + return instance + + +class FarmerTodoListQuerySerializer(serializers.Serializer): + status = serializers.ChoiceField(choices=[FarmerTodoTask.STATUS_OPEN, FarmerTodoTask.STATUS_DONE], required=False) + priority = serializers.CharField(required=False) + date = serializers.DateField(required=False, input_formats=["%Y-%m-%d"]) + from_date = serializers.DateField(required=False, input_formats=["%Y-%m-%d"], source="from") + to = serializers.DateField(required=False, input_formats=["%Y-%m-%d"]) + zone = serializers.CharField(required=False) + search = serializers.CharField(required=False) + farm_uuid = serializers.UUIDField(required=False) + + def validate_priority(self, value): + normalized = PRIORITY_INPUT_MAP.get(value.strip().lower(), PRIORITY_INPUT_MAP.get(value.strip())) + if normalized is None: + raise serializers.ValidationError("priority must be one of زیاد, متوسط, کم, high, medium, low") + return normalized + + def validate(self, attrs): + from_date = attrs.get("from") + to_date = attrs.get("to") + if from_date and to_date and to_date < from_date: + raise serializers.ValidationError({"to": "to cannot be before from"}) + return attrs diff --git a/farmer_todos/tests.py b/farmer_todos/tests.py new file mode 100644 index 0000000..7785392 --- /dev/null +++ b/farmer_todos/tests.py @@ -0,0 +1,238 @@ +from datetime import date, time, timedelta + +from django.contrib.auth import get_user_model +from django.test import TestCase +from rest_framework.test import APIRequestFactory, force_authenticate + +from access_control.models import SubscriptionPlan +from farm_hub.models import FarmHub, FarmType + +from .models import FarmerTodoTask, FarmerTodoZone +from .views import ( + FarmerTodoDetailView, + FarmerTodoListCreateView, + FarmerTodoSummaryView, + FarmerTodoTagsView, + FarmerTodoZonesView, +) + + +class FarmerTodoViewTests(TestCase): + def setUp(self): + self.factory = APIRequestFactory() + self.user = get_user_model().objects.create_user( + username="todo-user", + password="secret123", + email="todo@example.com", + phone_number="09123333333", + ) + self.other_user = get_user_model().objects.create_user( + username="todo-other", + password="secret123", + email="todo-other@example.com", + phone_number="09124444444", + ) + self.plan = SubscriptionPlan.objects.create(code="todo-plan", name="Todo Plan") + self.farm_type = FarmType.objects.create(name="باغی") + self.farm = FarmHub.objects.create( + owner=self.user, + farm_type=self.farm_type, + subscription_plan=self.plan, + name="Farm A", + ) + self.other_farm = FarmHub.objects.create( + owner=self.other_user, + farm_type=self.farm_type, + subscription_plan=self.plan, + name="Farm B", + ) + self.zone = FarmerTodoZone.objects.create( + farm=self.farm, + label="قطعه گندم - شمال مزرعه", + value="قطعه گندم - شمال مزرعه", + ) + self.task = FarmerTodoTask.objects.create( + farm=self.farm, + zone=self.zone, + uuid="11111111-1111-1111-1111-111111111111", + title="بررسی رطوبت ردیف شمالی", + scheduled_date=date.today(), + time=time(6, 30), + priority=FarmerTodoTask.PRIORITY_HIGH, + description="اگر رطوبت کمتر از 28٪ بود، آبیاری دوباره بررسی شود.", + status=FarmerTodoTask.STATUS_OPEN, + extended_props={"tags": ["آبیاری"]}, + ) + + def test_list_tasks_returns_expected_shape(self): + request = self.factory.get(f"/api/farmer-todos/?farm_uuid={self.farm.farm_uuid}") + force_authenticate(request, user=self.user) + + response = FarmerTodoListCreateView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["meta"]["total"], 1) + self.assertEqual(response.data["tasks"][0]["zone"], self.zone.value) + self.assertEqual(response.data["tasks"][0]["priority"], "زیاد") + self.assertEqual(response.data["tasks"][0]["time"], "06:30") + self.assertEqual(str(response.data["tasks"][0]["id"]), str(self.task.uuid)) + + def test_create_task_creates_zone_and_tags(self): + request = self.factory.post( + "/api/farmer-todos/", + { + "farm_uuid": str(self.farm.farm_uuid), + "title": "بازدید پمپ جنوب", + "zone": "انبار مرکزی", + "scheduledDate": "2025-02-24", + "time": "07:00", + "priority": "medium", + "note": "بعد از ثبت انجام، مورد غیرعادی را یادداشت کن.", + "tags": ["روزانه", "ثبت دستی"], + "status": "open", + }, + format="json", + ) + force_authenticate(request, user=self.user) + + response = FarmerTodoListCreateView.as_view()(request) + + self.assertEqual(response.status_code, 201) + self.assertEqual(response.data["task"]["priority"], "متوسط") + self.assertEqual(response.data["task"]["zone"], "انبار مرکزی") + self.assertEqual(response.data["task"]["tags"], ["روزانه", "ثبت دستی"]) + self.assertTrue(FarmerTodoZone.objects.filter(farm=self.farm, value="انبار مرکزی").exists()) + + def test_update_task_supports_status_only_payload(self): + request = self.factory.put( + f"/api/farmer-todos/{self.task.uuid}/", + {"status": "done"}, + format="json", + ) + force_authenticate(request, user=self.user) + + response = FarmerTodoDetailView.as_view()(request, task_id=self.task.uuid) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["task"]["status"], "done") + + def test_filter_by_search_and_priority(self): + FarmerTodoTask.objects.create( + farm=self.farm, + zone=self.zone, + title="نمونه برداری خاک", + scheduled_date=date(2025, 2, 25), + time=time(9, 15), + priority=FarmerTodoTask.PRIORITY_LOW, + description="سه نقطه برداشت شود.", + status=FarmerTodoTask.STATUS_OPEN, + ) + request = self.factory.get( + f"/api/farmer-todos/?farm_uuid={self.farm.farm_uuid}&priority=high&search=رطوبت" + ) + force_authenticate(request, user=self.user) + + response = FarmerTodoListCreateView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["meta"]["total"], 1) + self.assertEqual(response.data["tasks"][0]["title"], "بررسی رطوبت ردیف شمالی") + + def test_zones_and_tags_endpoints_return_separate_lists(self): + zone_request = self.factory.get(f"/api/farmer-todos/zones/?farm_uuid={self.farm.farm_uuid}") + tag_request = self.factory.get(f"/api/farmer-todos/tags/?farm_uuid={self.farm.farm_uuid}") + force_authenticate(zone_request, user=self.user) + force_authenticate(tag_request, user=self.user) + + zone_response = FarmerTodoZonesView.as_view()(zone_request) + tag_response = FarmerTodoTagsView.as_view()(tag_request) + + self.assertEqual(zone_response.status_code, 200) + self.assertEqual(tag_response.status_code, 200) + self.assertEqual(zone_response.data["zones"][0]["value"], self.zone.value) + self.assertTrue(any(item["value"] == "آبیاری" for item in tag_response.data["tags"])) + + def test_summary_returns_expected_counts(self): + FarmerTodoTask.objects.create( + farm=self.farm, + zone=self.zone, + title="کار انجام شده", + scheduled_date=date.today(), + time=time(8, 0), + priority=FarmerTodoTask.PRIORITY_MEDIUM, + description="", + status=FarmerTodoTask.STATUS_DONE, + ) + upcoming = FarmerTodoTask.objects.create( + farm=self.farm, + zone=self.zone, + title="کار بعدی", + scheduled_date=date.today() + timedelta(days=1), + time=time(7, 0), + priority=FarmerTodoTask.PRIORITY_HIGH, + description="", + status=FarmerTodoTask.STATUS_OPEN, + ) + request = self.factory.get(f"/api/farmer-todos/summary/?farm_uuid={self.farm.farm_uuid}") + force_authenticate(request, user=self.user) + + response = FarmerTodoSummaryView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["completedCount"], 1) + self.assertEqual(response.data["urgentCount"], 2) + self.assertEqual(str(response.data["nextTask"]["id"]), str(self.task.uuid)) + self.assertNotEqual(str(response.data["nextTask"]["id"]), str(upcoming.uuid)) + + def test_delete_task_returns_success(self): + request = self.factory.delete(f"/api/farmer-todos/{self.task.uuid}/") + force_authenticate(request, user=self.user) + + response = FarmerTodoDetailView.as_view()(request, task_id=self.task.uuid) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data, {"success": True}) + self.assertFalse(FarmerTodoTask.objects.filter(pk=self.task.pk).exists()) + + def test_validation_error_returns_message_and_details(self): + request = self.factory.post( + "/api/farmer-todos/", + { + "farm_uuid": str(self.farm.farm_uuid), + "title": "", + "priority": "unknown", + }, + format="json", + ) + force_authenticate(request, user=self.user) + + response = FarmerTodoListCreateView.as_view()(request) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.data["code"], "TASK_VALIDATION_ERROR") + self.assertIn("message", response.data) + self.assertIn("details", response.data) + + def test_detail_rejects_foreign_task(self): + foreign_zone = FarmerTodoZone.objects.create( + farm=self.other_farm, + label="foreign zone", + value="foreign zone", + ) + foreign_task = FarmerTodoTask.objects.create( + farm=self.other_farm, + zone=foreign_zone, + uuid="22222222-2222-2222-2222-222222222222", + title="foreign task", + scheduled_date=date(2025, 2, 24), + time=time(6, 30), + priority=FarmerTodoTask.PRIORITY_HIGH, + status=FarmerTodoTask.STATUS_OPEN, + ) + request = self.factory.get(f"/api/farmer-todos/{foreign_task.uuid}/") + force_authenticate(request, user=self.user) + + response = FarmerTodoDetailView.as_view()(request, task_id=foreign_task.uuid) + + self.assertEqual(response.status_code, 404) + self.assertEqual(response.data["message"], "Task not found.") diff --git a/farmer_todos/urls.py b/farmer_todos/urls.py new file mode 100644 index 0000000..4717987 --- /dev/null +++ b/farmer_todos/urls.py @@ -0,0 +1,17 @@ +from django.urls import path + +from .views import ( + FarmerTodoDetailView, + FarmerTodoListCreateView, + FarmerTodoSummaryView, + FarmerTodoTagsView, + FarmerTodoZonesView, +) + +urlpatterns = [ + path("zones/", FarmerTodoZonesView.as_view(), name="farmer-todo-zones"), + path("tags/", FarmerTodoTagsView.as_view(), name="farmer-todo-tags"), + path("summary/", FarmerTodoSummaryView.as_view(), name="farmer-todo-summary"), + path("/", FarmerTodoDetailView.as_view(), name="farmer-todo-detail"), + path("", FarmerTodoListCreateView.as_view(), name="farmer-todo-list-create"), +] diff --git a/farmer_todos/views.py b/farmer_todos/views.py new file mode 100644 index 0000000..9653c1b --- /dev/null +++ b/farmer_todos/views.py @@ -0,0 +1,242 @@ +from datetime import date + +from django.db.models import Q +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, extend_schema +from rest_framework import serializers, status +from rest_framework.exceptions import NotFound +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from farm_hub.models import FarmHub +from farmer_calendar.enums import FARMER_TAG_ITEMS +from farmer_calendar.models import FarmerCalendarZone + +from .models import FarmerTodoTask +from .serializers import ( + FarmerTodoListQuerySerializer, + FarmerTodoTagSerializer, + FarmerTodoTaskResponseSerializer, + FarmerTodoTaskWriteSerializer, + FarmerTodoZoneSerializer, +) + + +class FarmerTodoBaseView(APIView): + permission_classes = [IsAuthenticated] + + @staticmethod + def error_response(message, details=None, code="TASK_VALIDATION_ERROR", status_code=status.HTTP_400_BAD_REQUEST): + payload = {"code": code, "message": message} + if details is not None: + payload["details"] = details + return Response(payload, status=status_code) + + def handle_exception(self, exc): + if isinstance(exc, serializers.ValidationError): + details = exc.detail + message = "Invalid farmer todo payload" + if isinstance(details, dict): + first_value = next(iter(details.values()), None) + if isinstance(first_value, list) and first_value: + message = str(first_value[0]) + elif first_value: + message = str(first_value) + elif isinstance(details, list) and details: + message = str(details[0]) + return self.error_response(message=message, details=details) + if isinstance(exc, NotFound): + return self.error_response( + message=str(exc.detail), + code="TASK_NOT_FOUND", + status_code=status.HTTP_404_NOT_FOUND, + ) + return super().handle_exception(exc) + + def _get_user_farms(self, request): + return FarmHub.objects.filter(owner=request.user).order_by("id") + + def _resolve_farm(self, request, farm_uuid=None, required=False): + farms = self._get_user_farms(request) + if farm_uuid: + try: + return farms.get(farm_uuid=farm_uuid) + except FarmHub.DoesNotExist as exc: + raise serializers.ValidationError({"farm_uuid": ["Farm not found."]}) from exc + if required: + farm_count = farms.count() + if farm_count == 1: + return farms.first() + if farm_count == 0: + raise serializers.ValidationError({"farm_uuid": ["No farm found for this user."]}) + raise serializers.ValidationError({"farm_uuid": ["farm_uuid is required when multiple farms exist."]}) + return None + + def _get_task(self, request, task_id): + queryset = FarmerTodoTask.objects.select_related("farm", "zone").prefetch_related("tags") + try: + return queryset.get(uuid=task_id, farm__owner=request.user) + except FarmerTodoTask.DoesNotExist as exc: + raise NotFound("Task not found.") from exc + + +class FarmerTodoListCreateView(FarmerTodoBaseView): + @extend_schema( + tags=["Farmer Todos"], + parameters=[ + OpenApiParameter(name="status", type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, required=False), + OpenApiParameter(name="priority", type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, required=False), + OpenApiParameter(name="date", type=OpenApiTypes.DATE, location=OpenApiParameter.QUERY, required=False), + OpenApiParameter(name="from", type=OpenApiTypes.DATE, location=OpenApiParameter.QUERY, required=False), + OpenApiParameter(name="to", type=OpenApiTypes.DATE, location=OpenApiParameter.QUERY, required=False), + OpenApiParameter(name="zone", type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, required=False), + OpenApiParameter(name="search", type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, required=False), + OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=False), + ], + ) + def get(self, request): + query_data = request.query_params.copy() + if "from" in query_data and "from_date" not in query_data: + query_data["from_date"] = query_data["from"] + query_serializer = FarmerTodoListQuerySerializer(data=query_data) + query_serializer.is_valid(raise_exception=True) + filters = query_serializer.validated_data + + queryset = FarmerTodoTask.objects.filter(farm__owner=request.user).select_related("zone").prefetch_related("tags") + farm = self._resolve_farm(request, filters.get("farm_uuid"), required=False) + if farm is not None: + queryset = queryset.filter(farm=farm) + if filters.get("status"): + queryset = queryset.filter(status=filters["status"]) + if filters.get("priority"): + queryset = queryset.filter(priority=filters["priority"]) + if filters.get("date"): + queryset = queryset.filter(scheduled_date=filters["date"]) + if filters.get("from"): + queryset = queryset.filter(scheduled_date__gte=filters["from"]) + if filters.get("to"): + queryset = queryset.filter(scheduled_date__lte=filters["to"]) + if filters.get("zone"): + queryset = queryset.filter(zone__value=filters["zone"].strip()) + if filters.get("search"): + search_value = filters["search"].strip() + queryset = queryset.filter(Q(title__icontains=search_value) | Q(description__icontains=search_value)) + + tasks = queryset.order_by("scheduled_date", "time", "created_at") + data = FarmerTodoTaskResponseSerializer(tasks, many=True).data + return Response({"tasks": data, "meta": {"total": len(data)}}, status=status.HTTP_200_OK) + + @extend_schema(tags=["Farmer Todos"]) + def post(self, request): + serializer = FarmerTodoTaskWriteSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + farm = self._resolve_farm(request, serializer.validated_data.get("farm_uuid"), required=True) + task = serializer.save(farm=farm) + data = FarmerTodoTaskResponseSerializer(task).data + return Response({"task": data}, status=status.HTTP_201_CREATED) + + +class FarmerTodoDetailView(FarmerTodoBaseView): + @extend_schema(tags=["Farmer Todos"]) + def put(self, request, task_id): + task = self._get_task(request, task_id) + serializer = FarmerTodoTaskWriteSerializer(task, data=request.data, partial=True) + serializer.is_valid(raise_exception=True) + + requested_farm_uuid = serializer.validated_data.get("farm_uuid") + if requested_farm_uuid and str(task.farm.farm_uuid) != str(requested_farm_uuid): + return self.error_response( + message="farm_uuid cannot change an existing task", + details={"farm_uuid": ["farm_uuid cannot change an existing task"]}, + ) + + task = serializer.save() + data = FarmerTodoTaskResponseSerializer(task).data + return Response({"task": data}, status=status.HTTP_200_OK) + + @extend_schema(tags=["Farmer Todos"]) + def delete(self, request, task_id): + task = self._get_task(request, task_id) + task.delete() + return Response({"success": True}, status=status.HTTP_200_OK) + + @extend_schema(tags=["Farmer Todos"]) + def get(self, request, task_id): + task = self._get_task(request, task_id) + data = FarmerTodoTaskResponseSerializer(task).data + return Response({"task": data}, status=status.HTTP_200_OK) + + +class FarmerTodoZonesView(FarmerTodoBaseView): + @extend_schema( + tags=["Farmer Todos"], + parameters=[ + OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=False), + ], + ) + def get(self, request): + farm = self._resolve_farm(request, request.query_params.get("farm_uuid"), required=False) + queryset = FarmerCalendarZone.objects.filter(farm__owner=request.user, is_active=True) + if farm is not None: + queryset = queryset.filter(farm=farm) + if farm is None: + unique_zones = {} + for zone in queryset.order_by("label", "created_at"): + unique_zones.setdefault(zone.value, zone) + zones = list(unique_zones.values()) + else: + zones = queryset.order_by("label") + data = FarmerTodoZoneSerializer(zones, many=True).data + return Response({"zones": data, "meta": {"total": len(data)}}, status=status.HTTP_200_OK) + + +class FarmerTodoTagsView(FarmerTodoBaseView): + @extend_schema( + tags=["Farmer Todos"], + parameters=[ + OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=False), + ], + ) + def get(self, request): + self._resolve_farm(request, request.query_params.get("farm_uuid"), required=False) + data = FarmerTodoTagSerializer(FARMER_TAG_ITEMS, many=True).data + return Response({"tags": data, "meta": {"total": len(data)}}, status=status.HTTP_200_OK) + + +class FarmerTodoSummaryView(FarmerTodoBaseView): + @extend_schema( + tags=["Farmer Todos"], + parameters=[ + OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=False), + ], + ) + def get(self, request): + farm = self._resolve_farm(request, request.query_params.get("farm_uuid"), required=False) + queryset = FarmerTodoTask.objects.filter(farm__owner=request.user).select_related("zone").prefetch_related("tags") + if farm is not None: + queryset = queryset.filter(farm=farm) + + today = date.today() + total_count = queryset.count() + today_count = queryset.filter(scheduled_date=today).count() + completed_count = queryset.filter(status=FarmerTodoTask.STATUS_DONE).count() + urgent_count = queryset.filter(priority=FarmerTodoTask.PRIORITY_HIGH, status=FarmerTodoTask.STATUS_OPEN).count() + next_task = queryset.filter( + status=FarmerTodoTask.STATUS_OPEN, + ).filter( + Q(scheduled_date__gt=today) | Q(scheduled_date=today) + ).order_by("scheduled_date", "time", "created_at").first() + + progress_value = int((completed_count / total_count) * 100) if total_count else 0 + next_task_data = FarmerTodoTaskResponseSerializer(next_task).data if next_task else None + return Response( + { + "todayTasksCount": today_count, + "completedCount": completed_count, + "urgentCount": urgent_count, + "progressValue": progress_value, + "nextTask": next_task_data, + }, + status=status.HTTP_200_OK, + )