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

This commit is contained in:
vickytechkey 2026-10-15 19:42:15 +05:30
parent 13d057024b
commit c6b177ac55
15 changed files with 1332 additions and 94 deletions

View file

@ -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", "")

View file

@ -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')),
]

View file

@ -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]

View 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
View 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()")

View 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()

View 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()

View 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'],
},
),
]

View file

@ -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
View 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",
]

View file

@ -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
View 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
View 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
View 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,
)

View file

@ -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)