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
+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)