UPDATE
This commit is contained in:
@@ -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` هستند.
|
||||
@@ -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"
|
||||
@@ -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"),
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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.")
|
||||
@@ -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"),
|
||||
]
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user