Skip to content

Commit fb9a73c

Browse files
committed
Make the Design BOM list read-only with edit links, and sort entries naturally by reference
Inline per-row editing required rendering a full Parts dropdown for every BOM row, which was the main cause of the Design detail page slowing down as designs and the Parts library grew; rows now link to a dedicated edit page instead. Reference designators (e.g. R2 vs R11) now sort numerically rather than as plain strings.
1 parent d42f120 commit fb9a73c

7 files changed

Lines changed: 94 additions & 41 deletions

File tree

CLAUDE.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ ERP/stock-and-ordering features. Provides the **Settings** hub with **Production
150150
- `Location` — a physical location in a hierarchy (e.g. building › room › shelf). Fields: `parent` (self-referential FK, nullable, `CASCADE` — deleting a parent deletes all descendants), `name`, `description`, `order` (`Meta.ordering = ['order', 'name']`). The `_build_location_tree(all_locations, parent_id, depth)` helper in `views.py` performs a depth-first traversal of a pre-fetched list and returns `[(location, depth), ...]` for template rendering with indentation.
151151
- `PartCategory` — a category in a hierarchy for classifying parts (e.g. Passives › Resistors › SMD). Fields: `parent` (self-referential FK, nullable, `CASCADE`), `name`, `description`, `order` (`Meta.ordering = ['order', 'name']`, `verbose_name_plural = 'part categories'`). The `_build_part_category_tree(all_categories, parent_id, depth)` helper in `views.py` performs a depth-first traversal, returning `[(category, depth), ...]` for indented rendering — same pattern as `_build_location_tree`.
152152
- `Part` — a component part. Fields: `name`, `description`, `category` (FK → `PartCategory`, nullable, `SET_NULL` — deleting a category leaves parts uncategorised), `device`, `package`, `value`, `fusion_library`, `stock` (`IntegerField`, nullable — a manually-tracked on-hand count, independent of the supplier-listing stock below), `image` (`ImageField`, stored under `part_images/`), `created_dt` (`Meta.ordering = ['name']`). `__str__` appends the value in parentheses if set. `total_stock` property sums `stock` across all `PartSource` listings (`None` if none have a known level) — shown as "Available" in the Parts list, distinct from the manually-tracked `stock` field above shown as "Stock". `has_stale_source_data` property is `True` if any of the part's `PartSource` listings has `has_stale_variant_data` (see below) — drives a warning icon next to "Available" in the Parts list and Part edit page, and next to "Available" in the Batch detail page's Parts Required section (see `Batch` below), when supplier pricing/stock hasn't been refreshed within `STALE_REFRESH_THRESHOLD` (48 hours) or has never been refreshed at all.
153-
- `DesignBomEntry` — a single placed component on a `Design`'s BOM (e.g. RefDes `R3` = a 10k resistor `Part`); one row per physical placement rather than a collapsed line item with a quantity, so placement data can support pick-and-place/AOI use cases later. Fields: `design` (FK → `device.Design`, `CASCADE`, `related_name='bom_entries'`), `part` (FK → `Part`, `PROTECT`, `related_name='design_bom_entries'`), `reference` (the reference designator, e.g. `"R3"`), `pos_x`/`pos_y` (`DecimalField`, 9/4, nullable), `rotation` (`DecimalField`, 6/2, nullable), `side` (`TOP`/`BOTTOM` via `SIDE_CHOICES`, blank). `Meta.ordering = ['reference']`; `unique_together` on `(design, reference)`. `pos_x`/`pos_y`/`rotation`/`side` are never user-editable (`DesignBomEntryForm` only exposes `reference`/`part`) — they're only ever populated from the design's PCB Design File asset by `_apply_brd_placements()` (see `design_bom_populate` below). Shown on the Design detail page's Bill of Materials table (Reference/Part/Device/Package/Value/Position/Rotation/Side columns; Position shown as "x, y" in mm, blank/`—` fields shown as `—`).
153+
- `DesignBomEntry` — a single placed component on a `Design`'s BOM (e.g. RefDes `R3` = a 10k resistor `Part`); one row per physical placement rather than a collapsed line item with a quantity, so placement data can support pick-and-place/AOI use cases later. Fields: `design` (FK → `device.Design`, `CASCADE`, `related_name='bom_entries'`), `part` (FK → `Part`, `PROTECT`, `related_name='design_bom_entries'`), `reference` (the reference designator, e.g. `"R3"`), `pos_x`/`pos_y` (`DecimalField`, 9/4, nullable), `rotation` (`DecimalField`, 6/2, nullable), `side` (`TOP`/`BOTTOM` via `SIDE_CHOICES`, blank). `Meta.ordering = ['reference']`; `unique_together` on `(design, reference)`. `pos_x`/`pos_y`/`rotation`/`side` are never user-editable (`DesignBomEntryForm` only exposes `reference`/`part`) — they're only ever populated from the design's PCB Design File asset by `_apply_brd_placements()` (see `design_bom_populate` below). `reference_sort_key` property splits `reference` into a `(letter prefix, numeric value, remainder)` tuple for natural sorting (so `"R2"` sorts before `"R11"`) — `Meta.ordering`/the DB-level `ORDER BY` is plain string ordering and is left as-is for other consumers (CSV export, admin), so this key is applied explicitly wherever the BOM list needs natural order (currently only `device.views.design_detail`, which `sorted()`s the queryset in Python rather than trying to express natural sort portably at the DB level). Shown on the Design detail page's Bill of Materials table (Reference/Part/Device/Package/Value/Position/Rotation/Side columns; Position shown as "x, y" in mm, blank/`—` fields shown as `—`) as a read-only list — see `design_bom_entry_edit` below for how rows are edited.
154154
- `PartSource` — a supplier's **listing** for a `Part`: one manufacturer SKU as stocked by one supplier. Fields: `part` (FK, `CASCADE`, `related_name='sources'`), `supplier_name`, `manufacturer_sku`, `stock` (`PositiveIntegerField`, nullable — `None` means unknown). `Meta.ordering = ['supplier_name']`. Stock is held here rather than on `PartSourceVariant` because suppliers such as DigiKey sell the same physical inventory pool under several packaging-specific SKUs (cut tape, tape & reel, Digi-Reel, etc.) that all report the same stock level but different pricing/MOQ — those become separate `PartSourceVariant` rows under one `PartSource`. `has_stale_variant_data` property is `True` if any of this listing's `PartSourceVariant`s has `last_refreshed = None` (never fetched) or older than `STALE_REFRESH_THRESHOLD` (module-level constant, 48 hours) — used both by `Part.has_stale_source_data` above and directly next to each listing's stock figure in the Part edit page's Sources table.
155155
- `PartSourceVariant` — a specific orderable SKU/packaging option under a `PartSource` listing. Fields: `source` (FK → `PartSource`, `CASCADE`, `related_name='variants'`), `supplier_sku`, `packaging` (e.g. "Tape & Reel (TR)", "Cut Tape (CT)"), `url` (`URLField`), `moq` (`PositiveIntegerField`, nullable — minimum order quantity; `None` means unknown), `last_refreshed` (`DateTimeField`, nullable — when pricing/stock was last fetched from a supplier API via `_refresh_variant()`; `None` means it was only ever added manually via `part_source_add` and has never been fetched). `Meta.ordering = ['supplier_sku']`.
156156
- `PartPriceBreak` — a quantity-based price break for a `PartSourceVariant` (e.g. qty 1 @ $0.50, qty 10 @ $0.45). Fields: `variant` (FK → `PartSourceVariant`, `CASCADE`, `related_name='price_breaks'`), `quantity`, `price` (`DecimalField`, 12/6), `currency` (ISO code, default `'USD'` — most supplier APIs don't report a currency code at all, so it's stored as an assumption rather than something read from the response; LCSC's API returns a `"$"` symbol rather than an ISO code, so that's normalised to `'USD'` too rather than stored as-is). `Meta.ordering = ['quantity']`, `unique_together = [('variant', 'quantity')]`. `CURRENCY_SYMBOLS` class dict + `symbol` property map the stored ISO code to a display prefix (e.g. `'USD'``'$'`); the Part edit page renders breaks as `{{ symbol }}{{ price }} {{ currency }}` (e.g. `$0.5904 USD`).
@@ -203,7 +203,9 @@ ERP/stock-and-ordering features. Provides the **Settings** hub with **Production
203203
- `part_import_bom` (`/parts/import-bom/`) — POST-only; reads a CSV file (`utf-8-sig` encoding) with columns `reference`, `device`, `package`, `value`, `library`. Each row is resolved via the shared `_resolve_bom_csv_row()` helper (see below) and reports added/skipped/excluded counts via messages.
204204
- `_resolve_bom_csv_row(row, exclusion_rules, equivalence_rules, library_settings_by_name)` — shared per-row BOM CSV resolution, used by both `part_import_bom` above and `design_bom_populate` below. In order: skips the row if it matches a `BomExclusionRule` (`_bom_row_is_excluded()`); applies the first matching `BomEquivalenceRule` to remap `library`/`device`/`package`/`value` (`_bom_apply_equivalence()`); applies the row's `BomLibrarySetting` (if any, looked up by the post-transformation library) to blank out `device`/`package`/`value` per its `ignore_*` flags; then checks for an existing `Part` matching `device`, `package`, and `value` (case-insensitive `__iexact`; `fusion_library` is intentionally excluded so the same physical part from different Fusion libraries is not duplicated), otherwise creates a new part with `name = " ".join([value, package, device.capitalize()])` and `fusion_library` set to the (possibly remapped) library. Returns `(reference, part, created)`, or `None` if excluded. `_bom_field_matches(rule_value, row_value)` is the shared "blank rule field matches anything" comparison helper used by both rule types.
205205
- `design_bom_populate` (`/design-bom/<design_id>/populate/`, POST-only) — seeds a `Design`'s `DesignBomEntry` rows from its uploaded BOM CSV `DesignAsset`, via `_resolve_bom_csv_row()`. Rows whose `reference` is already present on the design are skipped, so re-running after manual edits never duplicates or overwrites them. Also calls `_apply_brd_placements()` (see below) to backfill `pos_x`/`pos_y`/`rotation`/`side` on every entry — new and pre-existing — from the design's PCB Design File asset. The success message reports added/skipped/excluded counts plus, when applicable, how many new `Part`s were created in the Parts library and how many entries' positions were updated from the PCB design file. Exposed as a "Populate from BOM" button on the Design detail page's Bill of Materials section (staff only).
206-
- `design_bom_entry_add` / `_edit` / `_delete` (`/design-bom/<design_id>/add/`, `/design-bom/entry/<entry_id>/edit/`, `/design-bom/entry/<entry_id>/delete/`) — manual CRUD for individual `DesignBomEntry` rows on the Design detail page, via `DesignBomEntryForm` (`reference`/`part` only).
206+
- `design_bom_entry_add` (`/design-bom/<design_id>/add/`, POST-only) — adds a new `DesignBomEntry` via the inline add row at the bottom of the Design detail page's Bill of Materials table (staff only), using `DesignBomEntryForm` (`reference`/`part` only).
207+
- `design_bom_entry_edit` (`/design-bom/entry/<entry_id>/edit/`) — GET renders a dedicated edit page ([erp/design_bom_entry_edit.html](pyproj/erp/templates/erp/design_bom_entry_edit.html), staff only) with `DesignBomEntryForm` pre-filled for the entry; POST validates and saves, then redirects back to the Design detail page at its `#bom` anchor. The Bill of Materials table itself is read-only — each row is the whole `<tr>` (`clickable-row` class, same pattern as the Design Files/Attachments tables) linking to this edit page rather than holding inline form fields, since editing every row's `part` field inline meant rendering a full `Part` dropdown (`Part.objects.all()`, default queryset) once per row — an N+1 query and an O(rows × parts) HTML cost that was the main cause of the Design detail page being slow once the BOM list and Parts library both grew.
208+
- `design_bom_entry_delete` (`/design-bom/entry/<entry_id>/delete/`, POST-only) — deletes a `DesignBomEntry`. Its row's delete button (`cil-trash`) sits in a cell with `onclick="event.stopPropagation()"` so clicking it doesn't also trigger the row's navigation to the edit page.
207209
- `_parse_brd_placements(brd_path)` — parses a Fusion-exported EAGLE `.brd` XML file's `drawing/board/elements/element` entries into `{reference designator: {pos_x, pos_y, rotation, side}}`. Joins on the reference designator (matches the BOM CSV's `reference` column exactly, since both are generated from the same board) rather than library/device/package/value, which don't line up reliably between the two exports. An element's `rot` attribute (e.g. `R90`, `MR180`) is parsed into a plain degree value plus a `TOP`/`BOTTOM` side — an `M` prefix means mirrored, i.e. a bottom-side placement.
208210
- `_apply_brd_placements(design)` — looks up the design's `PCB_DESIGN` (PCB Design File) `DesignAsset`, parses it via `_parse_brd_placements()`, and `bulk_update()`s `pos_x`/`pos_y`/`rotation`/`side` on every matching `DesignBomEntry`. Returns `None` if the design has no PCB Design File asset, otherwise the number of entries updated. Called by `design_bom_populate` above.
209211
- `part_import_filter_list` (`/settings/part-import-filters/`) — the merged "Part Import Filters" settings page: renders `BomExclusionRule`, `BomEquivalenceRule`, and `BomLibrarySetting` as three sections on one page, in the order they're applied in `part_import_bom`. `_part_import_filter_context()` builds the shared context (querysets + a form per section) reused by this view and by each rule type's `_add` view below.

__VERSION.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,4 @@ def _branch_suffix():
1313
pass
1414
return ''
1515

16-
VERSION = '2026.06.29.1' + _branch_suffix()
16+
VERSION = '2026.06.30.1' + _branch_suffix()

pyproj/device/templates/device/design_detail.html

Lines changed: 20 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@ <h5 class="mt-4">Design Files</h5>
186186
{% endif %}
187187

188188
{% if user.is_staff or bom_entries %}
189-
<h5 class="mt-4">Bill of Materials</h5>
189+
<h5 class="mt-4" id="bom">Bill of Materials</h5>
190190
<div class="card border-0 mb-3">
191191
<div class="card-body py-10">
192192
{% if user.is_staff %}
@@ -200,57 +200,42 @@ <h5 class="mt-4">Bill of Materials</h5>
200200
</form>
201201
</div>
202202
{% endif %}
203-
<table class="table table-striped">
203+
<table class="table table-striped" id="bom-table">
204204
<thead>
205205
<tr>
206206
<th>Reference</th>
207207
<th>Part</th>
208208
<th>Device</th>
209209
<th>Package</th>
210210
<th>Value</th>
211-
<th>Position</th>
212-
<th>Rotation</th>
211+
<th>X</th>
212+
<th>Y</th>
213+
<th>Rot</th>
213214
<th>Side</th>
214215
{% if user.is_staff %}<th></th>{% endif %}
215216
</tr>
216217
</thead>
217218
<tbody>
218-
{% for entry, entry_form in bom_entries_with_forms %}
219-
<tr>
219+
{% for entry in bom_entries %}
220+
<tr{% if user.is_staff %} class="clickable-row" onclick="window.location='{% url 'erp:design_bom_entry_edit' entry.pk %}'" style="cursor:pointer"{% endif %}>
221+
<td>{{ entry.reference }}</td>
222+
<td>{{ entry.part.name }}</td>
223+
<td>{{ entry.part.device }}</td>
224+
<td>{{ entry.part.package }}</td>
225+
<td>{{ entry.part.value }}</td>
226+
<td>{% if entry.pos_x != None %}{{ entry.pos_x|stringformat:"g" }}{% else %}&mdash;{% endif %}</td>
227+
<td>{% if entry.pos_x != None %}{{ entry.pos_y|stringformat:"g" }}{% else %}&mdash;{% endif %}</td>
228+
<td>{% if entry.rotation != None %}{{ entry.rotation|stringformat:"g" }}{% else %}&mdash;{% endif %}</td>
229+
<td>{{ entry.get_side_display|default:"—" }}</td>
220230
{% if user.is_staff %}
221-
<form method="post" action="{% url 'erp:design_bom_entry_edit' entry.pk %}" class="d-contents">
222-
{% csrf_token %}
223-
<td>{{ entry_form.reference }}</td>
224-
<td>{{ entry_form.part }}</td>
225-
<td>{{ entry.part.device }}</td>
226-
<td>{{ entry.part.package }}</td>
227-
<td>{{ entry.part.value }}</td>
228-
<td>{% if entry.pos_x != None %}{{ entry.pos_x|stringformat:"g" }}, {{ entry.pos_y|stringformat:"g" }}{% else %}&mdash;{% endif %}</td>
229-
<td>{% if entry.rotation != None %}{{ entry.rotation|stringformat:"g" }}{% else %}&mdash;{% endif %}</td>
230-
<td>{{ entry.get_side_display|default:"—" }}</td>
231-
<td class="text-nowrap">
232-
<button type="submit" class="btn btn-sm btn-primary me-1" title="Save">
233-
<i class="cil-save"></i>
234-
</button>
235-
</td>
236-
</form>
237-
<td>
231+
<td class="text-nowrap" style="width:1px;background-color:white !important;box-shadow:none !important;border-bottom:none !important" onclick="event.stopPropagation()">
238232
<form method="post" action="{% url 'erp:design_bom_entry_delete' entry.pk %}">
239233
{% csrf_token %}
240234
<button type="submit" class="btn btn-sm btn-danger" title="Delete">
241235
<i class="cil-trash"></i>
242236
</button>
243237
</form>
244238
</td>
245-
{% else %}
246-
<td>{{ entry.reference }}</td>
247-
<td>{{ entry.part.name }}</td>
248-
<td>{{ entry.part.device }}</td>
249-
<td>{{ entry.part.package }}</td>
250-
<td>{{ entry.part.value }}</td>
251-
<td>{% if entry.pos_x != None %}{{ entry.pos_x|stringformat:"g" }}, {{ entry.pos_y|stringformat:"g" }}{% else %}&mdash;{% endif %}</td>
252-
<td>{% if entry.rotation != None %}{{ entry.rotation|stringformat:"g" }}&deg;{% else %}&mdash;{% endif %}</td>
253-
<td>{{ entry.get_side_display|default:"—" }}</td>
254239
{% endif %}
255240
</tr>
256241
{% endfor %}
@@ -269,6 +254,9 @@ <h5 class="mt-4">Bill of Materials</h5>
269254
{% endif %}
270255
</tbody>
271256
</table>
257+
<style>
258+
#bom-table tr.clickable-row:hover td { background-color: rgba(0,0,0,0.075) !important; }
259+
</style>
272260
</div>
273261
</div>
274262
{% endif %}

0 commit comments

Comments
 (0)