feat: implement core document verification engine with vendor waterfall failover and API endpoints
All checks were successful
Deploy Beta (NATIVE) / deploy (push) Successful in 31s
All checks were successful
Deploy Beta (NATIVE) / deploy (push) Successful in 31s
This commit is contained in:
parent
13d057024b
commit
c6b177ac55
15 changed files with 1332 additions and 94 deletions
|
|
@ -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", "")
|
||||
|
|
|
|||
|
|
@ -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')),
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
20
verifier/engine/__init__.py
Normal file
20
verifier/engine/__init__.py
Normal file
|
|
@ -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",
|
||||
]
|
||||
120
verifier/engine/base.py
Normal file
120
verifier/engine/base.py
Normal file
|
|
@ -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()")
|
||||
141
verifier/engine/orchestrator.py
Normal file
141
verifier/engine/orchestrator.py
Normal file
|
|
@ -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()
|
||||
69
verifier/engine/registry.py
Normal file
69
verifier/engine/registry.py
Normal file
|
|
@ -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()
|
||||
80
verifier/migrations/0001_initial.py
Normal file
80
verifier/migrations/0001_initial.py
Normal file
|
|
@ -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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
@ -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}"
|
||||
|
|
|
|||
67
verifier/serializers.py
Normal file
67
verifier/serializers.py
Normal file
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -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"])
|
||||
|
|
|
|||
10
verifier/urls.py
Normal file
10
verifier/urls.py
Normal file
|
|
@ -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/<uuid:reference_id>/", TransactionDetailView.as_view(), name="transaction_detail"),
|
||||
]
|
||||
8
verifier/vendors/__init__.py
vendored
Normal file
8
verifier/vendors/__init__.py
vendored
Normal file
|
|
@ -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"]
|
||||
311
verifier/vendors/codeplus.py
vendored
Normal file
311
verifier/vendors/codeplus.py
vendored
Normal file
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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/<uuid:reference_id>/
|
||||
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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue