Coverage for apps/application_1003/schemas.py: 80%
531 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"""Ninja schemas for the 1003 financial entities + qualification."""
3from __future__ import annotations
5import re
6from decimal import Decimal
8from ninja import ModelSchema, Schema
9from pydantic import field_validator
11from apps.application_1003.models import (
12 Asset,
13 Declaration,
14 Demographics,
15 DownPaymentSource,
16 Employer,
17 GiftGrant,
18 HousingExpense,
19 Income,
20 Liability,
21 LoanIdentifier,
22 RealEstate,
23 Residence,
24 SubjectProperty,
25)
26from domain.state_compliance import STATE_COMPLIANCE
28VALID_STATE_CODES = frozenset(r["state_code"] for r in STATE_COMPLIANCE)
29ZIP_RE = re.compile(r"^\d{5}(-\d{4})?$")
30CITY_RE = re.compile(r"^[A-Za-z\s.'\-]+$")
31COUNTY_RE = re.compile(r"^[A-Za-z\s.'\-]+$")
32ADDRESS_RE = re.compile(r"^[A-Za-z0-9\s.,'#\-/]+$")
35def _clean_phone(v: str | None) -> str:
36 if not v or not v.strip(): 36 ↛ 37line 36 didn't jump to line 37 because the condition on line 36 was never true
37 return ""
38 digits = "".join(c for c in v if c.isdigit())
39 if len(digits) != 10:
40 raise ValueError(f"Phone number must contain exactly 10 digits (got {len(digits)})")
41 return v.strip()
44def _clean_state(v: str | None) -> str:
45 if not v or not v.strip(): 45 ↛ 46line 45 didn't jump to line 46 because the condition on line 45 was never true
46 return ""
47 code = v.strip().upper()
48 if code not in VALID_STATE_CODES:
49 raise ValueError(f"Invalid US state code: '{code}'")
50 return code
53def _clean_zip(v: str | None) -> str:
54 if not v or not v.strip(): 54 ↛ 55line 54 didn't jump to line 55 because the condition on line 54 was never true
55 return ""
56 clean = v.strip()
57 if not ZIP_RE.match(clean):
58 raise ValueError(f"Invalid US postal code: '{clean}'")
59 return clean
62def _clean_city(v: str | None) -> str:
63 if not v or not v.strip(): 63 ↛ 64line 63 didn't jump to line 64 because the condition on line 63 was never true
64 return ""
65 clean = v.strip()
66 if len(clean) < 2 or len(clean) > 100: 66 ↛ 67line 66 didn't jump to line 67 because the condition on line 66 was never true
67 raise ValueError("City must be between 2 and 100 characters")
68 if not CITY_RE.match(clean):
69 raise ValueError("City must contain only letters, spaces, hyphens, periods, or apostrophes")
70 return clean
73def _clean_county(v: str | None) -> str:
74 if not v or not v.strip(): 74 ↛ 75line 74 didn't jump to line 75 because the condition on line 74 was never true
75 return ""
76 clean = v.strip()
77 if len(clean) < 2 or len(clean) > 100: 77 ↛ 78line 77 didn't jump to line 78 because the condition on line 77 was never true
78 raise ValueError("County must be between 2 and 100 characters")
79 if not COUNTY_RE.match(clean):
80 raise ValueError("County must contain only letters, spaces, hyphens, periods, or apostrophes")
81 return clean
84def _clean_address_line(v: str | None) -> str:
85 if not v or not v.strip(): 85 ↛ 86line 85 didn't jump to line 86 because the condition on line 85 was never true
86 return ""
87 clean = v.strip()
88 if len(clean) < 3 or len(clean) > 255:
89 raise ValueError("Street address must be between 3 and 255 characters")
90 if not ADDRESS_RE.match(clean): 90 ↛ 91line 90 didn't jump to line 91 because the condition on line 90 was never true
91 raise ValueError("Street address contains invalid characters")
92 return clean
95def _clean_non_negative(v: Decimal | None, max_val: Decimal = Decimal("9999999999.99")) -> Decimal | None:
96 if v is not None: 96 ↛ 101line 96 didn't jump to line 101 because the condition on line 96 was always true
97 if v < Decimal("0.00"):
98 raise ValueError("Amount cannot be negative")
99 if v > max_val: 99 ↛ 100line 99 didn't jump to line 100 because the condition on line 99 was never true
100 raise ValueError(f"Amount cannot exceed {max_val}")
101 return v
104class EmployerOut(ModelSchema):
105 class Meta:
106 model = Employer
107 exclude = ["loan"]
110class EmployerIn(ModelSchema):
111 class Meta:
112 model = Employer
113 exclude = ["id", "loan", "created_at"]
114 fields_optional = ["borrower"]
116 @field_validator("employer_name", check_fields=False)
117 @classmethod
118 def validate_name(cls, v: str) -> str:
119 clean = v.strip() if v else ""
120 if not clean:
121 raise ValueError("Employer name cannot be blank")
122 if len(clean) > 255: 122 ↛ 123line 122 didn't jump to line 123 because the condition on line 122 was never true
123 raise ValueError("Employer name cannot exceed 255 characters")
124 return clean
126 @field_validator("position_title", check_fields=False)
127 @classmethod
128 def validate_position(cls, v: str | None) -> str:
129 if not v:
130 return ""
131 clean = v.strip()
132 if len(clean) > 255:
133 raise ValueError("Position title cannot exceed 255 characters")
134 return clean
136 @field_validator("phone", check_fields=False)
137 @classmethod
138 def validate_phone(cls, v: str | None) -> str:
139 return _clean_phone(v)
141 @field_validator("state_code", check_fields=False)
142 @classmethod
143 def validate_state_code(cls, v: str | None) -> str:
144 return _clean_state(v)
146 @field_validator("postal_code", check_fields=False)
147 @classmethod
148 def validate_postal_code(cls, v: str | None) -> str:
149 return _clean_zip(v)
151 @field_validator("address_line", check_fields=False)
152 @classmethod
153 def validate_address(cls, v: str | None) -> str:
154 return _clean_address_line(v)
156 @field_validator("city", check_fields=False)
157 @classmethod
158 def validate_city(cls, v: str | None) -> str:
159 return _clean_city(v)
161 @field_validator("monthly_income_amt", check_fields=False)
162 @classmethod
163 def validate_monthly_income(cls, v: Decimal | None) -> Decimal | None:
164 return _clean_non_negative(v, Decimal("999999999.99"))
166 @field_validator("years_in_line_of_work", check_fields=False)
167 @classmethod
168 def validate_years(cls, v: Decimal | None) -> Decimal | None:
169 if v is not None: 169 ↛ 172line 169 didn't jump to line 172 because the condition on line 169 was always true
170 if v < Decimal("0.00") or v > Decimal("99.99"):
171 raise ValueError("Years in line of work must be between 0 and 99.99 (enter duration, not start year)")
172 return v
174 @field_validator("ownership_pct", check_fields=False)
175 @classmethod
176 def validate_ownership(cls, v: Decimal | None) -> Decimal | None:
177 if v is not None: 177 ↛ 180line 177 didn't jump to line 180 because the condition on line 177 was always true
178 if v < Decimal("0.00") or v > Decimal("100.00"):
179 raise ValueError("Ownership percentage must be between 0% and 100%")
180 return v
183class EmployerPatch(ModelSchema):
184 class Meta:
185 model = Employer
186 exclude = ["id", "loan", "created_at"]
187 fields_optional = "__all__"
189 @field_validator("employer_name", check_fields=False)
190 @classmethod
191 def validate_name(cls, v: str | None) -> str | None:
192 if v is None:
193 return None
194 clean = v.strip()
195 if not clean:
196 raise ValueError("Employer name cannot be blank")
197 if len(clean) > 255:
198 raise ValueError("Employer name cannot exceed 255 characters")
199 return clean
201 @field_validator("position_title", check_fields=False)
202 @classmethod
203 def validate_position(cls, v: str | None) -> str | None:
204 if v is None:
205 return None
206 clean = v.strip()
207 if len(clean) > 255:
208 raise ValueError("Position title cannot exceed 255 characters")
209 return clean
211 @field_validator("phone", check_fields=False)
212 @classmethod
213 def validate_phone(cls, v: str | None) -> str | None:
214 return _clean_phone(v) if v else v
216 @field_validator("state_code", check_fields=False)
217 @classmethod
218 def validate_state_code(cls, v: str | None) -> str | None:
219 return _clean_state(v) if v else v
221 @field_validator("postal_code", check_fields=False)
222 @classmethod
223 def validate_postal_code(cls, v: str | None) -> str | None:
224 return _clean_zip(v) if v else v
226 @field_validator("address_line", check_fields=False)
227 @classmethod
228 def validate_address(cls, v: str | None) -> str | None:
229 return _clean_address_line(v) if v else v
231 @field_validator("city", check_fields=False)
232 @classmethod
233 def validate_city(cls, v: str | None) -> str | None:
234 return _clean_city(v) if v else v
236 @field_validator("monthly_income_amt", check_fields=False)
237 @classmethod
238 def validate_monthly_income(cls, v: Decimal | None) -> Decimal | None:
239 return _clean_non_negative(v, Decimal("999999999.99"))
241 @field_validator("years_in_line_of_work", check_fields=False)
242 @classmethod
243 def validate_years(cls, v: Decimal | None) -> Decimal | None:
244 if v is not None:
245 if v < Decimal("0.00") or v > Decimal("99.99"):
246 raise ValueError("Years in line of work must be between 0 and 99.99 (enter duration, not start year)")
247 return v
249 @field_validator("ownership_pct", check_fields=False)
250 @classmethod
251 def validate_ownership(cls, v: Decimal | None) -> Decimal | None:
252 if v is not None:
253 if v < Decimal("0.00") or v > Decimal("100.00"):
254 raise ValueError("Ownership percentage must be between 0% and 100%")
255 return v
258class IncomeOut(ModelSchema):
259 class Meta:
260 model = Income
261 exclude = ["loan"]
264class IncomeIn(ModelSchema):
265 class Meta:
266 model = Income
267 exclude = ["id", "loan", "created_at"]
268 fields_optional = ["borrower"]
270 @field_validator("monthly_amt", "annual_amt", check_fields=False)
271 @classmethod
272 def validate_amounts(cls, v: Decimal | None) -> Decimal | None:
273 return _clean_non_negative(v)
276class IncomePatch(ModelSchema):
277 class Meta:
278 model = Income
279 exclude = ["id", "loan", "created_at"]
280 fields_optional = "__all__"
282 @field_validator("monthly_amt", "annual_amt", check_fields=False)
283 @classmethod
284 def validate_amounts(cls, v: Decimal | None) -> Decimal | None:
285 return _clean_non_negative(v)
288class AssetOut(ModelSchema):
289 class Meta:
290 model = Asset
291 exclude = ["loan"]
294class AssetIn(ModelSchema):
295 borrowers: list[int] = []
297 class Meta:
298 model = Asset
299 exclude = ["id", "loan", "created_at"]
301 @field_validator("cash_or_market_value_amt", "gift_total_value_amt", check_fields=False)
302 @classmethod
303 def validate_amounts(cls, v: Decimal | None) -> Decimal | None:
304 return _clean_non_negative(v)
307class AssetPatch(ModelSchema):
308 class Meta:
309 model = Asset
310 exclude = ["id", "loan", "created_at"]
311 fields_optional = "__all__"
313 @field_validator("cash_or_market_value_amt", "gift_total_value_amt", check_fields=False)
314 @classmethod
315 def validate_amounts(cls, v: Decimal | None) -> Decimal | None:
316 return _clean_non_negative(v)
319class RealEstateOut(ModelSchema):
320 class Meta:
321 model = RealEstate
322 exclude = ["loan"]
325class RealEstateIn(ModelSchema):
326 borrowers: list[int] = []
328 class Meta:
329 model = RealEstate
330 exclude = ["id", "loan", "created_at"]
332 @field_validator(
333 "property_value_amt",
334 "mortgage_unpaid_balance_amt",
335 "mortgage_monthly_payment_amt",
336 "gross_rental_income_amt",
337 check_fields=False,
338 )
339 @classmethod
340 def validate_amounts(cls, v: Decimal | None) -> Decimal | None:
341 return _clean_non_negative(v)
343 @field_validator("address_line", check_fields=False)
344 @classmethod
345 def validate_address(cls, v: str | None) -> str:
346 return _clean_address_line(v)
348 @field_validator("city", check_fields=False)
349 @classmethod
350 def validate_city(cls, v: str | None) -> str:
351 return _clean_city(v)
353 @field_validator("state_code", check_fields=False)
354 @classmethod
355 def validate_state(cls, v: str | None) -> str:
356 return _clean_state(v)
358 @field_validator("postal_code", check_fields=False)
359 @classmethod
360 def validate_zip(cls, v: str | None) -> str:
361 return _clean_zip(v)
364class RealEstatePatch(ModelSchema):
365 class Meta:
366 model = RealEstate
367 exclude = ["id", "loan", "created_at"]
368 fields_optional = "__all__"
370 @field_validator(
371 "property_value_amt",
372 "mortgage_unpaid_balance_amt",
373 "mortgage_monthly_payment_amt",
374 "gross_rental_income_amt",
375 check_fields=False,
376 )
377 @classmethod
378 def validate_amounts(cls, v: Decimal | None) -> Decimal | None:
379 return _clean_non_negative(v)
381 @field_validator("address_line", check_fields=False)
382 @classmethod
383 def validate_address(cls, v: str | None) -> str | None:
384 return _clean_address_line(v) if v else v
386 @field_validator("city", check_fields=False)
387 @classmethod
388 def validate_city(cls, v: str | None) -> str | None:
389 return _clean_city(v) if v else v
391 @field_validator("state_code", check_fields=False)
392 @classmethod
393 def validate_state(cls, v: str | None) -> str | None:
394 return _clean_state(v) if v else v
396 @field_validator("postal_code", check_fields=False)
397 @classmethod
398 def validate_zip(cls, v: str | None) -> str | None:
399 return _clean_zip(v) if v else v
402class LiabilityOut(ModelSchema):
403 class Meta:
404 model = Liability
405 exclude = ["loan"]
408class LiabilityIn(ModelSchema):
409 borrowers: list[int] = []
411 class Meta:
412 model = Liability
413 exclude = ["id", "loan", "created_at"]
415 @field_validator("monthly_payment_amt", "unpaid_balance_amt", check_fields=False)
416 @classmethod
417 def validate_amounts(cls, v: Decimal | None) -> Decimal | None:
418 return _clean_non_negative(v)
421class LiabilityPatch(ModelSchema):
422 class Meta:
423 model = Liability
424 exclude = ["id", "loan", "created_at"]
425 fields_optional = "__all__"
427 @field_validator("monthly_payment_amt", "unpaid_balance_amt", check_fields=False)
428 @classmethod
429 def validate_amounts(cls, v: Decimal | None) -> Decimal | None:
430 return _clean_non_negative(v)
433class HousingExpenseOut(ModelSchema):
434 class Meta:
435 model = HousingExpense
436 exclude = ["loan"]
439class HousingExpenseIn(ModelSchema):
440 class Meta:
441 model = HousingExpense
442 exclude = ["id", "loan", "created_at"]
444 @field_validator("payment_amt", check_fields=False)
445 @classmethod
446 def validate_payment(cls, v: Decimal | None) -> Decimal | None:
447 return _clean_non_negative(v)
450class HousingExpensePatch(ModelSchema):
451 class Meta:
452 model = HousingExpense
453 exclude = ["id", "loan", "created_at"]
454 fields_optional = "__all__"
456 @field_validator("payment_amt", check_fields=False)
457 @classmethod
458 def validate_payment(cls, v: Decimal | None) -> Decimal | None:
459 return _clean_non_negative(v)
462class QualificationOut(Schema):
463 monthly_income: Decimal
464 monthly_debt: Decimal
465 housing_expense: Decimal
466 housing_dti_pct: Decimal
467 total_dti_pct: Decimal
470# --- 1003 extension schemas (details/08) ---------------------------------------
471# Loan-FK collections (registered via _register_crud).
472class DownPaymentSourceOut(ModelSchema):
473 class Meta:
474 model = DownPaymentSource
475 exclude = ["loan"]
478class DownPaymentSourceIn(ModelSchema):
479 class Meta:
480 model = DownPaymentSource
481 exclude = ["id", "loan", "created_at"]
484class DownPaymentSourcePatch(ModelSchema):
485 class Meta:
486 model = DownPaymentSource
487 exclude = ["id", "loan", "created_at"]
488 fields_optional = "__all__"
491class GiftGrantOut(ModelSchema):
492 class Meta:
493 model = GiftGrant
494 exclude = ["loan"]
497class GiftGrantIn(ModelSchema):
498 class Meta:
499 model = GiftGrant
500 exclude = ["id", "loan", "created_at"]
503class GiftGrantPatch(ModelSchema):
504 class Meta:
505 model = GiftGrant
506 exclude = ["id", "loan", "created_at"]
507 fields_optional = "__all__"
510class LoanIdentifierOut(ModelSchema):
511 class Meta:
512 model = LoanIdentifier
513 exclude = ["loan"]
516class LoanIdentifierIn(ModelSchema):
517 class Meta:
518 model = LoanIdentifier
519 exclude = ["id", "loan", "created_at"]
522class LoanIdentifierPatch(ModelSchema):
523 class Meta:
524 model = LoanIdentifier
525 exclude = ["id", "loan", "created_at"]
526 fields_optional = "__all__"
529# Residence (borrower-FK; custom endpoints).
530class ResidenceOut(ModelSchema):
531 class Meta:
532 model = Residence
533 fields = "__all__"
536class ResidenceIn(Schema):
537 borrower_id: int
538 residence_type: str
539 basis_type: str = ""
540 duration_months: int | None = None
541 monthly_rent_amt: Decimal | None = None
542 address_line: str = ""
543 unit_id: str = ""
544 city: str = ""
545 county: str = ""
546 state_code: str = ""
547 postal_code: str = ""
549 @field_validator("address_line")
550 @classmethod
551 def validate_address(cls, v: str | None) -> str:
552 return _clean_address_line(v)
554 @field_validator("city")
555 @classmethod
556 def validate_city(cls, v: str | None) -> str:
557 return _clean_city(v)
559 @field_validator("county")
560 @classmethod
561 def validate_county(cls, v: str | None) -> str:
562 return _clean_county(v)
564 @field_validator("state_code")
565 @classmethod
566 def validate_state(cls, v: str | None) -> str:
567 return _clean_state(v)
569 @field_validator("postal_code")
570 @classmethod
571 def validate_zip(cls, v: str | None) -> str:
572 return _clean_zip(v)
574 @field_validator("monthly_rent_amt")
575 @classmethod
576 def validate_rent(cls, v: Decimal | None) -> Decimal | None:
577 return _clean_non_negative(v, Decimal("999999999.99"))
579 @field_validator("duration_months")
580 @classmethod
581 def validate_duration(cls, v: int | None) -> int | None:
582 if v is not None and not (0 <= v <= 1200): 582 ↛ 583line 582 didn't jump to line 583 because the condition on line 582 was never true
583 raise ValueError("Duration months must be between 0 and 1200")
584 return v
587class ResidencePatch(ModelSchema):
588 class Meta:
589 model = Residence
590 exclude = ["id", "borrower", "created_at"]
591 fields_optional = "__all__"
593 @field_validator("address_line", check_fields=False)
594 @classmethod
595 def validate_address(cls, v: str | None) -> str | None:
596 return _clean_address_line(v) if v else v
598 @field_validator("city", check_fields=False)
599 @classmethod
600 def validate_city(cls, v: str | None) -> str | None:
601 return _clean_city(v) if v else v
603 @field_validator("county", check_fields=False)
604 @classmethod
605 def validate_county(cls, v: str | None) -> str | None:
606 return _clean_county(v) if v else v
608 @field_validator("state_code", check_fields=False)
609 @classmethod
610 def validate_state(cls, v: str | None) -> str | None:
611 return _clean_state(v) if v else v
613 @field_validator("postal_code", check_fields=False)
614 @classmethod
615 def validate_zip(cls, v: str | None) -> str | None:
616 return _clean_zip(v) if v else v
618 @field_validator("monthly_rent_amt", check_fields=False)
619 @classmethod
620 def validate_rent(cls, v: Decimal | None) -> Decimal | None:
621 return _clean_non_negative(v, Decimal("999999999.99"))
623 @field_validator("duration_months", check_fields=False)
624 @classmethod
625 def validate_duration(cls, v: int | None) -> int | None:
626 if v is not None and not (0 <= v <= 1200):
627 raise ValueError("Duration months must be between 0 and 1200")
628 return v
631# One-to-one sets (get-or-create endpoints).
632class DeclarationOut(ModelSchema):
633 class Meta:
634 model = Declaration
635 exclude = ["borrower"]
638class DeclarationPatch(ModelSchema):
639 class Meta:
640 model = Declaration
641 exclude = ["id", "borrower", "created_at", "updated_at"]
642 fields_optional = "__all__"
645class DemographicsOut(ModelSchema):
646 class Meta:
647 model = Demographics
648 exclude = ["borrower"]
651class DemographicsPatch(ModelSchema):
652 class Meta:
653 model = Demographics
654 exclude = ["id", "borrower", "created_at", "updated_at"]
655 fields_optional = "__all__"
658class SubjectPropertyOut(ModelSchema):
659 class Meta:
660 model = SubjectProperty
661 exclude = ["loan"]
664class SubjectPropertyPatch(ModelSchema):
665 class Meta:
666 model = SubjectProperty
667 exclude = ["id", "loan", "created_at", "updated_at"]
668 fields_optional = "__all__"
670 @field_validator("address_line", check_fields=False)
671 @classmethod
672 def validate_address_line(cls, v: str | None) -> str | None:
673 return _clean_address_line(v) if v else v
675 @field_validator("city", check_fields=False)
676 @classmethod
677 def validate_city(cls, v: str | None) -> str | None:
678 return _clean_city(v) if v else v
680 @field_validator("county", check_fields=False)
681 @classmethod
682 def validate_county(cls, v: str | None) -> str | None:
683 return _clean_county(v) if v else v
685 @field_validator("state_code", check_fields=False)
686 @classmethod
687 def validate_state(cls, v: str | None) -> str | None:
688 return _clean_state(v) if v else v
690 @field_validator("financed_unit_count", check_fields=False)
691 @classmethod
692 def validate_financed_unit_count(cls, v: int | None) -> int | None:
693 if v is not None and not (1 <= v <= 4): 693 ↛ 694line 693 didn't jump to line 694 because the condition on line 693 was never true
694 raise ValueError("Financed unit count must be between 1 and 4")
695 return v
697 @field_validator("year_built", check_fields=False)
698 @classmethod
699 def validate_year_built(cls, v: int | None) -> int | None:
700 if v is not None and not (1700 <= v <= 2030):
701 raise ValueError("Year built must be a 4-digit year between 1700 and 2030")
702 return v
704 @field_validator("postal_code", check_fields=False)
705 @classmethod
706 def validate_postal_code(cls, v: str | None) -> str | None:
707 if v is None or not v.strip(): 707 ↛ 708line 707 didn't jump to line 708 because the condition on line 707 was never true
708 return v
709 return _clean_zip(v)
711 @field_validator(
712 "real_estate_taxes_annual_amt",
713 "hazard_insurance_annual_amt",
714 "estimated_value_amt",
715 check_fields=False,
716 )
717 @classmethod
718 def validate_property_amounts(cls, v: Decimal | None) -> Decimal | None:
719 return _clean_non_negative(v, Decimal("999999999.99"))
721 @field_validator("hoa_dues_monthly_amt", check_fields=False)
722 @classmethod
723 def validate_hoa_dues(cls, v: Decimal | None) -> Decimal | None:
724 return _clean_non_negative(v, Decimal("9999999.99"))