diff --git a/app_output.txt b/app_output.txt new file mode 100644 index 00000000..c9acd8bc --- /dev/null +++ b/app_output.txt @@ -0,0 +1,30 @@ +/home/jules/.pyenv/versions/3.12.13/lib/python3.12/site-packages/apscheduler/__init__.py:1: UserWarning: pkg_resources is deprecated as an API. See https://setuptools.pypa.io/en/latest/pkg_resources.html. The pkg_resources package is slated for removal as early as 2025-11-30. Refrain from using this package or pin to Setuptools<81. + from pkg_resources import get_distribution, DistributionNotFound +2026-03-26 15:17:56,481 - backend.adaptive_weights - INFO - Adaptive weights loaded/reloaded. +2026-03-26 15:17:56,515 - backend.rag_service - INFO - Loaded 5 civic policies for RAG. +/home/jules/.pyenv/versions/3.12.13/lib/python3.12/site-packages/pydub/utils.py:170: RuntimeWarning: Couldn't find ffmpeg or avconv - defaulting to ffmpeg, but may not work + warn("Couldn't find ffmpeg or avconv - defaulting to ffmpeg, but may not work", RuntimeWarning) +INFO: Started server process [16333] +INFO: Waiting for application startup. +2026-03-26 15:17:56,898 - backend.main - INFO - Shared HTTP Client initialized. +2026-03-26 15:17:56,898 - backend.main - INFO - Starting database initialization... +2026-03-26 15:17:56,910 - backend.main - INFO - Base.metadata.create_all completed. +2026-03-26 15:17:56,910 - backend.main - INFO - Database initialized successfully (migrations skipped for local dev). +2026-03-26 15:17:56,910 - backend.main - INFO - Initializing grievance service... +2026-03-26 15:17:56,910 - backend.main - INFO - Grievance service initialization skipped for local dev. +2026-03-26 15:17:56,911 - backend.main - INFO - Scheduler skipped for local development +INFO: Application startup complete. +INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit) +2026-03-26 15:17:56,912 - backend.main - INFO - AI services initialized successfully. +2026-03-26 15:17:56,913 - backend.main - INFO - Maharashtra data pre-loaded successfully. +2026-03-26 15:17:56,914 - backend.main - INFO - Telegram bot initialization skipped for local testing. +Starting server on port 8000 +🤖 AI Service Type: GEMINI +INFO: 127.0.0.1:41058 - "GET /health HTTP/1.1" 200 OK +INFO: Shutting down +INFO: Waiting for application shutdown. +2026-03-26 15:18:11,570 - backend.main - INFO - Shared HTTP Client closed. +2026-03-26 15:18:11,570 - root - INFO - Bot thread is not initialized +2026-03-26 15:18:11,570 - backend.main - INFO - Telegram bot thread stopped. +INFO: Application shutdown complete. +INFO: Finished server process [16333] diff --git a/backend/cache.py b/backend/cache.py index c69e22f5..34db5a23 100644 --- a/backend/cache.py +++ b/backend/cache.py @@ -175,4 +175,5 @@ def invalidate(self): 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) grievance_last_hash_cache = ThreadSafeCache(ttl=3600, max_size=1) +visit_last_hash_cache = ThreadSafeCache(ttl=3600, max_size=2) # For O(1) visit chaining 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..38267907 100644 --- a/backend/geofencing_service.py +++ b/backend/geofencing_service.py @@ -104,14 +104,30 @@ def generate_visit_hash(visit_data: dict) -> str: HMAC-SHA256 hash of visit data """ try: - # Normalize check_in_time to ISO format string for determinism + # Normalize check_in_time for determinism check_in_time = visit_data.get('check_in_time') if isinstance(check_in_time, datetime): - check_in_time_str = check_in_time.isoformat() + # Normalize to UTC and format consistently without timezone string + # This ensures consistency even if DB strips timezone info + if check_in_time.tzinfo: + check_in_time = check_in_time.astimezone(timezone.utc).replace(tzinfo=None) + check_in_time_str = check_in_time.strftime('%Y-%m-%dT%H:%M:%S') + elif isinstance(check_in_time, str): + # Try to parse and re-format for normalization + try: + # Handle ISO format with Z or +00:00 + ts = check_in_time.replace('Z', '+00:00') + dt = datetime.fromisoformat(ts) + if dt.tzinfo: + dt = dt.astimezone(timezone.utc).replace(tzinfo=None) + check_in_time_str = dt.strftime('%Y-%m-%dT%H:%M:%S') + except Exception: + check_in_time_str = check_in_time else: check_in_time_str = str(check_in_time) if check_in_time else "" # Create a deterministic string from visit data + # Chaining: include previous_visit_hash in the data string data_string = ( f"{visit_data.get('issue_id')}" f"{visit_data.get('officer_email')}" @@ -119,6 +135,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..2463a2e8 100644 --- a/backend/init_db.py +++ b/backend/init_db.py @@ -185,8 +185,13 @@ def index_exists(table, index_name): Base.metadata.tables['field_officer_visits'].create(bind=conn) logger.info("Created field_officer_visits table") - # Indexes for field_officer_visits (run regardless of table creation) + # Field Officer Visits Migrations 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") + + # Indexes for field_officer_visits (run regardless of table creation) 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 +204,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..fd7d2a61 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).") + # Automated migrations for new columns (e.g. blockchain fields) + await run_in_threadpool(migrate_db) + logger.info("Database initialized successfully with migrations.") 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..80553894 100644 --- a/backend/requirements-render.txt +++ b/backend/requirements-render.txt @@ -1,24 +1,28 @@ -fastapi -uvicorn -python-dotenv -sqlalchemy -python-telegram-bot -google-generativeai -python-multipart -psycopg2-binary -huggingface-hub -httpx -pywebpush -Pillow -firebase-functions -firebase-admin -a2wsgi -python-jose[cryptography] -passlib[bcrypt] -bcrypt<4.0.0 -SpeechRecognition -pydub +fastapi==0.111.0 +uvicorn==0.42.0 +python-dotenv==1.2.2 +sqlalchemy==2.0.48 +python-telegram-bot==22.7 +google-generativeai==0.8.6 +python-multipart==0.0.22 +psycopg2-binary==2.9.11 +huggingface-hub==0.36.2 +httpx>=0.27.2,<0.29.0 +pywebpush==2.3.0 +Pillow==12.1.1 +firebase-functions==0.5.0 +firebase-admin==6.8.0 +a2wsgi==1.10.10 +python-jose[cryptography]==3.5.0 +passlib[bcrypt]==1.7.4 +bcrypt==3.2.2 +SpeechRecognition==3.15.2 +pydub==0.25.1 googletrans==4.0.2 -langdetect -numpy -scikit-learn +langdetect==1.0.9 +numpy==2.4.3 +scikit-learn==1.8.0 +python-magic==0.4.27 +joblib==1.5.3 +pytest==9.0.2 +urllib3<2.0.0 diff --git a/backend/routers/field_officer.py b/backend/routers/field_officer.py index 85cdcac7..4e811243 100644 --- a/backend/routers/field_officer.py +++ b/backend/routers/field_officer.py @@ -20,6 +20,7 @@ OfficerCheckOutRequest, FieldOfficerVisitResponse, PublicFieldOfficerVisitResponse, + BlockchainVerificationResponse, VisitHistoryResponse, VisitStatsResponse, VisitImageUploadResponse @@ -27,9 +28,11 @@ 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 logger = logging.getLogger(__name__) @@ -92,6 +95,15 @@ def officer_check_in(request: OfficerCheckInRequest, db: Session = Depends(get_d radius_meters=request.geofence_radius_meters or 100.0 ) + # Blockchain feature: calculate integrity hash for the visit + # Performance Boost: Use thread-safe cache to eliminate DB query for last hash + prev_hash = visit_last_hash_cache.get("last_hash") + if prev_hash is None: + # Cache miss: Fetch only the last hash 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") + # Create visit record check_in_time = datetime.now(timezone.utc) @@ -101,10 +113,11 @@ def officer_check_in(request: OfficerCheckInRequest, db: Session = Depends(get_d '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 '' + 'visit_notes': request.visit_notes or '', + 'previous_visit_hash': prev_hash } - # Generate immutable hash + # Generate immutable hash with chaining visit_hash = generate_visit_hash(visit_data) new_visit = FieldOfficerVisit( @@ -123,6 +136,7 @@ def officer_check_in(request: OfficerCheckInRequest, db: Session = Depends(get_d visit_notes=request.visit_notes, status='checked_in', visit_hash=visit_hash, + previous_visit_hash=prev_hash, is_public=True ) @@ -130,6 +144,9 @@ def officer_check_in(request: OfficerCheckInRequest, db: Session = Depends(get_d 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}. " f"Distance: {distance:.2f}m, Within fence: {within_fence}" @@ -155,6 +172,8 @@ def officer_check_in(request: OfficerCheckInRequest, db: Session = Depends(get_d status=new_visit.status, verified_by=new_visit.verified_by, verified_at=new_visit.verified_at, + visit_hash=new_visit.visit_hash, + previous_visit_hash=new_visit.previous_visit_hash, is_public=new_visit.is_public, created_at=new_visit.created_at ) @@ -230,6 +249,8 @@ def officer_check_out(request: OfficerCheckOutRequest, db: Session = Depends(get status=visit.status, verified_by=visit.verified_by, verified_at=visit.verified_at, + visit_hash=visit.visit_hash, + previous_visit_hash=visit.previous_visit_hash, is_public=visit.is_public, created_at=visit.created_at ) @@ -484,3 +505,65 @@ def verify_visit( 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(visit_id: int, db: Session = Depends(get_db)): + """ + Verify the cryptographic integrity of a field officer visit using blockchain-style chaining. + Optimized: Uses previous_visit_hash column for O(1) verification. + """ + try: + visit = db.query(FieldOfficerVisit).filter(FieldOfficerVisit.id == visit_id).first() + + if not visit: + raise HTTPException(status_code=404, detail="Visit not found") + + # Chaining logic: previous_visit_hash is already stored in the record + # Chaining data components must match generate_visit_hash + 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, + 'visit_notes': visit.visit_notes or '', + 'previous_visit_hash': visit.previous_visit_hash or '' + } + + is_valid = verify_visit_integrity(visit_data, visit.visit_hash) + + # Also verify the link to previous record's hash if it exists + if is_valid and visit.previous_visit_hash: + # For O(1) we trust the stored previous_visit_hash for the current record's seal, + # but we can also check if a record with that hash actually exists + prev_exists = db.query(FieldOfficerVisit.id).filter( + FieldOfficerVisit.visit_hash == visit.previous_visit_hash + ).first() + if not prev_exists: + # This doesn't mean the CURRENT record is tampered, but the CHAIN is broken + message = "Integrity verified, but chain link refers to a missing previous record." + return BlockchainVerificationResponse( + is_valid=True, + current_hash=visit.visit_hash, + computed_hash=visit.visit_hash, # verify_visit_integrity already checked this + message=message + ) + + if is_valid: + message = "Integrity verified. This visit record is cryptographically sealed and has not been tampered with." + else: + message = "Integrity check failed! The visit data does not match its cryptographic seal." + + return BlockchainVerificationResponse( + is_valid=is_valid, + current_hash=visit.visit_hash, + computed_hash=generate_visit_hash(visit_data), + message=message + ) + + 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") diff --git a/backend/schemas.py b/backend/schemas.py index 7dd398e0..0c1dd09d 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -515,6 +515,8 @@ class FieldOfficerVisitResponse(BaseModel): status: str = Field(..., description="Visit status") verified_by: Optional[str] = Field(None, description="Verified by") verified_at: Optional[datetime] = Field(None, description="Verification timestamp") + visit_hash: Optional[str] = Field(None, description="Integrity hash") + previous_visit_hash: Optional[str] = Field(None, description="Previous visit hash") is_public: bool = Field(..., description="Public visibility") created_at: datetime = Field(..., description="Creation timestamp") @@ -541,6 +543,8 @@ class PublicFieldOfficerVisitResponse(BaseModel): status: str = Field(..., description="Visit status") verified_by: Optional[str] = Field(None, description="Verified by") verified_at: Optional[datetime] = Field(None, description="Verification timestamp") + visit_hash: Optional[str] = Field(None, description="Integrity hash") + previous_visit_hash: Optional[str] = Field(None, description="Previous visit hash") is_public: bool = Field(..., description="Public visibility") created_at: datetime = Field(..., description="Creation timestamp") diff --git a/backend/tests/test_field_officer_blockchain.py b/backend/tests/test_field_officer_blockchain.py new file mode 100644 index 00000000..ffa3a788 --- /dev/null +++ b/backend/tests/test_field_officer_blockchain.py @@ -0,0 +1,88 @@ +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 Base, get_db +from backend.models import Issue, User, UserRole, FieldOfficerVisit +from backend.geofencing_service import generate_visit_hash +import hashlib + +# Setup test database +SQLALCHEMY_DATABASE_URL = "sqlite:///./test_blockchain.db" +engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}) +TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +def override_get_db(): + try: + db = TestingSessionLocal() + yield db + finally: + db.close() + +app.dependency_overrides[get_db] = override_get_db + +@pytest.fixture(autouse=True) +def setup_db(): + Base.metadata.drop_all(bind=engine) + Base.metadata.create_all(bind=engine) + yield + Base.metadata.drop_all(bind=engine) + +def test_field_officer_blockchain_chaining(): + client = TestClient(app) + db = TestingSessionLocal() + + # 1. Create a test issue + issue = Issue( + description="Pothole on Main St", + category="Road", + latitude=18.5204, + longitude=73.8567, + status="open" + ) + db.add(issue) + db.commit() + db.refresh(issue) + + # 2. Perform first check-in + checkin1_data = { + "issue_id": issue.id, + "officer_email": "officer1@example.com", + "officer_name": "Officer One", + "check_in_latitude": 18.5204, + "check_in_longitude": 73.8567, + "visit_notes": "First visit" + } + response1 = client.post("/api/field-officer/check-in", json=checkin1_data) + assert response1.status_code == 200 + visit1 = response1.json() + hash1 = visit1.get("visit_hash") + + # Check if previous_visit_hash exists in response + assert "previous_visit_hash" in visit1, "previous_visit_hash should be in the response" + assert visit1["previous_visit_hash"] == "" or visit1["previous_visit_hash"] is None, "First visit should have empty or None previous hash" + + # 3. Perform second check-in + checkin2_data = { + "issue_id": issue.id, + "officer_email": "officer2@example.com", + "officer_name": "Officer Two", + "check_in_latitude": 18.5205, + "check_in_longitude": 73.8568, + "visit_notes": "Second visit" + } + response2 = client.post("/api/field-officer/check-in", json=checkin2_data) + assert response2.status_code == 200 + visit2 = response2.json() + hash2 = visit2.get("visit_hash") + + assert "previous_visit_hash" in visit2 + assert visit2["previous_visit_hash"] == hash1, "Second visit should link to first visit's hash" + + # 4. Verify O(1) integrity endpoint + verify_response = client.get(f"/api/field-officer/{visit2['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 diff --git a/backend_output.txt b/backend_output.txt deleted file mode 100644 index d5987975..00000000 --- a/backend_output.txt +++ /dev/null @@ -1,6 +0,0 @@ -INFO: Will watch for changes in these directories: ['E:\\projects\\VishwaGuru\\VishwaGuru'] -INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit) -INFO: Started reloader process [42100] using StatReload -2026-03-09 02:00:56,311 - backend.adaptive_weights - INFO - Adaptive weights loaded/reloaded. -WARNING: StatReload detected changes in 'backend\main.py'. Reloading... - \ No newline at end of file diff --git a/test_out.txt b/test_out.txt deleted file mode 100644 index 274ac5ea..00000000 --- a/test_out.txt +++ /dev/null @@ -1,2 +0,0 @@ -.............................. [100%] -30 passed in 1.23s diff --git a/test_results.txt b/test_results.txt deleted file mode 100644 index bf3154e6..00000000 Binary files a/test_results.txt and /dev/null differ