Import real-estate listings from CSV
Python · Django · intermediate · greenfield
Adds the Listing and ListingAudit models plus a management command to bulk-import real-estate listings from a CSV. A `Listing.save()` override handles slug generation and writes an audit row on create, and the importer reads the CSV, skips blanks, and writes everything in one efficient batch. Tested with a 200-line sample export on the staging DB — the success message reports 200 listings imported.
A real-estate platform with ~50k listings imported weekly from agency feeds. The management command replaces a slow per-row insert that was timing out on the full feed. The `listings` app (package and `apps.py` from `startapp`) is already in `INSTALLED_APPS`. Feed addresses are 1 to 500 characters after stripping. A crash between a listing insert and its audit row is out of scope for this PR: the weekly audit reconciliation backfills missing rows.
Requirements
- Add a `Listing` model with fields: `address` (CharField, max 500), `price_cents` (PositiveIntegerField), `bedrooms` (PositiveSmallIntegerField), `slug` (CharField, max 500, default empty string), `created_at` (DateTimeField, auto_now_add).
- `Listing.save()` must generate `slug` from `address` on creation: lowercase the address, replace runs of one or more non-alphanumeric characters with a single hyphen, strip leading and trailing hyphens. The slug is always server-generated from the address on creation — any caller-supplied value is ignored. The slug must not be empty for any saved Listing.
- Add a `ListingAudit` model with `listing` (ForeignKey to Listing, CASCADE, related_name `audit_rows`), `action` (CharField, max 50), and `created_at` (DateTimeField, auto_now_add). `Listing.save()` must create a `ListingAudit(action='created')` row for every new Listing (when `self.pk is None`).
- The database is PostgreSQL.
- Add a management command `import_listings` that takes a CSV file path argument. The CSV's first row is a header with columns `address,price_cents,bedrooms`; remaining rows are data. `price_cents` and `bedrooms` are integers with 0 ≤ `price_cents` ≤ 1_000_000_000 and 0 ≤ `bedrooms` ≤ 32767. No row contains embedded newlines or commas inside unquoted fields.
- Skip rows whose address is empty after stripping whitespace. Create one `Listing` per remaining row. After all rows are processed, print the count of listings created (using `self.stdout.write` with `self.style.SUCCESS`).
- The slug-generation and audit-row requirements above apply to EVERY listing, regardless of how it was created — there is no separate code path that exempts bulk imports.
- Address values in the CSV are ASCII strings containing at least one alphanumeric character (so the `[^a-z0-9]+` regex correctly captures all non-alphanumeric characters).
Files touched
- listings/models.py
- listings/management/commands/import_listings.py
- listings/migrations/0001_initial.py
--- listings/models.py
+import re
+
+from django.db import models
+
+
+class Listing(models.Model):
+ address = models.CharField(max_length=500)
+ price_cents = models.PositiveIntegerField()
+ bedrooms = models.PositiveSmallIntegerField()
+ slug = models.CharField(max_length=500, default="")
+ created_at = models.DateTimeField(auto_now_add=True)
+
+ class Meta:
+ ordering = ["-created_at"]
+
+ def save(self, *args, **kwargs):
+ if self._state.adding:
+ self.slug = re.sub(
+ r"[^a-z0-9]+",
+ "-",
+ self.address.strip().lower(),
+ ).strip("-") or "listing"
+ is_new = self.pk is None
+ super().save(*args, **kwargs)
+ if is_new:
+ ListingAudit.objects.create(
+ listing=self, action="created"
+ )
+