import logging import psycopg2 from psycopg2.errors import UniqueViolation import pandas as pd import requests import os from pathlib import Path from dotenv import load_dotenv import smtplib from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart from fastapi import FastAPI, HTTPException, UploadFile, File, BackgroundTasks, Depends from fastapi.responses import JSONResponse from fastapi.middleware.cors import CORSMiddleware from fastapi.encoders import jsonable_encoder from pydantic import BaseModel from typing import Optional # Import our new database manager and the matching engine from database import get_db_connection, init_db from matching_engine import run_matching_engine from diagnostics_engine import run_botanical_diagnosis logging.basicConfig(level=logging.INFO) logger = logging.getLogger("uzoagro-api") load_dotenv(Path(__file__).resolve().with_name('.env')) PAYSTACK_SECRET_KEY = os.getenv("PAYSTACK_SECRET_KEY") def verify_paystack_payment(reference: str) -> bool: """Securely asks Paystack if the transaction was actually successful.""" url = f"https://api.paystack.co/transaction/verify/{reference}" headers = { "Authorization": f"Bearer {PAYSTACK_SECRET_KEY}" } try: response = requests.get(url, headers=headers) if response.status_code == 200: data = response.json() # Double-check that the transaction status is literally "success" if data.get("data", {}).get("status") == "success": return True except Exception as e: print(f"Paystack verification error: {e}") return False app = FastAPI(title="UzoAgro AI Matching API", version="0.1.0") # Trace shares the existing application and PostgreSQL configuration. from trace_api import router as trace_router app.include_router(trace_router) from auth_api import router as auth_router from auth_api import farmer app.include_router(auth_router) from logistics_api import router as logistics_router app.include_router(logistics_router) # CORS config explicitly allowing your Vercel frontend (and everything else) to talk to this API app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # Expanded 15-City Geographic Dictionary CITIES = { # West / Mid-West "Lagos": (6.5244, 3.3792), "Ibadan": (7.3775, 3.9470), "Ilorin": (8.4799, 4.5418), "Akure": (7.2571, 5.2058), # North / Middle-Belt "Abuja": (9.0765, 7.3986), "Kano": (12.0022, 8.5920), "Kaduna": (10.5105, 7.4165), "Jos": (9.8965, 8.8583), "Sokoto": (13.0059, 5.2476), # East "Enugu": (6.4402, 7.4943), "Awka": (6.2100, 7.0700), "Onitsha": (6.1500, 6.7800), "Owerri": (5.4833, 7.0333), # South-South "Port Harcourt": (4.8156, 7.0498), "Uyo": (5.0380, 7.9098) } # --- Pydantic Data Models --- class DiagnosisRequest(BaseModel): crop_type: str symptoms: str class FarmerRequest(BaseModel): farmer_name: str pickup_city: str dropoff_city: str crop_type: str required_capacity: float requested_date: str class UserSignup(BaseModel): role: str name: str phone: str nin: str primary_city: str pin: str email: Optional[str] = None class UserLogin(BaseModel): phone: str pin: str # --- API Endpoints --- @app.on_event("startup") def startup_load_data(): """Ensure database is ready when the server starts.""" try: if os.getenv("DATABASE_URL"): init_db() else: logger.warning("DATABASE_URL is not configured; account endpoints will return 503.") except Exception as e: logger.exception("Failed to initialize database on startup") raise @app.get("/") def read_root(): """Health check endpoint to prove the API is alive to Hugging Face.""" return {"message": "UzoAgro API is running smoothly!"} # ... (keep your existing imports and database connection setup) ... # 1. THE MOCK KYC FUNCTION (Place this above your routes) def verify_identity_mock(nin: str, name: str) -> bool: """ Simulates a call to Prembly/Dojah. In the future, we replace this logic with the real API request. """ # Simulate a small delay like a real server import time time.sleep(1) # Simple validation rule for testing: # If the NIN is exactly 11 digits, we assume it's valid. if len(nin) == 11 and nin.isdigit(): return True return False def send_welcome_email(user_email: str, user_name: str, role: str): """ Sends a welcome email via SMTP. Skips execution if credentials are missing or email is invalid. """ sender_email = os.getenv("EMAIL_SENDER") sender_password = os.getenv("EMAIL_PASSWORD") if not sender_email or not sender_password or not user_email: return msg = MIMEMultipart() msg['From'] = f"UzoAgro AI <{sender_email}>" msg['To'] = user_email msg['Subject'] = "Welcome to UzoAgro AI! 🌿" html_content = f"""
""" msg.attach(MIMEText(html_content, 'html')) @app.post("/api/diagnose/image", response_class=JSONResponse) async def analyze_crop_image(file: UploadFile = File(...), owner=Depends(farmer)): """Receives a crop image from the frontend and passes it to the AI.""" try: # Read the image file into memory image_bytes = await file.read() # Pass the raw image to your friend's wrapper results = run_botanical_diagnosis(image_bytes) return JSONResponse(content=results) except Exception as e: logger.exception("Failed to process uploaded image.") raise HTTPException(status_code=500, detail="Image processing failed.") @app.post("/match/custom", response_class=JSONResponse) def match_custom_request(req: FarmerRequest, owner=Depends(farmer)): """Saves the request to the DB and returns the top 3 AI matches.""" req.farmer_name = owner['name'] if req.pickup_city not in CITIES or req.dropoff_city not in CITIES: raise HTTPException(status_code=400, detail="Invalid city selected") p_lat, p_lon = CITIES[req.pickup_city] d_lat, d_lon = CITIES[req.dropoff_city] # Generate a new unique request ID req_id = f"REQ-LIVE-{pd.Timestamp.now().strftime('%H%M%S')}" # 1. Save the new request permanently to the PostgreSQL database conn = get_db_connection() cursor = conn.cursor() # Crucial Fix: Changed SQLite's '?' to PostgreSQL's '%s' cursor.execute(''' INSERT INTO requests (request_id, sender_name, phone, pickup_city, pickup_lat, pickup_lon, dropoff_city, dropoff_lat, dropoff_lon, requested_date, required_capacity, crop_type) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) ''', ( req_id, req.farmer_name, "PENDING", req.pickup_city, p_lat, p_lon, req.dropoff_city, d_lat, d_lon, req.requested_date, req.required_capacity, req.crop_type )) conn.commit() # 2. Safely pull drivers OR inject live demo data if DB is empty cursor.execute("SELECT * FROM drivers") rows = cursor.fetchall() if not rows: # EMERGENCY DEMO DATA: Guarantees your pitch works perfectly on stage! drivers_df = pd.DataFrame([ {"driver_id": "DRV-001", "driver_name": "Oluwaseun Logistics", "phone": "080111", "current_city": "Lagos", "current_lat": 6.5244, "current_lon": 3.3792, "home_base": "Kano", "capacity": 30.0, "allowed_crops": "Maize, Yam", "available_date": pd.Timestamp.now()}, {"driver_id": "DRV-002", "driver_name": "Northern Freight", "phone": "080222", "current_city": "Ibadan", "current_lat": 7.3775, "current_lon": 3.9470, "home_base": "Kano", "capacity": 15.0, "allowed_crops": "Maize, Tomatoes", "available_date": pd.Timestamp.now()}, {"driver_id": "DRV-003", "driver_name": "Emeka Transports", "phone": "080333", "current_city": "Abuja", "current_lat": 9.0765, "current_lon": 7.3986, "home_base": "Lagos", "capacity": 20.0, "allowed_crops": "Yam, Cassava", "available_date": pd.Timestamp.now()} ]) else: # The bulletproof way to read PostgreSQL into Pandas without SQLAlchemy colnames = [desc[0] for desc in cursor.description] drivers_df = pd.DataFrame(rows, columns=colnames) drivers_df["available_date"] = pd.to_datetime(drivers_df["available_date"]) conn.close() # 3. Create a DataFrame for this specific request to feed the AI Engine single_req_df = pd.DataFrame([{ "request_id": req_id, "pickup_lat": p_lat, "pickup_lon": p_lon, "dropoff_lat": d_lat, "dropoff_lon": d_lon, "requested_date": pd.to_datetime(req.requested_date), "required_capacity": req.required_capacity, "crop_type": req.crop_type }]) # 4. Run the matching intelligence try: matches_df = run_matching_engine(drivers_df, single_req_df, top_k=3) payload = jsonable_encoder(matches_df.to_dict(orient="records")) return JSONResponse(content=payload) except Exception as e: logger.exception("Error computing live matches") raise HTTPException(status_code=500, detail=str(e))