agroplan-ai-cli 1.0.29 → 1.0.30
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.
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"cli_version": "1.0.
|
|
3
|
-
"backend_template_version": "1.0.
|
|
2
|
+
"cli_version": "1.0.30",
|
|
3
|
+
"backend_template_version": "1.0.30",
|
|
4
4
|
"zarc_index_version": "2025-2026-fast-index-v2",
|
|
5
5
|
"price_index_version": "2025-05-reference-v1",
|
|
6
6
|
"features": [
|
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
"market_profit_validation",
|
|
18
18
|
"market_profit_confidence_refinement",
|
|
19
19
|
"market_profit_comparative_evaluation",
|
|
20
|
-
"market_profit_experimental_optimizer"
|
|
20
|
+
"market_profit_experimental_optimizer",
|
|
21
|
+
"smart_crop_calendar_engine"
|
|
21
22
|
],
|
|
22
|
-
"generated_at": "2026-05-
|
|
23
|
+
"generated_at": "2026-05-10T15:30:00Z"
|
|
23
24
|
}
|
package/backend-template/api.py
CHANGED
|
@@ -8,6 +8,7 @@ from pydantic import BaseModel
|
|
|
8
8
|
from typing import Optional
|
|
9
9
|
import sys
|
|
10
10
|
import os
|
|
11
|
+
import uuid
|
|
11
12
|
|
|
12
13
|
# Adiciona o diretório backend ao path para imports
|
|
13
14
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
@@ -1308,6 +1309,114 @@ def otimizar_lucro_mercado_experimental(
|
|
|
1308
1309
|
detail="Erro ao gerar otimização experimental de lucro de mercado."
|
|
1309
1310
|
)
|
|
1310
1311
|
|
|
1312
|
+
@app.post("/planejamento/calendario")
|
|
1313
|
+
def gerar_calendario(request: dict):
|
|
1314
|
+
"""
|
|
1315
|
+
Gera calendário agrícola para uma cultura.
|
|
1316
|
+
|
|
1317
|
+
Fase 10.1: Base local para soja, milho e feijão
|
|
1318
|
+
Fase futura: Integração com clima real e replanejamento
|
|
1319
|
+
"""
|
|
1320
|
+
try:
|
|
1321
|
+
from core.crop_calendar_engine import gerar_calendario_cultura
|
|
1322
|
+
from core.planning_models import Field, SoilType, Slope, WaterAvailability
|
|
1323
|
+
from datetime import datetime
|
|
1324
|
+
|
|
1325
|
+
# Validar campos obrigatórios
|
|
1326
|
+
if "cultura" not in request:
|
|
1327
|
+
raise HTTPException(status_code=400, detail="Campo 'cultura' é obrigatório")
|
|
1328
|
+
if "planting_date" not in request:
|
|
1329
|
+
raise HTTPException(status_code=400, detail="Campo 'planting_date' é obrigatório")
|
|
1330
|
+
if "field" not in request:
|
|
1331
|
+
raise HTTPException(status_code=400, detail="Campo 'field' é obrigatório")
|
|
1332
|
+
|
|
1333
|
+
# Parsear data de plantio
|
|
1334
|
+
try:
|
|
1335
|
+
planting_date = datetime.fromisoformat(request["planting_date"]).date()
|
|
1336
|
+
except Exception:
|
|
1337
|
+
raise HTTPException(status_code=400, detail="Formato de data inválido. Use ISO 8601 (YYYY-MM-DD)")
|
|
1338
|
+
|
|
1339
|
+
# Criar objeto Field
|
|
1340
|
+
field_data = request["field"]
|
|
1341
|
+
field = Field(
|
|
1342
|
+
id=field_data.get("id", str(uuid.uuid4())),
|
|
1343
|
+
property_id=field_data.get("property_id", "temp"),
|
|
1344
|
+
name=field_data.get("name", "Talhão Temporário"),
|
|
1345
|
+
area_ha=float(field_data.get("area_ha", 10)),
|
|
1346
|
+
soil_type=SoilType(field_data.get("soil_type", "argiloso")),
|
|
1347
|
+
slope=Slope(field_data.get("slope", "plano")),
|
|
1348
|
+
water_availability=WaterAvailability(field_data.get("water_availability", "media"))
|
|
1349
|
+
)
|
|
1350
|
+
|
|
1351
|
+
# Gerar calendário
|
|
1352
|
+
resultado = gerar_calendario_cultura(
|
|
1353
|
+
cultura=request["cultura"],
|
|
1354
|
+
planting_date=planting_date,
|
|
1355
|
+
field=field,
|
|
1356
|
+
crop_plan_id=request.get("crop_plan_id"),
|
|
1357
|
+
weather_context=request.get("weather_context"),
|
|
1358
|
+
zarc_context=request.get("zarc_context")
|
|
1359
|
+
)
|
|
1360
|
+
|
|
1361
|
+
return converter_tipos_python(resultado)
|
|
1362
|
+
|
|
1363
|
+
except HTTPException:
|
|
1364
|
+
raise
|
|
1365
|
+
except Exception as e:
|
|
1366
|
+
import traceback
|
|
1367
|
+
if DEBUG_ERRORS:
|
|
1368
|
+
raise HTTPException(
|
|
1369
|
+
status_code=500,
|
|
1370
|
+
detail={
|
|
1371
|
+
"error": str(e),
|
|
1372
|
+
"traceback": traceback.format_exc()
|
|
1373
|
+
}
|
|
1374
|
+
)
|
|
1375
|
+
else:
|
|
1376
|
+
raise HTTPException(
|
|
1377
|
+
status_code=500,
|
|
1378
|
+
detail="Erro ao gerar calendário agrícola."
|
|
1379
|
+
)
|
|
1380
|
+
|
|
1381
|
+
@app.get("/planejamento/culturas")
|
|
1382
|
+
def listar_culturas():
|
|
1383
|
+
"""Lista culturas disponíveis no sistema de planejamento"""
|
|
1384
|
+
try:
|
|
1385
|
+
from core.crop_calendar_engine import get_culturas_disponiveis, get_cultura_info
|
|
1386
|
+
|
|
1387
|
+
culturas = get_culturas_disponiveis()
|
|
1388
|
+
|
|
1389
|
+
return {
|
|
1390
|
+
"total": len(culturas),
|
|
1391
|
+
"culturas": culturas,
|
|
1392
|
+
"detalhes": {
|
|
1393
|
+
cultura: get_cultura_info(cultura)
|
|
1394
|
+
for cultura in culturas
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
except Exception as e:
|
|
1398
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
1399
|
+
|
|
1400
|
+
@app.get("/planejamento/culturas/{cultura}")
|
|
1401
|
+
def obter_cultura_info(cultura: str):
|
|
1402
|
+
"""Obtém informações detalhadas de uma cultura"""
|
|
1403
|
+
try:
|
|
1404
|
+
from core.crop_calendar_engine import get_cultura_info
|
|
1405
|
+
|
|
1406
|
+
info = get_cultura_info(cultura)
|
|
1407
|
+
|
|
1408
|
+
if not info:
|
|
1409
|
+
raise HTTPException(
|
|
1410
|
+
status_code=404,
|
|
1411
|
+
detail=f"Cultura '{cultura}' não encontrada"
|
|
1412
|
+
)
|
|
1413
|
+
|
|
1414
|
+
return info
|
|
1415
|
+
except HTTPException:
|
|
1416
|
+
raise
|
|
1417
|
+
except Exception as e:
|
|
1418
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
1419
|
+
|
|
1311
1420
|
@app.post("/cache/limpar")
|
|
1312
1421
|
def limpar_cache(request: Request):
|
|
1313
1422
|
"""Limpa o cache de resultados pesados (protegido por token)"""
|
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Engine de Calendário Agrícola
|
|
3
|
+
|
|
4
|
+
Gera calendários de tarefas para culturas baseado em:
|
|
5
|
+
- Ciclo da cultura
|
|
6
|
+
- Data de plantio
|
|
7
|
+
- Características do talhão
|
|
8
|
+
- Contexto climático (opcional)
|
|
9
|
+
- Contexto ZARC (opcional)
|
|
10
|
+
|
|
11
|
+
Fase inicial: Base local para soja, milho e feijão
|
|
12
|
+
Fase futura: Integração com clima real e replanejamento
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from datetime import date, timedelta
|
|
16
|
+
from typing import Dict, List, Optional
|
|
17
|
+
import uuid
|
|
18
|
+
|
|
19
|
+
from .planning_models import (
|
|
20
|
+
Field, CropCycle, CropPhase, CalendarTask,
|
|
21
|
+
TaskType, TaskPriority, TaskStatus
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# Base de Conhecimento Local - Fase 10.1
|
|
26
|
+
|
|
27
|
+
CROP_CYCLES: Dict[str, Dict] = {
|
|
28
|
+
"soja": {
|
|
29
|
+
"cycle_days": 120,
|
|
30
|
+
"phases": [
|
|
31
|
+
{
|
|
32
|
+
"name": "germinacao",
|
|
33
|
+
"days": 10,
|
|
34
|
+
"description": "Emergência das plântulas",
|
|
35
|
+
"critical_water": True,
|
|
36
|
+
"tasks": [
|
|
37
|
+
{"type": "irrigate", "title": "Irrigar se não houver chuva", "priority": "high", "weather_sensitive": True},
|
|
38
|
+
{"type": "inspect_pests", "title": "Inspecionar germinação", "priority": "medium", "weather_sensitive": False}
|
|
39
|
+
]
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"name": "vegetativa",
|
|
43
|
+
"days": 40,
|
|
44
|
+
"description": "Crescimento vegetativo",
|
|
45
|
+
"critical_water": False,
|
|
46
|
+
"tasks": [
|
|
47
|
+
{"type": "fertilize", "title": "Aplicar fertilizante de cobertura", "priority": "high", "weather_sensitive": True},
|
|
48
|
+
{"type": "inspect_pests", "title": "Inspecionar pragas", "priority": "medium", "weather_sensitive": False},
|
|
49
|
+
{"type": "irrigate", "title": "Irrigar moderadamente", "priority": "medium", "weather_sensitive": True}
|
|
50
|
+
]
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"name": "florescimento",
|
|
54
|
+
"days": 30,
|
|
55
|
+
"description": "Floração e formação de vagens",
|
|
56
|
+
"critical_water": True,
|
|
57
|
+
"tasks": [
|
|
58
|
+
{"type": "irrigate", "title": "Irrigar - fase crítica", "priority": "critical", "weather_sensitive": True},
|
|
59
|
+
{"type": "inspect_diseases", "title": "Inspecionar doenças", "priority": "high", "weather_sensitive": False},
|
|
60
|
+
{"type": "monitor_growth", "title": "Monitorar temperatura", "priority": "medium", "weather_sensitive": False}
|
|
61
|
+
]
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
"name": "enchimento_graos",
|
|
65
|
+
"days": 30,
|
|
66
|
+
"description": "Enchimento de grãos",
|
|
67
|
+
"critical_water": True,
|
|
68
|
+
"tasks": [
|
|
69
|
+
{"type": "irrigate", "title": "Irrigar - fase crítica", "priority": "critical", "weather_sensitive": True},
|
|
70
|
+
{"type": "monitor_growth", "title": "Monitorar maturação", "priority": "high", "weather_sensitive": False}
|
|
71
|
+
]
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
"name": "maturacao",
|
|
75
|
+
"days": 10,
|
|
76
|
+
"description": "Maturação e secagem",
|
|
77
|
+
"critical_water": False,
|
|
78
|
+
"tasks": [
|
|
79
|
+
{"type": "harvest", "title": "Preparar colheita", "priority": "high", "weather_sensitive": True},
|
|
80
|
+
{"type": "monitor_growth", "title": "Monitorar umidade dos grãos", "priority": "medium", "weather_sensitive": False}
|
|
81
|
+
]
|
|
82
|
+
}
|
|
83
|
+
],
|
|
84
|
+
"optimal_temp_min": 20,
|
|
85
|
+
"optimal_temp_max": 30,
|
|
86
|
+
"critical_water_phases": ["germinacao", "florescimento", "enchimento_graos"],
|
|
87
|
+
"harvest_window_days": 15
|
|
88
|
+
},
|
|
89
|
+
"milho": {
|
|
90
|
+
"cycle_days": 140,
|
|
91
|
+
"phases": [
|
|
92
|
+
{
|
|
93
|
+
"name": "germinacao",
|
|
94
|
+
"days": 12,
|
|
95
|
+
"description": "Emergência das plântulas",
|
|
96
|
+
"critical_water": True,
|
|
97
|
+
"tasks": [
|
|
98
|
+
{"type": "irrigate", "title": "Irrigar se não houver chuva", "priority": "high", "weather_sensitive": True},
|
|
99
|
+
{"type": "inspect_pests", "title": "Inspecionar germinação", "priority": "medium", "weather_sensitive": False}
|
|
100
|
+
]
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
"name": "vegetativa",
|
|
104
|
+
"days": 50,
|
|
105
|
+
"description": "Crescimento vegetativo",
|
|
106
|
+
"critical_water": False,
|
|
107
|
+
"tasks": [
|
|
108
|
+
{"type": "fertilize", "title": "Aplicar fertilizante nitrogenado", "priority": "high", "weather_sensitive": True},
|
|
109
|
+
{"type": "inspect_pests", "title": "Inspecionar pragas (lagarta)", "priority": "high", "weather_sensitive": False},
|
|
110
|
+
{"type": "irrigate", "title": "Irrigar moderadamente", "priority": "medium", "weather_sensitive": True}
|
|
111
|
+
]
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
"name": "florescimento",
|
|
115
|
+
"days": 28,
|
|
116
|
+
"description": "Floração e polinização",
|
|
117
|
+
"critical_water": True,
|
|
118
|
+
"tasks": [
|
|
119
|
+
{"type": "irrigate", "title": "Irrigar - fase crítica", "priority": "critical", "weather_sensitive": True},
|
|
120
|
+
{"type": "monitor_growth", "title": "Monitorar polinização", "priority": "high", "weather_sensitive": False}
|
|
121
|
+
]
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
"name": "enchimento_graos",
|
|
125
|
+
"days": 40,
|
|
126
|
+
"description": "Enchimento de grãos",
|
|
127
|
+
"critical_water": True,
|
|
128
|
+
"tasks": [
|
|
129
|
+
{"type": "irrigate", "title": "Irrigar - fase crítica", "priority": "critical", "weather_sensitive": True},
|
|
130
|
+
{"type": "inspect_diseases", "title": "Inspecionar doenças foliares", "priority": "high", "weather_sensitive": False},
|
|
131
|
+
{"type": "monitor_growth", "title": "Monitorar desenvolvimento das espigas", "priority": "medium", "weather_sensitive": False}
|
|
132
|
+
]
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
"name": "maturacao",
|
|
136
|
+
"days": 10,
|
|
137
|
+
"description": "Maturação fisiológica",
|
|
138
|
+
"critical_water": False,
|
|
139
|
+
"tasks": [
|
|
140
|
+
{"type": "harvest", "title": "Preparar colheita", "priority": "high", "weather_sensitive": True},
|
|
141
|
+
{"type": "monitor_growth", "title": "Monitorar umidade dos grãos", "priority": "medium", "weather_sensitive": False}
|
|
142
|
+
]
|
|
143
|
+
}
|
|
144
|
+
],
|
|
145
|
+
"optimal_temp_min": 18,
|
|
146
|
+
"optimal_temp_max": 32,
|
|
147
|
+
"critical_water_phases": ["germinacao", "florescimento", "enchimento_graos"],
|
|
148
|
+
"harvest_window_days": 20
|
|
149
|
+
},
|
|
150
|
+
"feijao": {
|
|
151
|
+
"cycle_days": 90,
|
|
152
|
+
"phases": [
|
|
153
|
+
{
|
|
154
|
+
"name": "germinacao",
|
|
155
|
+
"days": 8,
|
|
156
|
+
"description": "Emergência das plântulas",
|
|
157
|
+
"critical_water": True,
|
|
158
|
+
"tasks": [
|
|
159
|
+
{"type": "irrigate", "title": "Irrigar se não houver chuva", "priority": "high", "weather_sensitive": True},
|
|
160
|
+
{"type": "inspect_pests", "title": "Inspecionar germinação", "priority": "medium", "weather_sensitive": False}
|
|
161
|
+
]
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
"name": "vegetativa",
|
|
165
|
+
"days": 30,
|
|
166
|
+
"description": "Crescimento vegetativo",
|
|
167
|
+
"critical_water": False,
|
|
168
|
+
"tasks": [
|
|
169
|
+
{"type": "fertilize", "title": "Aplicar fertilizante de cobertura", "priority": "high", "weather_sensitive": True},
|
|
170
|
+
{"type": "inspect_pests", "title": "Inspecionar pragas (vaquinha, mosca-branca)", "priority": "high", "weather_sensitive": False},
|
|
171
|
+
{"type": "irrigate", "title": "Irrigar moderadamente", "priority": "medium", "weather_sensitive": True}
|
|
172
|
+
]
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
"name": "florescimento",
|
|
176
|
+
"days": 22,
|
|
177
|
+
"description": "Floração e formação de vagens",
|
|
178
|
+
"critical_water": True,
|
|
179
|
+
"tasks": [
|
|
180
|
+
{"type": "irrigate", "title": "Irrigar - fase crítica", "priority": "critical", "weather_sensitive": True},
|
|
181
|
+
{"type": "inspect_diseases", "title": "Inspecionar doenças (antracnose, ferrugem)", "priority": "high", "weather_sensitive": False},
|
|
182
|
+
{"type": "monitor_growth", "title": "Monitorar formação de vagens", "priority": "medium", "weather_sensitive": False}
|
|
183
|
+
]
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
"name": "enchimento_graos",
|
|
187
|
+
"days": 20,
|
|
188
|
+
"description": "Enchimento de grãos",
|
|
189
|
+
"critical_water": True,
|
|
190
|
+
"tasks": [
|
|
191
|
+
{"type": "irrigate", "title": "Irrigar - fase crítica", "priority": "critical", "weather_sensitive": True},
|
|
192
|
+
{"type": "monitor_growth", "title": "Monitorar desenvolvimento das vagens", "priority": "high", "weather_sensitive": False}
|
|
193
|
+
]
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
"name": "maturacao",
|
|
197
|
+
"days": 10,
|
|
198
|
+
"description": "Maturação e secagem",
|
|
199
|
+
"critical_water": False,
|
|
200
|
+
"tasks": [
|
|
201
|
+
{"type": "harvest", "title": "Preparar colheita", "priority": "high", "weather_sensitive": True},
|
|
202
|
+
{"type": "monitor_growth", "title": "Monitorar umidade dos grãos", "priority": "medium", "weather_sensitive": False}
|
|
203
|
+
]
|
|
204
|
+
}
|
|
205
|
+
],
|
|
206
|
+
"optimal_temp_min": 18,
|
|
207
|
+
"optimal_temp_max": 29,
|
|
208
|
+
"critical_water_phases": ["germinacao", "florescimento", "enchimento_graos"],
|
|
209
|
+
"harvest_window_days": 10
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def get_crop_cycle(cultura: str) -> Optional[CropCycle]:
|
|
215
|
+
"""
|
|
216
|
+
Retorna o ciclo de uma cultura.
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
cultura: Nome da cultura
|
|
220
|
+
|
|
221
|
+
Returns:
|
|
222
|
+
CropCycle ou None se cultura não encontrada
|
|
223
|
+
"""
|
|
224
|
+
if cultura not in CROP_CYCLES:
|
|
225
|
+
return None
|
|
226
|
+
|
|
227
|
+
cycle_data = CROP_CYCLES[cultura]
|
|
228
|
+
|
|
229
|
+
phases = [
|
|
230
|
+
CropPhase(
|
|
231
|
+
name=phase["name"],
|
|
232
|
+
days=phase["days"],
|
|
233
|
+
description=phase["description"],
|
|
234
|
+
critical_water=phase["critical_water"],
|
|
235
|
+
tasks=[task["type"] for task in phase["tasks"]]
|
|
236
|
+
)
|
|
237
|
+
for phase in cycle_data["phases"]
|
|
238
|
+
]
|
|
239
|
+
|
|
240
|
+
return CropCycle(
|
|
241
|
+
culture=cultura,
|
|
242
|
+
cycle_days=cycle_data["cycle_days"],
|
|
243
|
+
phases=phases,
|
|
244
|
+
critical_water_phases=cycle_data["critical_water_phases"],
|
|
245
|
+
optimal_temp_min=cycle_data["optimal_temp_min"],
|
|
246
|
+
optimal_temp_max=cycle_data["optimal_temp_max"],
|
|
247
|
+
harvest_window_days=cycle_data["harvest_window_days"]
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def gerar_calendario_cultura(
|
|
252
|
+
cultura: str,
|
|
253
|
+
planting_date: date,
|
|
254
|
+
field: Field,
|
|
255
|
+
crop_plan_id: Optional[str] = None,
|
|
256
|
+
weather_context: Optional[Dict] = None,
|
|
257
|
+
zarc_context: Optional[Dict] = None
|
|
258
|
+
) -> Dict:
|
|
259
|
+
"""
|
|
260
|
+
Gera calendário de tarefas para uma cultura.
|
|
261
|
+
|
|
262
|
+
Args:
|
|
263
|
+
cultura: Nome da cultura
|
|
264
|
+
planting_date: Data de plantio
|
|
265
|
+
field: Dados do talhão
|
|
266
|
+
crop_plan_id: ID do plano de cultura (opcional)
|
|
267
|
+
weather_context: Contexto climático (opcional, fase futura)
|
|
268
|
+
zarc_context: Contexto ZARC (opcional, fase futura)
|
|
269
|
+
|
|
270
|
+
Returns:
|
|
271
|
+
Dict com calendário e informações do ciclo
|
|
272
|
+
"""
|
|
273
|
+
|
|
274
|
+
# Verificar se cultura existe
|
|
275
|
+
if cultura not in CROP_CYCLES:
|
|
276
|
+
return {
|
|
277
|
+
"error": f"Cultura '{cultura}' não encontrada",
|
|
278
|
+
"culturas_disponiveis": list(CROP_CYCLES.keys())
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
cycle_data = CROP_CYCLES[cultura]
|
|
282
|
+
crop_plan_id = crop_plan_id or str(uuid.uuid4())
|
|
283
|
+
|
|
284
|
+
# Calcular data estimada de colheita
|
|
285
|
+
estimated_harvest_date = planting_date + timedelta(days=cycle_data["cycle_days"])
|
|
286
|
+
|
|
287
|
+
# Gerar tarefas
|
|
288
|
+
tasks = []
|
|
289
|
+
current_date = planting_date
|
|
290
|
+
|
|
291
|
+
# Tarefa de preparação do solo (7 dias antes do plantio)
|
|
292
|
+
prepare_date = planting_date - timedelta(days=7)
|
|
293
|
+
tasks.append(
|
|
294
|
+
CalendarTask(
|
|
295
|
+
id=str(uuid.uuid4()),
|
|
296
|
+
crop_plan_id=crop_plan_id,
|
|
297
|
+
date=prepare_date,
|
|
298
|
+
type=TaskType.PREPARE_SOIL,
|
|
299
|
+
title="Preparar solo para plantio",
|
|
300
|
+
description=f"Preparar solo {field.soil_type.value} para plantio de {cultura}",
|
|
301
|
+
priority=TaskPriority.HIGH,
|
|
302
|
+
source="system",
|
|
303
|
+
weather_sensitive=False
|
|
304
|
+
)
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
# Tarefa de plantio
|
|
308
|
+
tasks.append(
|
|
309
|
+
CalendarTask(
|
|
310
|
+
id=str(uuid.uuid4()),
|
|
311
|
+
crop_plan_id=crop_plan_id,
|
|
312
|
+
date=planting_date,
|
|
313
|
+
type=TaskType.PLANT,
|
|
314
|
+
title=f"Plantar {cultura}",
|
|
315
|
+
description=f"Plantio de {cultura} em {field.area_ha} ha",
|
|
316
|
+
priority=TaskPriority.CRITICAL,
|
|
317
|
+
source="system",
|
|
318
|
+
weather_sensitive=True
|
|
319
|
+
)
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
# Tarefas por fase
|
|
323
|
+
for phase in cycle_data["phases"]:
|
|
324
|
+
phase_start = current_date
|
|
325
|
+
phase_end = current_date + timedelta(days=phase["days"])
|
|
326
|
+
|
|
327
|
+
# Distribuir tarefas ao longo da fase
|
|
328
|
+
for i, task_data in enumerate(phase["tasks"]):
|
|
329
|
+
# Espaçar tarefas uniformemente na fase
|
|
330
|
+
task_offset = (phase["days"] // (len(phase["tasks"]) + 1)) * (i + 1)
|
|
331
|
+
task_date = phase_start + timedelta(days=task_offset)
|
|
332
|
+
|
|
333
|
+
tasks.append(
|
|
334
|
+
CalendarTask(
|
|
335
|
+
id=str(uuid.uuid4()),
|
|
336
|
+
crop_plan_id=crop_plan_id,
|
|
337
|
+
date=task_date,
|
|
338
|
+
type=TaskType[task_data["type"].upper()],
|
|
339
|
+
title=task_data["title"],
|
|
340
|
+
description=f"{task_data['title']} - Fase: {phase['description']}",
|
|
341
|
+
priority=TaskPriority[task_data["priority"].upper()],
|
|
342
|
+
source="system",
|
|
343
|
+
weather_sensitive=task_data["weather_sensitive"]
|
|
344
|
+
)
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
current_date = phase_end
|
|
348
|
+
|
|
349
|
+
# Tarefa de colheita
|
|
350
|
+
tasks.append(
|
|
351
|
+
CalendarTask(
|
|
352
|
+
id=str(uuid.uuid4()),
|
|
353
|
+
crop_plan_id=crop_plan_id,
|
|
354
|
+
date=estimated_harvest_date,
|
|
355
|
+
type=TaskType.HARVEST,
|
|
356
|
+
title=f"Colher {cultura}",
|
|
357
|
+
description=f"Colheita de {cultura} - Janela de {cycle_data['harvest_window_days']} dias",
|
|
358
|
+
priority=TaskPriority.CRITICAL,
|
|
359
|
+
source="system",
|
|
360
|
+
weather_sensitive=True
|
|
361
|
+
)
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
# Ordenar tarefas por data
|
|
365
|
+
tasks.sort(key=lambda t: t.date)
|
|
366
|
+
|
|
367
|
+
# Montar resposta
|
|
368
|
+
return {
|
|
369
|
+
"cultura": cultura,
|
|
370
|
+
"planting_date": planting_date.isoformat(),
|
|
371
|
+
"estimated_harvest_date": estimated_harvest_date.isoformat(),
|
|
372
|
+
"cycle_days": cycle_data["cycle_days"],
|
|
373
|
+
"field": field.to_dict(),
|
|
374
|
+
"crop_plan_id": crop_plan_id,
|
|
375
|
+
"cycle_info": {
|
|
376
|
+
"optimal_temp_min": cycle_data["optimal_temp_min"],
|
|
377
|
+
"optimal_temp_max": cycle_data["optimal_temp_max"],
|
|
378
|
+
"critical_water_phases": cycle_data["critical_water_phases"],
|
|
379
|
+
"harvest_window_days": cycle_data["harvest_window_days"],
|
|
380
|
+
"phases": [
|
|
381
|
+
{
|
|
382
|
+
"name": phase["name"],
|
|
383
|
+
"days": phase["days"],
|
|
384
|
+
"description": phase["description"],
|
|
385
|
+
"critical_water": phase["critical_water"]
|
|
386
|
+
}
|
|
387
|
+
for phase in cycle_data["phases"]
|
|
388
|
+
]
|
|
389
|
+
},
|
|
390
|
+
"tasks": [task.to_dict() for task in tasks],
|
|
391
|
+
"total_tasks": len(tasks),
|
|
392
|
+
"weather_sensitive_tasks": sum(1 for task in tasks if task.weather_sensitive),
|
|
393
|
+
"critical_tasks": sum(1 for task in tasks if task.priority == TaskPriority.CRITICAL)
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def get_culturas_disponiveis() -> List[str]:
|
|
398
|
+
"""Retorna lista de culturas disponíveis no sistema"""
|
|
399
|
+
return list(CROP_CYCLES.keys())
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def get_cultura_info(cultura: str) -> Optional[Dict]:
|
|
403
|
+
"""
|
|
404
|
+
Retorna informações resumidas de uma cultura.
|
|
405
|
+
|
|
406
|
+
Args:
|
|
407
|
+
cultura: Nome da cultura
|
|
408
|
+
|
|
409
|
+
Returns:
|
|
410
|
+
Dict com informações ou None se não encontrada
|
|
411
|
+
"""
|
|
412
|
+
if cultura not in CROP_CYCLES:
|
|
413
|
+
return None
|
|
414
|
+
|
|
415
|
+
cycle_data = CROP_CYCLES[cultura]
|
|
416
|
+
|
|
417
|
+
return {
|
|
418
|
+
"cultura": cultura,
|
|
419
|
+
"cycle_days": cycle_data["cycle_days"],
|
|
420
|
+
"optimal_temp_min": cycle_data["optimal_temp_min"],
|
|
421
|
+
"optimal_temp_max": cycle_data["optimal_temp_max"],
|
|
422
|
+
"critical_water_phases": cycle_data["critical_water_phases"],
|
|
423
|
+
"harvest_window_days": cycle_data["harvest_window_days"],
|
|
424
|
+
"total_phases": len(cycle_data["phases"]),
|
|
425
|
+
"phases_names": [phase["name"] for phase in cycle_data["phases"]]
|
|
426
|
+
}
|
|
@@ -0,0 +1,366 @@
|
|
|
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
|
+
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from datetime import date, datetime
|
|
18
|
+
from typing import Optional, List, Dict
|
|
19
|
+
from enum import Enum
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# Enums para tipos padronizados
|
|
23
|
+
|
|
24
|
+
class SoilType(str, Enum):
|
|
25
|
+
"""Tipos de solo"""
|
|
26
|
+
ARGILOSO = "argiloso"
|
|
27
|
+
ARENOSO = "arenoso"
|
|
28
|
+
MISTO = "misto"
|
|
29
|
+
SILTOSO = "siltoso"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Slope(str, Enum):
|
|
33
|
+
"""Tipos de relevo"""
|
|
34
|
+
PLANO = "plano"
|
|
35
|
+
LEVE = "leve"
|
|
36
|
+
MEDIO = "medio"
|
|
37
|
+
INGREME = "ingreme"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class WaterAvailability(str, Enum):
|
|
41
|
+
"""Disponibilidade de água"""
|
|
42
|
+
BAIXA = "baixa"
|
|
43
|
+
MEDIA = "media"
|
|
44
|
+
ALTA = "alta"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Objective(str, Enum):
|
|
48
|
+
"""Objetivos de otimização"""
|
|
49
|
+
EQUILIBRADO = "equilibrado"
|
|
50
|
+
LUCRO = "lucro"
|
|
51
|
+
RISCO = "risco"
|
|
52
|
+
SUSTENTAVEL = "sustentavel"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class PlanStatus(str, Enum):
|
|
56
|
+
"""Status do plano de cultura"""
|
|
57
|
+
PLANNED = "planned"
|
|
58
|
+
ACTIVE = "active"
|
|
59
|
+
COMPLETED = "completed"
|
|
60
|
+
CANCELLED = "cancelled"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class TaskType(str, Enum):
|
|
64
|
+
"""Tipos de tarefa"""
|
|
65
|
+
PREPARE_SOIL = "prepare_soil"
|
|
66
|
+
PLANT = "plant"
|
|
67
|
+
IRRIGATE = "irrigate"
|
|
68
|
+
FERTILIZE = "fertilize"
|
|
69
|
+
INSPECT_PESTS = "inspect_pests"
|
|
70
|
+
INSPECT_DISEASES = "inspect_diseases"
|
|
71
|
+
MONITOR_GROWTH = "monitor_growth"
|
|
72
|
+
HARVEST = "harvest"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class TaskPriority(str, Enum):
|
|
76
|
+
"""Prioridade da tarefa"""
|
|
77
|
+
LOW = "low"
|
|
78
|
+
MEDIUM = "medium"
|
|
79
|
+
HIGH = "high"
|
|
80
|
+
CRITICAL = "critical"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class TaskStatus(str, Enum):
|
|
84
|
+
"""Status da tarefa"""
|
|
85
|
+
PENDING = "pending"
|
|
86
|
+
COMPLETED = "completed"
|
|
87
|
+
SKIPPED = "skipped"
|
|
88
|
+
RESCHEDULED = "rescheduled"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class AlertType(str, Enum):
|
|
92
|
+
"""Tipos de alerta climático"""
|
|
93
|
+
RAIN = "rain"
|
|
94
|
+
DROUGHT = "drought"
|
|
95
|
+
HEAT = "heat"
|
|
96
|
+
COLD = "cold"
|
|
97
|
+
FROST = "frost"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class AlertSeverity(str, Enum):
|
|
101
|
+
"""Severidade do alerta"""
|
|
102
|
+
INFO = "info"
|
|
103
|
+
WARNING = "warning"
|
|
104
|
+
CRITICAL = "critical"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class InterventionReason(str, Enum):
|
|
108
|
+
"""Razão da intervenção"""
|
|
109
|
+
MISSED_TASK = "missed_task"
|
|
110
|
+
WEATHER_EVENT = "weather_event"
|
|
111
|
+
SOIL_CONDITION = "soil_condition"
|
|
112
|
+
USER_REQUEST = "user_request"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class PlanningMode(str, Enum):
|
|
116
|
+
"""Modo de planejamento"""
|
|
117
|
+
GUIDED = "guided"
|
|
118
|
+
ADVANCED = "advanced"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# Modelos de Domínio
|
|
122
|
+
|
|
123
|
+
@dataclass
|
|
124
|
+
class Property:
|
|
125
|
+
"""Propriedade rural"""
|
|
126
|
+
id: str
|
|
127
|
+
name: str
|
|
128
|
+
uf: str
|
|
129
|
+
municipio: str
|
|
130
|
+
lat: Optional[float] = None
|
|
131
|
+
lon: Optional[float] = None
|
|
132
|
+
created_at: datetime = field(default_factory=datetime.now)
|
|
133
|
+
updated_at: datetime = field(default_factory=datetime.now)
|
|
134
|
+
|
|
135
|
+
def to_dict(self) -> Dict:
|
|
136
|
+
return {
|
|
137
|
+
"id": self.id,
|
|
138
|
+
"name": self.name,
|
|
139
|
+
"uf": self.uf,
|
|
140
|
+
"municipio": self.municipio,
|
|
141
|
+
"lat": self.lat,
|
|
142
|
+
"lon": self.lon,
|
|
143
|
+
"created_at": self.created_at.isoformat(),
|
|
144
|
+
"updated_at": self.updated_at.isoformat()
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@dataclass
|
|
149
|
+
class Field:
|
|
150
|
+
"""Talhão/campo"""
|
|
151
|
+
id: str
|
|
152
|
+
property_id: str
|
|
153
|
+
name: str
|
|
154
|
+
area_ha: float
|
|
155
|
+
soil_type: SoilType
|
|
156
|
+
slope: Slope
|
|
157
|
+
water_availability: WaterAvailability
|
|
158
|
+
geometry: Optional[Dict] = None # GeoJSON para mapa
|
|
159
|
+
created_at: datetime = field(default_factory=datetime.now)
|
|
160
|
+
|
|
161
|
+
def to_dict(self) -> Dict:
|
|
162
|
+
return {
|
|
163
|
+
"id": self.id,
|
|
164
|
+
"property_id": self.property_id,
|
|
165
|
+
"name": self.name,
|
|
166
|
+
"area_ha": self.area_ha,
|
|
167
|
+
"soil_type": self.soil_type.value,
|
|
168
|
+
"slope": self.slope.value,
|
|
169
|
+
"water_availability": self.water_availability.value,
|
|
170
|
+
"geometry": self.geometry,
|
|
171
|
+
"created_at": self.created_at.isoformat()
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@dataclass
|
|
176
|
+
class CropPlan:
|
|
177
|
+
"""Plano de cultura para um talhão"""
|
|
178
|
+
id: str
|
|
179
|
+
field_id: str
|
|
180
|
+
culture: str
|
|
181
|
+
planting_date: date
|
|
182
|
+
estimated_harvest_date: date
|
|
183
|
+
objective: Objective
|
|
184
|
+
status: PlanStatus = PlanStatus.PLANNED
|
|
185
|
+
created_at: datetime = field(default_factory=datetime.now)
|
|
186
|
+
|
|
187
|
+
def to_dict(self) -> Dict:
|
|
188
|
+
return {
|
|
189
|
+
"id": self.id,
|
|
190
|
+
"field_id": self.field_id,
|
|
191
|
+
"culture": self.culture,
|
|
192
|
+
"planting_date": self.planting_date.isoformat(),
|
|
193
|
+
"estimated_harvest_date": self.estimated_harvest_date.isoformat(),
|
|
194
|
+
"objective": self.objective.value,
|
|
195
|
+
"status": self.status.value,
|
|
196
|
+
"created_at": self.created_at.isoformat()
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@dataclass
|
|
201
|
+
class CropPhase:
|
|
202
|
+
"""Fase do ciclo da cultura"""
|
|
203
|
+
name: str
|
|
204
|
+
days: int
|
|
205
|
+
description: str
|
|
206
|
+
critical_water: bool
|
|
207
|
+
tasks: List[str]
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@dataclass
|
|
211
|
+
class CropCycle:
|
|
212
|
+
"""Ciclo completo de uma cultura"""
|
|
213
|
+
culture: str
|
|
214
|
+
cycle_days: int
|
|
215
|
+
phases: List[CropPhase]
|
|
216
|
+
critical_water_phases: List[str]
|
|
217
|
+
optimal_temp_min: float
|
|
218
|
+
optimal_temp_max: float
|
|
219
|
+
harvest_window_days: int
|
|
220
|
+
|
|
221
|
+
def to_dict(self) -> Dict:
|
|
222
|
+
return {
|
|
223
|
+
"culture": self.culture,
|
|
224
|
+
"cycle_days": self.cycle_days,
|
|
225
|
+
"phases": [
|
|
226
|
+
{
|
|
227
|
+
"name": phase.name,
|
|
228
|
+
"days": phase.days,
|
|
229
|
+
"description": phase.description,
|
|
230
|
+
"critical_water": phase.critical_water,
|
|
231
|
+
"tasks": phase.tasks
|
|
232
|
+
}
|
|
233
|
+
for phase in self.phases
|
|
234
|
+
],
|
|
235
|
+
"critical_water_phases": self.critical_water_phases,
|
|
236
|
+
"optimal_temp_min": self.optimal_temp_min,
|
|
237
|
+
"optimal_temp_max": self.optimal_temp_max,
|
|
238
|
+
"harvest_window_days": self.harvest_window_days
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@dataclass
|
|
243
|
+
class CalendarTask:
|
|
244
|
+
"""Tarefa do calendário agrícola"""
|
|
245
|
+
id: str
|
|
246
|
+
crop_plan_id: str
|
|
247
|
+
date: date
|
|
248
|
+
type: TaskType
|
|
249
|
+
title: str
|
|
250
|
+
description: str
|
|
251
|
+
priority: TaskPriority
|
|
252
|
+
source: str # system, user, weather_alert
|
|
253
|
+
status: TaskStatus = TaskStatus.PENDING
|
|
254
|
+
weather_sensitive: bool = False
|
|
255
|
+
completed_at: Optional[datetime] = None
|
|
256
|
+
|
|
257
|
+
def to_dict(self) -> Dict:
|
|
258
|
+
return {
|
|
259
|
+
"id": self.id,
|
|
260
|
+
"crop_plan_id": self.crop_plan_id,
|
|
261
|
+
"date": self.date.isoformat(),
|
|
262
|
+
"type": self.type.value,
|
|
263
|
+
"title": self.title,
|
|
264
|
+
"description": self.description,
|
|
265
|
+
"priority": self.priority.value,
|
|
266
|
+
"source": self.source,
|
|
267
|
+
"status": self.status.value,
|
|
268
|
+
"weather_sensitive": self.weather_sensitive,
|
|
269
|
+
"completed_at": self.completed_at.isoformat() if self.completed_at else None
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
@dataclass
|
|
274
|
+
class WeatherAlert:
|
|
275
|
+
"""Alerta climático"""
|
|
276
|
+
id: str
|
|
277
|
+
crop_plan_id: str
|
|
278
|
+
date: date
|
|
279
|
+
alert_type: AlertType
|
|
280
|
+
severity: AlertSeverity
|
|
281
|
+
message: str
|
|
282
|
+
action_suggested: Optional[str] = None
|
|
283
|
+
created_at: datetime = field(default_factory=datetime.now)
|
|
284
|
+
|
|
285
|
+
def to_dict(self) -> Dict:
|
|
286
|
+
return {
|
|
287
|
+
"id": self.id,
|
|
288
|
+
"crop_plan_id": self.crop_plan_id,
|
|
289
|
+
"date": self.date.isoformat(),
|
|
290
|
+
"alert_type": self.alert_type.value,
|
|
291
|
+
"severity": self.severity.value,
|
|
292
|
+
"message": self.message,
|
|
293
|
+
"action_suggested": self.action_suggested,
|
|
294
|
+
"created_at": self.created_at.isoformat()
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
@dataclass
|
|
299
|
+
class UserObservation:
|
|
300
|
+
"""Observação do usuário sobre o cultivo"""
|
|
301
|
+
id: str
|
|
302
|
+
crop_plan_id: str
|
|
303
|
+
date: date
|
|
304
|
+
note: str
|
|
305
|
+
impact: str # positive, neutral, negative
|
|
306
|
+
created_at: datetime = field(default_factory=datetime.now)
|
|
307
|
+
|
|
308
|
+
def to_dict(self) -> Dict:
|
|
309
|
+
return {
|
|
310
|
+
"id": self.id,
|
|
311
|
+
"crop_plan_id": self.crop_plan_id,
|
|
312
|
+
"date": self.date.isoformat(),
|
|
313
|
+
"note": self.note,
|
|
314
|
+
"impact": self.impact,
|
|
315
|
+
"created_at": self.created_at.isoformat()
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
@dataclass
|
|
320
|
+
class Intervention:
|
|
321
|
+
"""Intervenção/replanejamento"""
|
|
322
|
+
id: str
|
|
323
|
+
crop_plan_id: str
|
|
324
|
+
reason: InterventionReason
|
|
325
|
+
suggested_action: str
|
|
326
|
+
original_task_id: Optional[str] = None
|
|
327
|
+
new_date: Optional[date] = None
|
|
328
|
+
risk_adjustment: float = 0.0
|
|
329
|
+
created_at: datetime = field(default_factory=datetime.now)
|
|
330
|
+
applied: bool = False
|
|
331
|
+
|
|
332
|
+
def to_dict(self) -> Dict:
|
|
333
|
+
return {
|
|
334
|
+
"id": self.id,
|
|
335
|
+
"crop_plan_id": self.crop_plan_id,
|
|
336
|
+
"original_task_id": self.original_task_id,
|
|
337
|
+
"reason": self.reason.value,
|
|
338
|
+
"suggested_action": self.suggested_action,
|
|
339
|
+
"new_date": self.new_date.isoformat() if self.new_date else None,
|
|
340
|
+
"risk_adjustment": self.risk_adjustment,
|
|
341
|
+
"created_at": self.created_at.isoformat(),
|
|
342
|
+
"applied": self.applied
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
@dataclass
|
|
347
|
+
class PlanningSession:
|
|
348
|
+
"""Sessão de planejamento"""
|
|
349
|
+
id: str
|
|
350
|
+
property_id: str
|
|
351
|
+
mode: PlanningMode
|
|
352
|
+
objective: Objective
|
|
353
|
+
fields_count: int
|
|
354
|
+
cultures_recommended: List[str]
|
|
355
|
+
created_at: datetime = field(default_factory=datetime.now)
|
|
356
|
+
|
|
357
|
+
def to_dict(self) -> Dict:
|
|
358
|
+
return {
|
|
359
|
+
"id": self.id,
|
|
360
|
+
"property_id": self.property_id,
|
|
361
|
+
"mode": self.mode.value,
|
|
362
|
+
"objective": self.objective.value,
|
|
363
|
+
"fields_count": self.fields_count,
|
|
364
|
+
"cultures_recommended": self.cultures_recommended,
|
|
365
|
+
"created_at": self.created_at.isoformat()
|
|
366
|
+
}
|