This commit is contained in:
2026-05-11 03:27:21 +03:30
parent cf7cbb937c
commit d0e68a1a56
854 changed files with 102985 additions and 76 deletions
@@ -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` هستند.
+309
View File
@@ -0,0 +1,309 @@
from __future__ import annotations
from datetime import date, datetime, time, timedelta
from django.apps import apps as django_apps
from django.utils import timezone
from django.utils.dateparse import parse_date
AUTO_PLAN_SOURCE = "auto_plan_sync"
PLAN_TYPE_IRRIGATION = "irrigation"
PLAN_TYPE_FERTILIZATION = "fertilization"
def create_event_for_farm(
*,
farm,
title,
description="",
start=None,
end=None,
scheduled_date=None,
event_time=None,
priority=None,
tags=None,
zone_value="برنامه خودکار",
extended_props=None,
):
FarmerCalendarEvent = django_apps.get_model("farmer_calendar", "FarmerCalendarEvent")
FarmerCalendarZone = django_apps.get_model("farmer_calendar", "FarmerCalendarZone")
from .enums import FarmerPriority
if priority is None:
priority = FarmerPriority.MEDIUM
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"])
payload = dict(extended_props or {})
payload["tags"] = list(tags or [])
return FarmerCalendarEvent.objects.create(
farm=farm,
zone=zone,
title=title,
description=description,
deadline=int(end.timestamp()) if end else None,
scheduled_date=scheduled_date,
time=event_time,
start=start,
end=end,
priority=priority,
status=FarmerCalendarEvent.STATUS_OPEN,
extended_props=payload,
)
def delete_plan_events(*, farm, plan_type, plan_uuid):
FarmerCalendarEvent = django_apps.get_model("farmer_calendar", "FarmerCalendarEvent")
for event in FarmerCalendarEvent.objects.filter(farm=farm):
props = event.extended_props or {}
if (
props.get("source") == AUTO_PLAN_SOURCE
and props.get("plan_type") == plan_type
and str(props.get("plan_uuid")) == str(plan_uuid)
):
event.delete()
def sync_plan_events(plan, plan_type):
from .enums import FarmerPriority
delete_plan_events(farm=plan.farm, plan_type=plan_type, plan_uuid=plan.uuid)
if getattr(plan, "is_deleted", False) or not getattr(plan, "is_active", False):
return []
if plan_type == PLAN_TYPE_IRRIGATION:
items = _build_irrigation_events(plan)
elif plan_type == PLAN_TYPE_FERTILIZATION:
items = _build_fertilization_events(plan)
else:
items = []
created = []
for index, item in enumerate(items, start=1):
created.append(
create_event_for_farm(
farm=plan.farm,
title=item["title"],
description=item.get("description", ""),
start=item.get("start"),
end=item.get("end"),
scheduled_date=item.get("scheduled_date"),
event_time=item.get("time"),
priority=item.get("priority", FarmerPriority.MEDIUM),
tags=item.get("tags", []),
zone_value=item.get("zone_value", "برنامه خودکار"),
extended_props={
"source": AUTO_PLAN_SOURCE,
"plan_type": plan_type,
"plan_uuid": str(plan.uuid),
"plan_title": plan.title,
"entry_index": index,
**item.get("extended_props", {}),
},
)
)
return created
def _build_irrigation_events(plan):
from .enums import FarmerPriority, FarmerTag
payload = plan.plan_payload if isinstance(plan.plan_payload, dict) else {}
plan_data = payload.get("plan") if isinstance(payload.get("plan"), dict) else {}
water_balance = payload.get("water_balance") if isinstance(payload.get("water_balance"), dict) else {}
daily_entries = water_balance.get("daily") if isinstance(water_balance.get("daily"), list) else []
created = []
for entry in daily_entries:
if not isinstance(entry, dict):
continue
scheduled = _parse_date(entry.get("forecast_date"))
if not scheduled:
continue
start_time, end_time = _parse_time_range(entry.get("irrigation_timing") or plan_data.get("bestTimeOfDay"))
start, end = _build_datetimes(scheduled, start_time, end_time, default_duration_minutes=plan_data.get("durationMinutes"))
gross_amount = entry.get("gross_irrigation_mm")
title = f"آبیاری - {plan.crop_id or plan.title or 'مزرعه'}"
description_parts = []
if gross_amount not in (None, ""):
description_parts.append(f"مقدار آبیاری: {gross_amount} mm")
if plan_data.get("durationMinutes"):
description_parts.append(f"مدت زمان: {plan_data.get('durationMinutes')} دقیقه")
if entry.get("irrigation_timing"):
description_parts.append(f"بازه اجرا: {entry.get('irrigation_timing')}")
created.append(
{
"title": title,
"description": " | ".join(description_parts),
"scheduled_date": scheduled,
"time": start_time,
"start": start,
"end": end,
"priority": FarmerPriority.HIGH,
"tags": [FarmerTag.IRRIGATION.value],
"zone_value": "آبیاری",
"extended_props": {
"kind": "irrigation",
"gross_irrigation_mm": gross_amount,
"irrigation_timing": entry.get("irrigation_timing"),
},
}
)
if created:
return created
scheduled = timezone.localdate()
start_time, end_time = _parse_time_range(plan_data.get("bestTimeOfDay"))
start, end = _build_datetimes(scheduled, start_time, end_time, default_duration_minutes=plan_data.get("durationMinutes"))
return [
{
"title": f"آبیاری - {plan.crop_id or plan.title or 'مزرعه'}",
"description": f"برنامه فعال آبیاری: {plan.title}".strip(),
"scheduled_date": scheduled,
"time": start_time,
"start": start,
"end": end,
"priority": FarmerPriority.HIGH,
"tags": [FarmerTag.IRRIGATION.value],
"zone_value": "آبیاری",
"extended_props": {"kind": "irrigation_fallback"},
}
]
def _build_fertilization_events(plan):
from .enums import FarmerPriority, FarmerTag
payload = plan.plan_payload if isinstance(plan.plan_payload, dict) else {}
primary = payload.get("primary_recommendation") if isinstance(payload.get("primary_recommendation"), dict) else {}
guide = payload.get("application_guide") if isinstance(payload.get("application_guide"), dict) else {}
steps = guide.get("steps") if isinstance(guide.get("steps"), list) else []
interval = primary.get("application_interval") if isinstance(primary.get("application_interval"), dict) else {}
interval_days = _safe_int(interval.get("value"))
base_date = timezone.localdate()
fertilizer_name = primary.get("display_title") or primary.get("fertilizer_name") or plan.title or "برنامه کودی"
created = []
for index, step in enumerate(steps):
if not isinstance(step, dict):
continue
scheduled = _extract_step_date(step) or (base_date + timedelta(days=(index * interval_days if interval_days else index)))
start = timezone.make_aware(datetime.combine(scheduled, time(hour=8, minute=0)))
end = start + timedelta(minutes=30)
description = str(step.get("description") or guide.get("safety_warning") or "").strip()
created.append(
{
"title": f"کوددهی - {fertilizer_name}",
"description": description,
"scheduled_date": scheduled,
"time": start.time(),
"start": start,
"end": end,
"priority": FarmerPriority.MEDIUM,
"tags": [FarmerTag.FERTILIZATION.value],
"zone_value": "کوددهی",
"extended_props": {
"kind": "fertilization",
"step_number": step.get("step_number"),
"fertilizer_code": primary.get("fertilizer_code"),
},
}
)
if created:
return created
scheduled = base_date
start = timezone.make_aware(datetime.combine(scheduled, time(hour=8, minute=0)))
end = start + timedelta(minutes=30)
interval_label = interval.get("label") or ""
description = " | ".join(part for part in [str(primary.get("summary") or "").strip(), str(interval_label).strip()] if part)
return [
{
"title": f"کوددهی - {fertilizer_name}",
"description": description,
"scheduled_date": scheduled,
"time": start.time(),
"start": start,
"end": end,
"priority": FarmerPriority.MEDIUM,
"tags": [FarmerTag.FERTILIZATION.value],
"zone_value": "کوددهی",
"extended_props": {
"kind": "fertilization_fallback",
"fertilizer_code": primary.get("fertilizer_code"),
},
}
]
def _parse_date(value):
if isinstance(value, date):
return value
if not value:
return None
return parse_date(str(value))
def _parse_time_range(value):
if not value:
return None, None
raw = str(value).replace("تا", "-").replace("", "-")
parts = [part.strip() for part in raw.split("-") if part.strip()]
if not parts:
return None, None
start_time = _parse_time(parts[0])
end_time = _parse_time(parts[1]) if len(parts) > 1 else None
return start_time, end_time
def _parse_time(value):
if isinstance(value, time):
return value
if not value:
return None
cleaned = str(value).strip()
for fmt in ("%H:%M", "%H:%M:%S"):
try:
return datetime.strptime(cleaned, fmt).time()
except ValueError:
continue
return None
def _build_datetimes(scheduled, start_time, end_time, default_duration_minutes=None):
if scheduled is None:
return None, None
if start_time is None:
start_time = time(hour=6, minute=0)
start = timezone.make_aware(datetime.combine(scheduled, start_time))
if end_time is not None:
end = timezone.make_aware(datetime.combine(scheduled, end_time))
else:
end = start + timedelta(minutes=_safe_int(default_duration_minutes) or 30)
return start, end
def _extract_step_date(step):
for key in ("date", "scheduled_date", "application_date", "target_date", "forecast_date"):
parsed = _parse_date(step.get(key))
if parsed:
return parsed
return None
def _safe_int(value):
try:
return int(value)
except (TypeError, ValueError):
return None
+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,209 @@
import uuid
import django.db.models.deletion
from django.db import migrations, models
def _table_exists(connection, table_name):
with connection.cursor() as cursor:
return table_name in connection.introspection.table_names(cursor)
def _column_names(connection, table_name):
with connection.cursor() as cursor:
description = connection.introspection.get_table_description(cursor, table_name)
return {column.name for column in description}
def _constraint_names(connection, table_name):
with connection.cursor() as cursor:
constraints = connection.introspection.get_constraints(cursor, table_name)
return set(constraints.keys())
def sync_farmer_calendar_schema(apps, schema_editor):
connection = schema_editor.connection
zone_table = "farmer_calendar_zones"
event_table = "farmer_calendar_events"
if not _table_exists(connection, zone_table):
schema_editor.execute(
"""
CREATE TABLE farmer_calendar_zones (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
uuid CHAR(32) NOT NULL UNIQUE,
label VARCHAR(255) NOT NULL,
value VARCHAR(255) NOT NULL,
is_active BOOL NOT NULL DEFAULT TRUE,
created_at DATETIME(6) NOT NULL,
updated_at DATETIME(6) NOT NULL,
farm_id BIGINT NOT NULL,
CONSTRAINT farmer_calendar_zones_farm_id_fk
FOREIGN KEY (farm_id) REFERENCES farm_hubs (id)
)
"""
)
zone_constraints = _constraint_names(connection, zone_table)
if "uniq_farmer_calendar_zone_per_farm" not in zone_constraints:
schema_editor.execute(
"""
ALTER TABLE farmer_calendar_zones
ADD CONSTRAINT uniq_farmer_calendar_zone_per_farm UNIQUE (farm_id, value)
"""
)
event_columns = _column_names(connection, event_table)
if "priority" not in event_columns:
schema_editor.execute(
"""
ALTER TABLE farmer_calendar_events
ADD COLUMN priority VARCHAR(16) NULL
"""
)
if "scheduled_date" not in event_columns:
schema_editor.execute(
"""
ALTER TABLE farmer_calendar_events
ADD COLUMN scheduled_date DATE NULL
"""
)
if "status" not in event_columns:
schema_editor.execute(
"""
ALTER TABLE farmer_calendar_events
ADD COLUMN status VARCHAR(16) NOT NULL DEFAULT 'open'
"""
)
if "time" not in event_columns:
schema_editor.execute(
"""
ALTER TABLE farmer_calendar_events
ADD COLUMN time TIME NULL
"""
)
if "zone_id" not in event_columns:
schema_editor.execute(
"""
ALTER TABLE farmer_calendar_events
ADD COLUMN zone_id BIGINT NULL
"""
)
schema_editor.execute(
"""
ALTER TABLE farmer_calendar_events
ADD CONSTRAINT farmer_calendar_events_zone_id_fk
FOREIGN KEY (zone_id) REFERENCES farmer_calendar_zones (id)
"""
)
schema_editor.execute(
"""
ALTER TABLE farmer_calendar_events
MODIFY COLUMN start DATETIME(6) NULL
"""
)
schema_editor.execute(
"""
ALTER TABLE farmer_calendar_events
MODIFY COLUMN end DATETIME(6) NULL
"""
)
def noop_reverse(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
("farmer_calendar", "0001_initial"),
]
operations = [
migrations.SeparateDatabaseAndState(
database_operations=[
migrations.RunPython(sync_farmer_calendar_schema, noop_reverse),
],
state_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="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
@@ -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)