fix(minifigures): metadata now works and saves correctly

This commit is contained in:
2025-10-10 10:54:07 +02:00
parent a8d36bc5f1
commit dc34916331
13 changed files with 331 additions and 31 deletions
+5
View File
@@ -459,6 +459,11 @@ class IndividualMinifigure(RebrickableMinifigure):
def url_for_description(self, /) -> str:
return url_for('individual_minifigure.update_description', id=self.fields.id)
# Parts
def generic_parts(self, /):
from .part_list import BrickPartList
return BrickPartList().from_individual_minifigure(self)
# Override from_rebrickable to handle minifigure data
@staticmethod
def from_rebrickable(data: dict[str, Any], /, **_) -> dict[str, Any]:
+104 -2
View File
@@ -181,7 +181,18 @@ class BrickPart(RebrickablePart):
# Compute the url for updating checked state
def url_for_checked(self, /) -> str:
# Different URL for a minifigure part
# Check if this is an individual minifigure (has minifigure with id field, no brickset)
if self.minifigure is not None and hasattr(self.minifigure.fields, 'id') and self.brickset is None:
# Individual minifigure part
return url_for(
'individual_minifigure.checked_part',
id=self.minifigure.fields.id,
part=self.fields.part,
color=self.fields.color,
spare=self.fields.spare,
)
# Set-based part (with or without minifigure)
if self.minifigure is not None:
figure = self.minifigure.fields.figure
else:
@@ -228,7 +239,19 @@ class BrickPart(RebrickablePart):
# Compute the url for problematic part
def url_for_problem(self, problem: str, /) -> str:
# Different URL for a minifigure part
# Check if this is an individual minifigure (has minifigure with id field, no brickset)
if self.minifigure is not None and hasattr(self.minifigure.fields, 'id') and self.brickset is None:
# Individual minifigure part
return url_for(
'individual_minifigure.problem_part',
id=self.minifigure.fields.id,
part=self.fields.part,
color=self.fields.color,
spare=self.fields.spare,
problem=problem,
)
# Set-based part (with or without minifigure)
if self.minifigure is not None:
figure = self.minifigure.fields.figure
else:
@@ -243,3 +266,82 @@ class BrickPart(RebrickablePart):
spare=self.fields.spare,
problem=problem,
)
# Select a specific part from an individual minifigure
def select_specific_individual_minifigure(
self,
minifigure: 'BrickMinifigure',
part: str,
color: int,
spare: int,
/,
) -> Self:
# Save the parameters to the fields
self.minifigure = minifigure
self.fields.id = minifigure.fields.id
self.fields.part = part
self.fields.color = color
self.fields.spare = spare
if not self.select(override_query='individual_minifigure/part/select/specific'):
raise NotFoundException(
'Part {part} with color {color} (spare: {spare}) from individual minifigure {figure} ({id}) was not found in the database'.format(
part=self.fields.part,
color=self.fields.color,
spare=self.fields.spare,
figure=self.minifigure.fields.figure,
id=self.minifigure.fields.id,
),
)
return self
# Update a problematic part for individual minifigure
def update_problem_individual_minifigure(self, problem: str, json: Any | None, /) -> int:
amount: str | int = json.get('value', '') # type: ignore
# We need a positive integer
try:
if amount == '':
amount = 0
amount = int(amount)
if amount < 0:
amount = 0
except Exception:
raise ErrorException('"{amount}" is not a valid integer'.format(
amount=amount
))
if amount < 0:
raise ErrorException('Cannot set a negative amount')
setattr(self.fields, problem, amount)
BrickSQL().execute_and_commit(
'individual_minifigure/part/update/{problem}'.format(problem=problem),
parameters=self.sql_parameters()
)
return amount
# Update checked state for individual minifigure part
def update_checked_individual_minifigure(self, json: Any | None, /) -> bool:
# Handle both direct 'checked' key and changer.js 'value' key format
if json:
checked = json.get('checked', json.get('value', False))
else:
checked = False
checked = bool(checked)
# Update the field
self.fields.checked = checked
BrickSQL().execute_and_commit(
'individual_minifigure/part/update/checked',
parameters=self.sql_parameters()
)
return checked
+18 -1
View File
@@ -25,6 +25,7 @@ class BrickPartList(BrickRecordList[BrickPart]):
all_query: str = 'part/list/all'
all_by_owner_query: str = 'part/list/all_by_owner'
different_color_query = 'part/list/with_different_color'
individual_minifigure_query: str = 'individual_minifigure/part/list/from_instance'
last_query: str = 'part/list/last'
minifigure_query: str = 'part/list/from_minifigure'
problem_query: str = 'part/list/problem'
@@ -212,6 +213,20 @@ class BrickPartList(BrickRecordList[BrickPart]):
return self
# Load parts from an individual minifigure instance
def from_individual_minifigure(
self,
minifigure: 'BrickMinifigure',
/,
) -> Self:
# Save the minifigure
self.minifigure = minifigure
# Load the parts from the database using the instance-specific query
self.list(override_query=self.individual_minifigure_query)
return self
# Load generic parts from a print
def from_print(
self,
@@ -306,9 +321,11 @@ class BrickPartList(BrickRecordList[BrickPart]):
def sql_parameters(self, /) -> dict[str, Any]:
parameters: dict[str, Any] = super().sql_parameters()
# Set id
# Set id - prioritize brickset, then check minifigure
if self.brickset is not None:
parameters['id'] = self.brickset.fields.id
elif self.minifigure is not None and hasattr(self.minifigure.fields, 'id'):
parameters['id'] = self.minifigure.fields.id
# Use the minifigure number if present,
if self.minifigure is not None:
@@ -0,0 +1,38 @@
-- Query parts for a specific individual minifigure instance
SELECT
"bricktracker_individual_minifigure_parts"."id",
"bricktracker_individual_minifigures"."figure",
"bricktracker_individual_minifigure_parts"."part",
"bricktracker_individual_minifigure_parts"."color",
"bricktracker_individual_minifigure_parts"."spare",
"bricktracker_individual_minifigure_parts"."quantity",
"bricktracker_individual_minifigure_parts"."element",
"bricktracker_individual_minifigure_parts"."missing" AS "total_missing",
"bricktracker_individual_minifigure_parts"."damaged" AS "total_damaged",
"bricktracker_individual_minifigure_parts"."checked",
"rebrickable_parts"."color_name",
"rebrickable_parts"."color_rgb",
"rebrickable_parts"."color_transparent",
"rebrickable_parts"."bricklink_color_id",
"rebrickable_parts"."bricklink_color_name",
"rebrickable_parts"."bricklink_part_num",
"rebrickable_parts"."name",
"rebrickable_parts"."image",
"rebrickable_parts"."image_id",
"rebrickable_parts"."url",
"rebrickable_parts"."print",
NULL AS "total_quantity",
NULL AS "total_spare",
NULL AS "total_sets",
NULL AS "total_minifigures"
FROM "bricktracker_individual_minifigure_parts"
INNER JOIN "bricktracker_individual_minifigures"
ON "bricktracker_individual_minifigure_parts"."id" = "bricktracker_individual_minifigures"."id"
INNER JOIN "rebrickable_parts"
ON "bricktracker_individual_minifigure_parts"."part" = "rebrickable_parts"."part"
AND "bricktracker_individual_minifigure_parts"."color" = "rebrickable_parts"."color_id"
WHERE "bricktracker_individual_minifigure_parts"."id" IS NOT DISTINCT FROM :id
{% if order %}
ORDER BY {{ order | replace('"combined"', '"bricktracker_individual_minifigure_parts"') | replace('"bricktracker_parts"', '"bricktracker_individual_minifigure_parts"') }}
{% endif %}
@@ -0,0 +1,33 @@
-- Select a specific part from an individual minifigure instance
SELECT
"bricktracker_individual_minifigure_parts"."id",
"bricktracker_individual_minifigures"."figure",
"bricktracker_individual_minifigure_parts"."part",
"bricktracker_individual_minifigure_parts"."color",
"bricktracker_individual_minifigure_parts"."spare",
"bricktracker_individual_minifigure_parts"."quantity",
"bricktracker_individual_minifigure_parts"."element",
"bricktracker_individual_minifigure_parts"."missing",
"bricktracker_individual_minifigure_parts"."damaged",
"bricktracker_individual_minifigure_parts"."checked",
"rebrickable_parts"."color_name",
"rebrickable_parts"."color_rgb",
"rebrickable_parts"."color_transparent",
"rebrickable_parts"."bricklink_color_id",
"rebrickable_parts"."bricklink_color_name",
"rebrickable_parts"."bricklink_part_num",
"rebrickable_parts"."name",
"rebrickable_parts"."image",
"rebrickable_parts"."image_id",
"rebrickable_parts"."url",
"rebrickable_parts"."print"
FROM "bricktracker_individual_minifigure_parts"
INNER JOIN "bricktracker_individual_minifigures"
ON "bricktracker_individual_minifigure_parts"."id" = "bricktracker_individual_minifigures"."id"
INNER JOIN "rebrickable_parts"
ON "bricktracker_individual_minifigure_parts"."part" = "rebrickable_parts"."part"
AND "bricktracker_individual_minifigure_parts"."color" = "rebrickable_parts"."color_id"
WHERE "bricktracker_individual_minifigure_parts"."id" IS NOT DISTINCT FROM :id
AND "bricktracker_individual_minifigure_parts"."part" IS NOT DISTINCT FROM :part
AND "bricktracker_individual_minifigure_parts"."color" IS NOT DISTINCT FROM :color
AND "bricktracker_individual_minifigure_parts"."spare" IS NOT DISTINCT FROM :spare
@@ -0,0 +1,6 @@
UPDATE "bricktracker_individual_minifigure_parts"
SET "checked" = :checked
WHERE "bricktracker_individual_minifigure_parts"."id" IS NOT DISTINCT FROM :id
AND "bricktracker_individual_minifigure_parts"."part" IS NOT DISTINCT FROM :part
AND "bricktracker_individual_minifigure_parts"."color" IS NOT DISTINCT FROM :color
AND "bricktracker_individual_minifigure_parts"."spare" IS NOT DISTINCT FROM :spare
@@ -0,0 +1,6 @@
UPDATE "bricktracker_individual_minifigure_parts"
SET "damaged" = :damaged
WHERE "bricktracker_individual_minifigure_parts"."id" IS NOT DISTINCT FROM :id
AND "bricktracker_individual_minifigure_parts"."part" IS NOT DISTINCT FROM :part
AND "bricktracker_individual_minifigure_parts"."color" IS NOT DISTINCT FROM :color
AND "bricktracker_individual_minifigure_parts"."spare" IS NOT DISTINCT FROM :spare
@@ -0,0 +1,6 @@
UPDATE "bricktracker_individual_minifigure_parts"
SET "missing" = :missing
WHERE "bricktracker_individual_minifigure_parts"."id" IS NOT DISTINCT FROM :id
AND "bricktracker_individual_minifigure_parts"."part" IS NOT DISTINCT FROM :part
AND "bricktracker_individual_minifigure_parts"."color" IS NOT DISTINCT FROM :color
AND "bricktracker_individual_minifigure_parts"."spare" IS NOT DISTINCT FROM :spare
@@ -1,6 +1,7 @@
{% extends 'minifigure/base/base.sql' %}
{% block where %}
WHERE "bricktracker_minifigures"."id" IS NOT DISTINCT FROM :id
AND "rebrickable_minifigures"."figure" IS NOT DISTINCT FROM :figure
WHERE "combined"."id" IS NOT DISTINCT FROM :id
AND "combined"."figure" IS NOT DISTINCT FROM :figure
AND "combined"."source_type" = 'set'
{% endblock %}
+87 -10
View File
@@ -1,8 +1,11 @@
from flask import Blueprint, redirect, render_template, request, url_for
import logging
from flask import Blueprint, jsonify, redirect, render_template, request, url_for, Response
from flask_login import login_required
from .exceptions import exception_handler
from ..individual_minifigure import IndividualMinifigure
from ..part import BrickPart
from ..set_list import set_metadata_lists
from ..set_owner_list import BrickSetOwnerList
from ..set_tag_list import BrickSetTagList
@@ -10,6 +13,8 @@ from ..set_storage_list import BrickSetStorageList
from ..set_purchase_location_list import BrickSetPurchaseLocationList
from ..sql import BrickSQL
logger = logging.getLogger(__name__)
individual_minifigure_page = Blueprint('individual_minifigure', __name__, url_prefix='/individual-minifigures')
@@ -86,7 +91,7 @@ def update_quantity(*, id: str):
}
)
return redirect(url_for('individual_minifigure.details', id=id))
return jsonify({'success': True})
# Update description
@@ -108,7 +113,7 @@ def update_description(*, id: str):
}
)
return redirect(url_for('individual_minifigure.details', id=id))
return jsonify({'success': True})
# Update owner
@@ -117,10 +122,10 @@ def update_description(*, id: str):
@exception_handler(__file__)
def update_owner(*, id: str, metadata_id: str):
item = IndividualMinifigure().select_by_id(id)
owner = BrickSetOwnerList.from_id(metadata_id)
owner = BrickSetOwnerList.get(metadata_id)
owner.update_individual_minifigure_state(item, json=request.json)
return redirect(url_for('individual_minifigure.details', id=id))
return jsonify({'success': True})
# Update tag
@@ -129,10 +134,10 @@ def update_owner(*, id: str, metadata_id: str):
@exception_handler(__file__)
def update_tag(*, id: str, metadata_id: str):
item = IndividualMinifigure().select_by_id(id)
tag = BrickSetTagList.from_id(metadata_id)
tag = BrickSetTagList.get(metadata_id)
tag.update_individual_minifigure_state(item, json=request.json)
return redirect(url_for('individual_minifigure.details', id=id))
return jsonify({'success': True})
# Update status
@@ -145,7 +150,7 @@ def update_status(*, id: str, metadata_id: str):
status = BrickSetStatusList.get(metadata_id)
status.update_individual_minifigure_state(item, json=request.json)
return redirect(url_for('individual_minifigure.details', id=id))
return jsonify({'success': True})
# Update storage
@@ -167,7 +172,7 @@ def update_storage(*, id: str):
}
)
return redirect(url_for('individual_minifigure.details', id=id))
return jsonify({'success': True})
# Update purchase location
@@ -189,7 +194,79 @@ def update_purchase_location(*, id: str):
}
)
return redirect(url_for('individual_minifigure.details', id=id))
return jsonify({'success': True})
# Update problematic pieces of an individual minifigure
@individual_minifigure_page.route('/<id>/parts/<part>/<int:color>/<int:spare>/<problem>', methods=['POST'])
@login_required
@exception_handler(__file__, json=True)
def problem_part(
*,
id: str,
part: str,
color: int,
spare: int,
problem: str,
) -> Response:
minifigure = IndividualMinifigure().select_by_id(id)
brickpart = BrickPart().select_specific_individual_minifigure(
minifigure,
part,
color,
spare,
)
amount = brickpart.update_problem_individual_minifigure(problem, request.json)
# Info
logger.info('Individual minifigure {figure} ({id}): updated part ({part} color: {color}, spare: {spare}) {problem} count to {amount}'.format(
figure=minifigure.fields.figure,
id=minifigure.fields.id,
part=brickpart.fields.part,
color=brickpart.fields.color,
spare=brickpart.fields.spare,
problem=problem,
amount=amount
))
return jsonify({problem: amount})
# Update checked state of parts
@individual_minifigure_page.route('/<id>/parts/<part>/<int:color>/<int:spare>/checked', methods=['POST'])
@login_required
@exception_handler(__file__, json=True)
def checked_part(
*,
id: str,
part: str,
color: int,
spare: int,
) -> Response:
minifigure = IndividualMinifigure().select_by_id(id)
brickpart = BrickPart().select_specific_individual_minifigure(
minifigure,
part,
color,
spare,
)
checked = brickpart.update_checked_individual_minifigure(request.json)
# Info
logger.info('Individual minifigure {figure} ({id}): updated part ({part} color: {color}, spare: {spare}) checked state to {checked}'.format(
figure=minifigure.fields.figure,
id=minifigure.fields.id,
part=brickpart.fields.part,
color=brickpart.fields.color,
spare=brickpart.fields.spare,
checked=checked
))
return jsonify({'checked': checked})
# Delete individual minifigure instance
+14 -6
View File
@@ -136,6 +136,8 @@ class BrickChanger {
break;
case "text":
case "number":
case "textarea":
case "select":
value = this.html_element.value;
break;
@@ -191,10 +193,16 @@ class BrickChanger {
// Helper to setup the changer
const setup_changers = () => document.querySelectorAll("*[data-changer-id]").forEach(
el => new BrickChanger(
el.dataset.changerPrefix,
el.dataset.changerId,
el.dataset.changerUrl,
el.dataset.changerParent
)
el => {
try {
new BrickChanger(
el.dataset.changerPrefix,
el.dataset.changerId,
el.dataset.changerUrl,
el.dataset.changerParent
);
} catch (error) {
console.error('Error setting up changer for element:', el, 'Error:', error);
}
}
);
+5 -4
View File
@@ -25,16 +25,17 @@
<img class="card-medium-img" src="{{ item.url_for_image() }}" alt="{{ item.fields.figure }}" loading="lazy">
</a>
</div>
<div class="accordion accordion-flush border-top" id="individual-minifigure-details">
{{ accordion.header('Quantity', 'quantity', 'individual-minifigure-details', icon='functions') }}
<div class="accordion accordion-flush border-top" id="individual-minifigure-details-{{ item.fields.id }}">
{{ accordion.header('Quantity', 'accordion-quantity-' ~ item.fields.id, 'individual-minifigure-details-' ~ item.fields.id, icon='functions') }}
{{ form.input('Quantity', item.fields.id, 'quantity', item.url_for_quantity(), item.fields.quantity, icon='functions', type='number') }}
{{ accordion.footer() }}
{{ accordion.header('Description', 'description-section', 'individual-minifigure-details', icon='file-text-line') }}
{{ accordion.header('Description', 'accordion-description-' ~ item.fields.id, 'individual-minifigure-details-' ~ item.fields.id, icon='file-text-line') }}
{{ form.input('Description', item.fields.id, 'description', item.url_for_description(), item.fields.description or '', icon='file-text-line', textarea=true) }}
{{ accordion.footer() }}
{{ accordion.table(item.generic_parts(), 'Parts', 'accordion-parts-' ~ item.fields.id, 'individual-minifigure-details-' ~ item.fields.id, 'part/table.html', icon='shapes-line', alt=item.fields.figure) }}
{% include 'individual_minifigure/management.html' %}
{% if g.login.is_authenticated() %}
{{ accordion.header('Danger zone', 'danger-zone', 'individual-minifigure-details', danger=true, class='text-end') }}
{{ accordion.header('Danger zone', 'accordion-danger-zone-' ~ item.fields.id, 'individual-minifigure-details-' ~ item.fields.id, danger=true, class='text-end') }}
<a href="{{ url_for('individual_minifigure.delete', id=item.fields.id) }}" class="btn btn-danger" role="button" data-bs-toggle="modal" data-bs-target="#deleteModal"><i class="ri-close-line"></i> Delete this individual minifigure instance</a>
{{ accordion.footer() }}
{% endif %}
@@ -2,8 +2,8 @@
{% import 'macro/form.html' as form %}
{% if g.login.is_authenticated() %}
{{ accordion.header('Management', 'individual-minifigure-management', 'individual-minifigure-details', icon='settings-4-line', class='p-0') }}
{{ accordion.header('Owners', 'owner', 'individual-minifigure-management', icon='group-line', class='p-0') }}
{{ accordion.header('Management', 'accordion-management-' ~ item.fields.id, 'individual-minifigure-details-' ~ item.fields.id, icon='settings-4-line', class='p-0') }}
{{ accordion.header('Owners', 'accordion-owners-' ~ item.fields.id, 'accordion-management-' ~ item.fields.id, icon='group-line', class='p-0') }}
<ul class="list-group list-group-flush">
{% if brickset_owners | length %}
{% for owner in brickset_owners %}
@@ -17,7 +17,7 @@
<a class="list-group-item list-group-item-action" href="{{ url_for('admin.admin', open_owner=true) }}"><i class="ri-settings-4-line"></i> Manage the minifigure owners</a>
</div>
{{ accordion.footer() }}
{{ accordion.header('Storage', 'storage', 'individual-minifigure-management', icon='archive-2-line') }}
{{ accordion.header('Storage', 'accordion-storage-' ~ item.fields.id, 'accordion-management-' ~ item.fields.id, icon='archive-2-line') }}
{% if brickset_storages | length %}
{{ form.select('Storage', item.fields.id, brickset_storages.as_prefix(), brickset_storages.url_for_individual_minifigure_value(item.fields.id), item.fields.storage, brickset_storages, icon='building-line') }}
{% else %}
@@ -26,7 +26,7 @@
<hr>
<a href="{{ url_for('admin.admin', open_storage=true) }}" class="btn btn-primary" role="button"><i class="ri-settings-4-line"></i> Manage the storages</a>
{{ accordion.footer() }}
{{ accordion.header('Purchase', 'purchase', 'individual-minifigure-management', icon='wallet-3-line') }}
{{ accordion.header('Purchase', 'accordion-purchase-' ~ item.fields.id, 'accordion-management-' ~ item.fields.id, icon='wallet-3-line') }}
<div class="row row-cols-lg-auto g-1 justify-content-start align-items-center pb-2">
<div class="col-12 flex-grow-1">
{% if brickset_purchase_locations | length %}
@@ -39,7 +39,7 @@
<hr>
<a href="{{ url_for('admin.admin', open_purchase_location=true) }}" class="btn btn-primary" role="button"><i class="ri-settings-4-line"></i> Manage the purchase locations</a>
{{ accordion.footer() }}
{{ accordion.header('Statuses', 'status', 'individual-minifigure-management', icon='checkbox-line', class='p-0') }}
{{ accordion.header('Statuses', 'accordion-statuses-' ~ item.fields.id, 'accordion-management-' ~ item.fields.id, icon='checkbox-line', class='p-0') }}
<ul class="list-group list-group-flush">
{% if brickset_statuses | length %}
{% for status in brickset_statuses %}
@@ -53,7 +53,7 @@
<a class="list-group-item list-group-item-action" href="{{ url_for('admin.admin', open_status=true) }}"><i class="ri-settings-4-line"></i> Manage the statuses</a>
</div>
{{ accordion.footer() }}
{{ accordion.header('Tags', 'tag', 'individual-minifigure-management', icon='price-tag-2-line', class='p-0') }}
{{ accordion.header('Tags', 'accordion-tags-' ~ item.fields.id, 'accordion-management-' ~ item.fields.id, icon='price-tag-2-line', class='p-0') }}
<ul class="list-group list-group-flush">
{% if brickset_tags | length %}
{% for tag in brickset_tags %}