Coverage for apps/accounts/admin_api.py: 87%
350 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"""Accounts self (/me) + RBAC admin router (mounted at /api/accounts).
3Reuses the RBAC foundation unchanged: `StaffAuth`/`requires` guards, `assign_user_branch`, and
4the principal already resolved onto the request. Pure-Lumen.
5"""
7from __future__ import annotations
9from datetime import date
11from django.conf import settings
12from django.db import transaction as db_transaction
13from django.utils.crypto import get_random_string
14from ninja import Query, Router
16from apps.accounts.admin_schemas import (
17 AgentConfigOut,
18 AgentConfigPatch,
19 ApiClientCreate,
20 ApiClientOut,
21 BillingOut,
22 CompanyOut,
23 CompanyPatch,
24 ComplianceCheckInput,
25 ComplianceCheckOut,
26 FieldConfigsOut,
27 LeadFormConfigOut,
28 LeadFormConfigPatch,
29 LicenseCreate,
30 LicenseOut,
31 MeOut,
32 NewApiCredentialsOut,
33 NonDelegatedConfigOut,
34 NonDelegatedConfigPatch,
35 OrgInfoOut,
36 OrgUnitCreate,
37 OrgUnitOut,
38 PermissionOut,
39 PosConfigOut,
40 PosConfigPatch,
41 ProvisionApplyInput,
42 ProvisionApplyOut,
43 ProvisionPlanOut,
44 ProvisionPreviewInput,
45 RoleAssignmentCreate,
46 RoleAssignmentOut,
47 RoleAssignmentReplace,
48 RoleMatrixOut,
49 RoleOut,
50 StateComplianceOut,
51 UserActiveUpdate,
52 UserDetailOut,
53 UserInviteInput,
54 UserInviteOut,
55)
56from apps.accounts.auth import StaffAuth, assign_user_branch, requires
57from apps.accounts.models import (
58 ApiClient,
59 Company,
60 License,
61 OrgUnit,
62 Permission,
63 Role,
64 User,
65 UserRole,
66)
67from apps.integrations.encryption import encrypt_secret
68from domain.field_configs import get_field_configs
69from domain.provisioning import EXAMPLE_CONFIG, summarize_plan, validate_client_config
70from domain.rbac import PERMISSIONS, ROLE_PERMISSIONS, ROLES, permission_meta
71from domain.result import AppError, DomainError
72from domain.state_compliance import STATE_COMPLIANCE, validate_state_compliance
74router = Router(tags=["accounts"])
75# Mounted at /api/admin (not /api/accounts) — the Roles & Permissions tab's own prefix
76# (add-admin-roles-matrix-api). A separate router instance since it shares no other paths
77# with apps.integrations.admin_api's router, which already owns /api/admin.
78admin_console_router = Router(tags=["admin-console"])
79compliance_router = Router(tags=["compliance"])
80billing_router = Router(tags=["billing"])
81provision_router = Router(tags=["provisioning"])
84def _user(user_id: int) -> User:
85 user = User.objects.filter(pk=user_id).first()
86 if user is None: 86 ↛ 87line 86 didn't jump to line 87 because the condition on line 86 was never true
87 raise DomainError([AppError("NOT_FOUND", "User not found")])
88 return user
91def _resolve_role(role_name: str) -> Role:
92 role = Role.objects.filter(name=role_name).first()
93 if role is None:
94 raise DomainError([AppError("NOT_FOUND", f"Role not found: {role_name}")])
95 return role
98def _resolve_org_unit(org_unit_id: int | None) -> OrgUnit | None:
99 if org_unit_id is None: 99 ↛ 100line 99 didn't jump to line 100 because the condition on line 99 was never true
100 return None
101 org = OrgUnit.objects.filter(pk=org_unit_id).first()
102 if org is None: 102 ↛ 103line 102 didn't jump to line 103 because the condition on line 102 was never true
103 raise DomainError([AppError("NOT_FOUND", "Org unit not found")])
104 return org
107def _role_bindings(user: User) -> list[dict]:
108 """Uses ``user.user_roles.all()`` (not a fresh queryset) so a prefetch on ``user``
109 (e.g. ``list_users``) avoids N+1 queries across many users."""
110 bindings = user.user_roles.all()
111 return [{"id": b.id, "role": b.role.name, "org_unit_id": b.org_unit_id} for b in bindings]
114def _user_detail(user: User) -> dict:
115 return {
116 "id": user.id,
117 "email": user.email,
118 "first_name": user.first_name,
119 "last_name": user.last_name,
120 "title": user.title,
121 "phone": user.phone,
122 "nmls_id": user.nmls_id,
123 "org_unit_id": user.org_unit_id,
124 "is_active": user.is_active,
125 "roles": _role_bindings(user),
126 "licenses": list(user.licenses.all()),
127 }
130# --- Self ----------------------------------------------------------------------
131@router.get("/me", response=MeOut, auth=StaffAuth())
132def me(request):
133 p = request.principal
134 user = User.objects.filter(pk=p.user_id).first()
135 bindings = UserRole.objects.filter(user_id=p.user_id).select_related("role")
136 return {
137 "user_id": p.user_id,
138 "email": p.email,
139 "first_name": user.first_name if user else "",
140 "last_name": user.last_name if user else "",
141 "is_hq": p.is_hq,
142 "permissions": sorted(p.permissions),
143 "org_unit_ids": sorted(p.org_unit_ids),
144 "roles": [{"role": b.role.name, "org_unit_id": b.org_unit_id} for b in bindings],
145 }
148# --- Org units -----------------------------------------------------------------
149@router.get("/org-units", response=list[OrgUnitOut], auth=requires("MANAGE_USER_ACCOUNT"))
150def list_org_units(request):
151 return list(OrgUnit.objects.all())
154@router.post("/org-units", response={201: OrgUnitOut}, auth=requires("MANAGE_USER_ACCOUNT"))
155def create_org_unit(request, payload: OrgUnitCreate):
156 if payload.parent_id is not None and not OrgUnit.objects.filter(pk=payload.parent_id).exists(): 156 ↛ 157line 156 didn't jump to line 157 because the condition on line 156 was never true
157 raise DomainError([AppError("NOT_FOUND", "Parent org unit not found")])
158 org = OrgUnit.objects.create(
159 name=payload.name,
160 parent_id=payload.parent_id,
161 nmls_id=payload.nmls_id,
162 is_headquarters=payload.is_headquarters,
163 )
164 return 201, org
167# --- Roles + permissions -------------------------------------------------------
168@router.get("/roles", response=list[RoleOut], auth=requires("MANAGE_USER_ACCOUNT"))
169def list_roles(request):
170 out = []
171 for r in Role.objects.prefetch_related("role_permissions__permission"):
172 codes = sorted(rp.permission.code for rp in r.role_permissions.all())
173 out.append({"name": r.name, "permissions": codes})
174 return out
177@router.get("/permissions", response=list[PermissionOut], auth=requires("MANAGE_USER_ACCOUNT"))
178def list_permissions(request):
179 return list(Permission.objects.all())
182@admin_console_router.get(
183 "/roles", response=RoleMatrixOut, auth=requires("MANAGE_USER_ACCOUNT")
184)
185def role_matrix(request):
186 """The role x permission matrix (Admin Console → Roles & Permissions), pure data
187 from domain.rbac — no DB query needed."""
188 permissions = [
189 {"permission": code, "roles": ROLE_PERMISSIONS.get(code, []), **permission_meta(code)}
190 for code in PERMISSIONS
191 ]
192 return {"roles": list(ROLES), "permissions": permissions}
195def _get_company() -> Company:
196 """The one Company row (add-admin-organization-api) — lazily created on first read,
197 same singleton pattern as core_los.LoanNumberSequence."""
198 company = Company.objects.first()
199 if company is None:
200 company = Company.objects.create()
201 return company
204def _company_out(company: Company) -> dict:
205 return {
206 "name": company.name,
207 "nmls_id": company.nmls_id,
208 "type": company.type,
209 "dba": company.dba or None,
210 "phone": company.phone or None,
211 "mers_id": company.mers_id or None,
212 "address_line": company.address_line or None,
213 "city": company.city or None,
214 "state_code": company.state_code or None,
215 "postal_code": company.postal_code or None,
216 "business_hours": company.business_hours or None,
217 }
220@admin_console_router.get("/org", response=OrgInfoOut, auth=requires("MANAGE_USER_ACCOUNT"))
221def org_info(request):
222 org_units = [
223 {"id": str(u.id), "label": u.name}
224 for u in OrgUnit.objects.all().order_by("-is_headquarters", "name")
225 ]
227 loan_officers = []
228 officers = (
229 User.objects.filter(user_roles__role__name="Loan Officer")
230 .exclude(nmls_id="")
231 .distinct()
232 .prefetch_related("licenses")
233 )
234 for user in officers:
235 states = [
236 lic.state_code if lic.active and lic.sponsored
237 else f"{lic.state_code} ({'unsponsored' if lic.active else 'inactive'})"
238 for lic in user.licenses.all()
239 ]
240 loan_officers.append(
241 {
242 "id": user.id,
243 "name": f"{user.first_name} {user.last_name}",
244 "nmls_id": user.nmls_id,
245 "states": states,
246 }
247 )
249 return {
250 "company": _company_out(_get_company()),
251 "org_units": org_units,
252 "user_count": User.objects.filter(is_staff=True).count(),
253 "loan_officers": loan_officers,
254 }
257@admin_console_router.patch(
258 "/company", response=CompanyOut, auth=requires("MANAGE_USER_ACCOUNT")
259)
260def update_company(request, payload: CompanyPatch):
261 data = payload.model_dump(exclude_unset=True)
262 if "name" in data and not data["name"]:
263 raise DomainError([AppError("VALIDATION", "Company name is required.", "name")])
264 company = _get_company()
265 for field, value in data.items():
266 setattr(company, field, value if value is not None else "")
267 company.save()
268 return _company_out(company)
271DEFAULT_SECTIONS = [
272 "mortgage", "personal", "coborrower", "employment", "income",
273 "assets", "liabilities", "reo", "declarations", "demographics",
274 "additional", "credit", "review",
275]
278def _pos_config_out(company: Company) -> dict:
279 c = company.pos_config or {}
280 sections = DEFAULT_SECTIONS
281 languages = c.get("languages") or ["English", "Spanish"]
282 default_language = c.get("default_language") or c.get("defaultLanguage") or "English"
283 programs = c.get("programs") or ["Standard", "DSCR", "VA", "FHA"]
284 sections_by_program = c.get("sections_by_program") or {
285 "Standard": sections,
286 "DSCR": [s for s in sections if s not in ("employment", "income")],
287 "VA": sections,
288 "FHA": sections,
289 }
290 features = {
291 "credit_auth_required": c.get("credit_auth_required", True),
292 "allow_submit_without_credit_consent": c.get("allow_submit_without_credit_consent", False),
293 "generate_needs_on_submit": c.get("generate_needs_on_submit", True),
294 "co_borrower_invite": True,
295 "doc_upload": True,
296 "e_consent": True,
297 }
298 branding = {
299 "dba": company.dba or None,
300 "use_dba_in_disclosures": False,
301 }
302 return {
303 "host": "posapi.my1003app.com",
304 "sections": sections,
305 "languages": languages,
306 "default_language": default_language,
307 "programs": programs,
308 "sections_by_program": sections_by_program,
309 "features": features,
310 "branding": branding,
311 }
314def _lead_form_config_out(company: Company) -> dict:
315 c = company.lead_form_config or {}
316 return {
317 "sources": c.get("sources") or ["Website", "Referral", "Zillow", "Repeat", "Realtor", "SocialMedia", "Other"],
318 "statuses": c.get("statuses") or ["New", "Contacted", "Qualified", "Nurture", "Converted", "Lost"],
319 "tags": c.get("tags") or ["Hot", "Warm", "Cold", "Pre-approved", "VIP"],
320 "required_fields": c.get("required_fields") or ["firstName", "lastName"],
321 }
324def _agent_config_out(company: Company) -> dict:
325 c = company.agent_config or {}
326 return {
327 "agent_portal_enabled": c.get("agent_portal_enabled", True),
328 "co_brand_letters": c.get("co_brand_letters", False),
329 "show_agent_on_letter": c.get("show_agent_on_letter", True),
330 "realtor_domain": c.get("realtor_domain", "myagentloans.com"),
331 }
334def _non_delegated_config_out(company: Company) -> dict:
335 c = company.non_delegated_config or {}
336 return {
337 "enabled": c.get("enabled", False),
338 "sponsor_name": c.get("sponsor_name") or None,
339 "sponsor_ein": c.get("sponsor_ein") or None,
340 "underwriting_contact": c.get("underwriting_contact") or None,
341 }
344# --- 1003 Field Config ---------------------------------------------------------
345@admin_console_router.get("/field-configs", response=FieldConfigsOut, auth=StaffAuth())
346def field_configs(request):
347 return get_field_configs()
350# --- POS App Config ------------------------------------------------------------
351@admin_console_router.get("/pos-config", response=PosConfigOut, auth=StaffAuth())
352def get_pos_config(request):
353 return _pos_config_out(_get_company())
356@admin_console_router.patch("/pos-config", response=PosConfigOut, auth=requires("EDIT_ORG_POS_APP_CONFIG"))
357def update_pos_config(request, payload: PosConfigPatch):
358 company = _get_company()
359 current = dict(company.pos_config or {})
360 for k, v in payload.model_dump(exclude_unset=True).items():
361 current[k] = v
362 company.pos_config = current
363 company.save(update_fields=["pos_config"])
364 return _pos_config_out(company)
367# --- Lead Form Config ----------------------------------------------------------
368@admin_console_router.get("/lead-form-config", response=LeadFormConfigOut, auth=StaffAuth())
369def get_lead_form_config(request):
370 return _lead_form_config_out(_get_company())
373@admin_console_router.patch("/lead-form-config", response=LeadFormConfigOut, auth=requires("EDIT_LEAD_DETAILS"))
374def update_lead_form_config(request, payload: LeadFormConfigPatch):
375 company = _get_company()
376 current = dict(company.lead_form_config or {})
377 for k, v in payload.model_dump(exclude_unset=True).items():
378 current[k] = v
379 company.lead_form_config = current
380 company.save(update_fields=["lead_form_config"])
381 return _lead_form_config_out(company)
384# --- Agent Config --------------------------------------------------------------
385@admin_console_router.get("/agent-config", response=AgentConfigOut, auth=StaffAuth())
386def get_agent_config(request):
387 return _agent_config_out(_get_company())
390@admin_console_router.patch("/agent-config", response=AgentConfigOut, auth=requires("EDIT_ORG_POS_APP_CONFIG"))
391def update_agent_config(request, payload: AgentConfigPatch):
392 company = _get_company()
393 current = dict(company.agent_config or {})
394 for k, v in payload.model_dump(exclude_unset=True).items():
395 current[k] = v
396 company.agent_config = current
397 company.save(update_fields=["agent_config"])
398 return _agent_config_out(company)
401# --- Non-Delegated Config ------------------------------------------------------
402@admin_console_router.get("/non-delegated-config", response=NonDelegatedConfigOut, auth=StaffAuth())
403def get_non_delegated_config(request):
404 return _non_delegated_config_out(_get_company())
407@admin_console_router.patch("/non-delegated-config", response=NonDelegatedConfigOut, auth=requires("EDIT_AUS_CREDENTIALS"))
408def update_non_delegated_config(request, payload: NonDelegatedConfigPatch):
409 company = _get_company()
410 current = dict(company.non_delegated_config or {})
411 for k, v in payload.model_dump(exclude_unset=True).items():
412 current[k] = v
413 company.non_delegated_config = current
414 company.save(update_fields=["non_delegated_config"])
415 return _non_delegated_config_out(company)
418# --- API Clients ---------------------------------------------------------------
419@admin_console_router.get("/api-clients", response=list[ApiClientOut], auth=requires("MANAGE_USER_ACCOUNT"))
420def list_api_clients(request):
421 clients = ApiClient.objects.all()
422 return [
423 {
424 "client_id": c.client_id,
425 "org_id": str(c.org_unit_id or "HQ"),
426 "label": c.label or None,
427 "has_key": bool(c.api_key_enc),
428 }
429 for c in clients
430 ]
433@admin_console_router.post("/api-clients", response={201: NewApiCredentialsOut}, auth=requires("MANAGE_USER_ACCOUNT"))
434def create_api_client(request, payload: ApiClientCreate):
435 client_id = f"client_{get_random_string(16)}"
436 client_secret = get_random_string(32)
437 api_key = f"lmn_{get_random_string(32)}"
438 ApiClient.objects.create(
439 client_id=client_id,
440 label=payload.label or "Zapier",
441 secret_enc=encrypt_secret(client_secret),
442 api_key_enc=encrypt_secret(api_key),
443 )
444 return 201, {
445 "client_id": client_id,
446 "client_secret": client_secret,
447 "api_key": api_key,
448 }
451# --- State Compliance ----------------------------------------------------------
452@compliance_router.get("/compliance/states", response=list[StateComplianceOut], auth=StaffAuth())
453def list_compliance_states(request):
454 return STATE_COMPLIANCE
457@compliance_router.post("/compliance/validate", response=ComplianceCheckOut, auth=StaffAuth())
458def validate_compliance(request, payload: ComplianceCheckInput):
459 lo_dict = None
460 if payload.loan_officer_id:
461 user = User.objects.filter(pk=payload.loan_officer_id).prefetch_related("licenses").first()
462 if user: 462 ↛ 471line 462 didn't jump to line 471 because the condition on line 462 was always true
463 lo_dict = {
464 "id": user.id,
465 "name": f"{user.first_name} {user.last_name}".strip() or user.email,
466 "licenses": [
467 {"state_code": lic.state_code, "active": lic.active, "sponsored": lic.sponsored}
468 for lic in user.licenses.all()
469 ],
470 }
471 res = validate_state_compliance(
472 state_code=payload.state_code,
473 lo=lo_dict,
474 processor=payload.processor.dict() if payload.processor else None,
475 )
476 if not res.ok: 476 ↛ 477line 476 didn't jump to line 477 because the condition on line 476 was never true
477 raise DomainError(res.errors)
478 return res.value
481# --- Plans & Billing -----------------------------------------------------------
482@billing_router.get("/billing", response=BillingOut, auth=StaffAuth())
483def get_billing(request):
484 seats = User.objects.filter(is_active=True, is_staff=True).count() or 1
485 price_per_seat = 110
486 mrr = seats * price_per_seat
487 today = date.today()
488 if today.month == 12: 488 ↛ 489line 488 didn't jump to line 489 because the condition on line 488 was never true
489 next_invoice = date(today.year + 1, 1, 1)
490 else:
491 next_invoice = date(today.year, today.month + 1, 1)
492 return {
493 "provider": "unconfigured",
494 "plan": "Pro",
495 "status": "active",
496 "seats": seats,
497 "price_per_seat": price_per_seat,
498 "mrr": mrr,
499 "next_invoice_date": next_invoice.isoformat(),
500 "features": [
501 "Loan Origination & Pipeline",
502 "1003 Application & Credit Ingestion",
503 "Pricing Engine & Lenders",
504 "Document Management & E-Signatures",
505 "State Compliance Engine",
506 "Partner Automation API",
507 ],
508 }
511# --- Client Provisioning -------------------------------------------------------
512@provision_router.get("/provision/example", response=dict, auth=StaffAuth())
513def get_provision_example(request):
514 return EXAMPLE_CONFIG
517@provision_router.post("/provision/preview", response=ProvisionPlanOut, auth=requires("MANAGE_USER_ACCOUNT"))
518def preview_provision(request, payload: ProvisionPreviewInput):
519 val = validate_client_config(payload.config)
520 if not val.ok: 520 ↛ 521line 520 didn't jump to line 521 because the condition on line 520 was never true
521 raise DomainError(val.errors)
522 return summarize_plan(payload.config)
525@provision_router.post("/provision/apply", response={201: ProvisionApplyOut}, auth=requires("MANAGE_USER_ACCOUNT"))
526def apply_provision(request, payload: ProvisionApplyInput):
527 cfg = payload.config
528 val = validate_client_config(cfg)
529 if not val.ok: 529 ↛ 530line 529 didn't jump to line 530 because the condition on line 529 was never true
530 raise DomainError(val.errors)
532 if payload.clear_loans: 532 ↛ 533line 532 didn't jump to line 533 because the condition on line 532 was never true
533 from apps.core_los.models import Loan
534 Loan.objects.all().delete()
536 company_data = cfg.get("company", {})
537 company = _get_company()
538 company.name = company_data.get("name", company.name)
539 company.nmls_id = company_data.get("nmls_id") or company_data.get("nmlsId") or company.nmls_id
540 if company_data.get("type"): 540 ↛ 542line 540 didn't jump to line 542 because the condition on line 540 was always true
541 company.type = company_data["type"]
542 company.save()
544 # Provision branches
545 branch_map = {}
546 for b in cfg.get("branches", []):
547 unit, _ = OrgUnit.objects.get_or_create(
548 name=b.get("label") or b.get("id"),
549 defaults={"is_headquarters": b.get("id") == "HQ"},
550 )
551 branch_map[b.get("id")] = unit
553 # Provision people
554 for p in cfg.get("people", []):
555 email = p.get("email", "").strip().lower()
556 if not email: 556 ↛ 557line 556 didn't jump to line 557 because the condition on line 556 was never true
557 continue
558 first_name, _, last_name = (p.get("name") or "User").partition(" ")
559 user, created = User.objects.get_or_create(
560 email=email,
561 defaults={
562 "first_name": first_name,
563 "last_name": last_name,
564 "is_staff": True,
565 "nmls_id": p.get("nmls_id") or p.get("nmlsId") or "",
566 },
567 )
568 if created and p.get("password"): 568 ↛ 573line 568 didn't jump to line 573 because the condition on line 568 was always true
569 user.set_password(p["password"])
570 user.save()
572 # Roles
573 for r in p.get("roles", []):
574 role_obj = Role.objects.filter(name=r.get("role")).first()
575 org_id = r.get("org_unit_id") or r.get("orgUnitId")
576 org_unit = branch_map.get(org_id)
577 if role_obj: 577 ↛ 573line 577 didn't jump to line 573 because the condition on line 577 was always true
578 UserRole.objects.get_or_create(user=user, role=role_obj, org_unit=org_unit)
580 # Licenses
581 for lic in p.get("licenses", []):
582 sc = (lic.get("state_code") or lic.get("stateCode") or "").upper()
583 if sc: 583 ↛ 581line 583 didn't jump to line 581 because the condition on line 583 was always true
584 License.objects.update_or_create(
585 user=user,
586 state_code=sc,
587 defaults={
588 "active": lic.get("active", True),
589 "sponsored": lic.get("sponsored", True),
590 },
591 )
593 if cfg.get("api_client") or cfg.get("apiClient"): 593 ↛ 603line 593 didn't jump to line 603 because the condition on line 593 was always true
594 client_data = cfg.get("api_client") or cfg.get("apiClient") or {}
595 cid = client_data.get("client_id") or client_data.get("clientId")
596 secret = client_data.get("client_secret") or client_data.get("clientSecret")
597 if cid: 597 ↛ 603line 597 didn't jump to line 603 because the condition on line 597 was always true
598 ApiClient.objects.update_or_create(
599 client_id=cid,
600 defaults={"secret_enc": encrypt_secret(secret or "secret")},
601 )
603 return 201, {"plan": summarize_plan(cfg)}
606# --- Users ---------------------------------------------------------------------
607@router.get("/users", response=list[UserDetailOut], auth=requires("MANAGE_USER_ACCOUNT"))
608def list_users(request, org_unit_id: int | None = Query(None)):
609 qs = User.objects.filter(is_staff=True).prefetch_related("user_roles__role", "licenses")
610 if org_unit_id is not None:
611 qs = qs.filter(org_unit_id=org_unit_id)
612 return [_user_detail(user) for user in qs]
615@router.post(
616 "/users",
617 response={201: UserInviteOut},
618 auth=requires("MANAGE_USER_ACCOUNT"),
619 exclude_none=True,
620)
621def invite_user(request, payload: UserInviteInput):
622 name = payload.name.strip()
623 email = payload.email.strip()
624 if not name or not email: 624 ↛ 625line 624 didn't jump to line 625 because the condition on line 624 was never true
625 raise DomainError([AppError("VALIDATION", "Name and email are required.")])
626 if User.objects.filter(email__iexact=email).exists():
627 raise DomainError(
628 [AppError("VALIDATION", "An account with that email already exists.", "email")]
629 )
630 role = _resolve_role(payload.role_name)
631 org = _resolve_org_unit(payload.org_unit_id)
633 first_name, _, last_name = name.partition(" ")
634 temp_password = "lumen-" + get_random_string(
635 6, allowed_chars="abcdefghijklmnopqrstuvwxyz0123456789"
636 )
637 from apps.communications import emails
638 from apps.communications.delivery import EmailSendError, deliver_email
640 company = _get_company()
641 company_label = company.dba or company.name or "Lumen Lending"
642 with db_transaction.atomic():
643 user = User.objects.create_user(
644 email=email,
645 password=temp_password,
646 first_name=first_name,
647 last_name=last_name or "",
648 org_unit=org,
649 is_staff=True,
650 nmls_id=payload.nmls_id,
651 )
652 UserRole.objects.get_or_create(user=user, role=role, org_unit=org)
653 try:
654 # The user exists only if the invite email went out (add-sendgrid-email).
655 subject, content = emails.staff_invite(
656 company=company_label,
657 first_name=first_name,
658 role=role.name,
659 login_url=f"{settings.STAFF_LOS_URL}/login",
660 email=email,
661 temp_password=temp_password,
662 )
663 deliver_email(
664 to=email,
665 subject=subject,
666 content=content,
667 trigger="user:invite",
668 automated=True,
669 track_clicks=False,
670 raise_on_failure=True,
671 )
672 except EmailSendError as exc:
673 raise DomainError(
674 [AppError("EMAIL_SEND_FAILED", "We couldn't send the invite email. Try again.")]
675 ) from exc
676 # The password is only in the email; development also returns it for local testing.
677 result = {"user": _user_detail(user), "email_sent": True}
678 if settings.DEBUG: 678 ↛ 679line 678 didn't jump to line 679 because the condition on line 678 was never true
679 result["devTempPassword"] = temp_password
680 return 201, result
683@router.patch(
684 "/users/{user_id}/active", response=UserDetailOut, auth=requires("MANAGE_USER_ACCOUNT")
685)
686def set_user_active(request, user_id: int, payload: UserActiveUpdate):
687 user = _user(user_id)
688 user.is_active = payload.is_active
689 user.save(update_fields=["is_active"])
690 return _user_detail(user)
693@router.get("/users/{user_id}", response=UserDetailOut, auth=requires("MANAGE_USER_ACCOUNT"))
694def get_user(request, user_id: int):
695 return _user_detail(_user(user_id))
698# --- Role + branch assignment --------------------------------------------------
699@router.post(
700 "/users/{user_id}/role-assignments",
701 response={201: RoleAssignmentOut},
702 auth=requires("MANAGE_USER_ACCOUNT"),
703)
704def assign_role(request, user_id: int, payload: RoleAssignmentCreate):
705 user = _user(user_id)
706 role = Role.objects.filter(name=payload.role_name).first()
707 if role is None:
708 raise DomainError([AppError("NOT_FOUND", f"Role not found: {payload.role_name}")])
709 org = None
710 if payload.org_unit_id is not None: 710 ↛ 714line 710 didn't jump to line 714 because the condition on line 710 was always true
711 org = OrgUnit.objects.filter(pk=payload.org_unit_id).first()
712 if org is None: 712 ↛ 713line 712 didn't jump to line 713 because the condition on line 712 was never true
713 raise DomainError([AppError("NOT_FOUND", "Org unit not found")])
714 assign_user_branch(user, payload.role_name, org)
715 binding = UserRole.objects.get(user=user, role=role, org_unit=org)
716 return 201, {"id": binding.id, "role": role.name, "org_unit_id": binding.org_unit_id}
719@router.delete(
720 "/users/{user_id}/role-assignments/{assignment_id}",
721 response={204: None},
722 auth=requires("MANAGE_USER_ACCOUNT"),
723)
724def remove_role(request, user_id: int, assignment_id: int):
725 binding = UserRole.objects.filter(user_id=user_id, pk=assignment_id).first()
726 if binding is None: 726 ↛ 727line 726 didn't jump to line 727 because the condition on line 726 was never true
727 raise DomainError([AppError("NOT_FOUND", "Role assignment not found")])
728 binding.delete()
729 return 204, None
732@router.put(
733 "/users/{user_id}/role-assignments",
734 response=list[RoleAssignmentOut],
735 auth=requires("MANAGE_USER_ACCOUNT"),
736)
737def replace_roles(request, user_id: int, payload: RoleAssignmentReplace):
738 user = _user(user_id)
739 desired = [
740 (_resolve_role(r.role_name), _resolve_org_unit(r.org_unit_id)) for r in payload.roles
741 ]
743 current = list(UserRole.objects.filter(user=user).select_related("role"))
744 desired_keys = {(role.id, org.id if org else None) for role, org in desired}
745 for binding in current:
746 if (binding.role_id, binding.org_unit_id) not in desired_keys: 746 ↛ 745line 746 didn't jump to line 745 because the condition on line 746 was always true
747 binding.delete()
749 for role, org in desired:
750 UserRole.objects.get_or_create(user=user, role=role, org_unit=org)
752 return _role_bindings(user)
755# --- Licenses ------------------------------------------------------------------
756@router.get(
757 "/users/{user_id}/licenses", response=list[LicenseOut], auth=requires("MANAGE_USER_ACCOUNT")
758)
759def list_licenses(request, user_id: int):
760 return list(_user(user_id).licenses.all())
763@router.post(
764 "/users/{user_id}/licenses", response={201: LicenseOut}, auth=requires("EDIT_USER_LICENSES")
765)
766def add_license(request, user_id: int, payload: LicenseCreate):
767 user = _user(user_id)
768 if License.objects.filter(user=user, state_code=payload.state_code).exists(): 768 ↛ 769line 768 didn't jump to line 769 because the condition on line 768 was never true
769 raise DomainError([AppError("CONFLICT", f"License exists for {payload.state_code}")])
770 return 201, License.objects.create(user=user, **payload.dict())
773@router.delete(
774 "/users/{user_id}/licenses/{license_id}",
775 response={204: None},
776 auth=requires("EDIT_USER_LICENSES"),
777)
778def remove_license(request, user_id: int, license_id: int):
779 lic = License.objects.filter(user_id=user_id, pk=license_id).first()
780 if lic is None:
781 raise DomainError([AppError("NOT_FOUND", "License not found")])
782 lic.delete()
783 return 204, None