Coverage for apps/application_1003/api.py: 81%

181 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-11-04 17:24 +0000

1"""1003 financials router: full CRUD per entity + qualification (mounted under /api/loans).""" 

2 

3from uuid import UUID 

4 

5from django.db import DataError, IntegrityError 

6from django.db.models import ForeignKey 

7from ninja import Router 

8 

9from apps.accounts.auth import ensure_staff_scope, requires 

10from apps.application_1003.models import ( 

11 Asset, 

12 Declaration, 

13 Demographics, 

14 DownPaymentSource, 

15 Employer, 

16 GiftGrant, 

17 HousingExpense, 

18 Income, 

19 Liability, 

20 LoanIdentifier, 

21 RealEstate, 

22 Residence, 

23 SubjectProperty, 

24) 

25from apps.application_1003.schemas import ( 

26 AssetIn, 

27 AssetOut, 

28 AssetPatch, 

29 DeclarationOut, 

30 DeclarationPatch, 

31 DemographicsOut, 

32 DemographicsPatch, 

33 DownPaymentSourceIn, 

34 DownPaymentSourceOut, 

35 DownPaymentSourcePatch, 

36 EmployerIn, 

37 EmployerOut, 

38 EmployerPatch, 

39 GiftGrantIn, 

40 GiftGrantOut, 

41 GiftGrantPatch, 

42 HousingExpenseIn, 

43 HousingExpenseOut, 

44 HousingExpensePatch, 

45 IncomeIn, 

46 IncomeOut, 

47 IncomePatch, 

48 LiabilityIn, 

49 LiabilityOut, 

50 LiabilityPatch, 

51 LoanIdentifierIn, 

52 LoanIdentifierOut, 

53 LoanIdentifierPatch, 

54 QualificationOut, 

55 RealEstateIn, 

56 RealEstateOut, 

57 RealEstatePatch, 

58 ResidenceIn, 

59 ResidenceOut, 

60 ResidencePatch, 

61 SubjectPropertyOut, 

62 SubjectPropertyPatch, 

63) 

64from apps.core_los.models import Loan 

65from domain.calc import monthly_pi 

66from domain.qualification import HousingItem, IncomeItem, LiabilityItem, qualify 

67from domain.result import AppError, DomainError, format_db_error 

68 

69router = Router(tags=["1003"]) 

70 

71 

72def _scoped_loan(request, guid: UUID) -> Loan: 

73 loan = Loan.objects.filter(guid=guid).first() 

74 if loan is None: 74 ↛ 75line 74 didn't jump to line 75 because the condition on line 74 was never true

75 raise DomainError([AppError("NOT_FOUND", "Loan not found")]) 

76 ensure_staff_scope(request.principal, loan) 

77 return loan 

78 

79 

80def _item(model, loan, item_id): 

81 obj = model.objects.filter(loan=loan, pk=item_id).first() 

82 if obj is None: 82 ↛ 83line 82 didn't jump to line 83 because the condition on line 82 was never true

83 raise DomainError([AppError("NOT_FOUND", f"{model.__name__} not found")]) 

84 return obj 

85 

86 

87def _normalize_fks(model, data: dict) -> None: 

88 """ModelSchema emits FK fields by model-field name (e.g. ``borrower``) carrying the pk; 

89 rename to the ``_id`` attname so Django's create/setattr accept the integer.""" 

90 for field in model._meta.get_fields(): 

91 if isinstance(field, ForeignKey) and field.name in data: 

92 data[field.attname] = data.pop(field.name) 

93 

94 

95def _register_crud(path, model, out_s, in_s, patch_s, m2m=()): 

96 key = path.replace("-", "_") 

97 

98 def list_view(request, guid: UUID): 

99 loan = _scoped_loan(request, guid) 

100 return list(model.objects.filter(loan=loan)) 

101 

102 list_view.__name__ = f"list_{key}" 

103 router.get(f"/{{guid}}/{path}", response=list[out_s], auth=requires("LOAN_VIEW"))(list_view) 

104 

105 def create_view(request, guid: UUID, payload: in_s): 

106 loan = _scoped_loan(request, guid) 

107 data = payload.model_dump(exclude_unset=True) # type: ignore[attr-defined] # dynamic schema param 

108 m2m_vals = {f: data.pop(f, None) for f in m2m} 

109 _normalize_fks(model, data) 

110 primary = loan.borrowers.filter(is_primary=True).first() or loan.borrowers.first() 

111 if "borrower_id" in [f.attname for f in model._meta.fields] and not data.get("borrower_id"): 

112 if primary: 112 ↛ 114line 112 didn't jump to line 114 because the condition on line 112 was always true

113 data["borrower_id"] = primary.id 

114 if "borrowers" in m2m: 

115 if not m2m_vals.get("borrowers") and primary: 

116 m2m_vals["borrowers"] = [primary.id] 

117 try: 

118 obj = model.objects.create(loan=loan, **data) 

119 except (DataError, IntegrityError) as exc: 

120 raise DomainError( 

121 [AppError("VALIDATION_ERROR", format_db_error(exc, model.__name__))] 

122 ) from exc 

123 for field, ids in m2m_vals.items(): 

124 if ids is not None: 124 ↛ 123line 124 didn't jump to line 123 because the condition on line 124 was always true

125 getattr(obj, field).set(ids) 

126 return 201, obj 

127 

128 create_view.__name__ = f"create_{key}" 

129 router.post(f"/{{guid}}/{path}", response={201: out_s}, auth=requires("LOAN_EDIT"))(create_view) 

130 

131 def update_view(request, guid: UUID, item_id: int, payload: patch_s): 

132 loan = _scoped_loan(request, guid) 

133 obj = _item(model, loan, item_id) 

134 data = payload.model_dump(exclude_unset=True) # type: ignore[attr-defined] # dynamic schema param 

135 m2m_vals = {f: data.pop(f) for f in m2m if f in data} 

136 _normalize_fks(model, data) 

137 try: 

138 for attr, value in data.items(): 

139 setattr(obj, attr, value) 

140 obj.save() 

141 except (DataError, IntegrityError) as exc: 

142 raise DomainError( 

143 [AppError("VALIDATION_ERROR", format_db_error(exc, model.__name__))] 

144 ) from exc 

145 for field, ids in m2m_vals.items(): 145 ↛ 146line 145 didn't jump to line 146 because the loop on line 145 never started

146 getattr(obj, field).set(ids) 

147 return obj 

148 

149 update_view.__name__ = f"update_{key}" 

150 router.patch(f"/{{guid}}/{path}/{{item_id}}", response=out_s, auth=requires("LOAN_EDIT"))( 

151 update_view 

152 ) 

153 

154 def delete_view(request, guid: UUID, item_id: int): 

155 loan = _scoped_loan(request, guid) 

156 _item(model, loan, item_id).delete() 

157 return 204, None 

158 

159 delete_view.__name__ = f"delete_{key}" 

160 router.delete( 

161 f"/{{guid}}/{path}/{{item_id}}", response={204: None}, auth=requires("LOAN_EDIT") 

162 )(delete_view) 

163 

164 

165_register_crud("employers", Employer, EmployerOut, EmployerIn, EmployerPatch) 

166_register_crud("incomes", Income, IncomeOut, IncomeIn, IncomePatch) 

167_register_crud("assets", Asset, AssetOut, AssetIn, AssetPatch, m2m=["borrowers"]) 

168_register_crud( 

169 "real-estate", RealEstate, RealEstateOut, RealEstateIn, RealEstatePatch, m2m=["borrowers"] 

170) 

171_register_crud( 

172 "liabilities", Liability, LiabilityOut, LiabilityIn, LiabilityPatch, m2m=["borrowers"] 

173) 

174_register_crud( 

175 "housing-expenses", HousingExpense, HousingExpenseOut, HousingExpenseIn, HousingExpensePatch 

176) 

177 

178 

179@router.get("/{guid}/qualification", response=QualificationOut, auth=requires("LOAN_VIEW")) 

180def qualification(request, guid: UUID): 

181 loan = _scoped_loan(request, guid) 

182 incomes = [IncomeItem(i.monthly_amt, i.current_ind) for i in loan.incomes.all()] 

183 liabilities = [ 

184 LiabilityItem(liab.monthly_payment_amt, liab.exclude_from_dti_ind, liab.omit_ind) 

185 for liab in loan.liabilities.all() 

186 ] 

187 housing = [HousingItem(h.payment_amt, h.timing_type) for h in loan.housing_expenses.all()] 

188 

189 try: 

190 txn = loan.transaction 

191 except Loan.transaction.RelatedObjectDoesNotExist: 

192 txn = None 

193 fallback_pi = ( 

194 monthly_pi(txn.base_loan_amt, txn.note_rate_percent or 0, txn.term_months) 

195 if txn is not None 

196 else None 

197 ) 

198 

199 result = qualify(incomes, liabilities, housing, fallback_pi=fallback_pi) 

200 return { 

201 "monthly_income": result.monthly_income, 

202 "monthly_debt": result.monthly_debt, 

203 "housing_expense": result.housing_expense, 

204 "housing_dti_pct": result.housing_dti_pct, 

205 "total_dti_pct": result.total_dti_pct, 

206 } 

207 

208 

209# --- 1003 extension: loan-FK collections (details/08) -------------------------- 

210_register_crud( 

211 "down-payment-sources", DownPaymentSource, 

212 DownPaymentSourceOut, DownPaymentSourceIn, DownPaymentSourcePatch, 

213) 

214_register_crud("gift-grants", GiftGrant, GiftGrantOut, GiftGrantIn, GiftGrantPatch) 

215_register_crud( 

216 "loan-identifiers", LoanIdentifier, LoanIdentifierOut, LoanIdentifierIn, LoanIdentifierPatch 

217) 

218 

219 

220def _borrower(loan, borrower_id: int): 

221 borrower = loan.borrowers.filter(pk=borrower_id).first() 

222 if borrower is None: 222 ↛ 223line 222 didn't jump to line 223 because the condition on line 222 was never true

223 raise DomainError([AppError("NOT_FOUND", "Borrower not found on this loan")]) 

224 return borrower 

225 

226 

227# --- 1003 extension: borrower residences (borrower-FK) ------------------------- 

228@router.get("/{guid}/residences", response=list[ResidenceOut], auth=requires("LOAN_VIEW")) 

229def list_residences(request, guid: UUID): 

230 loan = _scoped_loan(request, guid) 

231 return list(Residence.objects.filter(borrower__loan=loan)) 

232 

233 

234@router.post("/{guid}/residences", response={201: ResidenceOut}, auth=requires("LOAN_EDIT")) 

235def create_residence(request, guid: UUID, payload: ResidenceIn): 

236 loan = _scoped_loan(request, guid) 

237 borrower = _borrower(loan, payload.borrower_id) 

238 data = payload.dict() 

239 data.pop("borrower_id") 

240 return 201, Residence.objects.create(borrower=borrower, **data) 

241 

242 

243@router.patch("/{guid}/residences/{item_id}", response=ResidenceOut, auth=requires("LOAN_EDIT")) 

244def update_residence(request, guid: UUID, item_id: int, payload: ResidencePatch): 

245 loan = _scoped_loan(request, guid) 

246 res = Residence.objects.filter(pk=item_id, borrower__loan=loan).first() 

247 if res is None: 

248 raise DomainError([AppError("NOT_FOUND", "Residence not found")]) 

249 for attr, value in payload.model_dump(exclude_unset=True).items(): 

250 setattr(res, attr, value) 

251 res.save() 

252 return res 

253 

254 

255@router.delete("/{guid}/residences/{item_id}", response={204: None}, auth=requires("LOAN_EDIT")) 

256def delete_residence(request, guid: UUID, item_id: int): 

257 loan = _scoped_loan(request, guid) 

258 res = Residence.objects.filter(pk=item_id, borrower__loan=loan).first() 

259 if res is None: 

260 raise DomainError([AppError("NOT_FOUND", "Residence not found")]) 

261 res.delete() 

262 return 204, None 

263 

264 

265# --- 1003 extension: per-borrower declarations + demographics (OneToOne) ------- 

266@router.get( 

267 "/{guid}/declarations/{borrower_id}", response=DeclarationOut, auth=requires("LOAN_VIEW") 

268) 

269def get_declaration(request, guid: UUID, borrower_id: int): 

270 loan = _scoped_loan(request, guid) 

271 decl, _ = Declaration.objects.get_or_create(borrower=_borrower(loan, borrower_id)) 

272 return decl 

273 

274 

275@router.patch( 

276 "/{guid}/declarations/{borrower_id}", response=DeclarationOut, auth=requires("LOAN_EDIT") 

277) 

278def update_declaration(request, guid: UUID, borrower_id: int, payload: DeclarationPatch): 

279 loan = _scoped_loan(request, guid) 

280 decl, _ = Declaration.objects.get_or_create(borrower=_borrower(loan, borrower_id)) 

281 for attr, value in payload.model_dump(exclude_unset=True).items(): 

282 setattr(decl, attr, value) 

283 decl.save() 

284 return decl 

285 

286 

287@router.get( 

288 "/{guid}/demographics/{borrower_id}", response=DemographicsOut, auth=requires("LOAN_VIEW") 

289) 

290def get_demographics(request, guid: UUID, borrower_id: int): 

291 loan = _scoped_loan(request, guid) 

292 demo, _ = Demographics.objects.get_or_create(borrower=_borrower(loan, borrower_id)) 

293 return demo 

294 

295 

296@router.patch( 

297 "/{guid}/demographics/{borrower_id}", response=DemographicsOut, auth=requires("LOAN_EDIT") 

298) 

299def update_demographics(request, guid: UUID, borrower_id: int, payload: DemographicsPatch): 

300 loan = _scoped_loan(request, guid) 

301 demo, _ = Demographics.objects.get_or_create(borrower=_borrower(loan, borrower_id)) 

302 for attr, value in payload.model_dump(exclude_unset=True).items(): 

303 setattr(demo, attr, value) 

304 demo.save() 

305 return demo 

306 

307 

308# --- 1003 extension: subject property (OneToOne loan) -------------------------- 

309@router.get("/{guid}/subject-property", response=SubjectPropertyOut, auth=requires("LOAN_VIEW")) 

310def get_subject_property(request, guid: UUID): 

311 loan = _scoped_loan(request, guid) 

312 sp, _ = SubjectProperty.objects.get_or_create(loan=loan) 

313 return sp 

314 

315 

316@router.patch("/{guid}/subject-property", response=SubjectPropertyOut, auth=requires("LOAN_EDIT")) 

317def update_subject_property(request, guid: UUID, payload: SubjectPropertyPatch): 

318 loan = _scoped_loan(request, guid) 

319 sp, _ = SubjectProperty.objects.get_or_create(loan=loan) 

320 data = payload.model_dump(exclude_unset=True) 

321 try: 

322 for attr, value in data.items(): 

323 setattr(sp, attr, value) 

324 sp.save() 

325 if "county" in data and data["county"] is not None: 

326 loan.county = sp.county 

327 loan.save(update_fields=["county"]) 

328 except (DataError, IntegrityError) as exc: 

329 raise DomainError( 

330 [AppError("VALIDATION_ERROR", f"Invalid subject property data: {exc}")] 

331 ) from exc 

332 return sp