agroplan-ai-cli 1.0.29 → 1.0.31
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/backend-template/VERSION.json +7 -4
- package/backend-template/api.py +292 -0
- package/backend-template/core/crop_calendar_engine.py +426 -0
- package/backend-template/core/field_storage.py +175 -0
- package/backend-template/core/planning_models.py +484 -0
- package/backend-template/data/user_fields/fields.json +0 -0
- package/package.json +1 -1
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Modelos de Domínio para Planejador de Safra Inteligente
|
|
3
|
+
|
|
4
|
+
Define as entidades principais do sistema de planejamento agrícola:
|
|
5
|
+
- Property: Propriedade rural
|
|
6
|
+
- Field: Talhão/campo
|
|
7
|
+
- CropPlan: Plano de cultura
|
|
8
|
+
- CropCycle: Ciclo da cultura
|
|
9
|
+
- CalendarTask: Tarefa do calendário
|
|
10
|
+
- WeatherAlert: Alerta climático
|
|
11
|
+
- UserObservation: Observação do usuário
|
|
12
|
+
- Intervention: Intervenção/replanejamento
|
|
13
|
+
- PlanningSession: Sessão de planejamento
|
|
14
|
+
|
|
15
|
+
Modelos Pydantic para API:
|
|
16
|
+
- ManualFieldCreate: Criação de talhão manual
|
|
17
|
+
- ManualFieldUpdate: Atualização de talhão manual
|
|
18
|
+
- ManualFieldResponse: Resposta de talhão manual
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from datetime import date, datetime
|
|
23
|
+
from typing import Optional, List, Dict
|
|
24
|
+
from enum import Enum
|
|
25
|
+
from pydantic import BaseModel, Field as PydanticField, field_validator
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# Enums para tipos padronizados
|
|
29
|
+
|
|
30
|
+
class SoilType(str, Enum):
|
|
31
|
+
"""Tipos de solo"""
|
|
32
|
+
ARGILOSO = "argiloso"
|
|
33
|
+
ARENOSO = "arenoso"
|
|
34
|
+
MISTO = "misto"
|
|
35
|
+
SILTOSO = "siltoso"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Slope(str, Enum):
|
|
39
|
+
"""Tipos de relevo"""
|
|
40
|
+
PLANO = "plano"
|
|
41
|
+
LEVE = "leve"
|
|
42
|
+
MEDIO = "medio"
|
|
43
|
+
INGREME = "ingreme"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class WaterAvailability(str, Enum):
|
|
47
|
+
"""Disponibilidade de água"""
|
|
48
|
+
BAIXA = "baixa"
|
|
49
|
+
MEDIA = "media"
|
|
50
|
+
ALTA = "alta"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Objective(str, Enum):
|
|
54
|
+
"""Objetivos de otimização"""
|
|
55
|
+
EQUILIBRADO = "equilibrado"
|
|
56
|
+
LUCRO = "lucro"
|
|
57
|
+
RISCO = "risco"
|
|
58
|
+
SUSTENTAVEL = "sustentavel"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class PlanStatus(str, Enum):
|
|
62
|
+
"""Status do plano de cultura"""
|
|
63
|
+
PLANNED = "planned"
|
|
64
|
+
ACTIVE = "active"
|
|
65
|
+
COMPLETED = "completed"
|
|
66
|
+
CANCELLED = "cancelled"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class TaskType(str, Enum):
|
|
70
|
+
"""Tipos de tarefa"""
|
|
71
|
+
PREPARE_SOIL = "prepare_soil"
|
|
72
|
+
PLANT = "plant"
|
|
73
|
+
IRRIGATE = "irrigate"
|
|
74
|
+
FERTILIZE = "fertilize"
|
|
75
|
+
INSPECT_PESTS = "inspect_pests"
|
|
76
|
+
INSPECT_DISEASES = "inspect_diseases"
|
|
77
|
+
MONITOR_GROWTH = "monitor_growth"
|
|
78
|
+
HARVEST = "harvest"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class TaskPriority(str, Enum):
|
|
82
|
+
"""Prioridade da tarefa"""
|
|
83
|
+
LOW = "low"
|
|
84
|
+
MEDIUM = "medium"
|
|
85
|
+
HIGH = "high"
|
|
86
|
+
CRITICAL = "critical"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class TaskStatus(str, Enum):
|
|
90
|
+
"""Status da tarefa"""
|
|
91
|
+
PENDING = "pending"
|
|
92
|
+
COMPLETED = "completed"
|
|
93
|
+
SKIPPED = "skipped"
|
|
94
|
+
RESCHEDULED = "rescheduled"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class AlertType(str, Enum):
|
|
98
|
+
"""Tipos de alerta climático"""
|
|
99
|
+
RAIN = "rain"
|
|
100
|
+
DROUGHT = "drought"
|
|
101
|
+
HEAT = "heat"
|
|
102
|
+
COLD = "cold"
|
|
103
|
+
FROST = "frost"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class AlertSeverity(str, Enum):
|
|
107
|
+
"""Severidade do alerta"""
|
|
108
|
+
INFO = "info"
|
|
109
|
+
WARNING = "warning"
|
|
110
|
+
CRITICAL = "critical"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class InterventionReason(str, Enum):
|
|
114
|
+
"""Razão da intervenção"""
|
|
115
|
+
MISSED_TASK = "missed_task"
|
|
116
|
+
WEATHER_EVENT = "weather_event"
|
|
117
|
+
SOIL_CONDITION = "soil_condition"
|
|
118
|
+
USER_REQUEST = "user_request"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class PlanningMode(str, Enum):
|
|
122
|
+
"""Modo de planejamento"""
|
|
123
|
+
GUIDED = "guided"
|
|
124
|
+
ADVANCED = "advanced"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# Modelos de Domínio
|
|
128
|
+
|
|
129
|
+
@dataclass
|
|
130
|
+
class Property:
|
|
131
|
+
"""Propriedade rural"""
|
|
132
|
+
id: str
|
|
133
|
+
name: str
|
|
134
|
+
uf: str
|
|
135
|
+
municipio: str
|
|
136
|
+
lat: Optional[float] = None
|
|
137
|
+
lon: Optional[float] = None
|
|
138
|
+
created_at: datetime = field(default_factory=datetime.now)
|
|
139
|
+
updated_at: datetime = field(default_factory=datetime.now)
|
|
140
|
+
|
|
141
|
+
def to_dict(self) -> Dict:
|
|
142
|
+
return {
|
|
143
|
+
"id": self.id,
|
|
144
|
+
"name": self.name,
|
|
145
|
+
"uf": self.uf,
|
|
146
|
+
"municipio": self.municipio,
|
|
147
|
+
"lat": self.lat,
|
|
148
|
+
"lon": self.lon,
|
|
149
|
+
"created_at": self.created_at.isoformat(),
|
|
150
|
+
"updated_at": self.updated_at.isoformat()
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@dataclass
|
|
155
|
+
class Field:
|
|
156
|
+
"""Talhão/campo"""
|
|
157
|
+
id: str
|
|
158
|
+
property_id: str
|
|
159
|
+
name: str
|
|
160
|
+
area_ha: float
|
|
161
|
+
soil_type: SoilType
|
|
162
|
+
slope: Slope
|
|
163
|
+
water_availability: WaterAvailability
|
|
164
|
+
geometry: Optional[Dict] = None # GeoJSON para mapa
|
|
165
|
+
created_at: datetime = field(default_factory=datetime.now)
|
|
166
|
+
|
|
167
|
+
def to_dict(self) -> Dict:
|
|
168
|
+
return {
|
|
169
|
+
"id": self.id,
|
|
170
|
+
"property_id": self.property_id,
|
|
171
|
+
"name": self.name,
|
|
172
|
+
"area_ha": self.area_ha,
|
|
173
|
+
"soil_type": self.soil_type.value,
|
|
174
|
+
"slope": self.slope.value,
|
|
175
|
+
"water_availability": self.water_availability.value,
|
|
176
|
+
"geometry": self.geometry,
|
|
177
|
+
"created_at": self.created_at.isoformat()
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@dataclass
|
|
182
|
+
class CropPlan:
|
|
183
|
+
"""Plano de cultura para um talhão"""
|
|
184
|
+
id: str
|
|
185
|
+
field_id: str
|
|
186
|
+
culture: str
|
|
187
|
+
planting_date: date
|
|
188
|
+
estimated_harvest_date: date
|
|
189
|
+
objective: Objective
|
|
190
|
+
status: PlanStatus = PlanStatus.PLANNED
|
|
191
|
+
created_at: datetime = field(default_factory=datetime.now)
|
|
192
|
+
|
|
193
|
+
def to_dict(self) -> Dict:
|
|
194
|
+
return {
|
|
195
|
+
"id": self.id,
|
|
196
|
+
"field_id": self.field_id,
|
|
197
|
+
"culture": self.culture,
|
|
198
|
+
"planting_date": self.planting_date.isoformat(),
|
|
199
|
+
"estimated_harvest_date": self.estimated_harvest_date.isoformat(),
|
|
200
|
+
"objective": self.objective.value,
|
|
201
|
+
"status": self.status.value,
|
|
202
|
+
"created_at": self.created_at.isoformat()
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@dataclass
|
|
207
|
+
class CropPhase:
|
|
208
|
+
"""Fase do ciclo da cultura"""
|
|
209
|
+
name: str
|
|
210
|
+
days: int
|
|
211
|
+
description: str
|
|
212
|
+
critical_water: bool
|
|
213
|
+
tasks: List[str]
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@dataclass
|
|
217
|
+
class CropCycle:
|
|
218
|
+
"""Ciclo completo de uma cultura"""
|
|
219
|
+
culture: str
|
|
220
|
+
cycle_days: int
|
|
221
|
+
phases: List[CropPhase]
|
|
222
|
+
critical_water_phases: List[str]
|
|
223
|
+
optimal_temp_min: float
|
|
224
|
+
optimal_temp_max: float
|
|
225
|
+
harvest_window_days: int
|
|
226
|
+
|
|
227
|
+
def to_dict(self) -> Dict:
|
|
228
|
+
return {
|
|
229
|
+
"culture": self.culture,
|
|
230
|
+
"cycle_days": self.cycle_days,
|
|
231
|
+
"phases": [
|
|
232
|
+
{
|
|
233
|
+
"name": phase.name,
|
|
234
|
+
"days": phase.days,
|
|
235
|
+
"description": phase.description,
|
|
236
|
+
"critical_water": phase.critical_water,
|
|
237
|
+
"tasks": phase.tasks
|
|
238
|
+
}
|
|
239
|
+
for phase in self.phases
|
|
240
|
+
],
|
|
241
|
+
"critical_water_phases": self.critical_water_phases,
|
|
242
|
+
"optimal_temp_min": self.optimal_temp_min,
|
|
243
|
+
"optimal_temp_max": self.optimal_temp_max,
|
|
244
|
+
"harvest_window_days": self.harvest_window_days
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
@dataclass
|
|
249
|
+
class CalendarTask:
|
|
250
|
+
"""Tarefa do calendário agrícola"""
|
|
251
|
+
id: str
|
|
252
|
+
crop_plan_id: str
|
|
253
|
+
date: date
|
|
254
|
+
type: TaskType
|
|
255
|
+
title: str
|
|
256
|
+
description: str
|
|
257
|
+
priority: TaskPriority
|
|
258
|
+
source: str # system, user, weather_alert
|
|
259
|
+
status: TaskStatus = TaskStatus.PENDING
|
|
260
|
+
weather_sensitive: bool = False
|
|
261
|
+
completed_at: Optional[datetime] = None
|
|
262
|
+
|
|
263
|
+
def to_dict(self) -> Dict:
|
|
264
|
+
return {
|
|
265
|
+
"id": self.id,
|
|
266
|
+
"crop_plan_id": self.crop_plan_id,
|
|
267
|
+
"date": self.date.isoformat(),
|
|
268
|
+
"type": self.type.value,
|
|
269
|
+
"title": self.title,
|
|
270
|
+
"description": self.description,
|
|
271
|
+
"priority": self.priority.value,
|
|
272
|
+
"source": self.source,
|
|
273
|
+
"status": self.status.value,
|
|
274
|
+
"weather_sensitive": self.weather_sensitive,
|
|
275
|
+
"completed_at": self.completed_at.isoformat() if self.completed_at else None
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
@dataclass
|
|
280
|
+
class WeatherAlert:
|
|
281
|
+
"""Alerta climático"""
|
|
282
|
+
id: str
|
|
283
|
+
crop_plan_id: str
|
|
284
|
+
date: date
|
|
285
|
+
alert_type: AlertType
|
|
286
|
+
severity: AlertSeverity
|
|
287
|
+
message: str
|
|
288
|
+
action_suggested: Optional[str] = None
|
|
289
|
+
created_at: datetime = field(default_factory=datetime.now)
|
|
290
|
+
|
|
291
|
+
def to_dict(self) -> Dict:
|
|
292
|
+
return {
|
|
293
|
+
"id": self.id,
|
|
294
|
+
"crop_plan_id": self.crop_plan_id,
|
|
295
|
+
"date": self.date.isoformat(),
|
|
296
|
+
"alert_type": self.alert_type.value,
|
|
297
|
+
"severity": self.severity.value,
|
|
298
|
+
"message": self.message,
|
|
299
|
+
"action_suggested": self.action_suggested,
|
|
300
|
+
"created_at": self.created_at.isoformat()
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
@dataclass
|
|
305
|
+
class UserObservation:
|
|
306
|
+
"""Observação do usuário sobre o cultivo"""
|
|
307
|
+
id: str
|
|
308
|
+
crop_plan_id: str
|
|
309
|
+
date: date
|
|
310
|
+
note: str
|
|
311
|
+
impact: str # positive, neutral, negative
|
|
312
|
+
created_at: datetime = field(default_factory=datetime.now)
|
|
313
|
+
|
|
314
|
+
def to_dict(self) -> Dict:
|
|
315
|
+
return {
|
|
316
|
+
"id": self.id,
|
|
317
|
+
"crop_plan_id": self.crop_plan_id,
|
|
318
|
+
"date": self.date.isoformat(),
|
|
319
|
+
"note": self.note,
|
|
320
|
+
"impact": self.impact,
|
|
321
|
+
"created_at": self.created_at.isoformat()
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
@dataclass
|
|
326
|
+
class Intervention:
|
|
327
|
+
"""Intervenção/replanejamento"""
|
|
328
|
+
id: str
|
|
329
|
+
crop_plan_id: str
|
|
330
|
+
reason: InterventionReason
|
|
331
|
+
suggested_action: str
|
|
332
|
+
original_task_id: Optional[str] = None
|
|
333
|
+
new_date: Optional[date] = None
|
|
334
|
+
risk_adjustment: float = 0.0
|
|
335
|
+
created_at: datetime = field(default_factory=datetime.now)
|
|
336
|
+
applied: bool = False
|
|
337
|
+
|
|
338
|
+
def to_dict(self) -> Dict:
|
|
339
|
+
return {
|
|
340
|
+
"id": self.id,
|
|
341
|
+
"crop_plan_id": self.crop_plan_id,
|
|
342
|
+
"original_task_id": self.original_task_id,
|
|
343
|
+
"reason": self.reason.value,
|
|
344
|
+
"suggested_action": self.suggested_action,
|
|
345
|
+
"new_date": self.new_date.isoformat() if self.new_date else None,
|
|
346
|
+
"risk_adjustment": self.risk_adjustment,
|
|
347
|
+
"created_at": self.created_at.isoformat(),
|
|
348
|
+
"applied": self.applied
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
@dataclass
|
|
353
|
+
class PlanningSession:
|
|
354
|
+
"""Sessão de planejamento"""
|
|
355
|
+
id: str
|
|
356
|
+
property_id: str
|
|
357
|
+
mode: PlanningMode
|
|
358
|
+
objective: Objective
|
|
359
|
+
fields_count: int
|
|
360
|
+
cultures_recommended: List[str]
|
|
361
|
+
created_at: datetime = field(default_factory=datetime.now)
|
|
362
|
+
|
|
363
|
+
def to_dict(self) -> Dict:
|
|
364
|
+
return {
|
|
365
|
+
"id": self.id,
|
|
366
|
+
"property_id": self.property_id,
|
|
367
|
+
"mode": self.mode.value,
|
|
368
|
+
"objective": self.objective.value,
|
|
369
|
+
"fields_count": self.fields_count,
|
|
370
|
+
"cultures_recommended": self.cultures_recommended,
|
|
371
|
+
"created_at": self.created_at.isoformat()
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
# Modelos Pydantic para API
|
|
376
|
+
|
|
377
|
+
class ManualFieldCreate(BaseModel):
|
|
378
|
+
"""Modelo para criação de talhão manual"""
|
|
379
|
+
name: str = PydanticField(..., min_length=1, max_length=100, description="Nome do talhão")
|
|
380
|
+
area_ha: float = PydanticField(..., gt=0, description="Área em hectares")
|
|
381
|
+
soil_type: str = PydanticField(..., description="Tipo de solo")
|
|
382
|
+
slope: str = PydanticField(..., description="Tipo de relevo")
|
|
383
|
+
water_availability: str = PydanticField(..., description="Disponibilidade de água")
|
|
384
|
+
uf: Optional[str] = PydanticField(None, min_length=2, max_length=2, description="UF")
|
|
385
|
+
municipio: Optional[str] = PydanticField(None, max_length=100, description="Município")
|
|
386
|
+
lat: Optional[float] = PydanticField(None, ge=-90, le=90, description="Latitude")
|
|
387
|
+
lon: Optional[float] = PydanticField(None, ge=-180, le=180, description="Longitude")
|
|
388
|
+
|
|
389
|
+
@field_validator('soil_type')
|
|
390
|
+
@classmethod
|
|
391
|
+
def validate_soil_type(cls, v: str) -> str:
|
|
392
|
+
allowed = ['argiloso', 'arenoso', 'misto', 'siltoso']
|
|
393
|
+
if v not in allowed:
|
|
394
|
+
raise ValueError(f'soil_type deve ser um de: {", ".join(allowed)}')
|
|
395
|
+
return v
|
|
396
|
+
|
|
397
|
+
@field_validator('slope')
|
|
398
|
+
@classmethod
|
|
399
|
+
def validate_slope(cls, v: str) -> str:
|
|
400
|
+
allowed = ['plano', 'suave', 'moderado', 'ingreme']
|
|
401
|
+
if v not in allowed:
|
|
402
|
+
raise ValueError(f'slope deve ser um de: {", ".join(allowed)}')
|
|
403
|
+
return v
|
|
404
|
+
|
|
405
|
+
@field_validator('water_availability')
|
|
406
|
+
@classmethod
|
|
407
|
+
def validate_water_availability(cls, v: str) -> str:
|
|
408
|
+
allowed = ['baixa', 'media', 'alta']
|
|
409
|
+
if v not in allowed:
|
|
410
|
+
raise ValueError(f'water_availability deve ser um de: {", ".join(allowed)}')
|
|
411
|
+
return v
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
class ManualFieldUpdate(BaseModel):
|
|
415
|
+
"""Modelo para atualização de talhão manual"""
|
|
416
|
+
name: Optional[str] = PydanticField(None, min_length=1, max_length=100)
|
|
417
|
+
area_ha: Optional[float] = PydanticField(None, gt=0)
|
|
418
|
+
soil_type: Optional[str] = None
|
|
419
|
+
slope: Optional[str] = None
|
|
420
|
+
water_availability: Optional[str] = None
|
|
421
|
+
uf: Optional[str] = PydanticField(None, min_length=2, max_length=2)
|
|
422
|
+
municipio: Optional[str] = PydanticField(None, max_length=100)
|
|
423
|
+
lat: Optional[float] = PydanticField(None, ge=-90, le=90)
|
|
424
|
+
lon: Optional[float] = PydanticField(None, ge=-180, le=180)
|
|
425
|
+
|
|
426
|
+
@field_validator('soil_type')
|
|
427
|
+
@classmethod
|
|
428
|
+
def validate_soil_type(cls, v: Optional[str]) -> Optional[str]:
|
|
429
|
+
if v is not None:
|
|
430
|
+
allowed = ['argiloso', 'arenoso', 'misto', 'siltoso']
|
|
431
|
+
if v not in allowed:
|
|
432
|
+
raise ValueError(f'soil_type deve ser um de: {", ".join(allowed)}')
|
|
433
|
+
return v
|
|
434
|
+
|
|
435
|
+
@field_validator('slope')
|
|
436
|
+
@classmethod
|
|
437
|
+
def validate_slope(cls, v: Optional[str]) -> Optional[str]:
|
|
438
|
+
if v is not None:
|
|
439
|
+
allowed = ['plano', 'suave', 'moderado', 'ingreme']
|
|
440
|
+
if v not in allowed:
|
|
441
|
+
raise ValueError(f'slope deve ser um de: {", ".join(allowed)}')
|
|
442
|
+
return v
|
|
443
|
+
|
|
444
|
+
@field_validator('water_availability')
|
|
445
|
+
@classmethod
|
|
446
|
+
def validate_water_availability(cls, v: Optional[str]) -> Optional[str]:
|
|
447
|
+
if v is not None:
|
|
448
|
+
allowed = ['baixa', 'media', 'alta']
|
|
449
|
+
if v not in allowed:
|
|
450
|
+
raise ValueError(f'water_availability deve ser um de: {", ".join(allowed)}')
|
|
451
|
+
return v
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
class ManualFieldResponse(BaseModel):
|
|
455
|
+
"""Modelo de resposta de talhão manual"""
|
|
456
|
+
id: str
|
|
457
|
+
name: str
|
|
458
|
+
area_ha: float
|
|
459
|
+
soil_type: str
|
|
460
|
+
slope: str
|
|
461
|
+
water_availability: str
|
|
462
|
+
uf: Optional[str] = None
|
|
463
|
+
municipio: Optional[str] = None
|
|
464
|
+
lat: Optional[float] = None
|
|
465
|
+
lon: Optional[float] = None
|
|
466
|
+
created_at: str
|
|
467
|
+
updated_at: str
|
|
468
|
+
|
|
469
|
+
class Config:
|
|
470
|
+
from_attributes = True
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
class GenerateCalendarRequest(BaseModel):
|
|
474
|
+
"""Modelo para geração de calendário"""
|
|
475
|
+
cultura: str = PydanticField(..., description="Nome da cultura")
|
|
476
|
+
planting_date: str = PydanticField(..., description="Data de plantio (YYYY-MM-DD)")
|
|
477
|
+
|
|
478
|
+
@field_validator('cultura')
|
|
479
|
+
@classmethod
|
|
480
|
+
def validate_cultura(cls, v: str) -> str:
|
|
481
|
+
allowed = ['soja', 'milho', 'feijao']
|
|
482
|
+
if v not in allowed:
|
|
483
|
+
raise ValueError(f'cultura deve ser uma de: {", ".join(allowed)}')
|
|
484
|
+
return v
|
|
Binary file
|