housing#

Cape Town neighbourhoods explorer with safety scores, rental & property pricing, historical trends, ward mapping, and integration with crime/loadshedding data.

Models#

Model

Description

District

Geographic grouping (Atlantic Seaboard, City Bowl, etc.)

Neighbourhood

Core neighbourhood record — safety, rental, property, Airbnb data

NeighbourhoodHighlight

Key-value highlight cards (icon + label + value)

PropertySnapshot

Point-in-time rental/property data for trend charts

Ward

Municipal electoral ward (2021 boundaries)

WardSafetySnapshot

Quarterly crime/safety snapshot per ward

NeighbourhoodWard

Through-table mapping neighbourhoods → wards with weights

URL Routes#

Path

View

Description

/housing/

index

Housing insights landing page

/housing/rental-market/

rental_market

Long-term + Airbnb/STR + housing impact

/housing/property-market/

property_market

Property prices, districts, types

/housing/hotel-market/

hotel_market

Hotel occupancy, ADR, segments

/housing/neighbourhoods/

neighbourhood_portal

Grid + Leaflet map of all neighbourhoods

/housing/neighbourhoods/<slug>/

neighbourhood_detail

Single neighbourhood detail page

Management Commands#

Command

Description

fetch_housing

Ingest housing.json from R2 / local fallback

ingest_ward_safety

Ingest crime_safety.json ward data into WardSafetySnapshot

neighbourhood_overview

Report on neighbourhood data quality and completeness

populate_neighbourhoods

Create / update neighbourhood records from data files

update_neighbourhood_metrics

Refresh rental, property, and Airbnb metrics

scaffold_neighbourhood_blogpost

Generate a safety-guide blog post for a neighbourhood from existing data

Data Flow#

flowchart LR R2[R2: housing.json] -->|fetch_housing| DB[(PostgreSQL)] R2B[R2: crime_safety.json] -->|ingest_ward_safety| DB DB --> Portal[neighbourhood_portal view] DB --> Detail[neighbourhood_detail view] DB --> Snap[PropertySnapshot trend charts]

How to Add a Neighbourhood#

The housing portal is fully data-driven — no code changes needed. Update these 5 data files and re-run populate_neighbourhoods. Optionally, scaffold a safety guide blog post (step 6).

1. housing/data/neighbourhoods.json#

Add an entry with the next unique order value (check current max). Fields:

Field

Notes

slug

URL-safe identifier, e.g. "walmer-estate"

name

Display name

district_slug

Must match an existing district slug

vibe

Short tagline (2–4 words)

description

1–2 sentence overview

best_for

Comma-separated audience tags

pros / cons

Pipe-delimited (|) bullet points

wards

Array of ward numbers (may be empty for out-of-metro areas)

latitude / longitude

Centre point as strings

blog_post_identifier

BlogPost lookup key (empty string if none)

blog_post_path

BlogPost URL path (empty string if none)

blog_safety_score

Manual safety override or null

safety_year

Reporting period, e.g. "2024/25"

order

Sort order within district

2. housing/data/neighbourhood_metrics.json#

Add a key under neighbourhoods with rent/property medians:

"new-slug": {
  "rent_1br": 9000,
  "rent_2br": 15500,
  "rent_sample_size": 8,
  "rent_std_dev": 3500,
  "price_1br": null,
  "price_sample_size": null,
  "price_std_dev": null,
  "listing_url": "https://www.property24.com/to-rent/..."
}

Use null for fields with no data.

3. housing/data/district_mappings.json#

Append the slug to the appropriate group in each of the three mapping arrays:

  • rental — e.g. "Woodstock / Observatory"

  • real_estate — e.g. "Woodstock"

  • airbnb — e.g. "Woodstock / Observatory"

4. static/geo/neighbourhoods.geojson#

Add a GeoJSON Feature with a Polygon geometry. Simplify to ~15 points. Source boundaries from OpenStreetMap.

{
  "type": "Feature",
  "properties": { "slug": "new-slug" },
  "geometry": {
    "type": "Polygon",
    "coordinates": [[ [lng, lat], ... ]]
  }
}

5. housing/fixtures/neighbourhood_translations.json#

Add an entry with translations for all 9 languages (de, es, fr, it, ja, nl, pt, ru, en). Translated fields: name, description, vibe, best_for, pros, cons, safety_reasoning.

The safety_reasoning translations are ward-level — reuse from a sibling neighbourhood in the same ward. Insert the entry after the sibling neighbourhood entry in the fixture.

6. (Optional) Scaffold a safety guide blog post#

USE_SQLITE=1 python manage.py scaffold_neighbourhood_blogpost --slug new-slug

This creates a BlogPost with Markdown content assembled from the neighbourhood’s safety data, ward research, and housing metrics. It links the post via FK and updates neighbourhoods.json. DB is the source of truth; the JSON sync is best-effort.

Options: --dry-run (preview only), --overwrite (replace existing), --access subscribed, --year 2026. Translation stubs are set as [TODO:XX] for manual translation later.

Verify#

USE_SQLITE=1 python manage.py populate_neighbourhoods
USE_SQLITE=1 python manage.py load_neighbourhood_translations
USE_SQLITE=1 python manage.py neighbourhood_overview
USE_SQLITE=1 python manage.py test housing products.tests.test_scaffold_blogpost --verbosity=2

Check /housing/neighbourhoods/ (map + table) and /housing/neighbourhoods/<slug>/ (detail page). If a blog post was created, check /products/blogpost/<pk>/ and the “Full Safety Guide” CTA on the neighbourhood detail page.

What NOT to change#

  • safety/ward_mapping.py WARD_NEIGHBORHOODS — cosmetic only, updated separately when ingest_ward_safety runs.

  • No model or migration changes needed.

Production Deployment#

The housing/data/*.json seed files are not git-tracked (gitignored). The PostgreSQL database is the production source of truth.

After adding a neighbourhood locally and verifying it works, deploy to production:

1. Trigger the housing workflow#

Run the Update Housing Data workflow (housing.yml) via GitHub Actions. This runs populate_neighbourhoods against the production DB, which creates the new neighbourhood record from the seed files bundled in the checkout.

Note: The seed files must be present in the CI checkout. If they are not, run populate_neighbourhoods manually against the production DB with the appropriate DATABASE_URL / Postgres env vars set.

2. Scaffold the blog post (if applicable)#

Blog posts are created against the production DB manually:

python manage.py scaffold_neighbourhood_blogpost --slug <slug>

This must run with production database credentials. The command creates the BlogPost, links it to the Neighbourhood, and updates neighbourhoods.json (best-effort).

3. Load neighbourhood translations#

The translation fixture is git-tracked (housing/fixtures/neighbourhood_translations.json). It loads automatically when the workflow runs, or manually:

python manage.py load_neighbourhood_translations

4. Apply blog post translations#

Blog post translations (title + content in 8 languages) are stored directly on the BlogPost model. These are not in any fixture — they must be applied via Django admin, a data migration, or a script run against the production DB.

R2 data#

Only neighbourhood_metrics.json syncs to R2 (data/housing/neighbourhood_metrics.json). Upload it via:

python manage.py update_neighbourhood_metrics

The other seed files (neighbourhoods.json, district_mappings.json) are local only.

Key Design Decisions#

  • Ward-to-neighbourhood mapping uses a weighted through-table (NeighbourhoodWard) so safety scores are aggregated proportionally when a neighbourhood spans multiple wards.

  • PropertySnapshot records are append-only — one per neighbourhood per data_date — enabling year-over-year trend visualisation.

  • Safety data flows from the safety app via WardSafetySnapshot; the Neighbourhood model stores a denormalised safety_score for fast reads.