diff --git a/bricktracker/views/individual_minifigure.py b/bricktracker/views/individual_minifigure.py new file mode 100644 index 0000000..ca4508e --- /dev/null +++ b/bricktracker/views/individual_minifigure.py @@ -0,0 +1,405 @@ +import logging +from functools import wraps + +from flask import ( + Blueprint, + Response, + abort, + current_app, + jsonify, + redirect, + render_template, + request, + url_for, +) +from flask_login import login_required + +from .exceptions import exception_handler +from ..individual_minifigure import IndividualMinifigure +from ..individual_minifigure_list import IndividualMinifigureList +from ..part import BrickPart +from ..set_list import set_metadata_lists +from ..set_owner_list import BrickSetOwnerList +from ..set_tag_list import BrickSetTagList +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') + + +def require_individual_minifigures_write(f): + """Decorator to block write operations (add/update/delete) when individual minifigures are disabled. + This prevents adding new minifigures but allows viewing existing ones.""" + @wraps(f) + def decorated_function(*args, **kwargs): + if current_app.config.get('DISABLE_INDIVIDUAL_MINIFIGURES', False): + abort(404) + return f(*args, **kwargs) + return decorated_function + + +# List all individual minifigures +@individual_minifigure_page.route('/') +@exception_handler(__file__) +def list() -> str: + minifigures = IndividualMinifigureList().all() + + return render_template( + 'individual_minifigures.html', + minifigures=minifigures, + **set_metadata_lists(as_class=True) + ) + + +# Individual minifigure instance details/edit +@individual_minifigure_page.route('/') +@exception_handler(__file__) +def details(*, id: str) -> str: + item = IndividualMinifigure().select_by_id(id) + + return render_template( + 'individual_minifigure/details.html', + item=item, + **set_metadata_lists(as_class=True) + ) + + +# Update individual minifigure instance +@individual_minifigure_page.route('//update', methods=['POST']) +@exception_handler(__file__) +@require_individual_minifigures_write +def update(*, id: str): + item = IndividualMinifigure().select_by_id(id) + + # Update basic fields + item.fields.quantity = int(request.form.get('quantity', 1)) + item.fields.description = request.form.get('description', '') + item.fields.storage = request.form.get('storage') or None + item.fields.purchase_location = request.form.get('purchase_location') or None + item.fields.purchase_date = request.form.get('purchase_date') or None + item.fields.purchase_price = request.form.get('purchase_price') or None + + # Update the individual minifigure + from ..sql import BrickSQL + BrickSQL().execute( + 'individual_minifigure/update', + parameters={ + 'id': item.fields.id, + 'quantity': item.fields.quantity, + 'description': item.fields.description, + 'storage': item.fields.storage, + 'purchase_location': item.fields.purchase_location, + 'purchase_date': item.fields.purchase_date, + 'purchase_price': item.fields.purchase_price, + }, + commit=False, + ) + + # Update owners + owners = request.form.getlist('owners') + for owner in BrickSetOwnerList.list(): + owner.update_individual_minifigure_state(item, state=(owner.fields.id in owners)) + + # Update tags + tags = request.form.getlist('tags') + for tag in BrickSetTagList.list(): + tag.update_individual_minifigure_state(item, state=(tag.fields.id in tags)) + + BrickSQL().commit() + + return redirect(url_for('individual_minifigure.details', id=id)) + + +# Update quantity +@individual_minifigure_page.route('//update/quantity', methods=['POST']) +@exception_handler(__file__) +@require_individual_minifigures_write +@login_required +def update_quantity(*, id: str): + item = IndividualMinifigure().select_by_id(id) + item.fields.quantity = int(request.json.get('value', 1)) + + BrickSQL().execute_and_commit( + 'individual_minifigure/update', + parameters={ + 'id': item.fields.id, + 'quantity': item.fields.quantity, + 'description': item.fields.description, + 'storage': item.fields.storage, + 'purchase_location': item.fields.purchase_location if hasattr(item.fields, 'purchase_location') else None, + 'purchase_date': item.fields.purchase_date if hasattr(item.fields, 'purchase_date') else None, + 'purchase_price': item.fields.purchase_price if hasattr(item.fields, 'purchase_price') else None, + } + ) + + return jsonify({'success': True}) + + +# Update description +@individual_minifigure_page.route('//update/description', methods=['POST']) +@exception_handler(__file__) +@require_individual_minifigures_write +@login_required +def update_description(*, id: str): + item = IndividualMinifigure().select_by_id(id) + item.fields.description = request.json.get('value', '') + + BrickSQL().execute_and_commit( + 'individual_minifigure/update', + parameters={ + 'id': item.fields.id, + 'quantity': item.fields.quantity, + 'description': item.fields.description, + 'storage': item.fields.storage, + 'purchase_location': item.fields.purchase_location if hasattr(item.fields, 'purchase_location') else None, + 'purchase_date': item.fields.purchase_date if hasattr(item.fields, 'purchase_date') else None, + 'purchase_price': item.fields.purchase_price if hasattr(item.fields, 'purchase_price') else None, + } + ) + + return jsonify({'success': True}) + + +# Update owner +@individual_minifigure_page.route('//update/owner/', methods=['POST']) +@exception_handler(__file__) +@require_individual_minifigures_write +@login_required +def update_owner(*, id: str, metadata_id: str): + item = IndividualMinifigure().select_by_id(id) + owner = BrickSetOwnerList.get(metadata_id) + owner.update_individual_minifigure_state(item, json=request.json) + + return jsonify({'success': True}) + + +# Update tag +@individual_minifigure_page.route('//update/tag/', methods=['POST']) +@exception_handler(__file__) +@require_individual_minifigures_write +@login_required +def update_tag(*, id: str, metadata_id: str): + item = IndividualMinifigure().select_by_id(id) + tag = BrickSetTagList.get(metadata_id) + tag.update_individual_minifigure_state(item, json=request.json) + + return jsonify({'success': True}) + + +# Update status +@individual_minifigure_page.route('//update/status/', methods=['POST']) +@exception_handler(__file__) +@require_individual_minifigures_write +@login_required +def update_status(*, id: str, metadata_id: str): + item = IndividualMinifigure().select_by_id(id) + from ..set_status_list import BrickSetStatusList + status = BrickSetStatusList.get(metadata_id) + status.update_individual_minifigure_state(item, json=request.json) + + return jsonify({'success': True}) + + +# Update storage +@individual_minifigure_page.route('//update/storage', methods=['POST']) +@exception_handler(__file__) +@require_individual_minifigures_write +@login_required +def update_storage(*, id: str): + item = IndividualMinifigure().select_by_id(id) + storage_id = request.json.get('value') + + BrickSQL().execute_and_commit( + 'individual_minifigure/update', + parameters={ + 'id': item.fields.id, + 'quantity': item.fields.quantity, + 'description': item.fields.description, + 'storage': storage_id if storage_id else None, + 'purchase_location': item.fields.purchase_location if hasattr(item.fields, 'purchase_location') else None, + 'purchase_date': item.fields.purchase_date if hasattr(item.fields, 'purchase_date') else None, + 'purchase_price': item.fields.purchase_price if hasattr(item.fields, 'purchase_price') else None, + } + ) + + return jsonify({'success': True}) + + +# Update purchase location +@individual_minifigure_page.route('//update/purchase_location', methods=['POST']) +@exception_handler(__file__) +@require_individual_minifigures_write +@login_required +def update_purchase_location(*, id: str): + item = IndividualMinifigure().select_by_id(id) + location_id = request.json.get('value') + + BrickSQL().execute_and_commit( + 'individual_minifigure/update', + parameters={ + 'id': item.fields.id, + 'quantity': item.fields.quantity, + 'description': item.fields.description, + 'storage': item.fields.storage, + 'purchase_location': location_id if location_id else None, + 'purchase_date': item.fields.purchase_date if hasattr(item.fields, 'purchase_date') else None, + 'purchase_price': item.fields.purchase_price if hasattr(item.fields, 'purchase_price') else None, + } + ) + + return jsonify({'success': True}) + + +# Update purchase date +@individual_minifigure_page.route('//update/purchase_date', methods=['POST']) +@exception_handler(__file__) +@require_individual_minifigures_write +@login_required +def update_purchase_date(*, id: str): + from datetime import datetime + + item = IndividualMinifigure().select_by_id(id) + purchase_date_str = request.json.get('value') + + # Convert date string to Unix timestamp (same as sets) + purchase_date = None + if purchase_date_str and purchase_date_str != '': + try: + purchase_date = datetime.strptime(purchase_date_str, '%Y/%m/%d').timestamp() + except ValueError as e: + logger.error('Invalid date format: {date}'.format(date=purchase_date_str)) + return jsonify({'error': '{date} is not a valid date'.format(date=purchase_date_str)}), 400 + + BrickSQL().execute_and_commit( + 'individual_minifigure/update', + parameters={ + 'id': item.fields.id, + 'quantity': item.fields.quantity, + 'description': item.fields.description, + 'storage': item.fields.storage, + 'purchase_location': item.fields.purchase_location if hasattr(item.fields, 'purchase_location') else None, + 'purchase_date': purchase_date, + 'purchase_price': item.fields.purchase_price if hasattr(item.fields, 'purchase_price') else None, + } + ) + + logger.info('Updated individual minifigure {id} purchase_date to: {date}'.format( + id=id, + date=purchase_date + )) + return jsonify({'success': True}) + + +# Update purchase price +@individual_minifigure_page.route('//update/purchase_price', methods=['POST']) +@exception_handler(__file__) +@require_individual_minifigures_write +@login_required +def update_purchase_price(*, id: str): + item = IndividualMinifigure().select_by_id(id) + purchase_price = request.json.get('value') + + BrickSQL().execute_and_commit( + 'individual_minifigure/update', + parameters={ + 'id': item.fields.id, + 'quantity': item.fields.quantity, + 'description': item.fields.description, + 'storage': item.fields.storage, + 'purchase_location': item.fields.purchase_location if hasattr(item.fields, 'purchase_location') else None, + 'purchase_date': item.fields.purchase_date if hasattr(item.fields, 'purchase_date') else None, + 'purchase_price': purchase_price if purchase_price else None, + } + ) + + return jsonify({'success': True}) + + +# Update problematic pieces of an individual minifigure +@individual_minifigure_page.route('//parts////', methods=['POST']) +@exception_handler(__file__, json=True) +@require_individual_minifigures_write +@login_required +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('//parts////checked', methods=['POST']) +@exception_handler(__file__, json=True) +@require_individual_minifigures_write +@login_required +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 +@individual_minifigure_page.route('//delete', methods=['POST']) +@exception_handler(__file__) +@require_individual_minifigures_write +@login_required +def delete(*, id: str): + item = IndividualMinifigure().select_by_id(id) + figure = item.fields.figure + item.delete() + + return redirect(url_for('minifigure.details', figure=figure)) diff --git a/bricktracker/views/individual_part.py b/bricktracker/views/individual_part.py new file mode 100644 index 0000000..43a9811 --- /dev/null +++ b/bricktracker/views/individual_part.py @@ -0,0 +1,659 @@ +import logging +from functools import wraps + +from flask import ( + Blueprint, + Response, + abort, + current_app, + jsonify, + redirect, + render_template, + request, + url_for, +) +from flask_login import login_required + +from .exceptions import exception_handler +from ..individual_part import IndividualPart +from ..individual_part_list import IndividualPartList +from ..individual_part_lot import IndividualPartLot +from ..individual_part_lot_list import IndividualPartLotList +from ..set_list import set_metadata_lists +from ..set_owner_list import BrickSetOwnerList +from ..set_tag_list import BrickSetTagList +from ..set_storage_list import BrickSetStorageList +from ..set_purchase_location_list import BrickSetPurchaseLocationList +from ..sql import BrickSQL + +logger = logging.getLogger(__name__) + +individual_part_page = Blueprint('individual_part', __name__, url_prefix='/individual-parts') + + +def require_individual_parts_write(f): + """Decorator to block write operations (add/update/delete) when individual parts are disabled. + This prevents adding new parts but allows viewing existing ones.""" + @wraps(f) + def decorated_function(*args, **kwargs): + if current_app.config.get('DISABLE_INDIVIDUAL_PARTS', False): + abort(404) + return f(*args, **kwargs) + return decorated_function + + +# List all individual parts +@individual_part_page.route('/') +@exception_handler(__file__) +def list() -> str: + parts = IndividualPartList().all() + + return render_template( + 'individual_parts.html', + parts=parts, + **set_metadata_lists(as_class=True) + ) + + +# Quick add individual part from set parts table +@individual_part_page.route('/quick-add', methods=['POST']) +@exception_handler(__file__) +@require_individual_parts_write +@login_required +def quick_add() -> Response: + """Quick add an individual part from a set's parts table""" + import uuid + + data = request.get_json() + + # Validate required fields + if not data or 'part' not in data or 'color' not in data: + return jsonify({'error': 'Missing required fields: part and color'}), 400 + + # Generate UUID for new individual part + part_id = str(uuid.uuid4()) + + # Extract fields with defaults + quantity = data.get('quantity', 1) + storage = data.get('storage') or None + purchase_location = data.get('purchase_location') or None + description = data.get('description') or None + + try: + sql = BrickSQL() + + # Insert the individual part + sql.execute( + 'individual_part/insert', + parameters={ + 'id': part_id, + 'part': data['part'], + 'color': data['color'], + 'quantity': quantity, + 'missing': 0, + 'damaged': 0, + 'checked': False, + 'description': description, + 'lot_id': None, # Not part of a lot + 'storage': storage, + 'purchase_location': purchase_location, + 'purchase_date': None, + 'purchase_price': None + } + ) + + # Initialize metadata rows (owners, tags, statuses) with default values + # Uses the same SQL as updating set metadata, consolidated tables accept any entity id + + # Initialize owner metadata (all set to 0 initially) + for owner in BrickSetOwnerList.list(): + sql.execute( + 'set/metadata/owner/update/state', + parameters={ + 'set_id': part_id, + 'state': 0 + }, + name=owner.as_column() + ) + + # Initialize tag metadata (all set to 0 initially) + for tag in BrickSetTagList.list(): + sql.execute( + 'set/metadata/tag/update/state', + parameters={ + 'set_id': part_id, + 'state': 0 + }, + name=tag.as_column() + ) + + # Initialize status metadata (all set to 0 initially) + # Note: Individual parts (not lots) support statuses + from ..set_status_list import BrickSetStatusList + for status in BrickSetStatusList.list(): + sql.execute( + 'set/metadata/status/update/state', + parameters={ + 'set_id': part_id, + 'state': 0 + }, + name=status.as_column() + ) + + sql.commit() + + logger.info('Quick-added individual part {id}: {part}/{color} x{quantity}'.format( + id=part_id, + part=data["part"], + color=data["color"], + quantity=quantity + )) + + return jsonify({ + 'success': True, + 'id': part_id, + 'message': 'Added {quantity}x {part} to individual parts inventory'.format( + quantity=quantity, + part=data["part"] + ) + }), 201 + + except Exception as e: + logger.error('Error quick-adding individual part: {error}'.format(error=e)) + return jsonify({'error': str(e)}), 500 + + +# Individual part instance details/edit +@individual_part_page.route('/') +@exception_handler(__file__) +def details(*, id: str) -> str: + item = IndividualPart().select_by_id(id) + + # Check if this part belongs to a lot + lot = None + if hasattr(item.fields, 'lot_id') and item.fields.lot_id: + try: + lot = IndividualPartLot().select_by_id(item.fields.lot_id) + except Exception as e: + logger.warning('Could not load lot {lot_id} for part {part_id}: {error}'.format( + lot_id=item.fields.lot_id, + part_id=id, + error=e + )) + + return render_template( + 'individual_part/details.html', + item=item, + lot=lot, + **set_metadata_lists(as_class=True) + ) + + +# Update individual part instance +@individual_part_page.route('//update', methods=['POST']) +@exception_handler(__file__) +@require_individual_parts_write +def update(*, id: str): + item = IndividualPart().select_by_id(id) + + # Update basic fields + item.fields.quantity = int(request.form.get('quantity', 1)) + item.fields.description = request.form.get('description', '') + item.fields.storage = request.form.get('storage') or None + item.fields.purchase_location = request.form.get('purchase_location') or None + item.fields.purchase_date = request.form.get('purchase_date') or None + item.fields.purchase_price = request.form.get('purchase_price') or None + + # Update the individual part + BrickSQL().execute( + 'individual_part/update_full', + parameters={ + 'id': item.fields.id, + 'quantity': item.fields.quantity, + 'description': item.fields.description, + 'storage': item.fields.storage, + 'purchase_location': item.fields.purchase_location, + 'purchase_date': item.fields.purchase_date, + 'purchase_price': item.fields.purchase_price, + }, + commit=False, + ) + + # Update owners + owners = request.form.getlist('owners') + for owner in BrickSetOwnerList.list(): + owner.update_individual_part_state(item, state=(owner.fields.id in owners)) + + # Update tags + tags = request.form.getlist('tags') + for tag in BrickSetTagList.list(): + tag.update_individual_part_state(item, state=(tag.fields.id in tags)) + + BrickSQL().commit() + + return redirect(url_for('individual_part.details', id=id)) + + +# Update quantity +@individual_part_page.route('//update/quantity', methods=['POST']) +@exception_handler(__file__) +@require_individual_parts_write +@login_required +def update_quantity(*, id: str): + item = IndividualPart().select_by_id(id) + value = request.json.get('value', '1') + + # Handle empty string or 0 - don't allow it + if value == '' or value == '0' or value == 0: + return jsonify({'success': False, 'error': 'Quantity cannot be 0. Use the delete button in the menu to remove this part from the lot.'}) + + try: + quantity = int(value) + if quantity < 1: + return jsonify({'success': False, 'error': 'Quantity must be at least 1'}) + except ValueError: + return jsonify({'success': False, 'error': 'Invalid quantity value'}) + + item.update_field('quantity', quantity) + return jsonify({'success': True}) + + +# Update description +@individual_part_page.route('//update/description', methods=['POST']) +@exception_handler(__file__) +@require_individual_parts_write +@login_required +def update_description(*, id: str): + item = IndividualPart().select_by_id(id) + description = request.json.get('value', '') + item.update_field('description', description) + return jsonify({'success': True}) + + +# Update purchase date +@individual_part_page.route('//update/purchase_date', methods=['POST']) +@exception_handler(__file__) +@require_individual_parts_write +@login_required +def update_purchase_date(*, id: str): + item = IndividualPart().select_by_id(id) + purchase_date = request.json.get('value', '') + + # Convert date string to timestamp if provided, otherwise None + if purchase_date and purchase_date.strip(): + try: + from datetime import datetime + # Expecting yyyy/mm/dd format + date_obj = datetime.strptime(purchase_date, '%Y/%m/%d') + timestamp = date_obj.timestamp() + item.update_field('purchase_date', timestamp) + except ValueError: + return jsonify({'success': False, 'error': 'Invalid date format. Expected yyyy/mm/dd'}) + else: + item.update_field('purchase_date', None) + + return jsonify({'success': True}) + + +# Update purchase price +@individual_part_page.route('//update/purchase_price', methods=['POST']) +@exception_handler(__file__) +@require_individual_parts_write +@login_required +def update_purchase_price(*, id: str): + item = IndividualPart().select_by_id(id) + purchase_price = request.json.get('value', '') + + # Convert to float if provided, otherwise None + if purchase_price and str(purchase_price).strip(): + try: + price = float(purchase_price) + item.update_field('purchase_price', price) + except ValueError: + return jsonify({'success': False, 'error': 'Invalid price value'}) + else: + item.update_field('purchase_price', None) + + return jsonify({'success': True}) + + +# Update owner +@individual_part_page.route('//update/owner/', methods=['POST']) +@exception_handler(__file__) +@require_individual_parts_write +@login_required +def update_owner(*, id: str, metadata_id: str): + item = IndividualPart().select_by_id(id) + owner = BrickSetOwnerList.get(metadata_id) + owner.update_individual_part_state(item, json=request.json) + return jsonify({'success': True}) + + +# Update tag +@individual_part_page.route('//update/tag/', methods=['POST']) +@exception_handler(__file__) +@require_individual_parts_write +@login_required +def update_tag(*, id: str, metadata_id: str): + item = IndividualPart().select_by_id(id) + tag = BrickSetTagList.get(metadata_id) + tag.update_individual_part_state(item, json=request.json) + return jsonify({'success': True}) + + +# Update status +@individual_part_page.route('//update/status/', methods=['POST']) +@exception_handler(__file__) +@require_individual_parts_write +@login_required +def update_status(*, id: str, metadata_id: str): + item = IndividualPart().select_by_id(id) + from ..set_status_list import BrickSetStatusList + status = BrickSetStatusList.get(metadata_id) + status.update_individual_part_state(item, json=request.json) + return jsonify({'success': True}) + + +# Update storage +@individual_part_page.route('//update/storage', methods=['POST']) +@exception_handler(__file__) +@require_individual_parts_write +@login_required +def update_storage(*, id: str): + item = IndividualPart().select_by_id(id) + storage_id = request.json.get('value') + item.update_field('storage', storage_id if storage_id else None) + return jsonify({'success': True}) + + +# Update purchase location +@individual_part_page.route('//update/purchase_location', methods=['POST']) +@exception_handler(__file__) +@require_individual_parts_write +@login_required +def update_purchase_location(*, id: str): + item = IndividualPart().select_by_id(id) + location_id = request.json.get('value') + item.update_field('purchase_location', location_id if location_id else None) + return jsonify({'success': True}) + + +# Update missing count +@individual_part_page.route('//update/missing', methods=['POST']) +@exception_handler(__file__) +@require_individual_parts_write +@login_required +def update_missing(*, id: str): + item = IndividualPart().select_by_id(id) + amount = item.update_problem('missing', request.json) + + logger.info('Individual part {part} (color: {color}, id: {id}): updated missing count to {amount}'.format( + part=item.fields.part, + color=item.fields.color, + id=item.fields.id, + amount=amount + )) + + return jsonify({'missing': amount}) + + +# Update damaged count +@individual_part_page.route('//update/damaged', methods=['POST']) +@exception_handler(__file__) +@require_individual_parts_write +@login_required +def update_damaged(*, id: str): + item = IndividualPart().select_by_id(id) + amount = item.update_problem('damaged', request.json) + + logger.info('Individual part {part} (color: {color}, id: {id}): updated damaged count to {amount}'.format( + part=item.fields.part, + color=item.fields.color, + id=item.fields.id, + amount=amount + )) + + return jsonify({'damaged': amount}) + + +# Update checked state +@individual_part_page.route('//update/checked', methods=['POST']) +@exception_handler(__file__) +@require_individual_parts_write +@login_required +def update_checked(*, id: str): + item = IndividualPart().select_by_id(id) + checked = item.update_checked(request.json) + + logger.info('Individual part {part} (color: {color}, id: {id}): updated checked state to {checked}'.format( + part=item.fields.part, + color=item.fields.color, + id=item.fields.id, + checked=checked + )) + + return jsonify({'checked': checked}) + + +# Delete individual part instance +@individual_part_page.route('//delete', methods=['POST']) +@exception_handler(__file__) +@require_individual_parts_write +@login_required +def delete_part(*, id: str): + item = IndividualPart().select_by_id(id) + lot_id = item.fields.lot_id if hasattr(item.fields, 'lot_id') else None + + item.delete() + + logger.info('Deleted individual part {part} (color: {color}, id: {id})'.format( + part=item.fields.part, + color=item.fields.color, + id=id + )) + + # If part was in a lot, redirect back to the lot + if lot_id: + return redirect(url_for('individual_part.lot_details', lot_id=lot_id)) + else: + return redirect(url_for('individual_part.list')) + + +# List all lots +@individual_part_page.route('/lot/') +@exception_handler(__file__) +def list_lots() -> str: + """List all individual part lots""" + # Use optimized query that includes part_count + lots = IndividualPartLotList().all() + + return render_template( + 'individual_part/lots.html', + lots=lots, + **set_metadata_lists(as_class=True) + ) + + +# Lot detail page +@individual_part_page.route('/lot/') +@exception_handler(__file__) +def lot_details(*, lot_id: str) -> str: + """Display details for an individual part lot (behaves like a set)""" + lot = IndividualPartLot().select_by_id(lot_id) + + return render_template( + 'individual_part/lot_details.html', + item=lot, # Pass as 'item' like sets do + solo=True, + **set_metadata_lists(as_class=True) + ) + + +# Update lot name +@individual_part_page.route('/lot//update/name', methods=['POST']) +@exception_handler(__file__) +@require_individual_parts_write +@login_required +def update_lot_name(*, lot_id: str): + lot = IndividualPartLot().select_by_id(lot_id) + name = request.json.get('value', '') + + from ..sql import BrickSQL + sql = BrickSQL() + sql.execute_and_commit('individual_part_lot/update/name', parameters={'name': name, 'id': lot_id}) + + logger.info('Updated lot {lot_id} name to: {name}'.format(lot_id=lot_id, name=name)) + + return jsonify({'success': True}) + + +# Update lot description +@individual_part_page.route('/lot//update/description', methods=['POST']) +@exception_handler(__file__) +@require_individual_parts_write +@login_required +def update_lot_description(*, lot_id: str): + lot = IndividualPartLot().select_by_id(lot_id) + description = request.json.get('value', '') + + from ..sql import BrickSQL + sql = BrickSQL() + sql.execute_and_commit('individual_part_lot/update/description', parameters={'description': description, 'id': lot_id}) + + logger.info('Updated lot {lot_id} description'.format(lot_id=lot_id)) + + return jsonify({'success': True}) + + +# Delete lot +@individual_part_page.route('/lot//delete', methods=['POST']) +@exception_handler(__file__) +@require_individual_parts_write +@login_required +def delete_lot(*, lot_id: str): + lot = IndividualPartLot().select_by_id(lot_id) + lot.delete() + + logger.info('Deleted individual part lot {lot_id}'.format(lot_id=lot_id)) + + return redirect(url_for('individual_part.list_lots')) + + +# Update lot owner +@individual_part_page.route('/lot//update/owner/', methods=['POST']) +@exception_handler(__file__) +@require_individual_parts_write +@login_required +def update_lot_owner(*, lot_id: str, metadata_id: str): + from ..set_owner_list import BrickSetOwnerList + lot = IndividualPartLot().select_by_id(lot_id) + owner = BrickSetOwnerList.get(metadata_id) + owner.update_individual_part_lot_state(lot, json=request.json) + return jsonify({'success': True}) + + +# Update lot tag +@individual_part_page.route('/lot//update/tag/', methods=['POST']) +@exception_handler(__file__) +@require_individual_parts_write +@login_required +def update_lot_tag(*, lot_id: str, metadata_id: str): + from ..set_tag_list import BrickSetTagList + lot = IndividualPartLot().select_by_id(lot_id) + tag = BrickSetTagList.get(metadata_id) + tag.update_individual_part_lot_state(lot, json=request.json) + return jsonify({'success': True}) + + +# Update lot status +@individual_part_page.route('/lot//update/status/', methods=['POST']) +@exception_handler(__file__) +@require_individual_parts_write +@login_required +def update_lot_status(*, lot_id: str, metadata_id: str): + from ..set_status_list import BrickSetStatusList + lot = IndividualPartLot().select_by_id(lot_id) + status = BrickSetStatusList.get(metadata_id) + status.update_individual_part_lot_state(lot, json=request.json) + return jsonify({'success': True}) + + +# Update lot storage +@individual_part_page.route('/lot//update/storage', methods=['POST']) +@exception_handler(__file__) +@require_individual_parts_write +@login_required +def update_lot_storage(*, lot_id: str): + from ..set_storage_list import BrickSetStorageList + lot = IndividualPartLot().select_by_id(lot_id) + storage_id = request.json.get('value', '') + + from ..sql import BrickSQL + sql = BrickSQL() + sql.execute_and_commit('individual_part_lot/update/storage', parameters={'storage': storage_id if storage_id else None, 'id': lot_id}) + + logger.info('Updated lot {lot_id} storage to: {storage}'.format(lot_id=lot_id, storage=storage_id)) + return jsonify({'success': True}) + + +# Update lot purchase location +@individual_part_page.route('/lot//update/purchase_location', methods=['POST']) +@exception_handler(__file__) +@require_individual_parts_write +@login_required +def update_lot_purchase_location(*, lot_id: str): + from ..set_purchase_location_list import BrickSetPurchaseLocationList + lot = IndividualPartLot().select_by_id(lot_id) + location_id = request.json.get('value', '') + + from ..sql import BrickSQL + sql = BrickSQL() + sql.execute_and_commit('individual_part_lot/update/purchase_location', parameters={'purchase_location': location_id if location_id else None, 'id': lot_id}) + + logger.info('Updated lot {lot_id} purchase_location to: {location}'.format(lot_id=lot_id, location=location_id)) + return jsonify({'success': True}) + + +# Update lot purchase date +@individual_part_page.route('/lot//update/purchase_date', methods=['POST']) +@exception_handler(__file__) +@require_individual_parts_write +@login_required +def update_lot_purchase_date(*, lot_id: str): + from datetime import datetime + + lot = IndividualPartLot().select_by_id(lot_id) + purchase_date_str = request.json.get('value') + + # Convert date string to Unix timestamp (same as sets) + purchase_date = None + if purchase_date_str and purchase_date_str != '': + try: + purchase_date = datetime.strptime(purchase_date_str, '%Y/%m/%d').timestamp() + except ValueError as e: + logger.error('Invalid date format: {date}'.format(date=purchase_date_str)) + return jsonify({'error': '{date} is not a valid date'.format(date=purchase_date_str)}), 400 + + from ..sql import BrickSQL + sql = BrickSQL() + sql.execute_and_commit('individual_part_lot/update/purchase_date', parameters={'purchase_date': purchase_date, 'id': lot_id}) + + logger.info('Updated lot {lot_id} purchase_date to: {date}'.format(lot_id=lot_id, date=purchase_date)) + return jsonify({'success': True}) + + +# Update lot purchase price +@individual_part_page.route('/lot//update/purchase_price', methods=['POST']) +@exception_handler(__file__) +@require_individual_parts_write +@login_required +def update_lot_purchase_price(*, lot_id: str): + lot = IndividualPartLot().select_by_id(lot_id) + purchase_price = request.json.get('value') + + from ..sql import BrickSQL + sql = BrickSQL() + sql.execute_and_commit('individual_part_lot/update/purchase_price', parameters={'purchase_price': purchase_price if purchase_price else None, 'id': lot_id}) + + logger.info('Updated lot {lot_id} purchase_price to: {price}'.format(lot_id=lot_id, price=purchase_price)) + return jsonify({'success': True}) diff --git a/bricktracker/views/purchase_location.py b/bricktracker/views/purchase_location.py new file mode 100644 index 0000000..9987ee5 --- /dev/null +++ b/bricktracker/views/purchase_location.py @@ -0,0 +1,38 @@ +from flask import Blueprint, render_template + +from .exceptions import exception_handler +from ..set_list import BrickSetList, set_metadata_lists +from ..set_purchase_location import BrickSetPurchaseLocation +from ..set_purchase_location_list import BrickSetPurchaseLocationList +from ..individual_minifigure_list import IndividualMinifigureList +from ..individual_part_list import IndividualPartList +from ..individual_part_lot_list import IndividualPartLotList + +purchase_location_page = Blueprint('purchase_location', __name__, url_prefix='/purchase-locations') + + +# Index +@purchase_location_page.route('/', methods=['GET']) +@exception_handler(__file__) +def list() -> str: + return render_template( + 'purchase_locations.html', + table_collection=BrickSetPurchaseLocationList.all(), + ) + + +# Purchase location details +@purchase_location_page.route('//details') +@exception_handler(__file__) +def details(*, id: str) -> str: + purchase_location = BrickSetPurchaseLocation().select_specific(id) + + return render_template( + 'purchase_location.html', + item=purchase_location, + sets=BrickSetList().using_purchase_location(purchase_location), + individual_minifigures=IndividualMinifigureList().using_purchase_location(purchase_location), + individual_parts=IndividualPartList().using_purchase_location(purchase_location), + individual_part_lots=IndividualPartLotList().using_purchase_location(purchase_location), + **set_metadata_lists(as_class=True) + )