File size: 15,327 Bytes
ffdf96a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
"""Trace MVP: persistent records, PIN sessions, vision assessments and chain verification."""
import base64
import hashlib
import hmac
import io
import json
import os
import secrets
import sqlite3
import threading
from datetime import date, datetime, timedelta, timezone
from pathlib import Path

import requests
from dotenv import load_dotenv
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from fastapi.responses import Response
from pydantic import BaseModel, Field

load_dotenv(Path(__file__).resolve().with_name('.env'))
router = APIRouter(prefix="/api/trace", tags=["UzoAgro Trace"])
lock = threading.RLock()
security = HTTPBearer(auto_error=False)


def now():
    return datetime.now(timezone.utc).isoformat()


def fingerprint(record):
    return hashlib.sha256(json.dumps(record, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()).hexdigest()


def db():
    if os.getenv("DATABASE_URL"):
        import psycopg2
        try:
            conn = psycopg2.connect(os.environ["DATABASE_URL"], connect_timeout=10)
        except psycopg2.Error:
            raise HTTPException(503, 'The Neon database is temporarily unavailable. Please try again.')
    elif os.getenv("TRACE_SQLITE_PATH"):
        conn = sqlite3.connect(os.getenv("TRACE_SQLITE_PATH", str(Path(__file__).parent / "trace.db")), timeout=20)
    else:
        raise HTTPException(503, "Neon database is not configured. Set DATABASE_URL on the backend.")
    with conn.cursor() if os.getenv("DATABASE_URL") else conn as cur:
        if os.getenv("DATABASE_URL"):
            cur.execute("CREATE TABLE IF NOT EXISTS trace_records (id TEXT PRIMARY KEY, payload TEXT NOT NULL)")
        else:
            conn.execute("CREATE TABLE IF NOT EXISTS trace_records (id TEXT PRIMARY KEY, payload TEXT NOT NULL)")
    conn.commit()
    return conn


def put(key, value):
    conn = db()
    try:
        cur = conn.cursor()
        mark = "%s" if os.getenv("DATABASE_URL") else "?"
        cur.execute(f"INSERT INTO trace_records(id,payload) VALUES ({mark},{mark}) ON CONFLICT(id) DO UPDATE SET payload=excluded.payload", (key, json.dumps(value)))
        conn.commit()
    finally:
        conn.close()


def get(key):
    conn = db()
    try:
        cur = conn.cursor()
        cur.execute("SELECT payload FROM trace_records WHERE id=" + ("%s" if os.getenv("DATABASE_URL") else "?"), (key,))
        row = cur.fetchone()
        return json.loads(row[0]) if row else None
    finally:
        conn.close()


def all_records(prefix):
    conn = db()
    try:
        cur = conn.cursor()
        cur.execute("SELECT payload FROM trace_records WHERE id LIKE " + ("%s" if os.getenv("DATABASE_URL") else "?"), (prefix + "%",))
        return [json.loads(row[0]) for row in cur.fetchall()]
    finally:
        conn.close()


# Share the existing authenticated farmer identity.
from auth_api import farmer as user


@router.get("/config")
def config():
    return {"contract_address": os.getenv("TRACE_CONTRACT_ADDRESS", ""), "chain_id": 84532, "rpc_url": os.getenv("BASE_SEPOLIA_RPC_URL", "https://sepolia.base.org"), "vision_ready": bool(os.getenv("OPENAI_API_KEY")), "storage": "postgresql" if os.getenv("DATABASE_URL") else "test sqlite" if os.getenv("TRACE_SQLITE_PATH") else "not configured"}


@router.get("/qr")
def qr(url: str, download: bool = False):
    import qrcode
    import qrcode.image.svg
    from urllib.parse import urlparse
    parsed = urlparse(url)
    if parsed.scheme not in ("http", "https") or len(url) > 1000:
        raise HTTPException(400, "Invalid verification URL.")
    image = qrcode.make(url, image_factory=qrcode.image.svg.SvgPathImage, border=3)
    stream = io.BytesIO()
    image.save(stream)
    headers = {"Content-Disposition": 'attachment; filename="uzoagro-batch.svg"'} if download else {}
    return Response(stream.getvalue(), media_type="image/svg+xml", headers=headers)


class Assessment(BaseModel):
    crop: str = Field(max_length=80)
    condition: str = Field(max_length=200)
    confidence: float = Field(ge=0, le=1)
    recommendation: str = Field(max_length=2000)


@router.post("/diagnose")
async def diagnose(file: UploadFile = File(...), owner=Depends(user)):
    if not os.getenv("OPENAI_API_KEY"):
        raise HTTPException(503, "Vision model is not configured. Use the labeled sample assessment to explore the workflow.")
    content = await file.read(8 * 1024 * 1024 + 1)
    if len(content) > 8 * 1024 * 1024:
        raise HTTPException(413, "Choose an image smaller than 8 MB.")
    try:
        from PIL import Image
        image = Image.open(io.BytesIO(content))
        if image.format not in ("JPEG", "PNG", "WEBP"):
            raise ValueError()
        image.verify()
        mime = {"JPEG": "image/jpeg", "PNG": "image/png", "WEBP": "image/webp"}[image.format]
    except Exception:
        raise HTTPException(400, "Upload a valid JPG, PNG or WebP crop image.")
    try:
        response = requests.post("https://api.openai.com/v1/responses", headers={"Authorization": "Bearer " + os.environ["OPENAI_API_KEY"]}, json={"model": os.getenv("VISION_MODEL", "gpt-4.1-mini"), "store": False, "input": [{"role": "user", "content": [{"type": "input_text", "text": 'Assess this crop image. Return ONLY JSON with crop, condition, confidence (0 to 1, an uncalibrated model estimate), recommendation. Be cautious about uncertainty. If not a crop, state that. Recommend consulting an extension professional, never prescribe pesticide concentrations or destructive actions.'}, {"type": "input_image", "image_url": "data:" + mime + ";base64," + base64.b64encode(content).decode()}]}]}, timeout=60)
        response.raise_for_status()
        output = "".join(c.get("text", "") for item in response.json().get("output", []) for c in item.get("content", []) if c.get("type") == "output_text")
        assessment = Assessment.model_validate_json(output.strip().removeprefix("```json").removeprefix("```").removesuffix("```").strip()).model_dump()
    except Exception:
        raise HTTPException(502, "The vision service could not return an assessment. Please try again.")
    record = assessment | {"id": "DIA-" + secrets.token_hex(6).upper(), "created_at": now(), "sample": False, "image_hash": hashlib.sha256(content).hexdigest()}
    record["hash"] = fingerprint(record)
    put("diagnosis:" + record["id"], {"owner": owner["phone"], "report": record})
    return record


@router.post("/diagnose/sample")
def sample_diagnosis(owner=Depends(user)):
    record = {"id": "DIA-" + secrets.token_hex(6).upper(), "crop": "Cassava", "condition": "Possible mosaic symptoms — sample", "confidence": 0.86, "recommendation": "This is a sample report, not an analysis of your image. For a real assessment, consult an agricultural extension professional.", "created_at": now(), "sample": True}
    record["hash"] = fingerprint(record)
    put("diagnosis:" + record["id"], {"owner": owner["phone"], "report": record})
    return record


class NewBatch(BaseModel):
    crop: str = Field(min_length=2, max_length=64)
    quantity: float = Field(gt=0, le=10000000)
    community: str = Field(min_length=2, max_length=120)
    harvest_date: date
    diagnosis_id: str | None = None


def batch_or_404(batch_id):
    batch = get("batch:" + batch_id)
    if not batch:
        raise HTTPException(404, "Batch not found. Check the ID and try again.")
    return batch


def owned(batch_id, owner):
    batch = batch_or_404(batch_id)
    if batch["owner"] != owner["phone"]:
        raise HTTPException(403, "Only the batch owner can update this record.")
    return batch


def public(batch):
    return {k: v for k, v in batch.items() if k != "owner"}


@router.post("/batches")
def create_batch(data: NewBatch, owner=Depends(user)):
    if data.harvest_date > date.today():
        raise HTTPException(422, "Harvest date cannot be in the future.")
    diagnosis = None
    if data.diagnosis_id:
        stored = get("diagnosis:" + data.diagnosis_id)
        if not stored or stored["owner"] != owner["phone"]:
            raise HTTPException(404, "Assessment not found.")
        diagnosis = stored["report"]
    batch_id = "UZA-" + secrets.token_hex(4).upper()
    metadata = data.model_dump(mode="json", exclude={"diagnosis_id"}) | {"id": batch_id, "farmer": owner["name"], "created_at": now(), "diagnosis": diagnosis}
    batch = {"id": batch_id, "owner": owner["phone"], "metadata": metadata, "hash": fingerprint(metadata), "chain_key": hashlib.sha256(batch_id.encode()).hexdigest(), "transaction": None, "checkpoints": []}
    put("batch:" + batch_id, batch)
    return public(batch)


@router.get("/batches")
def list_batches(owner=Depends(user)):
    return sorted([public(b) for b in all_records("batch:") if b["owner"] == owner["phone"]], key=lambda b: b["metadata"]["created_at"], reverse=True)


@router.get("/batches/{batch_id}")
def read_batch(batch_id: str):
    return public(batch_or_404(batch_id))


@router.get("/batches/{batch_id}/history")
def history(batch_id: str):
    batch = batch_or_404(batch_id)
    return {"batch_id": batch_id, "created_at": batch["metadata"]["created_at"], "checkpoints": batch["checkpoints"]}


class NewCheckpoint(BaseModel):
    stage: str = Field(pattern="^(Picked up|Received at hub|Delivered to buyer)$")
    location: str = Field(min_length=2, max_length=120)


@router.post("/batches/{batch_id}/checkpoints")
def add_checkpoint(batch_id: str, data: NewCheckpoint, owner=Depends(user)):
    with lock:
        batch = owned(batch_id, owner)
        expected = ["Picked up", "Received at hub", "Delivered to buyer"]
        if len(batch["checkpoints"]) >= 3 or data.stage != expected[len(batch["checkpoints"])]:
            raise HTTPException(409, "Record checkpoints in order: pickup, aggregation, delivery.")
        if any(not cp["transaction"] for cp in batch["checkpoints"]) and batch["transaction"]:
            raise HTTPException(409, "Anchor the previous checkpoint first.")
        record = data.model_dump() | {"id": "CP-" + secrets.token_hex(6).upper(), "batch_id": batch_id, "recorded_by": owner["name"], "created_at": now()}
        cp = {"record": record, "hash": fingerprint(record), "transaction": None}
        batch["checkpoints"].append(cp)
        put("batch:" + batch_id, batch)
        return public(batch)


ABI = [
    {"type": "function", "name": "getBatch", "stateMutability": "view", "inputs": [{"name": "id", "type": "bytes32"}], "outputs": [{"type": "tuple", "components": [{"name": "creator", "type": "address"}, {"name": "crop", "type": "string"}, {"name": "dataHash", "type": "bytes32"}, {"name": "createdAt", "type": "uint256"}, {"name": "exists", "type": "bool"}]}]},
    {"type": "function", "name": "getBatchCheckpoints", "stateMutability": "view", "inputs": [{"name": "id", "type": "bytes32"}], "outputs": [{"type": "tuple[]", "components": [{"name": "stage", "type": "string"}, {"name": "location", "type": "string"}, {"name": "dataHash", "type": "bytes32"}, {"name": "timestamp", "type": "uint256"}, {"name": "recordedBy", "type": "address"}]}]},
]


def chain():
    from web3 import Web3
    address = os.getenv("TRACE_CONTRACT_ADDRESS")
    if not address:
        raise HTTPException(503, "Deploy the contract and configure TRACE_CONTRACT_ADDRESS first.")
    w3 = Web3(Web3.HTTPProvider(os.getenv("BASE_SEPOLIA_RPC_URL", "https://sepolia.base.org"), request_kwargs={"timeout": 15}))
    if w3.eth.chain_id != 84532:
        raise HTTPException(503, "RPC must use Base Sepolia.")
    return w3, w3.eth.contract(address=Web3.to_checksum_address(address), abi=ABI)


class Anchor(BaseModel):
    transaction: str = Field(pattern=r"^0x[0-9a-fA-F]{64}$")
    checkpoint_id: str | None = None


@router.post("/batches/{batch_id}/anchor")
def anchor(batch_id: str, data: Anchor, owner=Depends(user)):
    with lock:
        batch = owned(batch_id, owner)
        target = batch
        index = None
        if data.checkpoint_id:
            matches = [(i, cp) for i, cp in enumerate(batch["checkpoints"]) if cp["record"]["id"] == data.checkpoint_id]
            if not matches or not batch["transaction"]:
                raise HTTPException(409, "Anchor the batch before its checkpoints.")
            index, target = matches[0]
        try:
            w3, contract = chain()
            receipt = w3.eth.get_transaction_receipt(data.transaction)
            tx = w3.eth.get_transaction(data.transaction)
            if receipt["status"] != 1 or not tx["to"] or tx["to"].lower() != contract.address.lower():
                raise HTTPException(422, "Transaction was not a successful call to this contract.")
            expected_event = w3.keccak(text="CheckpointAdded(bytes32,uint256,bytes32)" if index is not None else "BatchCreated(bytes32,address,bytes32)")
            logs = [entry for entry in receipt["logs"] if entry["address"].lower() == contract.address.lower() and entry["topics"][0] == expected_event and entry["topics"][1].hex().removeprefix("0x") == batch["chain_key"]]
            if not logs:
                raise HTTPException(422, "Transaction does not contain this batch event.")
            event_data = bytes(logs[0]["data"])
            if event_data[-32:].hex() != target["hash"] or (index is not None and int.from_bytes(event_data[:32]) != index):
                raise HTTPException(422, "Transaction fingerprint does not match this record.")
            state = contract.functions.getBatch(bytes.fromhex(batch["chain_key"])).call()
            if not state[4] or bytes(state[2]).hex() != batch["hash"]:
                raise HTTPException(422, "On-chain batch fingerprint does not match.")
        except HTTPException:
            raise
        except Exception:
            raise HTTPException(502, "Cannot confirm the transaction yet. Retry after confirmation.")
        target["transaction"] = data.transaction
        put("batch:" + batch_id, batch)
        return public(batch)


@router.get("/batches/{batch_id}/verify")
def verify(batch_id: str):
    batch = batch_or_404(batch_id)
    intact = hmac.compare_digest(fingerprint(batch["metadata"]), batch["hash"]) and all(fingerprint(cp["record"]) == cp["hash"] for cp in batch["checkpoints"])
    result = {"integrity": intact, "blockchain_verified": False, "checkpoints_verified": False, "status": "Unanchored", "batch": public(batch)}
    if not intact:
        result["status"] = "Fingerprint mismatch"
    elif batch["transaction"]:
        try:
            _, contract = chain()
            state = contract.functions.getBatch(bytes.fromhex(batch["chain_key"])).call()
            result["blockchain_verified"] = bool(state[4]) and bytes(state[2]).hex() == batch["hash"]
            checkpoints = contract.functions.getBatchCheckpoints(bytes.fromhex(batch["chain_key"])).call()
            result["checkpoints_verified"] = len(checkpoints) == len(batch["checkpoints"]) and all(bytes(chain_cp[2]).hex() == cp["hash"] for chain_cp, cp in zip(checkpoints, batch["checkpoints"]))
            result["status"] = "Blockchain verified" if result["blockchain_verified"] else "Fingerprint mismatch"
        except Exception:
            result["status"] = "Network unavailable — verification pending"
    return result