Skip to content

refactor(views): split core/views/__init__.py into 11 domain modules (H6) - #423

Merged
paruff merged 11 commits into
mainfrom
refactor/h6-split-views-permissions
Sep 3, 2026
Merged

paruff merged 11 commits into
mainfrom
refactor/h6-split-views-permissions

Conversation

@paruff

@paruff paruff commented Sep 3, 2026

Copy link
Copy Markdown
Owner

Implements H6 from the production audit, per the plan in #417.

What changed

core/views/__init__.py — 4,315 lines, 78 top-level symbols — is now a 77-line pure re-export shim over 11 domain modules:

Module Lines
permissions.py 79
constants.py 55
markets.py 183
exports.py 190
system.py 256
dashboards.py 289
screening.py 411
leasing.py 413
growth.py 490
properties.py 541
discovery.py 767
pipeline.py 838

One commit per module, in the order the plan specified (permissions first — no view surface, every RBAC-dependent module needs it; growth last — 35 of 36 test patch targets live there, so it moves only once the pattern is proven by ten prior commits). Each commit is independently manage.py check clean, ruff/mypy clean, and test-verified.

pipeline.py at 838 lines exceeds the plan's 800-line target — flagged rather than silently missed. Splitting it further would cut a single deal-stage state machine along an arbitrary line count rather than a real domain seam; screening.py (the actual second half of the original pipeline_* group) was already split out as its own module for exactly this reason.

Three real bugs the process caught — not hypothetical

Each was found by running the actual suite, not by ruff/mypy being clean (which they were, every time, when these were still broken):

  1. A stray shared constant. US_STATES physically sat between export_pdf and vrm_properties_list in the original file and got silently absorbed into whichever symbol's extracted range preceded it. It's needed by four different functions across four different new modules. Extracted to core/views/constants.py.

  2. Mypy suppression scope gap. mypy.ini had [mypy-core.views] ignore_errors = True — matches the exact module path only, not submodules. The moment real submodules existed, 3 pre-existing (unrelated to this refactor) type errors surfaced. Widened to [mypy-core.views.*], preserving today's exact suppression scope rather than fixing the underlying errors, which is separate work.

  3. The same-module patch trap, three times. mock.patch("core.views.X") only patches the re-export alias in __init__.py. When the caller of X moved into the same new module as X itself, the caller's bare-name lookup resolves through the submodule's own globals — the alias patch stops reaching it. Found via _generate_pdf/export_pdf (step 4, 1 site), compute_analysis_for_property/property_compare (step 8, 1 site — a different pattern: an object-reference monkeypatch.setattr(views, "X", ...) that no string grep would catch), and the four Census/FRED integration functions/growth_explorer (step 11, 46 sites across 3 test files). Every occurrence updated to patch the actual defining submodule; swept the whole suite each time to confirm no sibling occurrences were missed.

Also fixed along the way

  • My own process gap: the mypy failure in step 3 silently blocked that commit at a pre-commit hook, and I moved on to step 4 without registering it — both sat staged-but-uncommitted together until git log caught the divergence. Split back into two clean commits; no code was lost, but noting the failure mode.
  • My own tooling bug: ruff refuses to auto-delete unused imports in a file literally named __init__.py (confirmed via its own JSON diagnostics returning fix: null, not just an unapplied fix — package re-export convention). Worked around by running the fixer against a temp copy under a different name, then copying the cleaned result back, rather than my own regex-based import surgery (which corrupted multi-line import blocks and needed abandoning mid-refactor).

Verified

  • Every one of the 11 commits: manage.py check clean, full pre-commit suite (ruff, ruff format, mypy) clean
  • The complete unit+integration suite, run fresh at 3 checkpoints (exports, properties, and the final growth step): 2186 passed / 4 failed every time — the same 4 pre-existing ATTOM live-API failures established in fix(census): correct stale return contract and surface live source failures #413's baseline, unrelated to this refactor
  • All 36 original core.views.* test patch targets still resolve — some via direct re-export, some (3 documented cases above) repointed to the submodule that actually defines the name, which is the correct fix, not a workaround
  • core/urls.py's from . import views / from .views import MarketRefreshView and every views.<name> route target confirmed against the real re-export set (69 route references + 5 direct patch targets, cross-checked against what each module actually needs to re-export)

Test plan

  • CI green (ruff, mypy, unit, integration, e2e)
  • Spot-check a few routes manually (property detail, pipeline kanban, PDF export) since this is a large mechanical move

🤖 Generated with Claude Code

paruff and others added 11 commits September 3, 2026 00:17
First move of the core/views/__init__.py split (4315 lines, 78 symbols).
Per the plan (#417), permissions.py goes first: it has no Django view
surface and every later domain module that needs RBAC checks imports
from it.

Moves AuthenticatedUser, _get_property_role, is_owner_or_shared, and
_is_client_only_user verbatim. None of the four has an external
core.views.<name> reference (checked urls.py, all test patches), so
__init__.py imports them for internal use only where a not-yet-moved
view still calls them by bare name, and does not add them to __all__.

Verified: manage.py check clean, ruff clean, 2186 passed / 4 failed
(unit+integration) — the 4 failures are the pre-existing ATTOM live
tests, unrelated (see #413), requiring a real API key this environment
doesn't carry.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Moves home, health_check, system_status, refresh_all_sources, and
health_json verbatim. All five are routed in core/urls.py, so all five
are re-exported.

Verified: manage.py check clean, ruff clean, 165 unit tests pass,
37 targeted health/system integration tests pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Moves investment_targets_edit, MarketRefreshView, markets_list,
brrrr_calculator, and sell_index verbatim. All five routed in
core/urls.py.

Widens mypy.ini's [mypy-core.views] ignore_errors suppression with a
matching [mypy-core.views.*]. mypy config sections match the exact
module path only, not submodules, so the suppression that has covered
the whole views layer (core/views/__init__.py never had its Django-ORM
lookup types fixed) stopped applying the moment core.views.markets
became a real submodule -- surfacing 3 pre-existing type errors that
have nothing to do with this refactor. This preserves today's exact
suppression scope against the new file layout; fixing those errors is
separate work, not H6's.

Note: my first attempt to commit this step failed at the mypy pre-commit
hook, and I moved on to step 4 without registering that -- both step 3
and step 4 sat staged-but-uncommitted together until `git log` caught
the gap. Split back apart here; no code was lost, but flagging the
process failure.

Verified: manage.py check clean, ruff clean, mypy clean (all 5 changed
files), full pre-commit suite green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Moves _get_financing_value, _format_financing_value, _generate_pdf, and
export_pdf verbatim. Only export_pdf (routed) and _generate_pdf (has a
test patch target) are re-exported; the two formatting helpers are
exports.py-internal only.

Two fixes this step needed to be correct, both surfaced by running the
full suite rather than trusting ruff-clean as sufficient:

1. US_STATES was a stray module-level constant that happened to sit
   between export_pdf and vrm_properties_list in the original file. My
   extraction absorbed it into exports.py's line range along with
   export_pdf, even though export_pdf never uses it -- four OTHER
   functions do (onboard, growth_explorer, pipeline_screening_settings,
   vrm_properties_list), none of which have moved yet. Extracted it to
   core/views/constants.py, the shared home all four will import from
   as they move.

2. test_pdf_export_returns_500_on_generation_failure patches
   "core.views._generate_pdf" to make export_pdf observe a forced
   failure. Before this split both functions lived in the same module,
   so patching the core.views alias worked. After the split, export_pdf
   resolves _generate_pdf via its own module globals (core.views.exports),
   not through the core.views re-export alias -- patching the alias no
   longer touches what export_pdf actually calls. Updated the test to
   patch core.views.exports._generate_pdf, matching where the call
   actually resolves now. Production behavior is unchanged; this is a
   white-box test's patch coordinate catching up to real module structure.

This same trap will hit core.views.fetch_place_growth_metrics/
fetch_housing_demand_index/discover_places_in_state/FREDAdapter (43
patch sites, three test files) once growth.py moves in step 11 --
noted for that step rather than fixed blind now.

Verified: manage.py check clean, ruff/mypy clean, and (before this
step's re-commit split) the full unit+integration suite:
2186 passed / 4 failed -- same 4 pre-existing ATTOM failures as step
1's baseline, unrelated (see #413).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Moves the ten leasing_* views verbatim: list, kanban, add, detail,
showing, application, screening, lease, move_in, stabilize. All
routed in core/urls.py.

Verified: manage.py check clean, ruff/mypy clean, 165 unit tests pass,
13 targeted leasing integration tests pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Moves the 15 pipeline stage-machine views verbatim: list, kanban,
review_queue, review_csv, advance_stage, advance, kill, hold,
reactivate, detail, add_from_source, offer_create, dd_checklist,
renovation, closing_create. All routed in core/urls.py.

At 838 lines this exceeds the plan's 800-line target -- flagging
rather than silently missing it. Splitting further would mean cutting
a single deal-stage state machine apart along an arbitrary line count
rather than a real domain seam; screening.py (the actual second half of
the original pipeline_* group) was already split out as its own module
in the plan for exactly this reason. Leaving it as one cohesive unit.

Verified: manage.py check clean, ruff/mypy clean, 165 unit tests pass,
110 targeted pipeline integration tests pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Moves pipeline_screener, screener_filter, pipeline_screening_settings,
and screening_preview verbatim. All routed in core/urls.py.

pipeline_screening_settings uses US_STATES from constants.py.

Verified: manage.py check clean, ruff/mypy clean, 165 unit tests pass,
103 targeted screening integration tests pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Moves property_list, _parse_compare_ids, property_compare,
property_detail, financing_comparison, property_add, property_edit,
capex_item_edit, property_delete, property_add_income,
property_add_expense, and property_share verbatim. All routed in
core/urls.py except _parse_compare_ids (internal to property_compare
only).

Imports is_owner_or_shared, _get_property_role, _is_client_only_user
from .permissions -- the heaviest cross-module RBAC dependency of any
domain module, per the plan's reasoning for moving permissions.py first.

Also switched regen.py's re-export strategy for good this step: ruff
refuses to auto-delete unused imports in a literal __init__.py file
(package re-export convention -- confirmed via its JSON diagnostics,
which return fix: null there, not just an unapplied fix), so my
earlier __all__-based approach made ruff hedge on every import in the
file instead of pruning genuinely dead ones. Re-export lines now use
explicit self-aliasing (`X as X`, ruff's own suggested convention),
and I run the fixer against a temp copy under a non-"__init__.py" name
to get ruff's real (not my regex) multi-line-import-safe fix, then
copy the result back.

Second real trap found running the full suite:
test_compare_computes_analysis_when_missing did
`monkeypatch.setattr(views, "compute_analysis_for_property", fake)`
where `views` is `from core import views` -- an object reference, not
a string, so my earlier core.views.X string-grep never would have
caught it. Same root cause as the _generate_pdf trap in step 4:
property_compare now resolves compute_analysis_for_property through
properties.py's own module globals, not through the core.views alias.
Fixed by patching views.properties (submodule auto-registered as an
attribute of the core.views package on import) instead. Swept the
whole test suite for the same object-reference pattern; this was the
only occurrence.

Verified: manage.py check clean, ruff/mypy clean, full unit+integration
suite: 2186 passed / 4 failed -- same 4 pre-existing ATTOM failures as
every prior step's baseline (see #413).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Moves dashboard, onboard, pipeline_dashboard, portfolio_dashboard,
market_dashboard, and update_market_indicators verbatim. All routed
in core/urls.py. Imports _is_client_only_user from .permissions and
US_STATES from .constants.

Verified: manage.py check clean, ruff/mypy clean, 165 unit tests pass,
28 targeted dashboard/onboard integration tests pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Moves search_listings, analyze_property, report_listing,
report_property, vrm_properties_list, property_discovery,
hud_property_list, hud_property_detail, usda_property_list, and
usda_property_detail verbatim. All routed in core/urls.py. Imports
logger and User for local use, and US_STATES from .constants.

Only __init__.py's growth_areas, growth_areas_export_csv, and
growth_explorer remain -- step 11 (growth.py) empties it down to a
pure re-export shim.

Verified: manage.py check clean, ruff/mypy clean, 165 unit tests pass,
127 targeted discovery/search/hud/usda/vrm integration tests pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Final step. Moves growth_areas, growth_areas_export_csv, and
growth_explorer verbatim. core/views/__init__.py is now a pure
79-line re-export shim -- no logic, imports only, exactly the plan's
Definition of Done.

Removed the original header's `from core.integrations.market.census
import (...)` and `from core.integrations.sources.fred_adapter import
FREDAdapter` once growth.py owns them: keeping both the header's copy
and growth.py's own copy, both re-exported into __init__.py, is a
literal F811 redefinition of the same four names from two different
import statements. growth.py is now the sole importer; __init__.py
gets them via the explicit `.growth` re-export.

Third and largest instance of the same-module-caller-callee patch
trap flagged back in step 4 (#413's _generate_pdf,step 8's
compute_analysis_for_property): 46 occurrences across three test
files patched discover_places_in_state / fetch_housing_demand_index /
fetch_place_growth_metrics / FREDAdapter.fetch_state_employment_growth
via "core.views.X" -- all four are used exclusively by
growth_explorer, and all four are IMPORTED (not defined) in the
original file, so once growth_explorer's module changed, patching
the core.views alias stopped reaching what growth_explorer's own
module globals actually call. Updated every occurrence in
test_growth_explorer_view.py, test_pipeline_bridge.py, and
test_growth_explorer.py to "core.views.growth.X". Swept the whole
test suite first to confirm no other object-reference-style patches
(the compute_analysis_for_property pattern) existed for these names --
none did, all 46 were the string-literal form this sweep updates
directly. Zero unpatched occurrences remain.

H6 is complete: core/views/__init__.py went from 4315 lines / 78
top-level symbols to 79 lines of re-exports across 11 domain modules
(permissions, system, markets, exports, leasing, pipeline, screening,
properties, dashboards, discovery, growth). All 36 original
core.views.* test patch targets still resolve -- some directly via
re-export, some (this step, step 4, step 8) by being repointed to
the submodule that now actually defines the name, which is the
correct fix in each case, not a workaround.

Verified: manage.py check clean, ruff/mypy clean, full pre-commit
suite, and the definitive final gate -- the complete unit+integration
suite: 2186 passed / 4 failed, the same 4 pre-existing ATTOM failures
every checkpoint in this refactor has shown (see #413), unchanged
from step 1's original baseline.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Comment thread core/views/discovery.py
selected_sources = request.POST.getlist("sources")
if not selected_sources:
messages.warning(request, "Please select at least one source.")
return redirect(f"{request.path}?growth_area_id={growth_area.pk}")
Comment thread core/views/pipeline.py
advance_stage(prop)
return JsonResponse({"status": "ok", "stage": prop.stage})
except ValueError as exc:
return JsonResponse({"error": str(exc)}, status=400)
Comment thread core/views/pipeline.py

if not source_type or not source_id:
messages.error(request, "Missing source_type or source_id")
return redirect(next_url)
Comment thread core/views/pipeline.py
vrm = VrmProperty.objects.get(vrm_property_id=int(source_id))
except VrmProperty.DoesNotExist, ValueError, TypeError:
messages.error(request, "VRM property not found")
return redirect(next_url)
Comment thread core/views/pipeline.py
hud = HudProperty.objects.get(hud_case_number=source_id)
except HudProperty.DoesNotExist:
messages.error(request, "HUD property not found")
return redirect(next_url)
Comment thread core/views/pipeline.py
cn = CountyForeclosureNotice.objects.get(pk=int(source_id))
except CountyForeclosureNotice.DoesNotExist, ValueError, TypeError:
messages.error(request, "County notice not found")
return redirect(next_url)
Comment thread core/views/pipeline.py
return redirect("pipeline_detail", pk=pp.pk)

messages.error(request, f"Unknown source type: {source_type}")
return redirect(next_url)
Comment thread core/views/screening.py
request,
"Too many re-screens — please wait a few minutes and try again.",
)
return redirect(request.get_full_path())
Comment thread core/views/screening.py
messages.success(request, f"{pp.address[:40]} moved to Underwriting.")
except PipelineProperty.DoesNotExist:
pass
return redirect(request.get_full_path())
Comment thread core/views/screening.py
pp.save(update_fields=["status", "kill_reason", "updated_at"])
except PipelineProperty.DoesNotExist:
pass
return redirect(request.get_full_path())
@paruff
paruff merged commit 692ffdf into main Sep 3, 2026
31 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants