-
Notifications
You must be signed in to change notification settings - Fork 76
Expand file tree
/
Copy pathmain_monolithic.py
More file actions
1974 lines (1726 loc) · 76.9 KB
/
Copy pathmain_monolithic.py
File metadata and controls
1974 lines (1726 loc) · 76.9 KB
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
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# main.py
import collections
import io
import json
import collections
import asyncio
from error_utils import safe_detail
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
class SimulationRequest(BaseModel):
crop_type: str
temp_delta: float = Field(..., ge=-5, le=5)
rain_delta: float = Field(..., ge=-100, le=100)
class ClientErrorReport(BaseModel):
"""
Typed, bounded schema for frontend error reports sent to /api/log-error.
Fields are intentionally narrow:
- message : the human-readable error description (capped at 500 chars)
- source : optional filename / module where the error originated
- stack : optional stack trace (capped to prevent log flooding)
- level : severity hint from the client; defaults to "error"
All string fields are stripped of ANSI escape sequences and ASCII
control characters before being written to the log, so a crafted
payload cannot inject terminal control codes or forge log lines.
"""
message: str = Field(..., min_length=1, max_length=500)
source: Optional[str] = Field(default=None, max_length=200)
stack: Optional[str] = Field(default=None, max_length=2000)
level: str = Field(default="error", max_length=20)
class RAGQuery(BaseModel):
query: str = Field(..., min_length=3, max_length=500)
top_k: int = Field(default=3, ge=1, le=5)
# Blockchain Supply Chain Models
class RegisterActorRequest(BaseModel):
actor_id: str = Field(..., min_length=1, max_length=50)
name: str = Field(..., min_length=1, max_length=100)
actor_type: str = Field(..., min_length=1, max_length=50)
location: str = Field(..., min_length=1, max_length=100)
class CreateProductBatchRequest(BaseModel):
crop_type: str = Field(..., min_length=1, max_length=50)
farm_id: str = Field(..., min_length=1, max_length=50)
quantity: float = Field(..., gt=0)
unit: str = Field(..., min_length=1, max_length=20)
planting_date: str = Field(..., min_length=1)
harvesting_date: str = Field(..., min_length=1)
farmer_name: str = Field(..., min_length=1, max_length=100)
class AddSupplyChainNodeRequest(BaseModel):
batch_id: str = Field(..., min_length=1)
node_type: str = Field(..., min_length=1, max_length=50)
actor_name: str = Field(..., min_length=1, max_length=100)
location: str = Field(..., min_length=1, max_length=100)
action: str = Field(..., min_length=1, max_length=50)
temperature: Optional[float] = None
humidity: Optional[float] = None
quality_check: Optional[str] = None
notes: str = Field(default="", max_length=500)
class CreateSmartContractRequest(BaseModel):
batch_id: str = Field(..., min_length=1)
seller: str = Field(..., min_length=1, max_length=100)
buyer: str = Field(..., min_length=1, max_length=100)
price: float = Field(..., gt=0)
terms: Optional[Dict] = None
class ExecuteContractRequest(BaseModel):
contract_id: str = Field(..., min_length=1)
# Crop Quality Grading Models
class CropQualityGradingRequest(BaseModel):
crop_type: str = Field(..., min_length=1, max_length=50)
image_base64: str = Field(..., min_length=100) # Base64 encoded image
class CropQualityBatchRequest(BaseModel):
crop_type: str = Field(..., min_length=1, max_length=50)
images_base64: list = Field(..., min_items=1, max_items=100) # Multiple images
class QualityTrendsRequest(BaseModel):
crop_type: str = Field(..., min_length=1, max_length=50)
days: int = Field(default=7, ge=1, le=30)
# Date/Time utilities
from datetime import datetime, timezone
# Rate Limiting
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.util import get_remote_address
from slowapi.errors import RateLimitExceeded
# Firebase Admin SDK
import firebase_admin
from firebase_admin import credentials, auth as firebase_auth, firestore
# ML Ops Imports
from ml.registry import ModelRegistry
from ml.adapters.xgboost_adapter import XGBoostAdapter
from ml.router import ModelRouter
from ml.preprocessing import UnknownCategoryError, MissingFeatureError
# Persistence Layer
from persistence.repositories import (
FinanceApplicationRepository,
NotificationRepository,
SupplyChainRepository,
)
# RBAC (Role-Based Access Control)
from rbac import (
RBACManager,
RBACMiddleware,
Permission,
require_permission,
print_rbac_matrix,
)
# ML Governance (Drift Detection, Shadow Evaluation, Rollback Safety)
from ml.governance import (
DriftDetector,
ShadowEvaluator,
ModelVersionManager,
)
# Other internal modules
from alert_rules import generate_alerts
from whatsapp_service import send_whatsapp_message, format_alert_message
from whatsapp_store import subscriber_store
from crop_quality_grading import CropQualityGrader
from blockchain_supply_chain import SupplyChainBlockchain
from farm_finance_ai import FarmFinanceAI
from reportlab.lib.pagesizes import letter
from reportlab.pdfgen import canvas
from reportlab.lib import colors
from reportlab.lib.units import inch
from cryptography.hazmat.primitives.asymmetric import ed25519, padding
from cryptography.hazmat.primitives import serialization, hashes
from backend.rate_limit_config import build_limiter, rate_limit_exceeded_handler
# KMS Support
try:
from google.cloud import secretmanager
HAS_GCP_KMS = True
except ImportError:
HAS_GCP_KMS = False
@asynccontextmanager
async def lifespan(app: FastAPI):
"""
FastAPI lifespan context manager.
Runs inside **every** Uvicorn/Gunicorn worker process on startup, so the
ML pipeline and domain engines are always initialised regardless of how
many workers are spawned.
Multi-worker guarantee
----------------------
When Uvicorn is started with ``--workers N``, each worker forks/spawns
from the main process and imports ``main.py`` independently. The
``lifespan`` hook is invoked by FastAPI in every worker's event loop,
ensuring ``ModelRegistry`` is populated and domain engines are ready in
every process before the first request is served.
Domain engines are intentionally initialised here rather than at module
scope: module-level code runs on every import (e.g. during tests or
hot-reload), which would cause unnecessary re-construction and would
overwrite any in-memory state accumulated since the last reload.
"""
global _finance_repository, _notification_repository, _supply_chain_repository
global _farm_finance_ai, _supply_chain_blockchain, _crop_quality_grader
init_ml_pipeline()
# Domain engines — initialized exactly once per worker process at startup.
_finance_repository = FinanceApplicationRepository()
_notification_repository = NotificationRepository()
_supply_chain_repository = SupplyChainRepository()
_crop_quality_grader = CropQualityGrader()
_supply_chain_blockchain = SupplyChainBlockchain(repository=_supply_chain_repository)
_farm_finance_ai = FarmFinanceAI(repository=_finance_repository)
logger.info("Domain engines initialized with persistent repositories")
yield
# Shutdown: nothing to clean up for in-memory models.
app = FastAPI(title="Fasal Saathi Backend", version="2.0", lifespan=lifespan)
logger = logging.getLogger(__name__)
# Regex that matches ANSI escape sequences (e.g. \x1b[31m) and all other
# ASCII control characters (0x00-0x1f, 0x7f) except tab and newline.
# Used to sanitise client-supplied strings before they reach the log, so a
# crafted payload cannot inject terminal control codes or forge log lines.
_CONTROL_CHAR_RE = re.compile(
r"(\x9B|\x1B\[)[0-?]*[ -\/]*[@-~]" # ANSI CSI sequences
r"|\x1B[@-_]" # other ESC sequences
r"|[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]" # control chars except \t \n
)
def _sanitise_log_field(value: str) -> str:
"""Strip ANSI escape sequences and ASCII control characters from *value*."""
if not isinstance(value, str):
return ""
return _CONTROL_CHAR_RE.sub("", value)
# Initialize Limiter
limiter = build_limiter()
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, rate_limit_exceeded_handler)
# Initialize Firebase Admin
# Explicitly set to None before the try block so db_firestore is always
# defined at module level, even if an exception is raised mid-init.
db_firestore = None
if not firebase_admin._apps:
try:
# In a GCP environment this picks up Application Default Credentials
# automatically. For local dev set GOOGLE_APPLICATION_CREDENTIALS to
# the path of a service-account key file.
firebase_admin.initialize_app()
db_firestore = firestore.client()
logger.info("Firebase Admin: successfully initialized")
except Exception as e:
logger.warning(
"Firebase Admin: could not initialize — role-gated endpoints will "
"return 503 until Firestore is reachable. Reason: %s", e
)
async def verify_role(request: Request, required_roles: list = None):
"""
Verify the Firebase ID token and check the caller's role against Firestore.
Expects 'Authorization: Bearer <ID_TOKEN>' header.
Fail-closed design:
- If Firestore is unavailable the request is rejected with 503.
- If the user document does not exist the request is rejected with 403.
- The function never grants a role that was not explicitly stored in Firestore.
"""
auth_header = request.headers.get("Authorization")
if not auth_header or not auth_header.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Missing or invalid authentication token")
# Use a slice instead of split()[1] to avoid IndexError when the header
# is exactly "Bearer " with no token following it.
id_token = auth_header[7:].strip()
if not id_token:
raise HTTPException(status_code=401, detail="Missing or invalid authentication token")
# Verify the token signature with Firebase — raises on invalid/expired tokens.
try:
decoded_token = firebase_auth.verify_id_token(id_token, check_revoked=True)
except Exception:
raise HTTPException(status_code=401, detail="Authentication failed")
uid = decoded_token["uid"]
# Firestore must be available to resolve the caller's role.
# Failing open (granting admin when Firestore is down) is a security bug,
# so we reject the request instead.
if not db_firestore:
raise HTTPException(
status_code=503,
detail="Authorization service temporarily unavailable"
)
# Wrap the Firestore fetch so a transient network error (timeout, reset)
# returns the same clean 503 as a missing db_firestore, rather than an
# unhandled exception that leaks internal details as a raw 500.
try:
user_doc = db_firestore.collection("users").document(uid).get()
except Exception as e:
logger.error(
"Firestore fetch failed for uid=%s during role check: %s", uid, e
)
raise HTTPException(
status_code=503,
detail="Authorization service temporarily unavailable"
)
if not user_doc.exists:
raise HTTPException(status_code=403, detail="User profile not found")
user_role = user_doc.to_dict().get("role", "farmer")
if required_roles and user_role not in required_roles:
raise HTTPException(status_code=403, detail="Access denied: insufficient permissions")
return {"uid": uid, "role": user_role}
# --- Secure CORS Configuration ---
frontend_url = os.getenv("FRONTEND_URL", "http://localhost:5173")
trusted_origins = [
"http://localhost:5173", # Local development
"http://127.0.0.1:5173", # Local development alternative
"https://yourfrontend.com", # Production domain placeholder
]
# Add any custom frontend URLs from environment
if frontend_url and frontend_url not in trusted_origins:
trusted_origins.append(frontend_url)
# Support comma-separated list of additional origins
extra_origins = os.getenv("ADDITIONAL_ALLOWED_ORIGINS")
if extra_origins:
trusted_origins.extend([origin.strip() for origin in extra_origins.split(",")])
app.add_middleware(
CORSMiddleware,
allow_origins=trusted_origins,
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allow_headers=["Authorization", "Content-Type", "Accept", "Origin", "X-Requested-With"],
)
# Add RBAC middleware for access logging
app.add_middleware(RBACMiddleware)
# Log RBAC matrix on startup
logger.info(print_rbac_matrix())
# --- Models ---
class PredictRequest(BaseModel):
Crop: str = Field(..., max_length=50)
CropCoveredArea: float = Field(..., gt=0)
CHeight: int = Field(..., ge=0)
CNext: str = Field(..., max_length=50)
CLast: str = Field(..., max_length=50)
CTransp: str = Field(..., max_length=50)
IrriType: str = Field(..., max_length=50)
IrriSource: str = Field(..., max_length=50)
IrriCount: int = Field(..., ge=1)
WaterCov: int = Field(..., ge=0, le=100)
Season: str = Field(..., max_length=50)
class PredictResponse(BaseModel):
predicted_ExpYield: float
class WhatsAppSubscribeRequest(BaseModel):
phone_number: str
name: str
# user_id is accepted for backward compatibility but is IGNORED by the
# endpoint — the authoritative user identity is always derived from the
# verified Firebase ID token, never from client-supplied data.
user_id: Optional[str] = None
class YieldInput(BaseModel):
data: list[float]
class AlertTriggerRequest(BaseModel):
alert_type: str = Field(..., pattern=r'^(weather|pest|advisory)$')
message: str = Field(..., min_length=1, max_length=500)
class ReportRequest(BaseModel):
name: str = Field(..., max_length=100)
crop: str = Field(..., max_length=50)
area: str = Field(..., max_length=50)
profit: str = Field(..., max_length=50)
season: str = Field(..., max_length=50)
@validator("name", "crop", "area", "profit", "season", pre=True)
def reject_pipe_characters(cls, v):
# Belt-and-suspenders guard: the signing payload now uses JSON (which
# is unambiguous regardless of field content), but we also reject pipe
# characters at the model level so legacy code paths or future changes
# cannot accidentally reintroduce a delimiter-injection vulnerability.
if isinstance(v, str) and "|" in v:
raise ValueError(
"Field value must not contain the '|' character."
)
return v
class SeedVerifyRequest(BaseModel):
code: str = Field(..., min_length=4, max_length=100)
class FinanceAssessmentRequest(BaseModel):
farmer_name: str = Field(..., min_length=1, max_length=100)
crop_type: str = Field(..., min_length=1, max_length=50)
acreage: float = Field(default=0, ge=0)
annual_revenue: float = Field(default=0, ge=0)
annual_operating_cost: float = Field(default=0, ge=0)
existing_debt: float = Field(default=0, ge=0)
emergency_fund: float = Field(default=0, ge=0)
credit_score: int = Field(default=650, ge=300, le=900)
requested_loan_amount: float = Field(default=0, ge=0)
loan_tenure_months: int = Field(default=36, ge=6, le=120)
irrigation_cost: float = Field(default=0, ge=0)
labor_cost: float = Field(default=0, ge=0)
selected_lender: Optional[str] = Field(default=None, max_length=100)
farm_location: Optional[str] = Field(default=None, max_length=120)
notes: Optional[str] = Field(default=None, max_length=500)
# ML Governance Request Models
class StartShadowEvaluationRequest(BaseModel):
production_model: str = Field(..., min_length=1, max_length=50)
candidate_model: str = Field(..., min_length=1, max_length=50)
class RecordPredictionRequest(BaseModel):
eval_id: str = Field(..., min_length=1)
production_prediction: float
candidate_prediction: float
actual_value: float
class RegisterModelVersionRequest(BaseModel):
model_name: str = Field(..., min_length=1, max_length=50)
model_path: str = Field(..., min_length=1)
rmse: float = Field(..., gt=0)
r2_score: float = Field(default=0, ge=-1, le=1)
metadata: Optional[Dict[str, Any]] = None
# --- ML Governance Initialization ---
drift_detector = DriftDetector(
window_size=100,
prediction_drift_threshold=0.2,
input_drift_threshold=0.15,
)
shadow_evaluator = ShadowEvaluator(
min_samples=50,
error_improvement_threshold=0.05,
)
version_manager = ModelVersionManager(versions_dir="./model_versions")
# --- ML Pipeline Initialization ---
# init_ml_pipeline() is called inside the FastAPI lifespan context manager
# (defined above app = FastAPI(...)) so it runs in every Uvicorn worker
# process on startup. Do NOT call it here at module level — doing so would
# run it in the main process only and leave additional workers with empty
# registries in multi-worker deployments.
router = ModelRouter(default_model="xgboost")
def init_ml_pipeline():
try:
# Register XGBoost Adapter
xgb_adapter = XGBoostAdapter()
model_path = "yield_model.joblib"
if os.path.exists(model_path):
xgb_adapter.load(model_path)
ModelRegistry.register("xgboost", xgb_adapter)
logger.info("ML Pipeline: Registered XGBoost model.")
else:
logger.warning("ML Pipeline: %s not found.", model_path)
# You can register other models here (e.g., LSTM) as they become available
# ModelRegistry.register("lstm", LSTMAdapter("lstm_model.h5"))
except Exception as e:
logger.error("ML Pipeline Error: %s", e)
# Load model directly for backward compatibility or simple use cases if needed
try:
model = joblib.load("yield_model.joblib")
model_lag = joblib.load("sklearn_yield_model.pkl")
logger.info("Models loaded successfully")
except Exception as e:
logger.error("Error loading models: %s", e)
model = None
model_lag = None
# --- Static Notifications Storage ---
#
# Problems with the original bare list:
#
# 1. Unbounded growth — every trigger_whatsapp_alert call appended an entry
# that was never removed. After weeks in production the list could hold
# thousands of entries, all serialised and sent to every client on every
# GET /api/notifications poll.
#
# 2. Duplicate IDs under concurrency — `len(list) + 1` is not atomic. Two
# concurrent trigger-alert requests could both read the same length and
# produce entries with identical IDs, silently corrupting any client-side
# deduplication keyed on id.
#
# Fix — NotificationStore:
#
# • collections.deque(maxlen=MAX_NOTIFICATIONS) caps memory at a fixed
# ceiling. When the deque is full, the oldest entry is automatically
# evicted before the new one is appended — no manual cleanup needed.
#
# • itertools.count() produces a strictly monotonically increasing integer
# sequence. In CPython, next() on a count object is effectively atomic
# for the GIL-protected use case here, so two concurrent appends always
# get distinct IDs.
#
# • threading.Lock() serialises append() so the read-then-increment
# sequence is never interleaved across threads.
#
# • get_recent() filters by a TTL window so the response payload stays
# small even when the deque is at capacity.
# Maximum number of triggered-alert entries kept in memory at any time.
# Oldest entries are evicted automatically when this ceiling is reached.
_MAX_NOTIFICATIONS = 200
# How long a triggered-alert entry remains visible to clients.
_NOTIFICATION_TTL_HOURS = 24
class NotificationStore:
"""
Thread-safe, bounded, TTL-aware store for in-process notifications.
Parameters
----------
maxlen : int
Hard cap on the number of entries held in memory. When full,
the oldest entry is evicted before the new one is appended.
ttl_hours : int
Entries older than this many hours are excluded from get_recent().
"""
def __init__(self, maxlen: int = _MAX_NOTIFICATIONS, ttl_hours: int = _NOTIFICATION_TTL_HOURS):
self._deque: collections.deque = collections.deque(maxlen=maxlen)
self._lock = threading.Lock()
self._counter = itertools.count(start=1)
self._ttl = timedelta(hours=ttl_hours)
def append(self, alert_type: str, message: str) -> dict:
"""
Add a new notification entry and return it.
The ID is assigned from a monotonically increasing counter so
concurrent calls always produce distinct values.
"""
async with self._lock:
entry = {
"id": next(self._counter),
"type": alert_type,
"message": message,
"time": datetime.now().isoformat(),
}
self._deque.append(entry)
return entry
def get_recent(self) -> list:
"""
Return all entries newer than the configured TTL, oldest first.
Takes a snapshot under the lock so callers always see a consistent
view even if append() is running concurrently.
"""
cutoff = datetime.now() - self._ttl
async with self._lock:
snapshot = list(self._deque)
return [
e for e in snapshot
if datetime.fromisoformat(e["time"]) >= cutoff
]
# Seed the store with the initial weather advisory that was previously
# hard-coded in the bare list.
_notification_store = NotificationStore()
_notification_store.append(
alert_type="weather",
message="🌧️ Heavy rainfall expected in your region today.",
)
# Domain engine placeholders — actual instances are created once inside the
# lifespan context manager above, which runs at server startup (not on import).
# Endpoint handlers reference these module-level names; the lifespan assigns
# the real objects via ``global`` before any request is served.
_finance_repository = None
_notification_repository = None
_supply_chain_repository = None
_crop_quality_grader = None
_supply_chain_blockchain = None
_farm_finance_ai = None
# --- Routes ---
@app.get("/")
def root():
return {"message": "Fasal Saathi API", "status": "running"}
@app.get("/predict")
def predict_get():
return {"predicted_yield": 2500, "note": "Use POST endpoint for actual prediction"}
@app.post("/predict", response_model=PredictResponse)
@limiter.limit("5/minute")
def predict_yield(data: PredictRequest, request: Request):
"""
Standardised prediction endpoint using ML Router for dynamic model selection.
Returns HTTP 422 when the input contains an unknown categorical value or a
missing required feature, so callers receive an actionable error message
rather than a silently corrupted prediction.
"""
try:
input_data = data.model_dump() if hasattr(data, "model_dump") else data.dict()
context = {
"location": request.headers.get("X-User-Location", "Unknown"),
"crop": data.Crop,
}
predicted_yield = router.predict(input_data, context)
return {"predicted_ExpYield": float(predicted_yield)}
except UnknownCategoryError as e:
# The submitted categorical value was not in the training vocabulary.
raise HTTPException(
status_code=422,
detail={
"error": "unknown_category",
"field": e.column,
"value": str(e.value),
"message": str(e),
},
)
except MissingFeatureError as e:
# Required feature columns are absent after encoding.
raise HTTPException(
status_code=422,
detail={
"error": "missing_features",
"missing": e.missing_columns,
"message": str(e),
},
)
except Exception as e:
logger.error("Prediction Error: %s", e)
raise HTTPException(status_code=400, detail=safe_detail(e, 400))
@app.post("/predict-yield-lag")
@limiter.limit("5/minute")
async def predict_yield_lag(payload: YieldInput, request: Request):
if model_lag is None:
raise HTTPException(status_code=500, detail="Model not loaded")
try:
data = payload.data
if len(data) != 5:
raise ValueError("Exactly 5 values are required")
data = np.array(data).reshape(1, -1)
prediction = model_lag.predict(data)
return {
"prediction": round(float(prediction[0]), 2),
"model": "RandomForest Time Series (Lag Features)"
}
except ValueError as e:
raise HTTPException(status_code=400, detail=safe_detail(e, 400))
except Exception as e:
raise HTTPException(status_code=500, detail="Internal Server Error") from e
@app.post("/predict-yield-trend")
@limiter.limit("5/minute")
async def predict_yield_trend(payload: YieldInput, request: Request):
if model_lag is None:
raise HTTPException(status_code=500, detail="Model not loaded")
try:
data = payload.data
if len(data) != 5:
raise ValueError("Exactly 5 values are required")
temp = list(data)
trend = []
for _ in range(5):
features = temp[:5]
pred = model_lag.predict([features])[0]
pred_value = round(float(pred), 2)
trend.append(pred_value)
temp = [pred_value] + temp
return {
"trend": trend,
"prediction": trend[-1],
"model": "RandomForest Trend Forecast (Lag Features)"
}
except ValueError as e:
raise HTTPException(status_code=400, detail=safe_detail(e, 400))
except Exception as e:
raise HTTPException(status_code=500, detail="Internal Server Error") from e
@app.get("/api/notifications")
async def get_notifications(
request: Request,
crop: str = Query(default=None),
irrigation_count: int = Query(default=None, ge=0),
water_coverage: int = Query(default=None, ge=0, le=100),
season: str = Query(default=None)
):
"""
Return recent triggered-alert notifications combined with dynamic
farm advisory alerts generated from the query parameters.
Only notifications newer than the store's TTL window are included,
so the response payload stays small regardless of how long the
process has been running.
"""
# Check permission: user can read notifications
await RBACManager.raise_if_unauthorized(
request, [Permission.NOTIFICATIONS_READ], require_all=False
)
dynamic_alerts = generate_alerts(
crop=crop,
irrigation_count=irrigation_count,
water_coverage=water_coverage,
season=season
)
return {"success": True, "data": _notification_store.get_recent() + dynamic_alerts}
@app.post("/api/finance/analyze")
@limiter.limit("10/minute")
async def analyze_farm_finance(request: Request, body: FinanceAssessmentRequest):
"""Analyze farm finances and return loan recommendations."""
# Check permission: farmer can create finance requests
await RBACManager.raise_if_unauthorized(
request, [Permission.FINANCE_CREATE], require_all=False
)
analysis = _farm_finance_ai.analyze_financial_profile(body.model_dump())
return {"success": True, "data": analysis}
@app.post("/api/finance/applications")
@limiter.limit("5/minute")
async def create_finance_application(request: Request, body: FinanceAssessmentRequest):
"""Create a loan application from the current farm profile."""
# Check permission: farmer can create finance applications
await RBACManager.raise_if_unauthorized(
request, [Permission.FINANCE_CREATE], require_all=False
)
application = _farm_finance_ai.create_application(body.model_dump())
return {"success": True, "data": application}
@app.get("/api/finance/applications/{application_id}")
async def get_finance_application(application_id: str, request: Request):
# Check permission: user can read finance applications (own or all if expert/admin)
await RBACManager.raise_if_unauthorized(
request, [Permission.FINANCE_READ_OWN, Permission.FINANCE_READ_ALL], require_all=False
)
application = _farm_finance_ai.get_application(application_id)
if not application:
raise HTTPException(status_code=404, detail="Application not found")
return {"success": True, "data": application}
@app.get("/api/finance/products")
def get_finance_products():
return {"success": True, "data": _farm_finance_ai.list_marketplace()}
@app.get("/api/finance/marketplace")
def get_finance_marketplace():
return {"success": True, "data": _farm_finance_ai.list_marketplace()}
# --- WhatsApp Service Endpoints ---
#
# Subscriber persistence is handled by whatsapp_store.SubscriberStore, which
# provides thread-safe, crash-safe read-modify-write operations via a
# threading.Lock and atomic file replacement (write-to-tmp then os.replace).
# The old load_subscribers / save_subscribers helpers have been removed because
# they had no locking and used open(..., "w") directly, which could corrupt the
# file on a concurrent write or a mid-write crash.
@app.post("/api/whatsapp/subscribe")
@limiter.limit("2/minute")
async def subscribe_whatsapp(data: WhatsAppSubscribeRequest, request: Request):
# Require authentication so the subscriber's identity is always derived
# from the verified Firebase token — never from client-supplied data.
# Previously the endpoint accepted user_id from the request body, which
# allowed any caller to overwrite another user's subscription by sending
# a known user_id with an attacker-controlled phone number.
token_data = await verify_role(request)
uid = token_data.get("uid")
subscriber = {
"phone_number": data.phone_number,
"name": data.name,
"subscribed_at": datetime.now().isoformat(),
}
try:
subscriber_store.upsert(uid, subscriber)
except OSError as exc:
raise HTTPException(
status_code=500,
detail="Failed to save subscription. Please try again.",
) from exc
welcome_msg = (
f"Namaste {data.name}! 🙏\n\n"
"Welcome to *Fasal Saathi WhatsApp Alerts*. "
"You will now receive real-time updates directly here."
)
send_whatsapp_message(data.phone_number, welcome_msg)
return {"success": True, "message": "Successfully subscribed"}
@app.post("/api/whatsapp/trigger-alert")
@limiter.limit("10/minute")
async def trigger_whatsapp_alert(data: AlertTriggerRequest, request: Request):
"""
Broadcast a WhatsApp alert to all subscribers.
Requires authentication — admin or expert role only.
Previously this endpoint had no authentication check, no rate limit,
and no input constraints. Any unauthenticated caller could send
arbitrary messages to every subscribed farmer, enabling social
engineering attacks (fake market alerts, fake pest warnings) and
consuming Twilio API credits at the attacker's discretion.
"""
# RBAC: only admins and experts may broadcast alerts to all farmers.
await verify_role(request, required_roles=["admin", "expert"])
# get_all() acquires the lock and returns a stable snapshot, so this read
# cannot race with a concurrent subscription write.
subscribers = subscriber_store.get_all()
results = []
formatted_msg = format_alert_message(data.alert_type, data.message)
for user_id, info in subscribers.items():
res = send_whatsapp_message(info["phone_number"], formatted_msg)
results.append({"user_id": user_id, "success": res.get("success", False), "status": res.get("status", "error")})
# Use the bounded, thread-safe NotificationStore instead of the bare
# static_notifications list (which had no size cap and racy ID generation).
_notification_store.append(
alert_type=data.alert_type,
message=data.message,
)
delivered = sum(1 for r in results if r["success"])
return {"success": True, "results": results, "delivered": delivered, "total": len(results)}
@app.post("/api/whatsapp/webhook")
async def whatsapp_webhook(Body: str = Form(...), From: str = Form(...)):
"""
Handle incoming WhatsApp messages from Twilio.
Processing is offloaded to a background Celery task to immediately
acknowledge the webhook (preventing Twilio timeout/penalties under burst traffic)
and process the message asynchronously.
"""
sender_number = From.replace("whatsapp:", "")
# Offload message processing to reliable background task queue
from celery_worker import process_whatsapp_webhook_task
process_whatsapp_webhook_task.delay(Body, sender_number)
return {"status": "success"}
# --- Cryptographic Reports ---
#
# Key resolution priority (highest → lowest):
# 1. In-process cache – avoids repeated I/O on every request
# 2. GCP Secret Manager – production path; key never touches disk
# 3. Local persistent PEM file – dev/staging fallback; blocked in production
# 4. Fresh generation – last resort for local dev only
#
# When ENV=production the function raises HTTP 500 at steps 2, 3, and 4
# rather than falling through to a weaker path, so a plaintext disk key
# can never silently be used in production.
_cached_private_key = None
KEYS_DIR = "keys"
PRIVATE_KEY_PATH = os.path.join(KEYS_DIR, "report_signing.key")
PUBLIC_KEY_PATH = os.path.join(KEYS_DIR, "report_signing.pub")
IS_PRODUCTION = os.getenv("ENV", "").lower() == "production"
def get_signing_keys():
"""
Return the Ed25519 private key used to sign financial reports.
Resolution order:
1. In-process cache (fastest path after first call)
2. GCP Secret Manager (production-grade; key never written to disk)
3. Local PEM file (dev/staging only; raises in production)
4. Fresh generation (dev/staging only; raises in production)
"""
global _cached_private_key
# 1. In-process cache
if _cached_private_key is not None:
return _cached_private_key
# 2. GCP Secret Manager
project_id = os.getenv("GOOGLE_CLOUD_PROJECT")
secret_id = os.getenv("REPORT_SIGNING_SECRET_NAME", "report-signing-key")
if project_id:
if not HAS_GCP_KMS:
if IS_PRODUCTION:
raise HTTPException(
status_code=500,
detail="google-cloud-secret-manager is not installed but is required in production"
)
logger.warning("KMS: google-cloud-secret-manager not installed; skipping GCP path.")
else:
try:
client = secretmanager.SecretManagerServiceClient()
name = f"projects/{project_id}/secrets/{secret_id}/versions/latest"
response = client.access_secret_version(request={"name": name})
payload = response.payload.data.decode("UTF-8")
_cached_private_key = serialization.load_pem_private_key(
payload.encode(), password=None
)
logger.info("KMS: Loaded signing key from Secret Manager (secret: %s)", secret_id)
return _cached_private_key
except Exception as e:
if IS_PRODUCTION:
logger.error("KMS Error: %s", e)
raise HTTPException(
status_code=500,
detail="Failed to retrieve signing key from Secret Manager"
)
logger.warning("KMS: Could not reach Secret Manager (%s); falling back to local key.", e)
elif IS_PRODUCTION:
raise HTTPException(
status_code=500,
detail="GOOGLE_CLOUD_PROJECT is not set; cannot retrieve signing key in production"
)
# 3. Local persistent PEM file (dev/staging only)
if os.path.exists(PRIVATE_KEY_PATH):
try:
with open(PRIVATE_KEY_PATH, "rb") as f:
_cached_private_key = serialization.load_pem_private_key(f.read(), password=None)
logger.info("Key Management: Loaded existing local key from %s", PRIVATE_KEY_PATH)
return _cached_private_key
except Exception as e:
logger.warning("Key Management: Could not load local key file (%s); generating a new one.", e)
# 4. Fresh generation (dev/staging only)
logger.info("Key Management: Generating a fresh signing key for local development.")
private_key = ed25519.Ed25519PrivateKey.generate()
try:
os.makedirs(KEYS_DIR, exist_ok=True)
with open(PRIVATE_KEY_PATH, "wb") as f:
f.write(private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption()
))
with open(PUBLIC_KEY_PATH, "wb") as f:
f.write(private_key.public_key().public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo
))
logger.info("Key Management: Saved new key pair to %s/", KEYS_DIR)
except Exception as e:
logger.warning("Key Management: Could not persist generated key (%s); key is in-memory only.", e)
_cached_private_key = private_key
return private_key
@app.post("/api/reports/generate")
@limiter.limit("3/minute")
async def generate_signed_report(data: ReportRequest, request: Request):
# RBAC: Only Experts or Admins can generate signed reports
await verify_role(request, required_roles=["expert", "admin"])
try:
private_key = get_signing_keys()
# Create a buffer for the PDF
buffer = io.BytesIO()
p = canvas.Canvas(buffer, pagesize=letter)
width, height = letter
# 1. Header
p.setFont("Helvetica-Bold", 24)
p.setFillColor(colors.green)
p.drawCentredString(width/2, height - 1*inch, "FASAL SAATHI")
p.setFont("Helvetica-Bold", 18)
p.setFillColor(colors.black)
p.drawCentredString(width/2, height - 1.5*inch, "CERTIFIED FINANCIAL FARM REPORT")
p.setStrokeColor(colors.green)
p.line(1*inch, height - 1.7*inch, width - 1*inch, height - 1.7*inch)
# 2. Content
p.setFont("Helvetica", 14)
y = height - 2.5*inch
details = [
("Farmer Name:", data.name),
("Crop Type:", data.crop),
("Farm Area:", data.area),
("Season Profit:", f"Rs. {data.profit}"),
("Season:", data.season),
("Report Date:", datetime.now().strftime("%d %B, %Y")),
]
for label, value in details: