Import the supplier's product feed with a failed-rows report

Python · Django · beginner · greenfield

Adds the nightly product feed import. Rows are validated one at a time, so a typo in one product no longer blocks the whole feed: good rows are created or updated by SKU, bad rows are listed with their line numbers, and a non-zero exit makes the cron wrapper mail ops the list. The file is read and decoded before anything is written, so a file that is not valid UTF-8 fails before any product is written. Prices go through a strict pattern before they reach `Decimal`, which keeps `1e3`, `NaN` and `-5` out. Ran it locally against a copy of the supplier's latest export and against a file with every kind of broken row I could think of.

Django 5.1 on Python 3.12, PostgreSQL 16. `catalog.Product` (existing) has `sku = CharField(max_length=32, unique=True)`, `name = CharField(max_length=200)`, `price = DecimalField(max_digits=10, decimal_places=2)` and `stock = PositiveIntegerField()`; it has no other required fields, no custom `save()` and no signals. `catalog/management/commands/` already holds other commands. Cron runs the import once a night under `flock` (never two at a time) as `python manage.py import_supplier_products /srv/feeds/supplier-products.csv`, and mails ops the output when the exit status is non-zero. The supplier's exports are at most 5 MB (about 20,000 rows), never contain line breaks or other control characters inside a field, always balance their `"` quotes, and a run taking a few minutes is fine. Staff may edit products in the admin while the import runs.

Requirements

Files touched

--- catalog/management/commands/import_supplier_products.py
+import csv
+import io
+import re
+from decimal import Decimal
+
+from django.core.management.base import BaseCommand, CommandError
+
+from catalog.models import Product
+
+COLUMNS = ("sku", "name", "price", "stock")
+PRICE_RE = re.compile(r"[0-9]{1,8}(\.[0-9]{1,2})?")
+STOCK_RE = re.compile(r"[0-9]{1,7}")
+MAX_STOCK = 1_000_000
+
+
+class RowError(Exception):
+    pass
+
+
+def clean_row(row):
+    """Return (sku, values) for a valid row, or raise RowError naming the first problem."""
+    sku, name, price, stock = ((row[column] or "").strip() for column in COLUMNS)
+    if not sku:
+        raise RowError("sku is required")
+    if len(sku) > 32:
+        raise RowError("sku is longer than 32 characters")
+    if not name:
+        raise RowError("name is required")
+    if len(name) > 200:

Review this PR

Python practice