This commit is contained in:
2026-05-02 04:49:29 +03:30
parent 8159190a84
commit 8d73b1703c
26 changed files with 2534 additions and 0 deletions
Binary file not shown.
+2
View File
@@ -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",
+2
View File
@@ -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")),
]
+392
View File
@@ -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 <token>
```
## 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=<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/<event_uuid>/
```
#### 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/<event_uuid>/
```
#### 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/<event_uuid>/
```
#### 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` هستند.
View File
+7
View File
@@ -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"
+41
View File
@@ -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,
}
@@ -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"),
),
]
@@ -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"),
),
]
+84
View File
@@ -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
+119
View File
@@ -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()
+167
View File
@@ -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.")
+9
View File
@@ -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("<uuid:event_id>/", EventDetailView.as_view(), name="farmer-calendar-detail"),
path("", EventListCreateView.as_view(), name="farmer-calendar-list-create"),
]
+164
View File
@@ -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)
+507
View File
@@ -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 <token>
```
## 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=<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/<task_uuid>/
```
#### 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/<task_uuid>/
```
#### 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/<task_uuid>/
```
#### 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 هستند.
View File
+7
View File
@@ -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"
+85
View File
@@ -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"),
),
]
@@ -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),
]
View File
+10
View File
@@ -0,0 +1,10 @@
from farmer_calendar.models import (
FarmerCalendarEvent,
FarmerCalendarTag,
FarmerCalendarZone,
)
FarmerTodoZone = FarmerCalendarZone
FarmerTodoTag = FarmerCalendarTag
FarmerTodoTask = FarmerCalendarEvent
+178
View File
@@ -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
+238
View File
@@ -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.")
+17
View File
@@ -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("<uuid:task_id>/", FarmerTodoDetailView.as_view(), name="farmer-todo-detail"),
path("", FarmerTodoListCreateView.as_view(), name="farmer-todo-list-create"),
]
+242
View File
@@ -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,
)