Coverage for apps/core_los/public_api.py: 76%
82 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-04 17:24 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-04 17:24 +0000
1"""Public Partner API router (mounted at /api/public).
3Endpoints for third-party developer integration:
4 POST /api/public/auth/token -> PublicTokenOut
5 GET /api/public/loans -> list[PublicLoanSummaryOut]
6 POST /api/public/loans -> 201 PublicLoanCreatedOut
7"""
9from __future__ import annotations
11import random
12from decimal import Decimal
13from uuid import uuid4
15from django.db import transaction
16from ninja import Router
17from ninja.security import HttpBearer
19from apps.accounts.models import ApiClient, OrgUnit, User
20from apps.application_1003.models import Borrower
21from apps.core_los.models import Loan, LoanTransaction
22from apps.core_los.public_schemas import (
23 PublicLoanCreatedOut,
24 PublicLoanInput,
25 PublicLoanSummaryOut,
26 PublicTokenOut,
27 PublicTokenRequest,
28)
29from apps.integrations.encryption import decrypt_secret
30from domain.result import AppError, DomainError
32router = Router(tags=["developer-public-api"])
34# In-memory mapping of partner bearer tokens -> org_unit_id
35_PARTNER_TOKENS: dict[str, int] = {}
38class PartnerAuth(HttpBearer):
39 def authenticate(self, request, token: str) -> int | None:
40 if token in _PARTNER_TOKENS: 40 ↛ 43line 40 didn't jump to line 43 because the condition on line 40 was always true
41 return _PARTNER_TOKENS[token]
42 # In test/dev, also accept raw demo token if passed directly
43 if token == "lat_demo":
44 org = OrgUnit.objects.filter(is_headquarters=True).first() or OrgUnit.objects.first()
45 if org:
46 return org.id
47 raise DomainError([AppError("UNAUTHORIZED", "Missing or invalid access token.")])
50def _resolve_default_org() -> OrgUnit:
51 org = OrgUnit.objects.filter(is_headquarters=True).first() or OrgUnit.objects.first()
52 if org is None: 52 ↛ 53line 52 didn't jump to line 53 because the condition on line 52 was never true
53 org = OrgUnit.objects.create(name="Headquarters", is_headquarters=True)
54 return org
57@router.post("/auth/token", response=PublicTokenOut)
58def request_token(request, payload: PublicTokenRequest):
59 """Exchange partner client credentials for a bearer token."""
60 client_id = payload.client_id.strip()
61 client_secret = payload.client_secret.strip()
63 valid_org_id: int | None = None
65 # 1. Standard pre-seeded demo client (Arive lumen-demo / lumen-secret)
66 if client_id == "lumen-demo" and client_secret == "lumen-secret":
67 org = _resolve_default_org()
68 valid_org_id = org.id
69 else:
70 # 2. Database ApiClient
71 client = ApiClient.objects.filter(client_id=client_id).first()
72 if client: 72 ↛ 73line 72 didn't jump to line 73 because the condition on line 72 was never true
73 decrypted = decrypt_secret(client.secret_enc) if client.secret_enc else ""
74 if decrypted == client_secret or client.secret_enc == client_secret:
75 valid_org_id = client.org_unit_id or _resolve_default_org().id
77 if valid_org_id is None:
78 raise DomainError([AppError("UNAUTHORIZED", "Invalid client credentials.")])
80 token = f"lat_{uuid4().hex}"
81 _PARTNER_TOKENS[token] = valid_org_id
82 return {"access_token": token, "expires_in": 3600}
85@router.get("/loans", response=list[PublicLoanSummaryOut], auth=PartnerAuth())
86def list_partner_loans(request):
87 """List loans scoped to the authenticated partner organization."""
88 org_id: int = request.auth
89 loans = Loan.objects.filter(org_unit_id=org_id).order_by("-created_at")
91 out: list[dict] = []
92 for loan in loans:
93 primary_b = loan.borrowers.filter(is_primary=True).first() or loan.borrowers.first()
94 borrower_name = f"{primary_b.first_name} {primary_b.last_name}" if primary_b else "Unknown"
96 txn = getattr(loan, "transaction", None)
97 amt = float(txn.base_loan_amt) if txn and txn.base_loan_amt else 0.0
98 ltv = float(txn.ltv_ratio_percent) if txn and txn.ltv_ratio_percent else 80.0
100 rate_lock = getattr(loan, "rate_lock", None)
101 rate = float(rate_lock.locked_rate) if rate_lock and rate_lock.locked_rate else None
102 prod = rate_lock.product.name if rate_lock and rate_lock.product else None
103 lock_status = "Locked" if rate_lock else "Not Locked"
105 out.append(
106 {
107 "arive_loan_no": loan.arive_loan_no,
108 "guid": str(loan.guid),
109 "borrower_name": borrower_name,
110 "state_code": loan.state_code or "CA",
111 "county": loan.county or None,
112 "stage": loan.stage or "APPLICATION_INTAKE",
113 "purpose": loan.purpose or "Purchase",
114 "amount": amt,
115 "ltv": ltv,
116 "rate": rate,
117 "product": prod,
118 "lock": lock_status,
119 "open_tasks": loan.tasks.filter(completed=False).count(),
120 }
121 )
122 return out
125@router.post("/loans", response={201: PublicLoanCreatedOut}, auth=PartnerAuth())
126def create_partner_loan(request, payload: PublicLoanInput):
127 """Create a new loan via partner API."""
128 org_id: int = request.auth
130 lo = User.objects.filter(pk=payload.loan_officer_id).first()
131 if lo is None: 131 ↛ 132line 131 didn't jump to line 132 because the condition on line 131 was never true
132 lo = User.objects.filter(org_unit_id=org_id).first() or User.objects.first()
133 if lo is None: 133 ↛ 134line 133 didn't jump to line 134 because the condition on line 133 was never true
134 raise DomainError([AppError("NOT_FOUND", "No valid loan officer found.")])
136 # Generate next loan number
137 rand_seq = random.randint(10000, 99999)
138 loan_no = f"LMN-{rand_seq}"
139 while Loan.objects.filter(arive_loan_no=loan_no).exists(): 139 ↛ 140line 139 didn't jump to line 140 because the condition on line 139 was never true
140 rand_seq = random.randint(10000, 99999)
141 loan_no = f"LMN-{rand_seq}"
143 with transaction.atomic():
144 loan = Loan.objects.create(
145 arive_loan_no=loan_no,
146 org_unit_id=org_id,
147 loan_officer=lo,
148 state_code=payload.state_code,
149 occupancy=payload.occupancy,
150 purpose=payload.purpose,
151 stage="APPLICATION_INTAKE",
152 )
153 Borrower.objects.create(
154 loan=loan,
155 first_name=payload.borrower.first_name,
156 last_name=payload.borrower.last_name,
157 email=payload.borrower.email,
158 is_primary=True,
159 )
160 LoanTransaction.objects.create(
161 loan=loan,
162 base_loan_amt=Decimal("400000.00"),
163 note_amt=Decimal("400000.00"),
164 purchase_price_amt=Decimal("500000.00"),
165 appraised_value_amt=Decimal("500000.00"),
166 ltv_ratio_percent=Decimal("80.00"),
167 )
169 return 201, {"arive_loan_no": loan.arive_loan_no, "guid": loan.guid}