Import and export

The current view out as CSV, Excel or JSON — and back in again, with every bad cell reported at once and nothing written unless the whole file parses.

Available since v0.2.0

Export

On by default. The list page offers CSV, Excel and JSON of the current view — filters, search and ordering applied — so the file matches the table it came from.

class ProductAdmin(admin.ModelAdmin):
    exportable = True                                   # the default
    export_fields = ("sku", "name", "category", "price", "stock")

export_fields defaults to list_display. Widen it to include columns that are on the record but too many to put in a table; narrow it to keep something out of a file.

exportable = False    # a model whose rows should not leave the admin in a file

The two limits

FastFortSettings(admin={"export_limit": 50_000, "export_chunk_size": 1_000})

An export runs the current query with no pagination. Without a cap, a mis-clicked export everything on a table of ten million rows is a request that never finishes and a database that stops answering anyone else.

Rows stream in chunks rather than assembling in memory. The JSON writer builds the document record by record for the same reason — json.dumps on the whole list would need all of it resident first.

Geometry in an export

A list cell summarises a geometry: Polygon · 14 points. An export writes the same text the form control shows — a point as lat, lng, everything else as EWKT.

The difference is deliberate. A summary is the right thing to read in a table and a thing no importer can turn back into a polygon, and the promise import makes is that a file this wrote is a file it can take back.

Import

Off by default, and the asymmetry is the point: reading rows out is a permission the gate already grants; writing several thousand of them in one request is a different thing to hand somebody by accident. A model whose rows have side effects — an order that ships, a message that sends — should say so deliberately rather than inherit it.

class ProductAdmin(admin.ModelAdmin):
    importable = True
    import_fields = ("sku", "name", "price", "cost", "stock", "availability")

This adds /admin/catalog.product/import and a download a template link beside it — a file with the right headers and one example row, so the first attempt is not a guess.

import_fields never widens

FieldSpec.editable is checked first. Naming a read-only field here does not make it writable; the entry is dropped.

Narrowing is what it is for: a price list that may update prices and stock but never the supplier. Without it, the default is every editable field minus the ones no file can carry — passwords, uploads, binary columns and anything the spec marks sensitive.

What it does with a file

  1. Parses the whole thing using the same parsers the form uses. A date is a date the same way; a geometry goes through the same geo.parse.
  2. Resolves foreign keys by name or id. category may be 12 or Cables; the lookup uses the target admin’s own search_fields.
  3. Reports every bad cell at once, with its line number:
Row 4, price: "12,50" is not a number. Use a full stop for the decimal point.
Row 4, availability: "in stock" is not one of: in_stock, low_stock,
       backorder, discontinued.
Row 9, category: no Category matches "Cabels".
Row 11, sku: a Product with this sku already exists.

4 problems. Nothing was written.
  1. Writes nothing unless the whole file parses. Half an import is worse than no import, because the operator now has to work out which half.

No pandas, no openpyxl on the read path

CSV is read with the standard library. Excel is read by unzipping the workbook and walking the sheet XML — openpyxl is only needed to write one, and only if you want the .xlsx export.

A framework that pulls in pandas for a feature most projects use twice a year is one that has stopped being a dependency you can justify.

The whole thing, on one model

@admin.register(Product, key="catalog.product")
class ProductAdmin(admin.ModelAdmin):
    list_display = ("sku", "name", "category", "price", "stock", "availability")
    list_filter = ("availability", "is_active", "category")
    search_fields = ("sku", "name")

    exportable = True
    export_fields = ("sku", "name", "category", "supplier", "price", "cost", "stock")

    importable = True
    import_fields = ("sku", "name", "price", "cost", "stock", "availability")

Filter to backorder, export, edit the stock column in a spreadsheet, upload it back. The file that came out is the file that goes in.