feat(add): set metadata on add — purchase, notes, statuses

Allow setting owners, purchase (date, price, location), notes,
statuses, storage, and tags directly from the add set form.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-09 20:23:47 +01:00
parent 2536cbe170
commit 126fb1e5cb
6 changed files with 189 additions and 52 deletions
+37
View File
@@ -94,6 +94,36 @@ class BrickSet(RebrickableSet):
)
self.fields.purchase_location = purchase_location.fields.id
# Save the purchase date
purchase_date = data.get('purchase_date', None)
if purchase_date == '':
purchase_date = None
if purchase_date is not None:
try:
purchase_date = datetime.strptime(
purchase_date, '%Y/%m/%d'
).timestamp()
except Exception:
purchase_date = None
self.fields.purchase_date = purchase_date
# Save the purchase price
purchase_price = data.get('purchase_price', None)
if purchase_price == '':
purchase_price = None
if purchase_price is not None:
try:
purchase_price = float(purchase_price)
except Exception:
purchase_price = None
self.fields.purchase_price = purchase_price
# Save the description/notes
description = data.get('description', None)
if description == '':
description = None
self.fields.description = description
# Insert into database (deferred - will execute at final commit)
# All operations are atomic - if anything fails, nothing is committed
self.insert(commit=False)
@@ -105,6 +135,13 @@ class BrickSet(RebrickableSet):
owner = BrickSetOwnerList.get(id)
owner.update_set_state(self, state=True, commit=False)
# Save the statuses (deferred - will execute at final commit)
statuses: list[str] = list(data.get('statuses', []))
for id in statuses:
status = BrickSetStatusList.get(id)
status.update_set_state(self, state=True, commit=False)
# Save the tags (deferred - will execute at final commit)
tags: list[str] = list(data.get('tags', []))
+8 -2
View File
@@ -2,10 +2,16 @@ INSERT OR IGNORE INTO "bricktracker_sets" (
"id",
"set",
"storage",
"purchase_location"
"purchase_location",
"purchase_date",
"purchase_price",
"description"
) VALUES (
:id,
:set,
:storage,
:purchase_location
:purchase_location,
:purchase_date,
:purchase_price,
:description
)
+3
View File
@@ -4,6 +4,7 @@ from flask_login import login_required
from ..configuration_list import BrickConfigurationList
from .exceptions import exception_handler
from ..set_list import set_metadata_lists
from ..set_status_list import BrickSetStatusList
from ..socket import MESSAGES
add_page = Blueprint('add', __name__, url_prefix='/add')
@@ -21,6 +22,7 @@ def add() -> str:
path=current_app.config['SOCKET_PATH'],
namespace=current_app.config['SOCKET_NAMESPACE'],
messages=MESSAGES,
brickset_statuses=BrickSetStatusList.list(all=True),
**set_metadata_lists()
)
@@ -38,6 +40,7 @@ def bulk() -> str:
namespace=current_app.config['SOCKET_NAMESPACE'],
messages=MESSAGES,
bulk=True,
brickset_statuses=BrickSetStatusList.list(all=True),
**set_metadata_lists()
)
+8
View File
@@ -1,5 +1,13 @@
// Add page - handles both sets and individual minifigures
document.addEventListener("DOMContentLoaded", () => {
// Initialize date pickers
document.querySelectorAll('[data-add-date="true"]').forEach(el => {
new Datepicker(el, {
buttonClass: 'btn',
format: 'yyyy/mm/dd',
});
});
// Get template data from data attributes
const addContainer = document.getElementById('add-set');
if (!addContainer) return;
+52
View File
@@ -16,9 +16,13 @@ class BrickSetSocket extends BrickSocket {
this.html_input = document.getElementById(`${id}-set`);
this.html_no_confim = document.getElementById(`${id}-no-confirm`);
this.html_owners = document.getElementById(`${id}-owners`);
this.html_purchase_date = document.getElementById(`${id}-purchase-date`);
this.html_purchase_location = document.getElementById(`${id}-purchase-location`);
this.html_purchase_price = document.getElementById(`${id}-purchase-price`);
this.html_statuses = document.getElementById(`${id}-statuses`);
this.html_storage = document.getElementById(`${id}-storage`);
this.html_tags = document.getElementById(`${id}-tags`);
this.html_description = document.getElementById(`${id}-description`);
// Card elements
this.html_card = document.getElementById(`${id}-card`);
@@ -181,12 +185,44 @@ class BrickSetSocket extends BrickSocket {
this.html_progress_bar.scrollIntoView();
}
// Grab the purchase date
let purchase_date = '';
if (this.html_purchase_date) {
purchase_date = this.html_purchase_date.value;
}
// Grab the purchase price
let purchase_price = '';
if (this.html_purchase_price) {
purchase_price = this.html_purchase_price.value;
}
// Grab the statuses
const statuses = [];
if (this.html_statuses) {
this.html_statuses.querySelectorAll('input').forEach(input => {
if (input.checked) {
statuses.push(input.value);
}
});
}
// Grab the description/notes
let description = '';
if (this.html_description) {
description = this.html_description.value;
}
this.socket.emit(this.messages.IMPORT_SET, {
set: (set !== undefined) ? set : this.html_input.value,
owners: owners,
purchase_date: purchase_date,
purchase_location: purchase_location,
purchase_price: purchase_price,
statuses: statuses,
storage: storage,
tags: tags,
description: description,
refresh: this.refresh
});
} else {
@@ -361,10 +397,22 @@ class BrickSetSocket extends BrickSocket {
this.html_owners.querySelectorAll('input').forEach(input => input.disabled = !enabled);
}
if (this.html_purchase_date) {
this.html_purchase_date.disabled = !enabled;
}
if (this.html_purchase_location) {
this.html_purchase_location.disabled = !enabled;
}
if (this.html_purchase_price) {
this.html_purchase_price.disabled = !enabled;
}
if (this.html_statuses) {
this.html_statuses.querySelectorAll('input').forEach(input => input.disabled = !enabled);
}
if (this.html_storage) {
this.html_storage.disabled = !enabled;
}
@@ -373,6 +421,10 @@ class BrickSetSocket extends BrickSocket {
this.html_tags.querySelectorAll('input').forEach(input => input.disabled = !enabled);
}
if (this.html_description) {
this.html_description.disabled = !enabled;
}
if (this.html_card_confirm) {
this.html_card_confirm.disabled = !enabled;
}
+81 -50
View File
@@ -63,30 +63,43 @@
</div>
<h6 class="border-bottom mt-2">Metadata</h6>
<div class="accordion accordion" id="metadata">
{% if not (brickset_owners | length) and not (brickset_purchase_locations | length) and not (brickset_storages | length) and not (brickset_tags | length) %}
<div class="alert alert-warning" role="alert">
You have no metadata configured.
You can add entries in the <a href="{{ url_for('admin.admin', open_metadata=true) }}" class="btn btn-warning" role="button"><i class="ri-profile-line"></i> Set metadata management</a> section of the Admin panel.
{% if brickset_owners | length %}
{{ accordion.header('Owners', 'owners', 'metadata', icon='user-line') }}
<div id="add-owners">
{% for owner in brickset_owners %}
{% with id=owner.as_dataset() %}
<div class="form-check">
<input class="form-check-input" type="checkbox" value="{{ owner.fields.id }}" id="{{ id }}" autocomplete="off">
<label class="form-check-label" for="{{ id }}">{{ owner.fields.name }}</label>
</div>
{% endwith %}
{% endfor %}
</div>
{% else %}
{% if brickset_owners | length %}
{{ accordion.header('Owners', 'owners', 'metadata', icon='user-line') }}
<div id="add-owners">
{% for owner in brickset_owners %}
{% with id=owner.as_dataset() %}
<div class="form-check">
<input class="form-check-input" type="checkbox" value="{{ owner.fields.id }}" id="{{ id }}" autocomplete="off">
<label class="form-check-label" for="{{ id }}">{{ owner.fields.name }}</label>
</div>
{% endwith %}
{% endfor %}
</div>
{{ accordion.footer() }}
{% endif %}
{% if brickset_purchase_locations | length %}
{{ accordion.header('Purchase location', 'purchase-location', 'metadata', icon='building-line') }}
<label class="visually-hidden" for="add-purchase-location">{{ name }}</label>
{{ accordion.footer() }}
{% endif %}
{{ accordion.header('Purchase', 'purchase', 'metadata', icon='wallet-3-line') }}
<div class="alert alert-info" role="alert">The expected date format here is <code>yyyy/mm/dd</code> (year/month/day), but you can configure how it is displayed in the set card with the <code>PURCHASE_DATE_FORMAT</code> variable.</div>
<div class="row row-cols-lg-auto g-1 justify-content-start align-items-center pb-2">
<div class="col-12">
<label for="add-purchase-date" class="form-label visually-hidden">Date</label>
<div class="input-group">
<span class="input-group-text px-1"><i class="ri-calendar-line me-1"></i><span class="ms-1 d-none d-md-inline"> Date</span></span>
<input class="form-control form-control-sm flex-shrink-1 px-1" type="text" id="add-purchase-date" data-add-date="true" autocomplete="off">
</div>
</div>
<div class="col-12">
<label for="add-purchase-price" class="form-label visually-hidden">Price</label>
<div class="input-group">
<span class="input-group-text px-1"><i class="ri-wallet-3-line me-1"></i><span class="ms-1 d-none d-md-inline"> Price</span></span>
<input class="form-control form-control-sm flex-shrink-1 px-1" type="text" id="add-purchase-price" autocomplete="off">
{% if config['PURCHASE_CURRENCY'] %}<span class="input-group-text d-none d-md-inline px-1">{{ config['PURCHASE_CURRENCY'] }}</span>{% endif %}
</div>
</div>
{% if brickset_purchase_locations | length %}
<div class="col-12 flex-grow-1">
<label for="add-purchase-location" class="form-label visually-hidden">Location</label>
<div class="input-group">
<span class="input-group-text px-1"><i class="ri-building-line me-1"></i><span class="ms-1 d-none d-md-inline"> Location</span></span>
<select id="add-purchase-location" class="form-select" autocomplete="off">
<option value="" selected><i>None</i></option>
{% for purchase_location in brickset_purchase_locations %}
@@ -94,35 +107,53 @@
{% endfor %}
</select>
</div>
{{ accordion.footer() }}
{% endif %}
{% if brickset_storages | length %}
{{ accordion.header('Storage', 'storage', 'metadata', icon='archive-2-line') }}
<label class="visually-hidden" for="add-storage">{{ name }}</label>
<div class="input-group">
<select id="add-storage" class="form-select" autocomplete="off">
<option value="" selected><i>None</i></option>
{% for storage in brickset_storages %}
<option value="{{ storage.fields.id }}">{{ storage.fields.name }}</option>
{% endfor %}
</select>
</div>
{{ accordion.footer() }}
{% endif %}
{% if brickset_tags | length %}
{{ accordion.header('Tags', 'tags', 'metadata', icon='price-tag-2-line') }}
<div id="add-tags">
{% for tag in brickset_tags %}
{% with id=tag.as_dataset() %}
<div class="form-check">
<input class="form-check-input" type="checkbox" value="{{ tag.fields.id }}" id="{{ id }}" autocomplete="off">
<label class="form-check-label" for="{{ id }}">{{ tag.fields.name }}</label>
</div>
{% endwith %}
{% endfor %}
</div>
{{ accordion.footer() }}
</div>
{% endif %}
</div>
{{ accordion.footer() }}
{{ accordion.header('Notes', 'notes', 'metadata', icon='sticky-note-line') }}
<textarea class="form-control" id="add-description" rows="3" placeholder="Optional notes..."></textarea>
{{ accordion.footer() }}
{% if brickset_storages | length %}
{{ accordion.header('Storage', 'storage', 'metadata', icon='archive-2-line') }}
<label class="visually-hidden" for="add-storage">Storage</label>
<div class="input-group">
<select id="add-storage" class="form-select" autocomplete="off">
<option value="" selected><i>None</i></option>
{% for storage in brickset_storages %}
<option value="{{ storage.fields.id }}">{{ storage.fields.name }}</option>
{% endfor %}
</select>
</div>
{{ accordion.footer() }}
{% endif %}
{% if brickset_statuses | length %}
{{ accordion.header('Statuses', 'statuses', 'metadata', icon='checkbox-circle-line') }}
<div id="add-statuses">
{% for status in brickset_statuses %}
{% with id=status.as_dataset() %}
<div class="form-check">
<input class="form-check-input" type="checkbox" value="{{ status.fields.id }}" id="{{ id }}" autocomplete="off">
<label class="form-check-label" for="{{ id }}">{{ status.fields.name }}</label>
</div>
{% endwith %}
{% endfor %}
</div>
{{ accordion.footer() }}
{% endif %}
{% if brickset_tags | length %}
{{ accordion.header('Tags', 'tags', 'metadata', icon='price-tag-2-line') }}
<div id="add-tags">
{% for tag in brickset_tags %}
{% with id=tag.as_dataset() %}
<div class="form-check">
<input class="form-check-input" type="checkbox" value="{{ tag.fields.id }}" id="{{ id }}" autocomplete="off">
<label class="form-check-label" for="{{ id }}">{{ tag.fields.name }}</label>
</div>
{% endwith %}
{% endfor %}
</div>
{{ accordion.footer() }}
{% endif %}
</div>
<hr>