Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 87 additions & 37 deletions backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,13 +118,16 @@ def verify_token(credentials: HTTPAuthorizationCredentials = Depends(security))
resnet_model.fc = nn.Linear(resnet_model.fc.in_features, NUM_CLASSES)
MODEL_PATH = os.path.join(BASE_DIR, "model", "plant_disease_resnet18.pth")

if os.path.exists(MODEL_PATH):
resnet_model.load_state_dict(torch.load(MODEL_PATH, map_location=device))
resnet_model = resnet_model.to(device)
resnet_model.eval()
print(f"[OK] ResNet18 loaded - {NUM_CLASSES} classes on {device}")
if os.path.exists(MODEL_PATH) and os.path.getsize(MODEL_PATH) > 0:
try:
resnet_model.load_state_dict(torch.load(MODEL_PATH, map_location=device))
resnet_model = resnet_model.to(device)
resnet_model.eval()
print(f"[OK] ResNet18 loaded -- {NUM_CLASSES} classes on {device}")
except Exception as e:
print(f"[WARN] Failed to load model weights: {e}. Inference will fail.")
else:
print(f"[WARN] Model weights not found at {MODEL_PATH}. Inference will fail.")
print(f"[WARN] Model weights not found or empty at {MODEL_PATH}. Inference will fail.")

# CLIP model (OOD guard) — lazy load to keep startup fast
clip_model = None
Expand Down Expand Up @@ -220,12 +223,23 @@ class YieldCreate(BaseModel):
quality_notes: Optional[str] = None

class FieldCreate(BaseModel):
name: str
area_acres: float
polygon: List[Dict[str, float]]
center_latitude: Optional[float] = None
center_longitude: Optional[float] = None


class FieldResponse(BaseModel):
id: str
farm_id: str
name: str
area_acres: float
area_hectares: Optional[float] = None
polygon: List[Dict[str, float]]
center_latitude: Optional[float] = None
center_longitude: Optional[float] = None
created_at: Optional[str] = None

class SoilEstimationRequest(BaseModel):
farm_id: str
Expand Down Expand Up @@ -642,51 +656,87 @@ async def delete_farm(farm_id: str, user=Depends(verify_token)):
# ── Farm Fields (Polygon) ─────────────────────────────────────────────────────

@app.get("/api/farms/{farm_id}/fields")
async def get_fields(farm_id: str, user=Depends(verify_token)):
res = supabase.table("farms").select("location").eq("id", farm_id).eq("farmer_id", user.id).limit(1).execute()
if not res.data:
async def get_fields(
farm_id: str,
user: dict = Depends(verify_token),
) -> dict:
# Verify farm ownership before returning fields
owned = (
supabase.table("farms")
.select("id")
.eq("id", farm_id)
.eq("farmer_id", user.id)
.limit(1)
.execute()
)
if not owned.data:
raise HTTPException(status_code=404, detail="Farm not found")
fields = (res.data.get("location") or {}).get("fields", [])
return {"success": True, "data": fields}

res = (
supabase.table("farm_fields")
.select("*")
.eq("farm_id", farm_id)
.order("created_at")
.execute()
)
return {"success": True, "data": res.data or []}


@app.post("/api/farms/{farm_id}/fields", status_code=201)
async def add_field(farm_id: str, body: FieldCreate, user=Depends(verify_token)):
owned = supabase.table("farms").select("location, total_area").eq("id", farm_id).eq("farmer_id", user.id).limit(1).execute()
async def add_field(
farm_id: str,
body: FieldCreate,
user: dict = Depends(verify_token),
) -> dict:
# Verify farm ownership
owned = (
supabase.table("farms")
.select("id")
.eq("id", farm_id)
.eq("farmer_id", user.id)
.limit(1)
.execute()
)
if not owned.data:
raise HTTPException(status_code=403, detail="Access denied")

current_loc = owned.data[0][0].get("location") or {}
current_fields = current_loc.get("fields", [])

new_field = {
"id": str(uuid.uuid4()),
**body.model_dump(),
payload: dict = {
"farm_id": farm_id,
"name": body.name,
"area_acres": body.area_acres,
"polygon": body.polygon,
"center_latitude": body.center_latitude,
"center_longitude": body.center_longitude,
}
updated_fields = current_fields + [new_field]
total_area = sum(f.get("area_acres", 0) for f in updated_fields)
# Strip None values so DB defaults apply
payload = {k: v for k, v in payload.items() if v is not None}

res = supabase.table("farm_fields").insert(payload).execute()
if not res.data:
raise HTTPException(status_code=500, detail="Failed to save field")

supabase.table("farms").update({
"location": {**current_loc, "fields": updated_fields},
"total_area": total_area,
}).eq("id", farm_id).execute()
return {"success": True, "data": res.data[0]}

return {"success": True, "data": new_field}

@app.delete("/api/farms/{farm_id}/fields/{field_id}")
async def delete_field(farm_id: str, field_id: str, user=Depends(verify_token)):
owned = supabase.table("farms").select("location, total_area").eq("id", farm_id).eq("farmer_id", user.id).limit(1).execute()
async def delete_field(
farm_id: str,
field_id: str,
user: dict = Depends(verify_token),
) -> dict:
# Verify farm ownership
owned = (
supabase.table("farms")
.select("id")
.eq("id", farm_id)
.eq("farmer_id", user.id)
.limit(1)
.execute()
)
if not owned.data:
raise HTTPException(status_code=403, detail="Access denied")

current_loc = owned.data[0][0].get("location") or {}
current_fields = current_loc.get("fields", [])
updated_fields = [f for f in current_fields if f.get("id") != field_id]
total_area = sum(f.get("area_acres", 0) for f in updated_fields) or owned.data[0][0].get("total_area", 0)

supabase.table("farms").update({
"location": {**current_loc, "fields": updated_fields},
"total_area": total_area,
}).eq("id", farm_id).execute()
supabase.table("farm_fields").delete().eq("id", field_id).eq("farm_id", farm_id).execute()
return {"success": True, "message": "Field deleted"}


Expand Down
198 changes: 198 additions & 0 deletions backend/supabase/migrations/20260607000000_farm_fields_table.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
-- =============================================================================
-- AgroNavis — Migration 6: Dedicated farm_fields table
-- Applied: 2026-06-07
--
-- Replaces the JSONB fields array embedded in farms.location with a proper
-- relational table. This enables:
-- • RLS policies scoped to individual fields
-- • Indexed spatial lookups by farm
-- • Clean foreign-key cascade deletes
-- • Accurate total_area derived via DB aggregation
--
-- Backwards compatibility: farms.location.polygon (legacy single-polygon)
-- is left untouched so existing data continues to render in FarmMap.
-- =============================================================================

-- -----------------------------------------------------------------------------
-- 1. Create farm_fields table
-- -----------------------------------------------------------------------------

CREATE TABLE IF NOT EXISTS public.farm_fields (
id uuid NOT NULL DEFAULT gen_random_uuid(),
farm_id uuid NOT NULL REFERENCES public.farms(id) ON DELETE CASCADE,

name text NOT NULL,
area_acres numeric NOT NULL CHECK (area_acres > 0),
area_hectares numeric GENERATED ALWAYS AS (area_acres * 0.404686) STORED,

-- Polygon stored as JSONB array of {lat, lng} points
-- e.g. [{"lat": 22.57, "lng": 88.36}, ...]
polygon jsonb NOT NULL,

center_latitude numeric,
center_longitude numeric,

created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),

CONSTRAINT farm_fields_pkey PRIMARY KEY (id)
);

-- Index: fast lookup of all fields for a given farm
CREATE INDEX IF NOT EXISTS idx_farm_fields_farm_id
ON public.farm_fields (farm_id);

-- Index: spatial-ish lookup by centre coordinates
CREATE INDEX IF NOT EXISTS idx_farm_fields_center
ON public.farm_fields (center_latitude, center_longitude)
WHERE center_latitude IS NOT NULL AND center_longitude IS NOT NULL;


-- -----------------------------------------------------------------------------
-- 2. updated_at trigger
-- -----------------------------------------------------------------------------

CREATE TRIGGER set_farm_fields_updated_at
BEFORE UPDATE ON public.farm_fields
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();


-- -----------------------------------------------------------------------------
-- 3. Row Level Security
-- Farmers can only see and modify fields that belong to their own farms.
-- -----------------------------------------------------------------------------

ALTER TABLE public.farm_fields ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Farmers can view fields on own farms"
ON public.farm_fields FOR SELECT
USING (
auth.uid() IN (
SELECT farmer_id FROM public.farms WHERE id = farm_id
)
);

CREATE POLICY "Farmers can insert fields on own farms"
ON public.farm_fields FOR INSERT
WITH CHECK (
auth.uid() IN (
SELECT farmer_id FROM public.farms WHERE id = farm_id
)
);

CREATE POLICY "Farmers can update fields on own farms"
ON public.farm_fields FOR UPDATE
USING (
auth.uid() IN (
SELECT farmer_id FROM public.farms WHERE id = farm_id
)
);

CREATE POLICY "Farmers can delete fields on own farms"
ON public.farm_fields FOR DELETE
USING (
auth.uid() IN (
SELECT farmer_id FROM public.farms WHERE id = farm_id
)
);


-- -----------------------------------------------------------------------------
-- 4. Auto-update farms.total_area when fields change
-- Keeps the farms row in sync without needing application-level bookkeeping.
-- -----------------------------------------------------------------------------

CREATE OR REPLACE FUNCTION public.sync_farm_total_area()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
DECLARE
v_farm_id uuid;
v_total numeric;
BEGIN
-- Determine the affected farm_id from either NEW or OLD row
v_farm_id := COALESCE(NEW.farm_id, OLD.farm_id);

SELECT COALESCE(SUM(area_acres), 0)
INTO v_total
FROM public.farm_fields
WHERE farm_id = v_farm_id;

UPDATE public.farms
SET total_area = v_total
WHERE id = v_farm_id;

RETURN COALESCE(NEW, OLD);
END;
$$;

CREATE TRIGGER trg_sync_farm_total_area
AFTER INSERT OR UPDATE OR DELETE ON public.farm_fields
FOR EACH ROW
EXECUTE FUNCTION public.sync_farm_total_area();


-- -----------------------------------------------------------------------------
-- 5. Migrate existing JSONB fields into the new table
-- Reads farms.location->>'fields' and inserts each entry as a proper row.
-- Safe to run even if the JSONB array is empty or missing.
-- -----------------------------------------------------------------------------

DO $$
DECLARE
r RECORD;
field_row jsonb;
BEGIN
FOR r IN
SELECT id, location
FROM public.farms
WHERE location IS NOT NULL
AND location->'fields' IS NOT NULL
AND jsonb_array_length(location->'fields') > 0
LOOP
FOR field_row IN
SELECT jsonb_array_elements(r.location->'fields')
LOOP
INSERT INTO public.farm_fields (
id,
farm_id,
name,
area_acres,
polygon,
center_latitude,
center_longitude
) VALUES (
COALESCE((field_row->>'id')::uuid, gen_random_uuid()),
r.id,
COALESCE(field_row->>'name', 'Unnamed Field'),
COALESCE((field_row->>'area_acres')::numeric, 0),
COALESCE(field_row->'polygon', '[]'::jsonb),
(field_row->>'center_latitude')::numeric,
(field_row->>'center_longitude')::numeric
)
ON CONFLICT (id) DO NOTHING;
END LOOP;
END LOOP;
END;
$$;


-- -----------------------------------------------------------------------------
-- 6. Grant service_role access (used by FastAPI backend)
-- -----------------------------------------------------------------------------

GRANT SELECT, INSERT, UPDATE, DELETE ON public.farm_fields TO service_role;


-- =============================================================================
-- DONE
-- Table: farm_fields (id, farm_id, name, area_acres, area_hectares,
-- polygon, center_latitude, center_longitude,
-- created_at, updated_at)
-- Trigger: trg_sync_farm_total_area — keeps farms.total_area accurate
-- Trigger: set_farm_fields_updated_at — auto-updates updated_at
-- RLS: SELECT / INSERT / UPDATE / DELETE scoped to farm owner
-- Index: idx_farm_fields_farm_id, idx_farm_fields_center
-- Migration: existing JSONB fields copied into new table
-- =============================================================================
Loading
Loading