feat(sets): per-bag inventory with progress tracking and bag-aware audit

This commit is contained in:
2026-07-10 00:41:52 +02:00
parent 675de6b1bf
commit 75bf7b36ff
16 changed files with 501 additions and 7 deletions
+80
View File
@@ -0,0 +1,80 @@
from typing import Any
from .exceptions import ErrorException
from .sql import BrickSQL
# Per-bag part state (checked/missing) for sets with a sidecar bag inventory.
# The bag composition itself is never stored; only the user's progress is
# (keyed by set record id, bag number and the part identity tuple).
# Plain functions on purpose: a BrickRecord model is overkill for a
# two-column state table.
# Update one state field ('checked' or 'missing') from a changer.js payload.
# Returns the stored value.
def update_state(
set_id: str,
bag: str,
part: str,
color: int,
spare: int,
state: str,
json: Any | None,
/,
) -> Any:
value = json.get('value', '') if json else ''
if state == 'checked':
value = bool(value)
elif state == 'missing':
# Same rules as BrickPart.update_problem: positive integer, '' -> 0
try:
if value == '':
value = 0
value = int(value)
if value < 0:
value = 0
except Exception:
raise ErrorException('"{value}" is not a valid integer'.format(
value=value
))
else:
raise ErrorException('"{state}" is not a valid bag part state'.format(
state=state
))
BrickSQL().execute_and_commit(
'bag_part/update/{state}'.format(state=state),
parameters={
'id': set_id,
'bag': bag,
'part': part,
'color': color,
'spare': spare,
state: value,
},
)
return value
# All stored state for one set:
# {(bag, part, color, spare): {'checked': bool, 'missing': int}}
def list_state(
set_id: str,
/,
) -> dict[tuple[str, str, int, int], dict[str, Any]]:
rows = BrickSQL().fetchall(
'bag_part/list',
parameters={'id': set_id},
)
return {
(row['bag'], row['part'], row['color'], row['spare']): {
'checked': bool(row['checked']),
'missing': row['missing'],
}
for row in rows
}
+6
View File
@@ -363,6 +363,12 @@ class BrickSidecar(object):
def get_images(ref: str, /) -> dict[str, Any] | None:
return BrickSidecar._get_json('/sets/{ref}/images'.format(ref=ref))
# GET /sets/{ref}/bags -> {'set': ..., 'bag_count': n, 'bags': [...]},
# or None (the sidecar 404s when the set has no bag inventory).
@staticmethod
def get_bags(ref: str, /) -> dict[str, Any] | None:
return BrickSidecar._get_json('/sets/{ref}/bags'.format(ref=ref))
# Build a BrickTracker-served proxy URL for an <img src>. The browser hits
# BrickTracker (same origin), which fetches the image from the sidecar
# server-side. This works even when the sidecar is only reachable on the
+146 -1
View File
@@ -4,9 +4,10 @@ from datetime import datetime, timezone
from html.parser import HTMLParser
from typing import Any
from flask import current_app
from flask import current_app, url_for
from markupsafe import Markup, escape
from .bag_part import list_state as list_bag_part_state
from .sidecar import BrickSidecar
logger = logging.getLogger(__name__)
@@ -65,6 +66,8 @@ def summarize(
# Brickset "Notes" blurb (web-scraped; contains light HTML). Sanitised
# like the description so it renders safely.
'notes': _description(data.get('notes')),
# Whether the sidecar holds a per-bag part inventory for this set.
'has_bags': bool(data.get('has_bags')),
}
# Retired status from the exit date. exit_date is the full formatted date
@@ -131,6 +134,148 @@ def summarize(
return summary
# Join the sidecar's per-bag inventory onto the set's part rows. Returns
# (bags, breakdown):
# - bags: template-ready [{'number', 'is_extra', 'parts': [...]}] in sidecar
# order, each part carrying its per-bag quantity, display data, the stored
# per-bag checked/missing state with its changer prefixes/urls, and the DOM
# id of the main row's missing input (the sum-group key for the client-side
# missing sync).
# - breakdown: part row html_id() -> [[bag number, quantity], ...] pairs, for
# the bag column in the normal audit modal.
# Degrades to (None, {}) whenever the sidecar is off or has no bag data; the
# bag composition is joined at render time and never persisted (only the
# per-bag progress lives in bricktracker_bag_parts).
def bag_inventory(
brickset: Any,
parts: Any,
/,
) -> tuple[list[dict[str, Any]] | None, dict[str, list[list[Any]]]]:
if not BrickSidecar.enabled():
return None, {}
payload = BrickSidecar.get_bags(brickset.fields.set)
if not payload or not payload.get('bags'):
return None, {}
# One BrickTracker row per (part, color, spare) for the whole set; bag
# entries reference it many-to-one via (part_ex_id, color_id).
index: dict[tuple[str, int, int], Any] = {
(str(row.fields.part), int(row.fields.color), int(row.fields.spare)): row # noqa: E501
for row in parts
}
# Stored per-bag progress, keyed (bag, part, color, spare)
state = list_bag_part_state(brickset.fields.id)
# The changer prefixes keep the "-missing-"/"-checked-" fragments so the
# bulk operations and audit selectors match, and a per-bag "bag{i}"
# namespace so ids stay unique across bags.
def changer(index: int, kind: str, part: str, color: int, spare: int, bag: str) -> dict[str, Any]: # noqa: E501
return {
'prefix_{kind}'.format(kind=kind): 'bag{index}-part-{kind}-{part}-{color}-{spare}'.format( # noqa: E501
index=index, kind=kind, part=part, color=color, spare=spare,
),
'url_{kind}'.format(kind=kind): url_for(
'set.bag_part_state',
id=brickset.fields.id,
bag=bag,
part=part,
color=color,
spare=spare,
state=kind,
),
}
bags: list[dict[str, Any]] = []
breakdown: dict[str, list[list[Any]]] = {}
for i, bag in enumerate(payload['bags'], start=1):
number = str(bag.get('displayNumbers') or bag.get('bagNumber') or '?')
# "Extra" bags sometimes hold the spares, so try spare rows first
# there (with a fallback either way, see spare_order below)
is_extra = number.lower() == 'extra'
spare_order = (1, 0) if is_extra else (0, 1)
bag_parts: list[dict[str, Any]] = []
for part in bag.get('parts') or []:
part_ref = str(part.get('part_ex_id') or part.get('bl_part_id') or '') # noqa: E501
try:
color = int(part.get('color_id'))
except (TypeError, ValueError):
color = -1
quantity = part.get('quantity') or 0
row = None
for spare in spare_order:
row = index.get((part_ref, color, spare))
if row is not None:
break
if row is not None:
part_state = state.get(
(number, row.fields.part, row.fields.color, row.fields.spare), # noqa: E501
{},
)
entry: dict[str, Any] = {
'quantity': quantity,
'name': row.fields.name,
'color': row.fields.color,
'color_name': row.fields.color_name,
'color_rgb': row.fields.color_rgb,
'spare': bool(row.fields.spare),
'image_url': row.url_for_image(),
'url': row.url(),
# Final DOM id of the main row's missing input
# (macro/form.html renders "{prefix}-{id}").
'missing_input_id': '{prefix}-{id}'.format(
prefix=row.html_id('missing'),
id=row.fields.id,
),
'checked': part_state.get('checked', False),
# None renders an empty input, like the main table
'missing': part_state.get('missing') or None,
}
entry.update(changer(i, 'missing', row.fields.part, row.fields.color, row.fields.spare, number)) # noqa: E501
entry.update(changer(i, 'checked', row.fields.part, row.fields.color, row.fields.spare, number)) # noqa: E501
bag_parts.append(entry)
breakdown.setdefault(row.html_id(), []).append(
[number, quantity],
)
else:
# No matching row (e.g. a part the import assigned to a
# minifigure): display-only data from the sidecar, but the
# per-bag progress is still stored (keyed by the sidecar
# part ref, spare=0). A negative color means the sidecar
# gave no usable identity, so the row gets no inputs.
part_state = state.get((number, part_ref, color, 0), {})
entry = {
'quantity': quantity,
'name': part_ref or str(part.get('partId') or '?'),
'color': None,
'color_name': part.get('colorName'),
'color_rgb': str(part.get('colorHex') or '').lstrip('#') or None, # noqa: E501
'spare': False,
'image_url': None,
'url': None,
'missing_input_id': None,
'checked': part_state.get('checked', False),
'missing': part_state.get('missing') or None,
}
if part_ref and color >= 0:
entry.update(changer(i, 'missing', part_ref, color, 0, number)) # noqa: E501
entry.update(changer(i, 'checked', part_ref, color, 0, number)) # noqa: E501
bag_parts.append(entry)
bags.append({
'number': number,
'is_extra': is_extra,
'parts': bag_parts,
})
return bags, breakdown
# --- Helpers ------------------------------------------------------------
def _clean_str(value: Any) -> str | None:
+3
View File
@@ -0,0 +1,3 @@
SELECT "bag", "part", "color", "spare", "checked", "missing"
FROM "bricktracker_bag_parts"
WHERE "bricktracker_bag_parts"."id" IS NOT DISTINCT FROM :id
@@ -0,0 +1,4 @@
INSERT INTO "bricktracker_bag_parts" ("id", "bag", "part", "color", "spare", "checked")
VALUES (:id, :bag, :part, :color, :spare, :checked)
ON CONFLICT("id", "bag", "part", "color", "spare")
DO UPDATE SET "checked" = excluded."checked"
@@ -0,0 +1,4 @@
INSERT INTO "bricktracker_bag_parts" ("id", "bag", "part", "color", "spare", "missing")
VALUES (:id, :bag, :part, :color, :spare, :missing)
ON CONFLICT("id", "bag", "part", "color", "spare")
DO UPDATE SET "missing" = excluded."missing"
+21
View File
@@ -0,0 +1,21 @@
-- description: Add per-bag part state (checked/missing) for sidecar bag inventories
BEGIN TRANSACTION;
-- Per-bag progress for sets with a sidecar bag inventory. The bag composition
-- itself is never persisted (joined from the sidecar at render time); this
-- only stores what the user ticked/typed per bag. The main parts row's
-- missing count is kept as the sum of these per-bag values client-side.
CREATE TABLE "bricktracker_bag_parts" (
"id" TEXT NOT NULL,
"bag" TEXT NOT NULL,
"part" TEXT NOT NULL,
"color" INTEGER NOT NULL,
"spare" INTEGER NOT NULL DEFAULT 0,
"checked" INTEGER NOT NULL DEFAULT 0,
"missing" INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY("id", "bag", "part", "color", "spare"),
FOREIGN KEY("id") REFERENCES "bricktracker_sets"("id")
);
COMMIT;
+3
View File
@@ -10,6 +10,9 @@ WHERE "bricktracker_parts"."id" IS NOT DISTINCT FROM '{{ id }}';
DELETE FROM "bricktracker_minifigures"
WHERE "bricktracker_minifigures"."id" IS NOT DISTINCT FROM '{{ id }}';
DELETE FROM "bricktracker_bag_parts"
WHERE "bricktracker_bag_parts"."id" IS NOT DISTINCT FROM '{{ id }}';
DELETE FROM "bricktracker_set_tags"
WHERE "bricktracker_set_tags"."id" IS NOT DISTINCT FROM '{{ id }}';
+1 -1
View File
@@ -1,4 +1,4 @@
from typing import Final
__version__: Final[str] = '1.5.0'
__database_version__: Final[int] = 29
__database_version__: Final[int] = 30
+56
View File
@@ -14,6 +14,7 @@ from flask_login import login_required
from werkzeug.wrappers.response import Response
from .exceptions import exception_handler
from ..bag_part import update_state as update_bag_part_state
from ..exceptions import ErrorException
from ..minifigure import BrickMinifigure
from ..pagination_helper import get_pagination_config, build_pagination_context, get_request_params
@@ -22,6 +23,7 @@ from ..rebrickable_image import RebrickableImage
from ..rebrickable_set import RebrickableSet
from ..set import BrickSet
from ..sidecar import BrickSidecar
from ..sidecar_set import bag_inventory as sidecar_bag_inventory
from ..sidecar_set import summarize as sidecar_summarize
from ..set_custom_field_list import BrickSetCustomFieldList
from ..set_list import BrickSetList, set_metadata_lists
@@ -390,6 +392,15 @@ def details(*, id: str) -> str:
fetch_price=current_app.config.get('SIDECAR_AUTO_FETCH_PRICE', False),
)
# Per-bag inventory (BrickData): joined onto the part rows at render
# time, never persisted. Any failure degrades to "no bag UI".
sidecar_bags, bag_breakdown = None, {}
if sidecar_summary and sidecar_summary.get('has_bags'):
sidecar_bags, bag_breakdown = sidecar_bag_inventory(
item,
item.parts(),
)
# Check if there are multiple instances of this set
all_instances = BrickSetList()
# Load all sets with metadata context for tags, owners, etc.
@@ -415,6 +426,8 @@ def details(*, id: str) -> str:
open_instructions=request.args.get('open_instructions'),
brickset_statuses=BrickSetStatusList.list(all=True),
sidecar_summary=sidecar_summary,
sidecar_bags=sidecar_bags,
bag_breakdown=bag_breakdown,
**set_metadata_lists(as_class=True)
)
else:
@@ -425,6 +438,8 @@ def details(*, id: str) -> str:
open_instructions=request.args.get('open_instructions'),
brickset_statuses=BrickSetStatusList.list(all=True),
sidecar_summary=sidecar_summary,
sidecar_bags=sidecar_bags,
bag_breakdown=bag_breakdown,
**set_metadata_lists(as_class=True)
)
@@ -519,6 +534,47 @@ def checked_part(
return jsonify({'checked': checked})
# Update per-bag part state (sidecar bag inventory walkthrough)
@set_page.route('/<id>/bags/<bag>/parts/<part>/<int:color>/<int:spare>/<state>', methods=['POST']) # noqa: E501
@login_required
@exception_handler(__file__, json=True)
def bag_part_state(
*,
id: str,
bag: str,
part: str,
color: int,
spare: int,
state: str,
) -> Response:
# Validates the set exists (raises otherwise)
brickset = BrickSet().select_specific(id)
value = update_bag_part_state(
brickset.fields.id,
bag,
part,
color,
spare,
state,
request.json,
)
# Info
logger.info('Set {set} ({id}): updated bag {bag} part ({part} color: {color}, spare: {spare}) {state} to {value}'.format( # noqa: E501
set=brickset.fields.set,
id=brickset.fields.id,
bag=bag,
part=part,
color=color,
spare=spare,
state=state,
value=value,
))
return jsonify({state: value})
# Refresh a set
@set_page.route('/refresh/<set>/', methods=['GET'])
@set_page.route('/<id>/refresh', methods=['GET'])
+51
View File
@@ -0,0 +1,51 @@
// Keeps the main Parts table's missing count equal to the sum of the per-bag
// missing inputs for that part (a part can span several bags). Bag rows carry
// data-target-missing = the main row's input id; writing the main input and
// dispatching "change" lets the existing BrickChanger persist it.
//
// The sum always wins: a manual edit of the main field is overwritten by the
// next bag edit for that part. Parts without bag inputs stay fully manual.
(() => {
const sync = (target) => {
const main = document.getElementById(target);
if (!main || main.disabled) {
return;
}
let sum = 0;
document.querySelectorAll(`table[data-bag-table] tr[data-target-missing="${CSS.escape(target)}"] input[id*="-missing-"]`).forEach(input => {
sum += parseInt(input.value, 10) || 0;
});
// Empty string clears the field (0 missing), matching the "clear all
// missing" convention in parts-bulk-operations.js.
const value = sum > 0 ? String(sum) : '';
if (main.value !== value) {
main.value = value;
main.dispatchEvent(new Event('change', { bubbles: true }));
}
};
const handle = (e) => {
// Trusted "input" events fire per keystroke; the sum only needs to
// follow once the value is committed ("change"). The untrusted case
// is the BrickChanger clear button, which POSTs directly and only
// dispatches a programmatic "input" event.
if (e.type === 'input' && e.isTrusted) {
return;
}
const input = e.target;
if (!input.matches || !input.matches('input[id*="-missing-"]')) {
return;
}
const row = input.closest('tr[data-target-missing]');
if (row && input.closest('table[data-bag-table]')) {
sync(row.dataset.targetMissing);
}
};
document.addEventListener('DOMContentLoaded', () => {
document.addEventListener('change', handle);
document.addEventListener('input', handle);
});
})();
+57 -3
View File
@@ -17,6 +17,12 @@ class PartsAuditMode {
this.index = 0;
this.mode = 'missing'; // 'missing' or 'found'
// Bag tables (set/bags.html) carry their own per-bag inputs, so the
// audit works exactly like on the parts table; the flag only tweaks
// the modal wording (spares note, unmatched-row message).
const accordion = document.getElementById(accordionId);
this.bagMode = !!(accordion && accordion.querySelector('table[data-bag-table]'));
this.setupModal();
this.cacheElements();
this.setupEventListeners();
@@ -50,6 +56,14 @@ class PartsAuditMode {
<div class="mb-1">Quantity <span id="audit-qty">0</span></div>
<div class="mb-3"><span id="audit-name" class="fs-6"></span> <span id="audit-color" class="text-secondary ms-1"></span></div>
<div id="audit-bags" class="mb-1 d-none">
<table class="table table-sm table-bordered w-auto mx-auto mb-1">
<thead><tr><th>Bag</th><th>Qty</th></tr></thead>
<tbody id="audit-bags-body"></tbody>
</table>
</div>
<div id="audit-spares-note" class="text-secondary small mb-2 d-none">Spare parts are not included in bag quantities.</div>
<div id="audit-input-area">
<div class="btn-group mb-2" role="group" aria-label="Audit mode">
<button type="button" class="btn btn-sm" id="audit-mode-missing">Missing</button>
@@ -94,6 +108,9 @@ class PartsAuditMode {
name: document.getElementById('audit-name'),
color: document.getElementById('audit-color'),
qty: document.getElementById('audit-qty'),
bags: document.getElementById('audit-bags'),
bagsBody: document.getElementById('audit-bags-body'),
sparesNote: document.getElementById('audit-spares-note'),
inputArea: document.getElementById('audit-input-area'),
noInput: document.getElementById('audit-no-input'),
modeMissing: document.getElementById('audit-mode-missing'),
@@ -236,13 +253,20 @@ class PartsAuditMode {
return;
}
// Image: prefer the full-size lightbox target, fall back to the thumbnail.
// Image: prefer the full-size lightbox target, fall back to the
// thumbnail. Unmatched bag parts have none; hide the broken box.
const link = row.querySelector('a[data-lightbox]');
const img = row.querySelector('td img');
this.el.image.src = (link && link.getAttribute('href')) || (img && img.getAttribute('src')) || '';
const imageSrc = (link && link.getAttribute('href')) || (img && img.getAttribute('src')) || '';
this.el.image.src = imageSrc;
this.el.image.classList.toggle('d-none', !imageSrc);
// Unmatched bag parts render as plain text without a link.
const nameLink = row.querySelector('[data-col="name"] a');
this.el.name.textContent = nameLink ? nameLink.textContent.trim() : '';
const nameCell = row.querySelector('[data-col="name"]');
this.el.name.textContent = nameLink
? nameLink.textContent.trim()
: (nameCell ? nameCell.textContent.trim() : '');
const colorCell = row.querySelector('[data-col="color"]');
this.el.color.innerHTML = colorCell ? colorCell.innerHTML : '';
@@ -250,6 +274,36 @@ class PartsAuditMode {
const needed = this.neededQty(row);
this.el.qty.textContent = needed;
// Per-part bag breakdown (set pages with sidecar bag data). Bag-audit
// rows never carry data-bags, so this stays hidden there.
let bagData = null;
if (row.dataset.bags) {
try {
bagData = JSON.parse(row.dataset.bags);
} catch (e) {
bagData = null;
}
}
if (bagData && bagData.length) {
this.el.bagsBody.replaceChildren(...bagData.map(([bag, qty]) => {
const tr = document.createElement('tr');
const bagCell = document.createElement('td');
bagCell.textContent = bag;
const qtyCell = document.createElement('td');
qtyCell.textContent = qty;
tr.append(bagCell, qtyCell);
return tr;
}));
this.el.bags.classList.remove('d-none');
} else {
this.el.bags.classList.add('d-none');
}
// Bag inventories never list spare parts, so say so whenever bag
// quantities are on screen.
const showSparesNote = (bagData && bagData.length > 0) || this.bagMode;
this.el.sparesNote.classList.toggle('d-none', !showSparesNote);
// Toggle the input area depending on whether this table tracks missing.
const missing = this.missingInput(row);
if (missing) {
+1
View File
@@ -198,6 +198,7 @@
<script src="{{ url_for('static', filename='scripts/parts-table-filter.js') }}"></script>
<script src="{{ url_for('static', filename='scripts/parts-bulk-operations.js') }}"></script>
<script src="{{ url_for('static', filename='scripts/parts-audit-mode.js') }}"></script>
<script src="{{ url_for('static', filename='scripts/bag-missing-sync.js') }}"></script>
<script src="{{ url_for('static', filename='scripts/set-details.js') }}"></script>
<script src="{{ url_for('static', filename='scripts/quick-add-individual-part.js') }}"></script>
{% endif %}
+2 -1
View File
@@ -6,7 +6,8 @@
{{ table.header(color=true, quantity=not no_quantity, sets=all, minifigures=all, checked=not all and not read_only, hamburger_menu=not all and not read_only, accordion_id=accordion_id|default(''), filters=filters|default(false)) }}
<tbody>
{% for item in table_collection %}
<tr>
{# tojson escapes ' but not ", so the attribute must be single-quoted #}
<tr {% if bag_breakdown is defined and bag_breakdown and item.html_id() in bag_breakdown %}data-bags='{{ bag_breakdown[item.html_id()] | tojson }}'{% endif %}>
{{ table.image(item.url_for_image(), caption=item.fields.name, alt=item.fields.part, accordion=solo) }}
<td data-sort="{{ item.fields.name }}" data-col="name">
<a class="text-reset" href="{{ item.url() }}">{{ item.fields.name }}</a>
+60
View File
@@ -0,0 +1,60 @@
{% import 'macro/accordion.html' as accordion %}
{% import 'macro/form.html' as form %}
{% import 'macro/table.html' as table %}
{{ accordion.header('Bags', 'bags-inventory', 'set-details', quantity=sidecar_bags | length, icon='archive-line', class='p-0') }}
<div class="accordion accordion-flush" id="bags-list">
{% for bag in sidecar_bags %}
{% set bag_title = bag.number if bag.is_extra else 'Bag ' ~ bag.number %}
{% set bag_accordion_id = 'bag-' ~ loop.index %}
{{ accordion.header(bag_title, bag_accordion_id, 'bags-list', quantity=bag.parts | length, icon='archive-line', class='p-0') }}
<div class="p-2 border-bottom">
<small class="text-secondary">Spare parts are not included in bag inventories.</small>
</div>
<div class="table-responsive-sm">
<table class="table table-striped align-middle sortable mb-0" data-parts-filterable="true" data-bag-table="true">
{{ table.header(color=true, quantity=true, missing=true, damaged=false, checked=true, hamburger_menu=g.login.is_authenticated(), accordion_id=bag_accordion_id, filters=true) }}
<tbody>
{% for p in bag.parts %}
<tr {% if p.missing_input_id %}data-target-missing="{{ p.missing_input_id }}"{% endif %}>
{% if p.image_url %}
{{ table.image(p.image_url, caption=p.name, alt=p.name) }}
{% else %}
<td class="py-0"></td>
{% endif %}
<td data-sort="{{ p.name }}" data-col="name">
{% if p.url %}<a class="text-reset" href="{{ p.url }}">{{ p.name }}</a>{% else %}{{ p.name }}{% endif %}
{% if p.spare %}<span class="badge rounded-pill text-bg-warning fw-normal"><i class="ri-loop-left-line"></i> Spare</span>{% endif %}
</td>
<td data-sort="{{ p.color_name }}" data-col="color">
{% if p.color_rgb %}<span class="color-rgb color-rgb-table {% if p.color == 9999 %}color-any{% endif %} align-middle border border-black" {% if p.color != 9999 %}style="background-color: #{{ p.color_rgb }};"{% endif %}></span>{% endif %}
<span class="align-middle">{{ p.color_name }}</span>
</td>
<td data-col="quantity">{{ p.quantity }}</td>
{% if not config['HIDE_TABLE_MISSING_PARTS'] %}
<td data-sort="{{ p.missing or 0 }}" data-col="missing" class="table-td-input">
{% if p.url_missing %}
{{ form.input('Missing', item.fields.id, p.prefix_missing, p.url_missing, p.missing) }}
{% endif %}
</td>
{% endif %}
{% if not config['HIDE_TABLE_CHECKED_PARTS'] %}
<td data-sort="{{ p.checked | int }}" data-col="checked" class="table-td-input">
{% if p.url_checked %}
<center>{{ form.checkbox('', item.fields.id, p.prefix_checked, p.url_checked, p.checked) }}</center>
{% endif %}
</td>
{% endif %}
{% if g.login.is_authenticated() and (not config['HIDE_TABLE_MISSING_PARTS'] or not config['HIDE_TABLE_CHECKED_PARTS'] or (not config['DISABLE_QUICK_ADD_INDIVIDUAL_PARTS'] and not config['HIDE_INDIVIDUAL_PARTS'])) %}
{# Filler cell under the hamburger column so row/header cell counts match #}
<td></td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
{{ accordion.footer() }}
{% endfor %}
</div>
{{ accordion.footer() }}
+6 -1
View File
@@ -1,4 +1,6 @@
{% import 'macro/accordion.html' as accordion %}
{# "with context" so includes inside the macros (part/table.html) can see
render kwargs like bag_breakdown #}
{% import 'macro/accordion.html' as accordion with context %}
{% import 'macro/badge.html' as badge %}
{% import 'macro/card.html' as card %}
{% import 'macro/form.html' as form %}
@@ -147,6 +149,9 @@
{{ accordion.footer() }}
{% endif %}
{{ accordion.table(item.parts(), 'Parts', 'parts-inventory', 'set-details', 'part/table.html', icon='shapes-line', hamburger_menu=g.login.is_authenticated(), filters=true)}}
{% if sidecar_bags is defined and sidecar_bags %}
{% include 'set/bags.html' %}
{% endif %}
{% for minifigure in item.minifigures() %}
{{ accordion.table(minifigure.parts(), minifigure.fields.name, minifigure.fields.figure, 'set-details', 'part/table.html', quantity=minifigure.fields.quantity, icon='group-line', image=minifigure.url_for_image(), alt=minifigure.fields.figure, details=minifigure.url(), hamburger_menu=g.login.is_authenticated())}}
{% endfor %}