Coverage for apps/core_los/dashboard_api.py: 90%

135 statements  

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

1"""GET /api/dashboard — cross-loan aggregate reporting for the executive dashboard. 

2 

3Each widget runs its own query against the scoped loan set (design.md D1): simpler to 

4read and change than one mega-query, at the cost of N+1-at-the-widget-level, which is the 

5right trade-off for a human-page-load-cadence endpoint. 

6""" 

7 

8from __future__ import annotations 

9 

10from datetime import date, timedelta 

11from decimal import Decimal 

12from typing import Any 

13 

14from django.db.models import Count, Prefetch, Sum 

15from django.utils import timezone 

16from ninja import Router 

17 

18from apps.accounts.auth import requires, staff_loan_queryset 

19from apps.application_1003.models import Borrower 

20from apps.client_needs.models import ClientNeed 

21from apps.core_los.dashboard_schemas import DashboardResponseOut 

22from apps.core_los.schemas import _safe_transaction 

23from apps.underwriting.models import Condition 

24from apps.workflow.models import KeyDates, LoanTask, LoanTracker 

25from domain.conditions import OPEN_CONDITION_STATUSES 

26from domain.enums import ClientNeedStatus, LoanStage, TrackerStatus 

27 

28router = Router(tags=["dashboard"]) 

29 

30# Pipeline-health buckets (dashboard spec "Pipeline-stage buckets"). 

31_PROSPECT_STAGES = [ 

32 LoanStage.APPLICATION_INTAKE.value, LoanStage.PREAPPROVED.value, LoanStage.QUALIFICATION.value, 

33] 

34_PROCESSING_STAGES = [ 

35 LoanStage.LOAN_SETUP.value, LoanStage.DISCLOSURE_SENT.value, 

36 LoanStage.UNDERWRITING_SUBMITTED.value, LoanStage.APPROVED_WITH_CONDITION.value, 

37 LoanStage.RE_SUBMITTAL.value, 

38] 

39_CLOSING_STAGES = [ 

40 LoanStage.CLEAR_TO_CLOSE.value, LoanStage.DOCS_OUT.value, LoanStage.DOCS_SIGNED.value, 

41] 

42_FUNDED_STAGES = [LoanStage.LOAN_FUNDED.value] 

43 

44_OPEN_TRACKER_STATUSES = [ 

45 s.value for s in TrackerStatus if s not in (TrackerStatus.COMPLETED, TrackerStatus.NA) 

46] 

47_ACTIVE_LOCK_STATUSES = ["LOCKED", "RELOCKED"] 

48 

49 

50def _with_primary_borrower(qs): 

51 return qs.prefetch_related( 

52 Prefetch( 

53 "borrowers", 

54 queryset=Borrower.objects.filter(is_primary=True), 

55 to_attr="_primary_borrowers", 

56 ) 

57 ) 

58 

59 

60def _safe_key_dates(loan) -> KeyDates | None: 

61 try: 

62 return loan.key_dates 

63 except KeyDates.DoesNotExist: 

64 return None 

65 

66 

67def _loan_ref(loan) -> dict: 

68 primary = next(iter(getattr(loan, "_primary_borrowers", [])), None) 

69 return { 

70 "loan_guid": str(loan.guid), 

71 "loan_number": loan.arive_loan_no, 

72 "borrower_name": f"{primary.first_name} {primary.last_name}" if primary else "—", 

73 } 

74 

75 

76def _next_occurrence(anniversary: date, today: date) -> date: 

77 """The next calendar occurrence of `anniversary`'s month/day on or after `today`.""" 

78 try: 

79 this_year = anniversary.replace(year=today.year) 

80 except ValueError: 

81 # Feb 29 anniversary in a non-leap year: observe on Mar 1. 

82 this_year = date(today.year, 3, 1) 

83 if this_year >= today: 

84 return this_year 

85 try: 

86 return anniversary.replace(year=today.year + 1) 

87 except ValueError: 

88 return date(today.year + 1, 3, 1) 

89 

90 

91def _kpis(qs, today: date) -> dict: 

92 total = qs.aggregate(total=Sum("transaction__base_loan_amt"))["total"] 

93 pipeline_volume = total or Decimal("0.00") 

94 funded = qs.filter(stage__in=_FUNDED_STAGES).count() 

95 tasks_due = LoanTask.objects.filter( 

96 loan__in=qs, completed=False, due_date__lte=today 

97 ).count() 

98 return {"pipeline_volume": pipeline_volume, "funded": funded, "tasks_due": tasks_due} 

99 

100 

101def _by_pipeline(qs) -> dict: 

102 return { 

103 "prospect": qs.filter(stage__in=_PROSPECT_STAGES).count(), 

104 "processing": qs.filter(stage__in=_PROCESSING_STAGES).count(), 

105 "closing": qs.filter(stage__in=_CLOSING_STAGES).count(), 

106 "funded": qs.filter(stage__in=_FUNDED_STAGES).count(), 

107 } 

108 

109 

110def _rate_lock_watchlists(qs) -> tuple[list[dict], list[dict]]: 

111 now = timezone.now() 

112 locked = _with_primary_borrower( 

113 qs.exclude(stage__in=_FUNDED_STAGES).filter( 

114 rate_lock__status__in=_ACTIVE_LOCK_STATUSES 

115 ) 

116 ).select_related("rate_lock") 

117 

118 def _lock_row(loan): 

119 return { 

120 **_loan_ref(loan), 

121 "expires_at": loan.rate_lock.expires_at, 

122 "rate": loan.rate_lock.locked_rate, 

123 } 

124 

125 expiring_window = locked.filter( 

126 rate_lock__expires_at__gte=now, rate_lock__expires_at__lte=now + timedelta(days=14) 

127 ) 

128 expiring = [_lock_row(loan) for loan in expiring_window] 

129 expired = [_lock_row(loan) for loan in locked.filter(rate_lock__expires_at__lt=now)] 

130 expiring.sort(key=lambda row: row["expires_at"]) 

131 expired.sort(key=lambda row: row["expires_at"]) 

132 return expiring, expired 

133 

134 

135def _rate_alerts(qs) -> list[dict]: 

136 loans = _with_primary_borrower( 

137 qs.filter(rate_lock__status__in=_ACTIVE_LOCK_STATUSES, rate_lock__locked_rate__gt=7) 

138 ).select_related("rate_lock") 

139 return [{**_loan_ref(loan), "rate": loan.rate_lock.locked_rate} for loan in loans] 

140 

141 

142def _funded_ytd(qs, today: date) -> dict: 

143 result: dict[str, Any] = { 

144 "purchase_count": 0, "purchase_volume": Decimal("0.00"), 

145 "refinance_count": 0, "refinance_volume": Decimal("0.00"), 

146 } 

147 funded = qs.filter(stage__in=_FUNDED_STAGES).select_related("transaction", "key_dates") 

148 for loan in funded: 

149 txn = _safe_transaction(loan) 

150 if txn is None: 150 ↛ 151line 150 didn't jump to line 151 because the condition on line 150 was never true

151 continue 

152 key_dates = _safe_key_dates(loan) 

153 effective_date = (key_dates.funding_date if key_dates else None) or loan.updated_at.date() 

154 if effective_date.year != today.year: 

155 continue 

156 if loan.purpose == "Purchase": 156 ↛ 159line 156 didn't jump to line 159 because the condition on line 156 was always true

157 result["purchase_count"] += 1 

158 result["purchase_volume"] += txn.base_loan_amt 

159 elif loan.purpose == "Refinance": 

160 result["refinance_count"] += 1 

161 result["refinance_volume"] += txn.base_loan_amt 

162 return result 

163 

164 

165def _lender_spread(qs) -> list[dict]: 

166 rows = ( 

167 qs.filter(rate_lock__isnull=False) 

168 .values("rate_lock__product__investor") 

169 .annotate(count=Count("id"), volume=Sum("transaction__base_loan_amt")) 

170 .order_by("-volume") 

171 ) 

172 return [ 

173 { 

174 "lender_id": row["rate_lock__product__investor"], 

175 "count": row["count"], 

176 "volume": row["volume"] or Decimal("0.00"), 

177 } 

178 for row in rows 

179 ] 

180 

181 

182def _upcoming_events(qs, today: date) -> dict: 

183 with_borrower = _with_primary_borrower( 

184 qs.filter(borrowers__is_primary=True, borrowers__birth_date__isnull=False).distinct() 

185 ) 

186 birthdays = [] 

187 for loan in with_borrower: 

188 primary = next(iter(getattr(loan, "_primary_borrowers", [])), None) 

189 if primary is None or primary.birth_date is None: 189 ↛ 190line 189 didn't jump to line 190 because the condition on line 189 was never true

190 continue 

191 next_bday = _next_occurrence(primary.birth_date, today) 

192 if (next_bday - today).days <= 30: 

193 birthdays.append({**_loan_ref(loan), "date": next_bday}) 

194 

195 funded = _with_primary_borrower(qs.filter(stage__in=_FUNDED_STAGES)).select_related( 

196 "transaction", "key_dates" 

197 ) 

198 anniversaries = [] 

199 early_payoffs = [] 

200 mi_drop_eligible = [] 

201 for loan in funded: 

202 key_dates = _safe_key_dates(loan) 

203 funding_date = key_dates.funding_date if key_dates else None 

204 if funding_date is not None: 

205 next_anniv = _next_occurrence(funding_date, today) 

206 if (next_anniv - today).days <= 30: 206 ↛ 209line 206 didn't jump to line 209 because the condition on line 206 was always true

207 anniversaries.append({**_loan_ref(loan), "date": next_anniv}) 

208 

209 txn = _safe_transaction(loan) 

210 if txn is None: 210 ↛ 211line 210 didn't jump to line 211 because the condition on line 210 was never true

211 continue 

212 if txn.note_rate_percent is not None and txn.note_rate_percent >= Decimal("7.5"): 212 ↛ 213line 212 didn't jump to line 213 because the condition on line 212 was never true

213 early_payoffs.append({**_loan_ref(loan), "rate": txn.note_rate_percent}) 

214 if ( 

215 txn.mortgage_type == "Conventional" 

216 and txn.ltv_ratio_percent is not None 

217 and txn.ltv_ratio_percent > Decimal("80") 

218 ): 

219 mi_drop_eligible.append({**_loan_ref(loan), "ltv": txn.ltv_ratio_percent}) 

220 

221 return { 

222 "birthdays": birthdays, 

223 "loan_anniversaries": anniversaries, 

224 "early_payoffs": early_payoffs, 

225 "mi_drop_eligible": mi_drop_eligible, 

226 } 

227 

228 

229def _recent_loans(qs) -> list[dict]: 

230 loans = _with_primary_borrower(qs.order_by("-updated_at"))[:5] 

231 return [ 

232 {**_loan_ref(loan), "stage": loan.stage, "updated_at": loan.updated_at} for loan in loans 

233 ] 

234 

235 

236def _tracker_counts(qs) -> dict: 

237 by_key = dict( 

238 LoanTracker.objects.filter( 

239 loan__in=qs, key__in=["trid", "appraisal"], status__in=_OPEN_TRACKER_STATUSES 

240 ) 

241 .values("key") 

242 .annotate(c=Count("id")) 

243 .values_list("key", "c") 

244 ) 

245 closing = LoanTracker.objects.filter( 

246 loan__in=qs, category="Closing", status__in=_OPEN_TRACKER_STATUSES 

247 ).count() 

248 return { 

249 "loan": by_key.get("trid", 0), 

250 "appraisal": by_key.get("appraisal", 0), 

251 "closing": closing, 

252 } 

253 

254 

255def _conditions_pending(qs) -> int: 

256 return Condition.objects.filter( 

257 loan__in=qs, status__in=[s.value for s in OPEN_CONDITION_STATUSES] 

258 ).count() 

259 

260 

261def _needs_pending(qs) -> int: 

262 return ClientNeed.objects.filter(loan__in=qs, status=ClientNeedStatus.REQUESTED.value).count() 

263 

264 

265@router.get("/dashboard", response=DashboardResponseOut, auth=requires("LOAN_VIEW")) 

266def get_dashboard(request, scope: str = "all"): 

267 qs = staff_loan_queryset(request.principal, scope) 

268 today = timezone.now().date() 

269 

270 kpis = _kpis(qs, today) 

271 by_pipeline = _by_pipeline(qs) 

272 locks_expiring, locks_expired = _rate_lock_watchlists(qs) 

273 

274 return { 

275 "scope": scope, 

276 "pipeline_volume": kpis["pipeline_volume"], 

277 "in_process": by_pipeline["processing"], 

278 "funded": kpis["funded"], 

279 "tasks_due": kpis["tasks_due"], 

280 "by_pipeline": by_pipeline, 

281 "widgets": { 

282 "locks_expiring": locks_expiring, 

283 "locks_expired": locks_expired, 

284 "conditions_pending": _conditions_pending(qs), 

285 "needs_pending": _needs_pending(qs), 

286 "leads_new": 0, # No Lead/CRM model yet (design.md D4). 

287 "funded_ytd": _funded_ytd(qs, today), 

288 "lender_spread": _lender_spread(qs), 

289 "upcoming_events": _upcoming_events(qs, today), 

290 "recent_loans": _recent_loans(qs), 

291 "counts": _tracker_counts(qs), 

292 "contingencies": 0, # No contingency-tracking concept exists (design.md D3). 

293 "rate_alerts": _rate_alerts(qs), 

294 }, 

295 }