diff --git a/backend/cache.py b/backend/cache.py index c69e22f5..139dcfe3 100644 --- a/backend/cache.py +++ b/backend/cache.py @@ -174,5 +174,6 @@ def invalidate(self): nearby_issues_cache = ThreadSafeCache(ttl=60, max_size=100) # 1 minute TTL, max 100 entries user_upload_cache = ThreadSafeCache(ttl=3600, max_size=1000) # 1 hour TTL for upload limits blockchain_last_hash_cache = ThreadSafeCache(ttl=3600, max_size=1) +visit_last_hash_cache = ThreadSafeCache(ttl=3600, max_size=2) grievance_last_hash_cache = ThreadSafeCache(ttl=3600, max_size=1) user_issues_cache = ThreadSafeCache(ttl=300, max_size=50) # 5 minutes TTL diff --git a/backend/geofencing_service.py b/backend/geofencing_service.py index 3016d472..20696089 100644 --- a/backend/geofencing_service.py +++ b/backend/geofencing_service.py @@ -105,11 +105,14 @@ def generate_visit_hash(visit_data: dict) -> str: """ try: # Normalize check_in_time to ISO format string for determinism + # Ensure it matches how SQLite stores/retrieves it (often without TZ) check_in_time = visit_data.get('check_in_time') if isinstance(check_in_time, datetime): - check_in_time_str = check_in_time.isoformat() + check_in_time_str = check_in_time.strftime('%Y-%m-%dT%H:%M:%S') else: check_in_time_str = str(check_in_time) if check_in_time else "" + if '+' in check_in_time_str: + check_in_time_str = check_in_time_str.split('+')[0] # Create a deterministic string from visit data data_string = ( @@ -119,6 +122,7 @@ def generate_visit_hash(visit_data: dict) -> str: f"{visit_data.get('check_in_longitude')}" f"{check_in_time_str}" f"{visit_data.get('visit_notes', '')}" + f"{visit_data.get('previous_visit_hash', '')}" ) # Generate HMAC-SHA256 hash for tamper-resistance diff --git a/backend/init_db.py b/backend/init_db.py index 732da588..abc59540 100644 --- a/backend/init_db.py +++ b/backend/init_db.py @@ -187,6 +187,10 @@ def index_exists(table, index_name): # Indexes for field_officer_visits (run regardless of table creation) if inspector.has_table("field_officer_visits"): + if not column_exists("field_officer_visits", "previous_visit_hash"): + conn.execute(text("ALTER TABLE field_officer_visits ADD COLUMN previous_visit_hash VARCHAR")) + logger.info("Added previous_visit_hash column to field_officer_visits") + if not index_exists("field_officer_visits", "ix_field_officer_visits_issue_id"): conn.execute(text("CREATE INDEX IF NOT EXISTS ix_field_officer_visits_issue_id ON field_officer_visits (issue_id)")) @@ -199,6 +203,9 @@ def index_exists(table, index_name): if not index_exists("field_officer_visits", "ix_field_officer_visits_check_in_time"): conn.execute(text("CREATE INDEX IF NOT EXISTS ix_field_officer_visits_check_in_time ON field_officer_visits (check_in_time)")) + if not index_exists("field_officer_visits", "ix_field_officer_visits_previous_visit_hash"): + conn.execute(text("CREATE INDEX IF NOT EXISTS ix_field_officer_visits_previous_visit_hash ON field_officer_visits (previous_visit_hash)")) + logger.info("Database migration check completed successfully.") except Exception as e: diff --git a/backend/main.py b/backend/main.py index d747fc46..87fcbe53 100644 --- a/backend/main.py +++ b/backend/main.py @@ -85,9 +85,9 @@ async def lifespan(app: FastAPI): logger.info("Starting database initialization...") await run_in_threadpool(Base.metadata.create_all, bind=engine) logger.info("Base.metadata.create_all completed.") - # Temporarily disabled - comment out to debug startup issues - # await run_in_threadpool(migrate_db) - logger.info("Database initialized successfully (migrations skipped for local dev).") + # Enable database migrations to ensure schema is up to date + await run_in_threadpool(migrate_db) + logger.info("Database initialized and migrated successfully.") except Exception as e: logger.error(f"Database initialization failed: {e}", exc_info=True) # We continue to allow health checks even if DB has issues (for debugging) diff --git a/backend/models.py b/backend/models.py index 5389cae6..3e8f7545 100644 --- a/backend/models.py +++ b/backend/models.py @@ -253,6 +253,7 @@ class FieldOfficerVisit(Base): # Immutability hash (blockchain-like integrity) visit_hash = Column(String, nullable=True) # Hash of visit data for integrity verification + previous_visit_hash = Column(String, nullable=True, index=True) # Linked hash for O(1) verification # Metadata created_at = Column(DateTime, default=lambda: datetime.datetime.now(datetime.timezone.utc)) diff --git a/backend/requirements-render.txt b/backend/requirements-render.txt index c6649b20..aeecb1a7 100644 --- a/backend/requirements-render.txt +++ b/backend/requirements-render.txt @@ -6,6 +6,7 @@ python-telegram-bot google-generativeai python-multipart psycopg2-binary +python-magic huggingface-hub httpx pywebpush diff --git a/backend/routers/field_officer.py b/backend/routers/field_officer.py index 85cdcac7..5cc9379a 100644 --- a/backend/routers/field_officer.py +++ b/backend/routers/field_officer.py @@ -27,9 +27,12 @@ from backend.geofencing_service import ( is_within_geofence, generate_visit_hash, + verify_visit_integrity, calculate_visit_metrics, get_geofencing_service ) +from backend.cache import visit_last_hash_cache +from backend.schemas import BlockchainVerificationResponse logger = logging.getLogger(__name__) @@ -93,18 +96,29 @@ def officer_check_in(request: OfficerCheckInRequest, db: Session = Depends(get_d ) # Create visit record - check_in_time = datetime.now(timezone.utc) + # Fix: Strip milliseconds and timezone for deterministic hashing + check_in_time = datetime.now(timezone.utc).replace(microsecond=0) + + # Blockchain feature: retrieve previous hash for chaining + # Use cache for O(1) retrieval + prev_hash = visit_last_hash_cache.get("last_hash") + if prev_hash is None: + # Cache miss: Fetch from DB + prev_visit = db.query(FieldOfficerVisit.visit_hash).order_by(FieldOfficerVisit.id.desc()).first() + prev_hash = prev_visit[0] if prev_visit and prev_visit[0] else "" + visit_last_hash_cache.set(data=prev_hash, key="last_hash") visit_data = { 'issue_id': request.issue_id, 'officer_email': request.officer_email, 'check_in_latitude': request.check_in_latitude, 'check_in_longitude': request.check_in_longitude, - 'check_in_time': check_in_time.isoformat(), - 'visit_notes': request.visit_notes or '' + 'check_in_time': check_in_time, # Pass datetime object to helper + 'visit_notes': request.visit_notes or '', + 'previous_visit_hash': prev_hash } - # Generate immutable hash + # Generate immutable HMAC hash with chaining visit_hash = generate_visit_hash(visit_data) new_visit = FieldOfficerVisit( @@ -126,9 +140,14 @@ def officer_check_in(request: OfficerCheckInRequest, db: Session = Depends(get_d is_public=True ) + new_visit.previous_visit_hash = prev_hash + db.add(new_visit) db.commit() db.refresh(new_visit) + + # Update cache after successful commit + visit_last_hash_cache.set(data=visit_hash, key="last_hash") logger.info( f"Officer {request.officer_name} checked in at issue {request.issue_id}. " @@ -153,6 +172,8 @@ def officer_check_in(request: OfficerCheckInRequest, db: Session = Depends(get_d visit_images=new_visit.visit_images, visit_duration_minutes=new_visit.visit_duration_minutes, status=new_visit.status, + visit_hash=new_visit.visit_hash, + previous_visit_hash=new_visit.previous_visit_hash, verified_by=new_visit.verified_by, verified_at=new_visit.verified_at, is_public=new_visit.is_public, @@ -228,6 +249,8 @@ def officer_check_out(request: OfficerCheckOutRequest, db: Session = Depends(get visit_images=visit.visit_images, visit_duration_minutes=visit.visit_duration_minutes, status=visit.status, + visit_hash=visit.visit_hash, + previous_visit_hash=visit.previous_visit_hash, verified_by=visit.verified_by, verified_at=visit.verified_at, is_public=visit.is_public, @@ -478,9 +501,65 @@ def verify_visit( logger.info(f"Visit {visit_id} verified by {verifier_email}") return {"message": "Visit verified successfully", "visit_id": visit_id} - except HTTPException: raise except Exception as e: logger.error(f"Error verifying visit {visit_id}: {e}", exc_info=True) raise HTTPException(status_code=500, detail="Verification failed") + + +@router.get("/field-officer/{visit_id}/blockchain-verify", response_model=BlockchainVerificationResponse) +def verify_visit_blockchain_integrity(visit_id: int, db: Session = Depends(get_db)): + """ + Verify the cryptographic integrity of a visit record using O(1) single-record verification. + """ + try: + visit = db.query( + FieldOfficerVisit.issue_id, + FieldOfficerVisit.officer_email, + FieldOfficerVisit.check_in_latitude, + FieldOfficerVisit.check_in_longitude, + FieldOfficerVisit.check_in_time, + FieldOfficerVisit.visit_notes, + FieldOfficerVisit.visit_hash, + FieldOfficerVisit.previous_visit_hash + ).filter(FieldOfficerVisit.id == visit_id).first() + + if not visit: + raise HTTPException(status_code=404, detail="Visit not found") + + # Determine previous hash (O(1) from stored column) + prev_hash = visit.previous_visit_hash or "" + + # Reconstruct visit data for hash verification + # Normalization of time must match the generation logic + visit_data = { + 'issue_id': visit.issue_id, + 'officer_email': visit.officer_email, + 'check_in_latitude': visit.check_in_latitude, + 'check_in_longitude': visit.check_in_longitude, + 'check_in_time': visit.check_in_time, # Pass datetime object + 'visit_notes': visit.visit_notes or '', + 'previous_visit_hash': prev_hash + } + + # Verify integrity using the service helper + is_valid = verify_visit_integrity(visit_data, visit.visit_hash) + computed_hash = generate_visit_hash(visit_data) + + message = ( + "Integrity verified. This visit record is cryptographically sealed and part of an immutable chain." + if is_valid + else "Integrity check failed! The record data does not match its cryptographic seal." + ) + + return BlockchainVerificationResponse( + is_valid=is_valid, + current_hash=visit.visit_hash, + computed_hash=computed_hash, + message=message + ) + + except Exception as e: + logger.error(f"Error verifying visit blockchain for {visit_id}: {e}", exc_info=True) + raise HTTPException(status_code=500, detail="Failed to verify visit integrity") diff --git a/backend/schemas.py b/backend/schemas.py index 7dd398e0..c9cbbc99 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -513,6 +513,8 @@ class FieldOfficerVisitResponse(BaseModel): visit_images: Optional[List[str]] = Field(None, description="Visit image paths") visit_duration_minutes: Optional[int] = Field(None, description="Visit duration") status: str = Field(..., description="Visit status") + visit_hash: Optional[str] = Field(None, description="Integrity hash") + previous_visit_hash: Optional[str] = Field(None, description="Previous visit hash") verified_by: Optional[str] = Field(None, description="Verified by") verified_at: Optional[datetime] = Field(None, description="Verification timestamp") is_public: bool = Field(..., description="Public visibility") diff --git a/test_blockchain.db b/test_blockchain.db new file mode 100644 index 00000000..9f07e580 Binary files /dev/null and b/test_blockchain.db differ diff --git a/test_imports.py b/test_imports.py new file mode 100644 index 00000000..c4461941 --- /dev/null +++ b/test_imports.py @@ -0,0 +1,27 @@ + +import sys +import os + +# Add current directory to path +sys.path.append(os.getcwd()) + +try: + print("Testing imports...") + from backend.main import app + print("Successfully imported FastAPI app.") + + from backend.models import Base + print("Successfully imported models.") + + from backend.database import engine + print("Successfully imported database engine.") + + from backend.routers import issues, field_officer, voice + print("Successfully imported routers.") + + print("All critical imports successful.") +except Exception as e: + print(f"IMPORT ERROR: {e}") + import traceback + traceback.print_exc() + sys.exit(1) diff --git a/tests/test_visit_blockchain.py b/tests/test_visit_blockchain.py new file mode 100644 index 00000000..acc995bb --- /dev/null +++ b/tests/test_visit_blockchain.py @@ -0,0 +1,140 @@ + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from backend.main import app +from backend.database import get_db, Base +from backend.models import FieldOfficerVisit, Issue +import hashlib +from datetime import datetime, timezone + +# Setup test DB +TEST_SQLALCHEMY_DATABASE_URL = "sqlite:///./test_blockchain.db" + +@pytest.fixture +def test_db(): + engine = create_engine(TEST_SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}) + TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + Base.metadata.drop_all(bind=engine) + Base.metadata.create_all(bind=engine) + db = TestingSessionLocal() + try: + yield db + finally: + db.close() + +@pytest.fixture +def client(test_db): + def override_get_db(): + try: + yield test_db + finally: + pass + app.dependency_overrides[get_db] = override_get_db + with TestClient(app) as c: + yield c + app.dependency_overrides.clear() + +def test_visit_blockchain_chaining(client, test_db): + # 1. Create a dummy issue + issue = Issue( + description="Pothole in the road", + category="Road", + latitude=18.5204, + longitude=73.8567, + status="open" + ) + test_db.add(issue) + test_db.commit() + test_db.refresh(issue) + + # 2. First check-in + checkin_data1 = { + "issue_id": issue.id, + "officer_email": "officer1@example.com", + "officer_name": "Officer One", + "check_in_latitude": 18.5205, + "check_in_longitude": 73.8568, + "visit_notes": "First visit note" + } + response1 = client.post("/api/field-officer/check-in", json=checkin_data1) + assert response1.status_code == 200 + data1 = response1.json() + hash1 = data1["visit_hash"] + # Adjust expectation to match Column behavior: empty string for first, but schema might return None if not set explicitly + assert data1["previous_visit_hash"] in ["", None] + + # 3. Second check-in + checkin_data2 = { + "issue_id": issue.id, + "officer_email": "officer2@example.com", + "officer_name": "Officer Two", + "check_in_latitude": 18.5206, + "check_in_longitude": 73.8569, + "visit_notes": "Second visit note" + } + response2 = client.post("/api/field-officer/check-in", json=checkin_data2) + assert response2.status_code == 200 + data2 = response2.json() + hash2 = data2["visit_hash"] + assert data2["previous_visit_hash"] == hash1 # Should link to hash1 + + # 4. Verify integrity of second visit + verify_response = client.get(f"/api/field-officer/{data2['id']}/blockchain-verify") + assert verify_response.status_code == 200 + verify_data = verify_response.json() + assert verify_data["is_valid"] is True + assert verify_data["current_hash"] == hash2 + + # 5. Tamper with data and verify failure + # We'll directly modify the DB for the second visit + visit2 = test_db.query(FieldOfficerVisit).filter(FieldOfficerVisit.id == data2["id"]).first() + visit2.visit_notes = "TAMPERED NOTES" + test_db.commit() + + verify_response_tampered = client.get(f"/api/field-officer/{data2['id']}/blockchain-verify") + assert verify_response_tampered.status_code == 200 + verify_data_tampered = verify_response_tampered.json() + assert verify_data_tampered["is_valid"] is False + assert verify_data_tampered["current_hash"] == hash2 + assert verify_data_tampered["computed_hash"] != hash2 + +def test_visit_blockchain_fallback_to_db(client, test_db): + from backend.cache import visit_last_hash_cache + + # 1. Create dummy issue + issue = Issue( + description="Water leak", + category="Water", + latitude=18.5, + longitude=73.8, + status="open" + ) + test_db.add(issue) + test_db.commit() + test_db.refresh(issue) + + # 2. First check-in + client.post("/api/field-officer/check-in", json={ + "issue_id": issue.id, + "officer_email": "o1@ex.com", + "officer_name": "O1", + "check_in_latitude": 18.5, + "check_in_longitude": 73.8 + }) + + # Clear cache to force DB lookup for next check-in + visit_last_hash_cache.clear() + + # 3. Second check-in (should use DB lookup) + response = client.post("/api/field-officer/check-in", json={ + "issue_id": issue.id, + "officer_email": "o2@ex.com", + "officer_name": "O2", + "check_in_latitude": 18.5, + "check_in_longitude": 73.8 + }) + assert response.status_code == 200 + data = response.json() + assert data["previous_visit_hash"] not in ["", None]