feat(set): admin-defined typed custom fields per set (#146)

This commit is contained in:
2026-06-26 22:18:09 +02:00
parent 32a70c7358
commit c93a14f70b
25 changed files with 447 additions and 1 deletions
+2
View File
@@ -17,6 +17,7 @@ from bricktracker.template_filters import replace_query_filter
from bricktracker.version import __version__
from bricktracker.views.add import add_page
from bricktracker.views.admin.admin import admin_page
from bricktracker.views.admin.custom_field import admin_custom_field_page
from bricktracker.views.admin.database import admin_database_page
from bricktracker.views.admin.export import admin_export_page
from bricktracker.views.admin.image import admin_image_page
@@ -156,6 +157,7 @@ def setup_app(app: Flask) -> None:
# Register admin routes
app.register_blueprint(admin_page)
app.register_blueprint(admin_custom_field_page)
app.register_blueprint(admin_database_page)
app.register_blueprint(admin_export_page)
app.register_blueprint(admin_image_page)
+2
View File
@@ -6,6 +6,7 @@ from flask import url_for
from .exceptions import ErrorException, NotFoundException
from .fields import BrickRecordFields
from .record_list import BrickRecordList
from .set_custom_field import BrickSetCustomField
from .set_owner import BrickSetOwner
from .set_purchase_location import BrickSetPurchaseLocation
from .set_status import BrickSetStatus
@@ -17,6 +18,7 @@ logger = logging.getLogger(__name__)
T = TypeVar(
'T',
BrickSetCustomField,
BrickSetOwner,
BrickSetPurchaseLocation,
BrickSetStatus,
+4
View File
@@ -1,5 +1,6 @@
from .instructions_list import BrickInstructionsList
from .retired_list import BrickRetiredList
from .set_custom_field_list import BrickSetCustomFieldList
from .set_owner_list import BrickSetOwnerList
from .set_purchase_location_list import BrickSetPurchaseLocationList
from .set_status_list import BrickSetStatusList
@@ -31,6 +32,9 @@ def reload() -> None:
# Reload the set tags
BrickSetTagList.new(force=True)
# Reload the set custom fields
BrickSetCustomFieldList.new(force=True)
# Reload retired sets
BrickRetiredList(force=True)
+2
View File
@@ -10,6 +10,7 @@ from .exceptions import NotFoundException, DatabaseException, ErrorException
from .minifigure_list import BrickMinifigureList
from .part_list import BrickPartList
from .rebrickable_set import RebrickableSet
from .set_custom_field_list import BrickSetCustomFieldList
from .set_owner_list import BrickSetOwnerList
from .set_purchase_location_list import BrickSetPurchaseLocationList
from .set_status_list import BrickSetStatusList
@@ -334,6 +335,7 @@ class BrickSet(RebrickableSet):
# Load from database
if not self.select(
custom_fields=BrickSetCustomFieldList.as_columns(),
owners=BrickSetOwnerList.as_columns(),
statuses=BrickSetStatusList.as_columns(all=True),
tags=BrickSetTagList.as_columns(),
+99
View File
@@ -0,0 +1,99 @@
import logging
from typing import Any, Self
from .exceptions import DatabaseException, ErrorException
from .metadata import BrickMetadata
from .sql import BrickSQL
logger = logging.getLogger(__name__)
# Allowed custom field types. The type only drives the input widget and light
# validation; the value is always stored as TEXT.
CUSTOM_FIELD_TYPES = ('text', 'number', 'date')
# Lego set custom field metadata (a typed, admin-defined per-set value)
class BrickSetCustomField(BrickMetadata):
kind: str = 'custom field'
# Set value endpoint
set_state_endpoint: str = 'set.update_custom_field'
# Queries
delete_query: str = 'set/metadata/custom_field/delete'
insert_query: str = 'set/metadata/custom_field/insert'
select_query: str = 'set/metadata/custom_field/select'
update_field_query: str = 'set/metadata/custom_field/update/field'
update_set_value_query: str = 'set/metadata/custom_field/update/value'
# SQL column name uses an underscore (kind has a space), so override the
# default which would produce "custom-field_{id}".
def as_column(self, /) -> str:
return 'custom_field_{id}'.format(id=self.fields.id)
# Grab data from a form (name + type)
def from_form(self, form: dict[str, str], /) -> Self:
super().from_form(form)
field_type = form.get('type', 'text')
if field_type not in CUSTOM_FIELD_TYPES:
raise ErrorException(
'Unknown custom field type: {type}'.format(type=field_type)
)
self.fields.type = field_type
return self
# Insert into database (carry the type along)
def insert(self, /, **_) -> None:
super().insert(
type=self.fields.type
)
# Update the per-set value of this custom field. Unlike the base
# update_set_value (which targets a fixed column), the column name is
# dynamic, so it is injected into the query like update_set_state does.
def update_value(
self,
brickset: Any,
/,
*,
json: Any | None = None,
value: Any | None = None,
) -> Any:
if value is None and json is not None:
value = json.get('value', '')
if value == '':
value = None
parameters = self.sql_parameters()
parameters['set_id'] = brickset.fields.id
parameters['value'] = value
rows, _ = BrickSQL().execute_and_commit(
self.update_set_value_query,
parameters=parameters,
name=self.as_column(),
)
if rows != 1:
raise DatabaseException(
'Could not update the custom field value for set {set} ({id})'.format( # noqa: E501
set=brickset.fields.set,
id=brickset.fields.id,
)
)
logger.info(
'Custom field "{name}" value changed to "{value}" for set {set} ({id})'.format( # noqa: E501
name=self.fields.name,
value=value,
set=brickset.fields.set,
id=brickset.fields.id,
)
)
return value
+24
View File
@@ -0,0 +1,24 @@
from typing import Self
from .metadata_list import BrickMetadataList
from .set_custom_field import BrickSetCustomField
# Lego sets custom field list
class BrickSetCustomFieldList(BrickMetadataList[BrickSetCustomField]):
kind: str = 'set custom fields'
# Database
table: str = 'bricktracker_set_custom_fields'
order: str = '"bricktracker_metadata_custom_fields"."name"'
# Queries
select_query: str = 'set/metadata/custom_field/list'
# Set value endpoint
set_value_endpoint: str = 'set.update_custom_field'
# Instantiate the list with the proper class
@classmethod
def new(cls, /, *, force: bool = False) -> Self:
return cls(BrickSetCustomField, force=force)
+2
View File
@@ -3,6 +3,7 @@ from typing import Any, Self, Union
from flask import current_app
from .record_list import BrickRecordList
from .set_custom_field_list import BrickSetCustomFieldList
from .set_owner import BrickSetOwner
from .set_owner_list import BrickSetOwnerList
from .set_purchase_location import BrickSetPurchaseLocation
@@ -705,6 +706,7 @@ def set_metadata_lists(
]
]:
return {
'brickset_custom_fields': BrickSetCustomFieldList.list(),
'brickset_owners': BrickSetOwnerList.list(),
'brickset_purchase_locations': BrickSetPurchaseLocationList.list(as_class=as_class), # noqa: E501
'brickset_storages': BrickSetStorageList.list(as_class=as_class),
+23
View File
@@ -0,0 +1,23 @@
-- description: Add typed custom fields (admin-defined per-set fields)
BEGIN TRANSACTION;
-- Defines each custom field: an id, a name and a type (text / number / date).
-- The type only drives the input widget + light validation; the per-set value
-- is always stored as TEXT in a dynamic column on bricktracker_set_custom_fields.
CREATE TABLE "bricktracker_metadata_custom_fields" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"type" TEXT NOT NULL DEFAULT 'text',
PRIMARY KEY("id")
);
-- Holds the per-set value of each custom field. A "custom_field_{id}" TEXT
-- column is added for every field created (mirroring the tag/status pattern).
CREATE TABLE "bricktracker_set_custom_fields" (
"id" TEXT NOT NULL,
PRIMARY KEY("id"),
FOREIGN KEY("id") REFERENCES "bricktracker_sets"("id")
);
COMMIT;
+3
View File
@@ -23,6 +23,9 @@ SELECT
{% block statuses %}
{% if statuses %}{{ statuses }},{% endif %}
{% endblock %}
{% block custom_fields %}
{% if custom_fields %}{{ custom_fields }},{% endif %}
{% endblock %}
{% block total_missing %}
NULL AS "total_missing", -- dummy for order: total_missing
{% endblock %}
+5
View File
@@ -32,6 +32,11 @@ LEFT JOIN "bricktracker_set_tags"
ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "bricktracker_set_tags"."id"
{% endif %}
{% if custom_fields %}
LEFT JOIN "bricktracker_set_custom_fields"
ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "bricktracker_set_custom_fields"."id"
{% endif %}
-- LEFT JOIN + SELECT to avoid messing the total
LEFT JOIN (
SELECT
@@ -0,0 +1,11 @@
SELECT
"bricktracker_metadata_custom_fields"."id",
"bricktracker_metadata_custom_fields"."name",
"bricktracker_metadata_custom_fields"."type"
FROM "bricktracker_metadata_custom_fields"
{% block where %}{% endblock %}
{% if order %}
ORDER BY {{ order }}
{% endif %}
@@ -0,0 +1,9 @@
BEGIN TRANSACTION;
ALTER TABLE "bricktracker_set_custom_fields"
DROP COLUMN "custom_field_{{ id }}";
DELETE FROM "bricktracker_metadata_custom_fields"
WHERE "bricktracker_metadata_custom_fields"."id" IS NOT DISTINCT FROM '{{ id }}';
COMMIT;
@@ -0,0 +1,17 @@
BEGIN TRANSACTION;
-- Add the value column for this custom field (per-set TEXT value).
ALTER TABLE "bricktracker_set_custom_fields"
ADD COLUMN "custom_field_{{ id }}" TEXT;
INSERT INTO "bricktracker_metadata_custom_fields" (
"id",
"name",
"type"
) VALUES (
'{{ id }}',
'{{ name }}',
'{{ type }}'
);
COMMIT;
@@ -0,0 +1 @@
{% extends 'set/metadata/custom_field/base.sql' %}
@@ -0,0 +1,5 @@
{% extends 'set/metadata/custom_field/base.sql' %}
{% block where %}
WHERE "bricktracker_metadata_custom_fields"."id" IS NOT DISTINCT FROM :id
{% endblock %}
@@ -0,0 +1,3 @@
UPDATE "bricktracker_metadata_custom_fields"
SET "{{field}}" = :value
WHERE "bricktracker_metadata_custom_fields"."id" IS NOT DISTINCT FROM :id
@@ -0,0 +1,10 @@
INSERT INTO "bricktracker_set_custom_fields" (
"id",
"{{name}}"
) VALUES (
:set_id,
:value
)
ON CONFLICT("id")
DO UPDATE SET "{{name}}" = :value
WHERE "bricktracker_set_custom_fields"."id" IS NOT DISTINCT FROM :set_id
+1 -1
View File
@@ -1,4 +1,4 @@
from typing import Final
__version__: Final[str] = '1.5.0'
__database_version__: Final[int] = 28
__database_version__: Final[int] = 29
+10
View File
@@ -11,6 +11,8 @@ from ..exceptions import exception_handler
from ...instructions_list import BrickInstructionsList
from ...rebrickable_image import RebrickableImage
from ...retired_list import BrickRetiredList
from ...set_custom_field import BrickSetCustomField
from ...set_custom_field_list import BrickSetCustomFieldList
from ...set_owner import BrickSetOwner
from ...set_owner_list import BrickSetOwnerList
from ...set_purchase_location import BrickSetPurchaseLocation
@@ -129,6 +131,7 @@ def admin() -> str:
database_upgrade_needed: bool = False
database_version: int = -1
instructions: BrickInstructionsList | None = None
metadata_custom_fields: list[BrickSetCustomField] = []
metadata_owners: list[BrickSetOwner] = []
metadata_purchase_locations: list[BrickSetPurchaseLocation] = []
metadata_statuses: list[BrickSetStatus] = []
@@ -148,6 +151,7 @@ def admin() -> str:
instructions = BrickInstructionsList()
metadata_custom_fields = BrickSetCustomFieldList.list()
metadata_owners = BrickSetOwnerList.list()
metadata_purchase_locations = BrickSetPurchaseLocationList.list()
metadata_statuses = BrickSetStatusList.list(all=True)
@@ -176,6 +180,7 @@ def admin() -> str:
open_image = request.args.get('open_image', None)
open_instructions = request.args.get('open_instructions', None)
open_logout = request.args.get('open_logout', None)
open_custom_field = request.args.get('open_custom_field', None)
open_metadata = request.args.get('open_metadata', None)
open_owner = request.args.get('open_owner', None)
open_purchase_location = request.args.get('open_purchase_location', None)
@@ -187,6 +192,7 @@ def admin() -> str:
open_metadata = (
open_metadata or
open_custom_field or
open_owner or
open_purchase_location or
open_status or
@@ -215,6 +221,7 @@ def admin() -> str:
open_value = should_expand('value', request.args.get('open_value', None))
# Metadata sub-sections
open_custom_field = should_expand('custom_field', open_custom_field)
open_owner = should_expand('owner', open_owner)
open_purchase_location = should_expand('purchase_location', open_purchase_location)
open_status = should_expand('status', open_status)
@@ -224,6 +231,7 @@ def admin() -> str:
# Recalculate metadata section based on sub-sections or direct config
open_metadata = (
should_expand('metadata', open_metadata) or
open_custom_field or
open_owner or
open_purchase_location or
open_status or
@@ -267,6 +275,7 @@ def admin() -> str:
database_upgrade_needed=database_upgrade_needed,
database_version=database_version,
instructions=instructions,
metadata_custom_fields=metadata_custom_fields,
metadata_owners=metadata_owners,
metadata_purchase_locations=metadata_purchase_locations,
metadata_statuses=metadata_statuses,
@@ -280,6 +289,7 @@ def admin() -> str:
open_image=open_image,
open_instructions=open_instructions,
open_logout=open_logout,
open_custom_field=open_custom_field,
open_metadata=open_metadata,
open_owner=open_owner,
open_purchase_location=open_purchase_location,
+98
View File
@@ -0,0 +1,98 @@
from flask import (
Blueprint,
jsonify,
redirect,
request,
render_template,
url_for,
)
from flask_login import login_required
from werkzeug.wrappers.response import Response
from ..exceptions import exception_handler
from ...reload import reload
from ...set_custom_field import BrickSetCustomField
admin_custom_field_page = Blueprint(
'admin_custom_field',
__name__,
url_prefix='/admin/custom_field'
)
# Add a metadata custom field
@admin_custom_field_page.route('/add', methods=['POST'])
@login_required
@exception_handler(
__file__,
post_redirect='admin.admin',
error_name='custom_field_error',
open_custom_field=True
)
def add() -> Response:
BrickSetCustomField().from_form(request.form).insert()
reload()
return redirect(url_for('admin.admin', open_custom_field=True))
# Delete the metadata custom field
@admin_custom_field_page.route('<id>/delete', methods=['GET'])
@login_required
@exception_handler(__file__)
def delete(*, id: str) -> str:
return render_template(
'admin.html',
delete_custom_field=True,
custom_field=BrickSetCustomField().select_specific(id),
custom_field_error=request.args.get('custom_field_error')
)
# Actually delete the metadata custom field
@admin_custom_field_page.route('<id>/delete', methods=['POST'])
@login_required
@exception_handler(
__file__,
post_redirect='admin_custom_field.delete',
error_name='custom_field_error'
)
def do_delete(*, id: str) -> Response:
custom_field = BrickSetCustomField().select_specific(id)
custom_field.delete()
reload()
return redirect(url_for('admin.admin', open_custom_field=True))
# Change a field of a metadata custom field (name or type)
@admin_custom_field_page.route('/<id>/field/<name>', methods=['POST'])
@login_required
@exception_handler(__file__, json=True)
def update_field(*, id: str, name: str) -> Response:
custom_field = BrickSetCustomField().select_specific(id)
value = custom_field.update_field(name, json=request.json)
reload()
return jsonify({'value': value})
# Rename the metadata custom field
@admin_custom_field_page.route('<id>/rename', methods=['POST'])
@login_required
@exception_handler(
__file__,
post_redirect='admin.admin',
error_name='custom_field_error',
open_custom_field=True
)
def rename(*, id: str) -> Response:
custom_field = BrickSetCustomField().select_specific(id)
custom_field.from_form(request.form).rename()
reload()
return redirect(url_for('admin.admin', open_custom_field=True))
+14
View File
@@ -23,6 +23,7 @@ from ..rebrickable_set import RebrickableSet
from ..set import BrickSet
from ..sidecar import BrickSidecar
from ..sidecar_set import summarize as sidecar_summarize
from ..set_custom_field_list import BrickSetCustomFieldList
from ..set_list import BrickSetList, set_metadata_lists
from ..set_owner_list import BrickSetOwnerList
from ..set_purchase_location_list import BrickSetPurchaseLocationList
@@ -229,6 +230,19 @@ def update_tag(*, id: str, metadata_id: str) -> Response:
return jsonify({'value': state})
# Change the value of a custom field
@set_page.route('/<id>/custom_field/<metadata_id>', methods=['POST'])
@login_required
@exception_handler(__file__, json=True)
def update_custom_field(*, id: str, metadata_id: str) -> Response:
brickset = BrickSet().select_light(id)
custom_field = BrickSetCustomFieldList.get(metadata_id)
value = custom_field.update_value(brickset, json=request.json)
return jsonify({'value': value})
# Ask for deletion of a set
@set_page.route('/<id>/delete', methods=['GET'])
@login_required
+3
View File
@@ -20,6 +20,8 @@
{% include 'admin/owner/delete.html' %}
{% elif delete_purchase_location %}
{% include 'admin/purchase_location/delete.html' %}
{% elif delete_custom_field %}
{% include 'admin/custom_field/delete.html' %}
{% elif delete_status %}
{% include 'admin/status/delete.html' %}
{% elif delete_storage %}
@@ -52,6 +54,7 @@
{% include 'admin/status.html' %}
{% include 'admin/storage.html' %}
{% include 'admin/tag.html' %}
{% include 'admin/custom_field.html' %}
{{ accordion.footer() }}
{% include 'admin/refresh.html' %}
{% include 'admin/database.html' %}
+65
View File
@@ -0,0 +1,65 @@
{% import 'macro/accordion.html' as accordion %}
{{ accordion.header('Set custom fields', 'custom_field', 'metadata', expanded=open_custom_field, icon='edit-line', class='p-0') }}
{% if custom_field_error %}<div class="alert alert-danger m-2" role="alert"><strong>Error:</strong> {{ custom_field_error }}.</div>{% endif %}
<ul class="list-group list-group-flush">
{% if metadata_custom_fields | length %}
{% for custom_field in metadata_custom_fields %}
<li class="list-group-item">
<form action="{{ url_for('admin_custom_field.rename', id=custom_field.fields.id) }}" method="post" class="row row-cols-lg-auto g-3 align-items-center">
<div class="col-12 flex-grow-1">
<label class="visually-hidden" for="name-{{ custom_field.fields.id }}">Name</label>
<div class="input-group">
<div class="input-group-text">Name</div>
<input type="text" class="form-control" id="name-{{ custom_field.fields.id }}" name="name" value="{{ custom_field.fields.name }}">
<button type="submit" class="btn btn-primary"><i class="ri-edit-line"></i> Rename</button>
</div>
</div>
<div class="col-12">
<div class="input-group">
<div class="input-group-text"><i class="ri-shape-line"></i> Type</div>
<select class="form-select" id="type-{{ custom_field.fields.id }}"
data-changer-id="{{ custom_field.fields.id }}" data-changer-prefix="type" data-changer-url="{{ url_for('admin_custom_field.update_field', id=custom_field.fields.id, name='type') }}"
autocomplete="off">
<option value="text" {% if custom_field.fields.type == 'text' %}selected{% endif %}>Text</option>
<option value="number" {% if custom_field.fields.type == 'number' %}selected{% endif %}>Number</option>
<option value="date" {% if custom_field.fields.type == 'date' %}selected{% endif %}>Date</option>
</select>
<span id="status-type-{{ custom_field.fields.id }}" class="input-group-text ri-save-line px-1"></span>
</div>
</div>
<div class="col-12">
<a href="{{ url_for('admin_custom_field.delete', id=custom_field.fields.id) }}" class="btn btn-danger" role="button"><i class="ri-delete-bin-2-line"></i> Delete</a>
</div>
</form>
</li>
{% endfor %}
{% else %}
<li class="list-group-item text-center"><i class="ri-error-warning-line"></i> No custom field found.</li>
{% endif %}
<li class="list-group-item">
<form action="{{ url_for('admin_custom_field.add') }}" method="post" class="row row-cols-lg-auto g-3 align-items-center">
<div class="col-12 flex-grow-1">
<label class="visually-hidden" for="name">Name</label>
<div class="input-group">
<div class="input-group-text">Name</div>
<input type="text" class="form-control" id="name" name="name" value="">
</div>
</div>
<div class="col-12">
<div class="input-group">
<div class="input-group-text"><i class="ri-shape-line"></i> Type</div>
<select class="form-select" id="type" name="type" autocomplete="off">
<option value="text" selected>Text</option>
<option value="number">Number</option>
<option value="date">Date</option>
</select>
</div>
</div>
<div class="col-12">
<button type="submit" class="btn btn-primary"><i class="ri-add-circle-line"></i> Add</button>
</div>
</form>
</li>
</ul>
{{ accordion.footer() }}
+19
View File
@@ -0,0 +1,19 @@
{% import 'macro/accordion.html' as accordion %}
{{ accordion.header('Set custom fields danger zone', 'custom-field-danger', 'admin', expanded=true, danger=true, class='text-end') }}
<form action="{{ url_for('admin_custom_field.do_delete', id=custom_field.fields.id) }}" method="post">
{% if custom_field_error %}<div class="alert alert-danger text-start" role="alert"><strong>Error:</strong> {{ custom_field_error }}.</div>{% endif %}
<div class="alert alert-danger text-center" role="alert">You are about to <strong>delete a custom field</strong> and every value stored in it. This action is irreversible.</div>
<div class="row row-cols-lg-auto g-3 align-items-center">
<div class="col-12 flex-grow-1">
<div class="input-group">
<div class="input-group-text">Name</div>
<input type="text" class="form-control" value="{{ custom_field.fields.name }}" disabled>
</div>
</div>
</div>
<hr class="border-bottom">
<a class="btn btn-danger" href="{{ url_for('admin.admin', open_custom_field=true) }}" role="button"><i class="ri-arrow-left-long-line"></i> Back to the admin</a>
<button type="submit" class="btn btn-danger"><i class="ri-delete-bin-2-line"></i> Delete <strong>the custom field</strong></button>
</form>
{{ accordion.footer() }}
+15
View File
@@ -60,6 +60,21 @@
<a class="list-group-item list-group-item-action" href="{{ url_for('admin.admin', open_tag=true) }}"><i class="ri-settings-4-line"></i> Manage the set tags</a>
</div>
{{ accordion.footer() }}
{{ accordion.header('Custom fields', 'custom-field', 'set-management', icon='edit-line') }}
{% if brickset_custom_fields | length %}
<div class="row row-cols-1 g-2 pb-2">
{% for custom_field in brickset_custom_fields %}
<div class="col-12">
{{ form.input(custom_field.fields.name, item.fields.id, custom_field.as_dataset(), custom_field.url_for_set_state(item.fields.id), item.fields[custom_field.as_column()], icon=('calendar-line' if custom_field.fields.type == 'date' else ('hashtag' if custom_field.fields.type == 'number' else 'edit-line')), date=(custom_field.fields.type == 'date')) }}
</div>
{% endfor %}
</div>
<hr>
{% else %}
<p class="text-center"><i class="ri-error-warning-line"></i> No custom field found.</p>
{% endif %}
<a href="{{ url_for('admin.admin', open_custom_field=true) }}" class="btn btn-primary" role="button"><i class="ri-settings-4-line"></i> Manage the custom fields</a>
{{ accordion.footer() }}
{{ accordion.header('Data', 'data', 'set-management', icon='database-2-line') }}
<a href="{{ item.url_for_refresh() }}" class="btn btn-primary" role="button"><i class="ri-refresh-line"></i> Refresh the set data</a>
{{ accordion.footer() }}