From c6b177ac5506dfb983d95604a992aed049f3def1 Mon Sep 17 00:00:00 2001 From: vickytechkey Date: Thu, 15 Oct 2026 19:42:15 +0530 Subject: [PATCH] feat: implement core document verification engine with vendor waterfall failover and API endpoints --- docverify/settings.py | 169 ++++++++------- docverify/urls.py | 4 +- verifier/admin.py | 53 ++++- verifier/engine/__init__.py | 20 ++ verifier/engine/base.py | 120 +++++++++++ verifier/engine/orchestrator.py | 141 +++++++++++++ verifier/engine/registry.py | 69 ++++++ verifier/migrations/0001_initial.py | 80 +++++++ verifier/models.py | 93 ++++++++- verifier/serializers.py | 67 ++++++ verifier/tests.py | 138 +++++++++++- verifier/urls.py | 10 + verifier/vendors/__init__.py | 8 + verifier/vendors/codeplus.py | 311 ++++++++++++++++++++++++++++ verifier/views.py | 143 ++++++++++++- 15 files changed, 1332 insertions(+), 94 deletions(-) create mode 100644 verifier/engine/__init__.py create mode 100644 verifier/engine/base.py create mode 100644 verifier/engine/orchestrator.py create mode 100644 verifier/engine/registry.py create mode 100644 verifier/migrations/0001_initial.py create mode 100644 verifier/serializers.py create mode 100644 verifier/urls.py create mode 100644 verifier/vendors/__init__.py create mode 100644 verifier/vendors/codeplus.py diff --git a/docverify/settings.py b/docverify/settings.py index c9ddba6..8f7d773 100644 --- a/docverify/settings.py +++ b/docverify/settings.py @@ -1,129 +1,122 @@ -""" -Django settings for docverify project. - -Generated by 'django-admin startproject' using Django 6.1. - -For more information on this file, see -https://docs.djangoproject.com/en/6.1/topics/settings/ - -For the full list of settings and their values, see -https://docs.djangoproject.com/en/6.1/ref/settings/ -""" - +import os from pathlib import Path +from dotenv import load_dotenv # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent - -# Quick-start development settings - unsuitable for production -# See https://docs.djangoproject.com/en/6.1/howto/deployment/checklist/ +# Load .env file if available +load_dotenv(BASE_DIR / ".env") # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = 'django-insecure-n%6_st#u9=4%0%&y6$#@(g1gh-qen=yp5kfxjhx!i(grgbhn(-' +SECRET_KEY = os.environ.get( + "SECRET_KEY", + "django-insecure-docverify-multi-vendor-verification-engine-suite-key-2026", +) # SECURITY WARNING: don't run with debug turned on in production! -DEBUG = True - -ALLOWED_HOSTS = [] +DEBUG = os.environ.get("DEBUG", "1") in ("1", "true", "True") +ALLOWED_HOSTS = ["*"] # Application definition - INSTALLED_APPS = [ - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', - 'rest_framework', - 'verifier', + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + + # Third party apps + "rest_framework", + + # Local apps + "verifier", ] MIDDLEWARE = [ - 'django.middleware.security.SecurityMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.middleware.common.CommonMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware', + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", ] -ROOT_URLCONF = 'docverify.urls' +ROOT_URLCONF = "docverify.urls" TEMPLATES = [ { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", ], }, }, ] -WSGI_APPLICATION = 'docverify.wsgi.application' +WSGI_APPLICATION = "docverify.wsgi.application" - -# Database -# https://docs.djangoproject.com/en/6.1/ref/settings/#databases +# Database Configuration: PostgreSQL +db_host = os.environ.get('DB_HOST', '16.113.106.152') +db_port = int(os.environ.get('DB_PORT', '5432')) +db_user = os.environ.get('DB_USER', 'vignesh') +db_pass = os.environ.get('DB_PASSWORD', 'vignesh_secret_pass_2026') +env_name = os.environ.get('ENVIRONMENT', 'beta') +db_name = os.environ.get('DB_NAME', f"{env_name}-documentverifcation" if env_name == "beta" else "documentverifcation") DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': BASE_DIR / 'db.sqlite3', + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": db_name, + "USER": db_user, + "PASSWORD": db_pass, + "HOST": db_host, + "PORT": db_port, + "CONN_MAX_AGE": 60, } } - -# Password validation -# https://docs.djangoproject.com/en/6.1/ref/settings/#auth-password-validators - AUTH_PASSWORD_VALIDATORS = [ - { - 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', - }, + {"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"}, + {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, + {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, + {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, ] - -# Internationalization -# https://docs.djangoproject.com/en/6.1/topics/i18n/ - -LANGUAGE_CODE = 'en-us' - -TIME_ZONE = 'UTC' - +LANGUAGE_CODE = "en-us" +TIME_ZONE = "Asia/Kolkata" USE_I18N = True - USE_TZ = True +STATIC_URL = "static/" +STATIC_ROOT = BASE_DIR / "staticfiles" -# Static files (CSS, JavaScript, Images) -# https://docs.djangoproject.com/en/6.1/howto/static-files/ +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" -STATIC_URL = 'static/' - - -# Email -# https://docs.djangoproject.com/en/6.1/topics/email/#topic-email-configuration - -MAILERS = { - 'default': { - 'BACKEND': 'django.core.mail.backends.console.EmailBackend', - }, +REST_FRAMEWORK = { + "DEFAULT_PERMISSION_CLASSES": [ + "rest_framework.permissions.AllowAny", + ], + "DEFAULT_RENDERER_CLASSES": [ + "rest_framework.renderers.JSONRenderer", + "rest_framework.renderers.BrowsableAPIRenderer", + ], } + +# ────────────────────────────────────────────────────────────────────────────── +# Vendor API Configurations +# ────────────────────────────────────────────────────────────────────────────── +# Codeplus API Credentials +CODEPLUS_BASE_URL = os.environ.get("CODEPLUS_BASE_URL", "https://api.codeplus.in") +CODEPLUS_API_KEY = os.environ.get("CODEPLUS_API_KEY", "") +CODEPLUS_CLIENT_ID = os.environ.get("CODEPLUS_CLIENT_ID", "") +CODEPLUS_CLIENT_SECRET = os.environ.get("CODEPLUS_CLIENT_SECRET", "") diff --git a/docverify/urls.py b/docverify/urls.py index 3b12745..c2971db 100644 --- a/docverify/urls.py +++ b/docverify/urls.py @@ -15,8 +15,10 @@ Including another URLconf 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ from django.contrib import admin -from django.urls import path +from django.urls import path, include urlpatterns = [ path('admin/', admin.site.urls), + path('api/', include('verifier.urls')), ] + diff --git a/verifier/admin.py b/verifier/admin.py index 8c38f3f..dbdd50d 100644 --- a/verifier/admin.py +++ b/verifier/admin.py @@ -1,3 +1,54 @@ from django.contrib import admin +from .models import VendorConfig, VerificationTransaction, VendorAttemptLog -# Register your models here. + +class VendorAttemptLogInline(admin.TabularInline): + model = VendorAttemptLog + extra = 0 + readonly_fields = ( + "attempt_number", + "vendor_name", + "status", + "is_success", + "latency_ms", + "error_code", + "error_message", + "created_at", + ) + can_delete = False + + +@admin.register(VendorConfig) +class VendorConfigAdmin(admin.ModelAdmin): + list_display = ("name", "display_name", "priority", "is_active", "base_url", "updated_at") + list_editable = ("priority", "is_active") + search_fields = ("name", "display_name") + list_filter = ("is_active",) + + +@admin.register(VerificationTransaction) +class VerificationTransactionAdmin(admin.ModelAdmin): + list_display = ( + "reference_id", + "document_type", + "status", + "successful_vendor", + "attempts_count", + "total_latency_ms", + "created_at", + ) + list_filter = ("status", "document_type", "successful_vendor", "created_at") + search_fields = ("reference_id", "document_type", "successful_vendor") + readonly_fields = ( + "reference_id", + "document_type", + "status", + "successful_vendor", + "input_payload", + "final_data", + "attempts_count", + "total_latency_ms", + "created_at", + "updated_at", + ) + inlines = [VendorAttemptLogInline] diff --git a/verifier/engine/__init__.py b/verifier/engine/__init__.py new file mode 100644 index 0000000..1a2d3b7 --- /dev/null +++ b/verifier/engine/__init__.py @@ -0,0 +1,20 @@ +from .base import ( + BaseVendor, + DocumentType, + VendorResult, + VerificationStatus, +) +from .registry import VendorRegistry, registry +from .orchestrator import EngineResult, VerificationEngine, engine + +__all__ = [ + "BaseVendor", + "DocumentType", + "VendorResult", + "VerificationStatus", + "VendorRegistry", + "registry", + "EngineResult", + "VerificationEngine", + "engine", +] diff --git a/verifier/engine/base.py b/verifier/engine/base.py new file mode 100644 index 0000000..9e4ed1b --- /dev/null +++ b/verifier/engine/base.py @@ -0,0 +1,120 @@ +import time +from dataclasses import dataclass, field +from enum import Enum +from typing import Any, Dict, List, Optional + + +class DocumentType(str, Enum): + PAN = "pan" + AADHAAR = "aadhaar" + DRIVING_LICENSE = "driving_license" + VOTER_ID = "voter_id" + PASSPORT = "passport" + BANK_ACCOUNT = "bank_account" + GSTIN = "gstin" + CUSTOM = "custom" + + +class VerificationStatus(str, Enum): + SUCCESS = "SUCCESS" + FAILED = "FAILED" + ERROR = "ERROR" + SKIPPED = "SKIPPED" + + +@dataclass +class VendorResult: + """Standardized result returned by every vendor verification adapter.""" + vendor_name: str + is_success: bool + status: VerificationStatus + data: Dict[str, Any] = field(default_factory=dict) + raw_response: Any = None + error_code: Optional[str] = None + error_message: Optional[str] = None + latency_ms: float = 0.0 + retryable: bool = False + + def to_dict(self) -> Dict[str, Any]: + return { + "vendor_name": self.vendor_name, + "is_success": self.is_success, + "status": self.status.value if isinstance(self.status, VerificationStatus) else str(self.status), + "data": self.data, + "raw_response": self.raw_response, + "error_code": self.error_code, + "error_message": self.error_message, + "latency_ms": round(self.latency_ms, 2), + "retryable": self.retryable, + } + + +class BaseVendor: + """ + Abstract Base Class for all Verification Vendors (e.g. Codeplus, Cashfree, Karza, Surepass). + Subclasses implement vendor-specific logic while adhering to this unified contract. + """ + name: str = "base_vendor" + display_name: str = "Base Vendor" + priority: int = 100 + is_active: bool = True + supported_documents: List[str] = [] + + def __init__(self, config: Optional[Dict[str, Any]] = None): + self.config = config or {} + + def supports(self, document_type: str) -> bool: + """Check if this vendor supports the requested document type.""" + doc_key = document_type.lower() + return doc_key in [d.lower() for d in self.supported_documents] or "all" in self.supported_documents + + def verify( + self, + document_type: str, + data: Dict[str, Any], + files: Optional[Dict[str, Any]] = None, + ) -> VendorResult: + """ + Executes verification with latency tracking and universal exception handling. + Delegates actual execution to `execute_verification`. + """ + start_time = time.time() + doc_type_clean = document_type.lower() + + if not self.supports(doc_type_clean): + latency = (time.time() - start_time) * 1000 + return VendorResult( + vendor_name=self.name, + is_success=False, + status=VerificationStatus.SKIPPED, + error_code="DOC_NOT_SUPPORTED", + error_message=f"Vendor '{self.name}' does not support document type '{document_type}'", + latency_ms=latency, + retryable=True, + ) + + try: + result = self.execute_verification(doc_type_clean, data, files) + result.latency_ms = (time.time() - start_time) * 1000 + result.vendor_name = self.name + return result + except Exception as exc: + latency = (time.time() - start_time) * 1000 + return VendorResult( + vendor_name=self.name, + is_success=False, + status=VerificationStatus.ERROR, + error_code=exc.__class__.__name__, + error_message=str(exc), + latency_ms=latency, + retryable=True, + ) + + def execute_verification( + self, + document_type: str, + data: Dict[str, Any], + files: Optional[Dict[str, Any]] = None, + ) -> VendorResult: + """Must be implemented by subclasses.""" + raise NotImplementedError("Subclasses must implement execute_verification()") diff --git a/verifier/engine/orchestrator.py b/verifier/engine/orchestrator.py new file mode 100644 index 0000000..d57bd06 --- /dev/null +++ b/verifier/engine/orchestrator.py @@ -0,0 +1,141 @@ +import logging +import time +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional +from .base import BaseVendor, DocumentType, VendorResult, VerificationStatus +from .registry import registry, VendorRegistry + +logger = logging.getLogger(__name__) + + +@dataclass +class EngineResult: + """Consolidated result returned by the VerificationEngine.""" + is_success: bool + document_type: str + status: VerificationStatus + successful_vendor: Optional[str] = None + data: Dict[str, Any] = field(default_factory=dict) + attempts: List[Dict[str, Any]] = field(default_factory=list) + error_message: Optional[str] = None + total_latency_ms: float = 0.0 + + def to_dict(self) -> Dict[str, Any]: + return { + "is_success": self.is_success, + "document_type": self.document_type, + "status": self.status.value if isinstance(self.status, VerificationStatus) else str(self.status), + "successful_vendor": self.successful_vendor, + "data": self.data, + "attempts_count": len(self.attempts), + "attempts": self.attempts, + "error_message": self.error_message, + "total_latency_ms": round(self.total_latency_ms, 2), + } + + +class VerificationEngine: + """ + Core waterfall orchestrator. + Executes multiple vendors in a fallback chain. + If vendor A errors or fails, the engine falls back to vendor B, C, etc. + """ + + def __init__(self, vendor_registry: Optional[VendorRegistry] = None): + self.registry = vendor_registry or registry + + def verify( + self, + document_type: str, + data: Dict[str, Any], + files: Optional[Dict[str, Any]] = None, + preferred_vendor: Optional[str] = None, + ) -> EngineResult: + """ + Executes document verification through the vendor waterfall. + """ + start_time = time.time() + doc_type_clean = document_type.lower() + vendors = self.registry.get_vendors_for_document(doc_type_clean, preferred_vendor=preferred_vendor) + + if not vendors: + total_latency = (time.time() - start_time) * 1000 + return EngineResult( + is_success=False, + document_type=doc_type_clean, + status=VerificationStatus.ERROR, + error_message=f"No active vendors configured for document type '{document_type}'", + total_latency_ms=total_latency, + ) + + attempts: List[Dict[str, Any]] = [] + + logger.info( + "Starting verification for '%s'. Candidate vendors in order: %s", + doc_type_clean, + [v.name for v in vendors], + ) + + for attempt_index, vendor in enumerate(vendors, start=1): + logger.info( + "Attempt #%d: Dispatching verification to vendor '%s'", + attempt_index, + vendor.name, + ) + + result: VendorResult = vendor.verify(doc_type_clean, data, files) + attempt_record = result.to_dict() + attempt_record["attempt_number"] = attempt_index + attempts.append(attempt_record) + + if result.is_success: + total_latency = (time.time() - start_time) * 1000 + logger.info( + "Verification SUCCEEDED via vendor '%s' on attempt #%d (latency=%.2f ms)", + vendor.name, + attempt_index, + result.latency_ms, + ) + return EngineResult( + is_success=True, + document_type=doc_type_clean, + status=VerificationStatus.SUCCESS, + successful_vendor=vendor.name, + data=result.data, + attempts=attempts, + total_latency_ms=total_latency, + ) + else: + logger.warning( + "Vendor '%s' failed on attempt #%d: status=%s, code=%s, error=%s. Triggering fallback to next vendor.", + vendor.name, + attempt_index, + result.status, + result.error_code, + result.error_message, + ) + + # If all vendors failed + total_latency = (time.time() - start_time) * 1000 + last_error = attempts[-1].get("error_message") if attempts else "All vendors failed" + logger.error( + "All %d vendors failed verification for '%s'. Total time=%.2f ms", + len(vendors), + doc_type_clean, + total_latency, + ) + + return EngineResult( + is_success=False, + document_type=doc_type_clean, + status=VerificationStatus.FAILED, + successful_vendor=None, + data={}, + attempts=attempts, + error_message=f"All vendors exhausted. Last error: {last_error}", + total_latency_ms=total_latency, + ) + + +# Global default engine instance +engine = VerificationEngine() diff --git a/verifier/engine/registry.py b/verifier/engine/registry.py new file mode 100644 index 0000000..f6431d7 --- /dev/null +++ b/verifier/engine/registry.py @@ -0,0 +1,69 @@ +import logging +from typing import Dict, List, Optional +from .base import BaseVendor + +logger = logging.getLogger(__name__) + + +class VendorRegistry: + """ + Central registry for verification vendors. + Manages active vendor adapters and resolves waterfall order. + """ + + def __init__(self): + self._vendors: Dict[str, BaseVendor] = {} + + def register(self, vendor: BaseVendor, overwrite: bool = True) -> None: + """Register a vendor instance.""" + vendor_key = vendor.name.lower() + if vendor_key in self._vendors and not overwrite: + logger.warning("Vendor '%s' is already registered. Skipping.", vendor_key) + return + + self._vendors[vendor_key] = vendor + logger.info("Registered vendor '%s' (priority=%d, active=%s)", vendor.name, vendor.priority, vendor.is_active) + + def unregister(self, vendor_name: str) -> Optional[BaseVendor]: + """Unregister a vendor instance.""" + return self._vendors.pop(vendor_name.lower(), None) + + def get_vendor(self, vendor_name: str) -> Optional[BaseVendor]: + """Retrieve vendor by name.""" + return self._vendors.get(vendor_name.lower()) + + def get_all_vendors(self) -> List[BaseVendor]: + """Return all registered vendors sorted by priority (lowest number = highest priority).""" + return sorted(self._vendors.values(), key=lambda v: v.priority) + + def get_vendors_for_document( + self, + document_type: str, + preferred_vendor: Optional[str] = None, + ) -> List[BaseVendor]: + """ + Get all active vendors that support `document_type`, sorted by priority. + If `preferred_vendor` is specified, it is placed at the front of the chain. + """ + doc_key = document_type.lower() + active_candidates = [ + v for v in self._vendors.values() + if v.is_active and v.supports(doc_key) + ] + + # Sort by priority + active_candidates.sort(key=lambda v: v.priority) + + # If preferred vendor is requested and available, move it to index 0 + if preferred_vendor: + pref_key = preferred_vendor.lower() + matching = [v for v in active_candidates if v.name.lower() == pref_key] + if matching: + remaining = [v for v in active_candidates if v.name.lower() != pref_key] + return matching + remaining + + return active_candidates + + +# Global default registry instance +registry = VendorRegistry() diff --git a/verifier/migrations/0001_initial.py b/verifier/migrations/0001_initial.py new file mode 100644 index 0000000..06f3a20 --- /dev/null +++ b/verifier/migrations/0001_initial.py @@ -0,0 +1,80 @@ +# Generated by Django 6.1 on 2026-10-15 14:09 + +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='VendorConfig', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(help_text='Unique vendor identifier, e.g. codeplus', max_length=50, unique=True)), + ('display_name', models.CharField(help_text='Human-readable name', max_length=100)), + ('is_active', models.BooleanField(default=True)), + ('priority', models.PositiveIntegerField(default=10, help_text='Waterfall execution order (lower numbers execute first)')), + ('supported_documents', models.JSONField(default=list, help_text="List of supported doc types: ['pan', 'aadhaar', 'driving_license', 'voter_id', ...]")), + ('base_url', models.CharField(blank=True, max_length=255, null=True)), + ('api_key', models.CharField(blank=True, max_length=255, null=True)), + ('extra_credentials', models.JSONField(blank=True, default=dict)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'Vendor Configuration', + 'verbose_name_plural': 'Vendor Configurations', + 'ordering': ['priority', 'name'], + }, + ), + migrations.CreateModel( + name='VerificationTransaction', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('reference_id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True)), + ('document_type', models.CharField(db_index=True, max_length=50)), + ('status', models.CharField(choices=[('PENDING', 'Pending'), ('SUCCESS', 'Success'), ('FAILED', 'Failed'), ('ERROR', 'Error')], db_index=True, default='PENDING', max_length=20)), + ('successful_vendor', models.CharField(blank=True, max_length=50, null=True)), + ('input_payload', models.JSONField(blank=True, default=dict)), + ('final_data', models.JSONField(blank=True, default=dict)), + ('attempts_count', models.PositiveIntegerField(default=0)), + ('total_latency_ms', models.FloatField(default=0.0)), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'Verification Transaction', + 'verbose_name_plural': 'Verification Transactions', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='VendorAttemptLog', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('attempt_number', models.PositiveIntegerField()), + ('vendor_name', models.CharField(db_index=True, max_length=50)), + ('status', models.CharField(max_length=20)), + ('is_success', models.BooleanField(default=False)), + ('data', models.JSONField(blank=True, default=dict)), + ('raw_response', models.JSONField(blank=True, null=True)), + ('error_code', models.CharField(blank=True, max_length=100, null=True)), + ('error_message', models.TextField(blank=True, null=True)), + ('latency_ms', models.FloatField(default=0.0)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('transaction', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attempts', to='verifier.verificationtransaction')), + ], + options={ + 'verbose_name': 'Vendor Attempt Log', + 'verbose_name_plural': 'Vendor Attempt Logs', + 'ordering': ['attempt_number'], + }, + ), + ] diff --git a/verifier/models.py b/verifier/models.py index 71a8362..7648339 100644 --- a/verifier/models.py +++ b/verifier/models.py @@ -1,3 +1,94 @@ +import uuid from django.db import models -# Create your models here. + +class VendorConfig(models.Model): + """ + Dynamic configuration for verification vendors stored in database. + Allows toggling active state, priority adjustments, and credential updates via Django admin. + """ + name = models.CharField(max_length=50, unique=True, help_text="Unique vendor identifier, e.g. codeplus") + display_name = models.CharField(max_length=100, help_text="Human-readable name") + is_active = models.BooleanField(default=True) + priority = models.PositiveIntegerField( + default=10, + help_text="Waterfall execution order (lower numbers execute first)", + ) + supported_documents = models.JSONField( + default=list, + help_text="List of supported doc types: ['pan', 'aadhaar', 'driving_license', 'voter_id', ...]", + ) + base_url = models.CharField(max_length=255, blank=True, null=True) + api_key = models.CharField(max_length=255, blank=True, null=True) + extra_credentials = models.JSONField(default=dict, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["priority", "name"] + verbose_name = "Vendor Configuration" + verbose_name_plural = "Vendor Configurations" + + def __str__(self): + return f"{self.display_name} ({self.name}) - Priority: {self.priority} - {'Active' if self.is_active else 'Disabled'}" + + +class VerificationTransaction(models.Model): + """ + Master record of every document verification request processed by the engine. + """ + STATUS_CHOICES = ( + ("PENDING", "Pending"), + ("SUCCESS", "Success"), + ("FAILED", "Failed"), + ("ERROR", "Error"), + ) + + reference_id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True) + document_type = models.CharField(max_length=50, db_index=True) + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="PENDING", db_index=True) + successful_vendor = models.CharField(max_length=50, blank=True, null=True) + input_payload = models.JSONField(default=dict, blank=True) + final_data = models.JSONField(default=dict, blank=True) + attempts_count = models.PositiveIntegerField(default=0) + total_latency_ms = models.FloatField(default=0.0) + created_at = models.DateTimeField(auto_now_add=True, db_index=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["-created_at"] + verbose_name = "Verification Transaction" + verbose_name_plural = "Verification Transactions" + + def __str__(self): + return f"[{self.status}] {self.document_type} - {self.reference_id} ({self.successful_vendor or 'none'})" + + +class VendorAttemptLog(models.Model): + """ + Audit log of each individual vendor attempt in the waterfall cascade. + Allows inspection of which vendor failed, why, latency, and full payload details. + """ + transaction = models.ForeignKey( + VerificationTransaction, + related_name="attempts", + on_delete=models.CASCADE, + ) + attempt_number = models.PositiveIntegerField() + vendor_name = models.CharField(max_length=50, db_index=True) + status = models.CharField(max_length=20) + is_success = models.BooleanField(default=False) + data = models.JSONField(default=dict, blank=True) + raw_response = models.JSONField(null=True, blank=True) + error_code = models.CharField(max_length=100, blank=True, null=True) + error_message = models.TextField(blank=True, null=True) + latency_ms = models.FloatField(default=0.0) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["attempt_number"] + verbose_name = "Vendor Attempt Log" + verbose_name_plural = "Vendor Attempt Logs" + + def __str__(self): + return f"Tx: {self.transaction.reference_id} | #{self.attempt_number} {self.vendor_name} -> {self.status}" diff --git a/verifier/serializers.py b/verifier/serializers.py new file mode 100644 index 0000000..90662cd --- /dev/null +++ b/verifier/serializers.py @@ -0,0 +1,67 @@ +from rest_framework import serializers +from .models import VendorConfig, VerificationTransaction, VendorAttemptLog + + +class VerifyDocumentRequestSerializer(serializers.Serializer): + """Input payload serializer for document verification API.""" + document_type = serializers.CharField( + required=True, + help_text="Type of document, e.g. 'pan', 'aadhaar', 'driving_license', 'voter_id', 'bank_account', 'gstin'", + ) + data = serializers.DictField( + required=True, + help_text="Document verification attributes (e.g. pan_number, name, dob, etc.)", + ) + preferred_vendor = serializers.CharField( + required=False, + allow_null=True, + allow_blank=True, + help_text="Optional vendor to prioritize at head of fallback waterfall (e.g. 'codeplus')", + ) + + +class VendorAttemptLogSerializer(serializers.ModelSerializer): + class Meta: + model = VendorAttemptLog + fields = [ + "attempt_number", + "vendor_name", + "status", + "is_success", + "error_code", + "error_message", + "latency_ms", + "created_at", + ] + + +class VerificationTransactionSerializer(serializers.ModelSerializer): + attempts = VendorAttemptLogSerializer(many=True, read_only=True) + + class Meta: + model = VerificationTransaction + fields = [ + "reference_id", + "document_type", + "status", + "successful_vendor", + "final_data", + "attempts_count", + "total_latency_ms", + "created_at", + "attempts", + ] + + +class VendorConfigSerializer(serializers.ModelSerializer): + class Meta: + model = VendorConfig + fields = [ + "name", + "display_name", + "priority", + "is_active", + "supported_documents", + "base_url", + "updated_at", + ] diff --git a/verifier/tests.py b/verifier/tests.py index 7ce503c..b9d7fc5 100644 --- a/verifier/tests.py +++ b/verifier/tests.py @@ -1,3 +1,139 @@ from django.test import TestCase +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APIClient -# Create your tests here. +from verifier.engine.base import BaseVendor, VendorResult, VerificationStatus +from verifier.engine.registry import VendorRegistry +from verifier.engine.orchestrator import VerificationEngine +from verifier.vendors.codeplus import CodeplusVendor +from verifier.models import VerificationTransaction, VendorAttemptLog + + +class MockFailingVendor(BaseVendor): + """Simulates a primary vendor that encounters an API error/failure.""" + name = "vendor_alpha" + priority = 10 + is_active = True + supported_documents = ["pan", "aadhaar"] + + def execute_verification(self, document_type, data, files=None): + return VendorResult( + vendor_name=self.name, + is_success=False, + status=VerificationStatus.ERROR, + error_code="VENDOR_API_DOWNTIME", + error_message="Primary vendor Alpha API returned HTTP 503 Service Unavailable", + retryable=True, + ) + + +class MockSucceedingVendor(BaseVendor): + """Simulates a fallback vendor that successfully completes verification.""" + name = "vendor_beta" + priority = 20 + is_active = True + supported_documents = ["pan", "aadhaar", "driving_license"] + + def execute_verification(self, document_type, data, files=None): + return VendorResult( + vendor_name=self.name, + is_success=True, + status=VerificationStatus.SUCCESS, + data={ + "document_number": data.get("pan_number", "ABCDE1234F"), + "full_name": "Test User", + "verification_status": "VALID", + }, + ) + + +class VerificationEngineWaterfallTests(TestCase): + """Tests the core failover / waterfall mechanism of the VerificationEngine.""" + + def setUp(self): + self.custom_registry = VendorRegistry() + self.vendor_alpha = MockFailingVendor() + self.vendor_beta = MockSucceedingVendor() + + self.custom_registry.register(self.vendor_alpha) + self.custom_registry.register(self.vendor_beta) + self.engine = VerificationEngine(vendor_registry=self.custom_registry) + + def test_waterfall_falls_back_when_first_vendor_errors(self): + """When vendor_alpha throws an error, engine must fall back to vendor_beta and succeed.""" + result = self.engine.verify( + document_type="pan", + data={"pan_number": "ABCDE1234F", "name": "Test User"}, + ) + + self.assertTrue(result.is_success) + self.assertEqual(result.status, VerificationStatus.SUCCESS) + self.assertEqual(result.successful_vendor, "vendor_beta") + self.assertEqual(len(result.attempts), 2) + + # Check attempt 1 (failed) + attempt_1 = result.attempts[0] + self.assertEqual(attempt_1["vendor_name"], "vendor_alpha") + self.assertFalse(attempt_1["is_success"]) + self.assertEqual(attempt_1["error_code"], "VENDOR_API_DOWNTIME") + + # Check attempt 2 (succeeded) + attempt_2 = result.attempts[1] + self.assertEqual(attempt_2["vendor_name"], "vendor_beta") + self.assertTrue(attempt_2["is_success"]) + self.assertEqual(attempt_2["data"]["verification_status"], "VALID") + + def test_unsupported_document_type_returns_error(self): + """When no registered vendor supports the document, engine returns appropriate error.""" + result = self.engine.verify( + document_type="unsupported_doc", + data={"id": "12345"}, + ) + self.assertFalse(result.is_success) + self.assertIn("No active vendors", result.error_message) + + def test_preferred_vendor_overrides_priority(self): + """Specifying preferred_vendor places it at the front of the chain.""" + vendors = self.custom_registry.get_vendors_for_document("pan", preferred_vendor="vendor_beta") + self.assertEqual(vendors[0].name, "vendor_beta") + self.assertEqual(vendors[1].name, "vendor_alpha") + + +class CodeplusVendorTests(TestCase): + """Tests Codeplus vendor adapter configuration and handling.""" + + def setUp(self): + self.codeplus = CodeplusVendor(config={"base_url": "https://api.codeplus.in", "api_key": "test_key"}) + + def test_codeplus_metadata(self): + self.assertEqual(self.codeplus.name, "codeplus") + self.assertTrue(self.codeplus.supports("pan")) + self.assertTrue(self.codeplus.supports("aadhaar")) + self.assertTrue(self.codeplus.supports("driving_license")) + + def test_codeplus_headers(self): + headers = self.codeplus.get_headers() + self.assertEqual(headers["Authorization"], "Bearer test_key") + self.assertEqual(headers["x-api-key"], "test_key") + + +class VerificationAPITests(TestCase): + """Tests the DRF API endpoints with waterfall execution and persistence.""" + + def setUp(self): + self.client = APIClient() + + def test_vendor_list_api(self): + url = reverse("verifier:list_vendors") + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn("vendors", response.data) + vendor_names = [v["name"] for v in response.data["vendors"]] + self.assertIn("codeplus", vendor_names) + + def test_verify_api_validation_error(self): + url = reverse("verifier:verify_document") + response = self.client.post(url, {}, format="json") + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertFalse(response.data["is_success"]) diff --git a/verifier/urls.py b/verifier/urls.py new file mode 100644 index 0000000..d4a74f5 --- /dev/null +++ b/verifier/urls.py @@ -0,0 +1,10 @@ +from django.urls import path +from .views import DocumentVerifyView, VendorListView, TransactionDetailView + +app_name = "verifier" + +urlpatterns = [ + path("verify/", DocumentVerifyView.as_view(), name="verify_document"), + path("vendors/", VendorListView.as_view(), name="list_vendors"), + path("transactions//", TransactionDetailView.as_view(), name="transaction_detail"), +] diff --git a/verifier/vendors/__init__.py b/verifier/vendors/__init__.py new file mode 100644 index 0000000..3dd296c --- /dev/null +++ b/verifier/vendors/__init__.py @@ -0,0 +1,8 @@ +from verifier.engine.registry import registry +from .codeplus import CodeplusVendor + +# Register default vendors +codeplus_vendor = CodeplusVendor() +registry.register(codeplus_vendor) + +__all__ = ["CodeplusVendor", "codeplus_vendor"] diff --git a/verifier/vendors/codeplus.py b/verifier/vendors/codeplus.py new file mode 100644 index 0000000..032f7a1 --- /dev/null +++ b/verifier/vendors/codeplus.py @@ -0,0 +1,311 @@ +import logging +import os +from typing import Any, Dict, Optional +import requests +from django.conf import settings + +from verifier.engine.base import ( + BaseVendor, + DocumentType, + VendorResult, + VerificationStatus, +) + +logger = logging.getLogger(__name__) + + +class CodeplusVendor(BaseVendor): + """ + Codeplus Verification Adapter. + Implements verification workflows for PAN, Aadhaar, Driving License, Voter ID, + Bank Accounts, GSTIN, and custom documents via Codeplus APIs. + """ + name: str = "codeplus" + display_name: str = "Codeplus Verification Services" + priority: int = 10 # Lower number = higher priority in waterfall + is_active: bool = True + + supported_documents = [ + DocumentType.PAN.value, + DocumentType.AADHAAR.value, + DocumentType.DRIVING_LICENSE.value, + DocumentType.VOTER_ID.value, + DocumentType.PASSPORT.value, + DocumentType.BANK_ACCOUNT.value, + DocumentType.GSTIN.value, + DocumentType.CUSTOM.value, + ] + + def __init__(self, config: Optional[Dict[str, Any]] = None): + super().__init__(config=config) + self.base_url = ( + self.config.get("base_url") + or getattr(settings, "CODEPLUS_BASE_URL", None) + or os.environ.get("CODEPLUS_BASE_URL", "https://api.codeplus.in") + ).rstrip("/") + + self.api_key = ( + self.config.get("api_key") + or getattr(settings, "CODEPLUS_API_KEY", None) + or os.environ.get("CODEPLUS_API_KEY", "") + ) + + self.client_id = ( + self.config.get("client_id") + or getattr(settings, "CODEPLUS_CLIENT_ID", None) + or os.environ.get("CODEPLUS_CLIENT_ID", "") + ) + + self.client_secret = ( + self.config.get("client_secret") + or getattr(settings, "CODEPLUS_CLIENT_SECRET", None) + or os.environ.get("CODEPLUS_CLIENT_SECRET", "") + ) + + self.timeout = int(self.config.get("timeout", 15)) + self.session = requests.Session() + + def get_headers(self) -> Dict[str, str]: + """Construct standard HTTP headers for Codeplus API calls.""" + headers = { + "Content-Type": "application/json", + "Accept": "application/json", + } + if self.api_key: + headers["Authorization"] = f"Bearer {self.api_key}" + headers["x-api-key"] = self.api_key + + if self.client_id: + headers["x-client-id"] = self.client_id + if self.client_secret: + headers["x-client-secret"] = self.client_secret + + return headers + + def execute_verification( + self, + document_type: str, + data: Dict[str, Any], + files: Optional[Dict[str, Any]] = None, + ) -> VendorResult: + """ + Dispatches verification to specific document handlers. + """ + handlers = { + DocumentType.PAN.value: self.verify_pan, + DocumentType.AADHAAR.value: self.verify_aadhaar, + DocumentType.DRIVING_LICENSE.value: self.verify_driving_license, + DocumentType.VOTER_ID.value: self.verify_voter_id, + DocumentType.PASSPORT.value: self.verify_passport, + DocumentType.BANK_ACCOUNT.value: self.verify_bank_account, + DocumentType.GSTIN.value: self.verify_gstin, + } + + handler = handlers.get(document_type, self.verify_generic) + return handler(data, files) + + # ────────────────────────────────────────────────────────────────────────── + # Document-Specific Verification Methods + # (Structured for easy payload/endpoint mapping once examples are provided) + # ────────────────────────────────────────────────────────────────────────── + + def verify_pan(self, data: Dict[str, Any], files: Optional[Dict[str, Any]] = None) -> VendorResult: + """ + PAN Card Verification. + Expected data inputs: + - pan_number / pan: PAN number (e.g. ABCDE1234F) + - name / full_name: (Optional) Name to compare + - dob: (Optional) Date of Birth (YYYY-MM-DD or DD/MM/YYYY) + """ + endpoint = f"{self.base_url}/v1/pan/verify" + payload = { + "pan": data.get("pan_number") or data.get("pan"), + "name": data.get("name") or data.get("full_name"), + "dob": data.get("dob"), + } + return self._send_request(endpoint, payload=payload, files=files, doc_label="PAN") + + def verify_aadhaar(self, data: Dict[str, Any], files: Optional[Dict[str, Any]] = None) -> VendorResult: + """ + Aadhaar Verification (OTP generation, verification, or OCR). + """ + endpoint = f"{self.base_url}/v1/aadhaar/verify" + payload = { + "aadhaar_number": data.get("aadhaar_number") or data.get("aadhaar"), + "otp": data.get("otp"), + "client_ref_id": data.get("client_ref_id"), + } + return self._send_request(endpoint, payload=payload, files=files, doc_label="Aadhaar") + + def verify_driving_license(self, data: Dict[str, Any], files: Optional[Dict[str, Any]] = None) -> VendorResult: + """ + Driving License Verification. + """ + endpoint = f"{self.base_url}/v1/dl/verify" + payload = { + "dl_number": data.get("dl_number") or data.get("license_number"), + "dob": data.get("dob"), + } + return self._send_request(endpoint, payload=payload, files=files, doc_label="Driving License") + + def verify_voter_id(self, data: Dict[str, Any], files: Optional[Dict[str, Any]] = None) -> VendorResult: + """ + Voter ID (EPIC) Verification. + """ + endpoint = f"{self.base_url}/v1/voter-id/verify" + payload = { + "epic_number": data.get("epic_number") or data.get("voter_id"), + } + return self._send_request(endpoint, payload=payload, files=files, doc_label="Voter ID") + + def verify_passport(self, data: Dict[str, Any], files: Optional[Dict[str, Any]] = None) -> VendorResult: + """ + Passport Verification. + """ + endpoint = f"{self.base_url}/v1/passport/verify" + payload = { + "passport_number": data.get("passport_number"), + "dob": data.get("dob"), + "file_number": data.get("file_number"), + } + return self._send_request(endpoint, payload=payload, files=files, doc_label="Passport") + + def verify_bank_account(self, data: Dict[str, Any], files: Optional[Dict[str, Any]] = None) -> VendorResult: + """ + Bank Account / Penny Drop Verification. + """ + endpoint = f"{self.base_url}/v1/bank/verify" + payload = { + "account_number": data.get("account_number") or data.get("bank_account"), + "ifsc": data.get("ifsc"), + "name": data.get("name"), + } + return self._send_request(endpoint, payload=payload, files=files, doc_label="Bank Account") + + def verify_gstin(self, data: Dict[str, Any], files: Optional[Dict[str, Any]] = None) -> VendorResult: + """ + GSTIN Business Verification. + """ + endpoint = f"{self.base_url}/v1/gstin/verify" + payload = { + "gstin": data.get("gstin"), + } + return self._send_request(endpoint, payload=payload, files=files, doc_label="GSTIN") + + def verify_generic(self, data: Dict[str, Any], files: Optional[Dict[str, Any]] = None) -> VendorResult: + """Generic document verification fallback.""" + endpoint = f"{self.base_url}/v1/document/verify" + return self._send_request(endpoint, payload=data, files=files, doc_label="Generic Document") + + # ────────────────────────────────────────────────────────────────────────── + # Internal HTTP Dispatcher & Response Normalizer + # ────────────────────────────────────────────────────────────────────────── + + def _send_request( + self, + endpoint: str, + payload: Dict[str, Any], + files: Optional[Dict[str, Any]] = None, + doc_label: str = "Document", + ) -> VendorResult: + """ + Executes HTTP call to Codeplus and maps response into standardized VendorResult. + Catches errors to trigger fallback waterfall if Codeplus fails. + """ + headers = self.get_headers() + + # Clean payload of None values + clean_payload = {k: v for k, v in payload.items() if v is not None} + + try: + if files: + headers.pop("Content-Type", None) + response = self.session.post( + endpoint, + data=clean_payload, + files=files, + headers=headers, + timeout=self.timeout, + ) + else: + response = self.session.post( + endpoint, + json=clean_payload, + headers=headers, + timeout=self.timeout, + ) + + try: + raw_json = response.json() + except Exception: + raw_json = {"text": response.text} + + # Check HTTP status + if response.status_code >= 400: + error_msg = ( + raw_json.get("message") + or raw_json.get("error") + or f"Codeplus HTTP {response.status_code} Error" + ) + logger.warning( + "Codeplus %s verification failed with HTTP %d: %s", + doc_label, + response.status_code, + error_msg, + ) + return VendorResult( + vendor_name=self.name, + is_success=False, + status=VerificationStatus.FAILED if response.status_code < 500 else VerificationStatus.ERROR, + raw_response=raw_json, + error_code=f"HTTP_{response.status_code}", + error_message=str(error_msg), + retryable=response.status_code >= 500 or response.status_code in (429, 408), + ) + + # Codeplus success validation (can be tuned once exact example response is shared) + status_field = str(raw_json.get("status", "")).upper() + is_successful = status_field in ("SUCCESS", "VALID", "COMPLETED", "TRUE", "1") or raw_json.get("valid") is True + + if is_successful or ("data" in raw_json and "error" not in raw_json): + extracted_data = raw_json.get("data") if isinstance(raw_json.get("data"), dict) else raw_json + return VendorResult( + vendor_name=self.name, + is_success=True, + status=VerificationStatus.SUCCESS, + data=extracted_data, + raw_response=raw_json, + ) + else: + msg = raw_json.get("message") or raw_json.get("error") or "Document verification returned invalid status" + return VendorResult( + vendor_name=self.name, + is_success=False, + status=VerificationStatus.FAILED, + data=raw_json.get("data", {}), + raw_response=raw_json, + error_code="INVALID_STATUS", + error_message=str(msg), + retryable=False, + ) + + except requests.exceptions.Timeout as exc: + logger.warning("Codeplus %s call timed out: %s", doc_label, exc) + return VendorResult( + vendor_name=self.name, + is_success=False, + status=VerificationStatus.ERROR, + error_code="TIMEOUT", + error_message=f"Request to Codeplus timed out: {exc}", + retryable=True, + ) + except requests.exceptions.RequestException as exc: + logger.warning("Codeplus %s network exception: %s", doc_label, exc) + return VendorResult( + vendor_name=self.name, + is_success=False, + status=VerificationStatus.ERROR, + error_code="NETWORK_ERROR", + error_message=f"Network error connecting to Codeplus: {exc}", + retryable=True, + ) diff --git a/verifier/views.py b/verifier/views.py index 91ea44a..b02c097 100644 --- a/verifier/views.py +++ b/verifier/views.py @@ -1,3 +1,142 @@ -from django.shortcuts import render +import logging +from rest_framework import status +from rest_framework.response import Response +from rest_framework.views import APIView -# Create your views here. +# Import engine and vendors to ensure default registration +import verifier.vendors # noqa: F401 +from verifier.engine import engine, registry +from .models import VerificationTransaction, VendorAttemptLog +from .serializers import ( + VerifyDocumentRequestSerializer, + VerificationTransactionSerializer, +) + +logger = logging.getLogger(__name__) + + +def mask_sensitive_payload(payload: dict) -> dict: + """Mask sensitive inputs like full aadhaar numbers or passwords for audit safety.""" + masked = dict(payload) + sensitive_keys = ["aadhaar_number", "aadhaar", "otp", "password", "client_secret"] + for key in sensitive_keys: + if key in masked and isinstance(masked[key], str): + val = masked[key] + if len(val) > 4: + masked[key] = f"{val[:2]}****{val[-2:]}" + else: + masked[key] = "****" + return masked + + +class DocumentVerifyView(APIView): + """ + POST /api/verify/ + Verifies a document using the vendor waterfall failover engine. + If the first vendor encounters an error/failure, the engine automatically + cascades to the next available vendor. + """ + + def post(self, request, *args, **kwargs): + serializer = VerifyDocumentRequestSerializer(data=request.data) + if not serializer.is_valid(): + return Response( + {"is_success": False, "errors": serializer.errors}, + status=status.HTTP_400_BAD_REQUEST, + ) + + doc_type = serializer.validated_data["document_type"] + input_data = serializer.validated_data["data"] + preferred_vendor = serializer.validated_data.get("preferred_vendor") + files = request.FILES if hasattr(request, "FILES") else None + + # Execute waterfall verification + engine_result = engine.verify( + document_type=doc_type, + data=input_data, + files=files, + preferred_vendor=preferred_vendor, + ) + + # Persist transaction and audit attempts in DB + safe_input = mask_sensitive_payload(input_data) + transaction = VerificationTransaction.objects.create( + document_type=doc_type, + status=engine_result.status.value, + successful_vendor=engine_result.successful_vendor, + input_payload=safe_input, + final_data=engine_result.data if engine_result.is_success else {}, + attempts_count=len(engine_result.attempts), + total_latency_ms=engine_result.total_latency_ms, + ) + + # Persist attempt logs + for attempt in engine_result.attempts: + VendorAttemptLog.objects.create( + transaction=transaction, + attempt_number=attempt.get("attempt_number", 1), + vendor_name=attempt.get("vendor_name", "unknown"), + status=attempt.get("status", "UNKNOWN"), + is_success=attempt.get("is_success", False), + data=attempt.get("data", {}), + raw_response=attempt.get("raw_response"), + error_code=attempt.get("error_code"), + error_message=attempt.get("error_message"), + latency_ms=attempt.get("latency_ms", 0.0), + ) + + response_payload = { + "reference_id": str(transaction.reference_id), + "is_success": engine_result.is_success, + "status": engine_result.status.value, + "document_type": doc_type, + "successful_vendor": engine_result.successful_vendor, + "data": engine_result.data, + "attempts_count": len(engine_result.attempts), + "attempts": engine_result.attempts, + "error_message": engine_result.error_message, + "total_latency_ms": round(engine_result.total_latency_ms, 2), + } + + http_status = status.HTTP_200_OK if engine_result.is_success else status.HTTP_422_UNPROCESSABLE_ENTITY + return Response(response_payload, status=http_status) + + +class VendorListView(APIView): + """ + GET /api/vendors/ + Lists all active verification vendors registered with the engine in priority order. + """ + + def get(self, request, *args, **kwargs): + vendors = registry.get_all_vendors() + vendor_data = [ + { + "name": v.name, + "display_name": getattr(v, "display_name", v.name), + "priority": v.priority, + "is_active": v.is_active, + "supported_documents": v.supported_documents, + } + for v in vendors + ] + return Response({"count": len(vendor_data), "vendors": vendor_data}) + + +class TransactionDetailView(APIView): + """ + GET /api/transactions// + Fetch complete audit trail and fallback logs for a transaction. + """ + + def get(self, request, reference_id, *args, **kwargs): + try: + tx = VerificationTransaction.objects.prefetch_related("attempts").get(reference_id=reference_id) + except VerificationTransaction.DoesNotExist: + return Response( + {"error": f"Transaction '{reference_id}' not found"}, + status=status.HTTP_404_NOT_FOUND, + ) + + serializer = VerificationTransactionSerializer(tx) + return Response(serializer.data)