diff --git a/.env.sample b/.env.sample index 7cbc5c0..65a9d30 100644 --- a/.env.sample +++ b/.env.sample @@ -2,7 +2,7 @@ # If set, it will append a direct ORDER BY to the SQL query # while listing objects. You can look at the structure of the SQLite database to # see the schema and the column names. Some fields are compound and not visible -# directly from the schema (joins). You can check the query in the */list.sql files +# directly from the schema (joins). You can check the query in the */list.sql and */base/*.sql files # in the source to see all column names. # The usual syntax for those variables is ""."" [ASC|DESC]. # For composite fields (CASE, SUM, COUNT) the syntax is , there is no
name. @@ -28,7 +28,7 @@ # BK_AUTHENTICATION_KEY=change-this-to-something-random # Optional: Pattern of the link to Bricklink for a part. Will be passed to Python .format() -# Default: https://www.bricklink.com/v2/catalog/catalogitem.page?P={number} +# Default: https://www.bricklink.com/v2/catalog/catalogitem.page?P={part} # BK_BRICKLINK_LINK_PART_PATTERN= # Optional: Display Bricklink links wherever applicable @@ -60,6 +60,11 @@ # Legacy name: DOMAIN_NAME # BK_DOMAIN_NAME=http://localhost:3333 +# Optional: Format of the timestamp for files on disk (instructions, themes) +# Check https://docs.python.org/3/library/time.html#time.strftime for format details +# Default: %d/%m/%Y, %H:%M:%S +# BK_FILE_DATETIME_FORMAT=%m/%d/%Y, %H:%M + # Optional: IP address the server will listen on. # Default: 0.0.0.0 # BK_HOST=0.0.0.0 @@ -103,13 +108,30 @@ # Default: false # BK_HIDE_ALL_PARTS=true +# Optional: Hide the 'Problems' entry from the menu. Does not disable the route. +# Default: false +# Legacy name: BK_HIDE_MISSING_PARTS +# BK_HIDE_ALL_PROBLEMS_PARTS=true + # Optional: Hide the 'Sets' entry from the menu. Does not disable the route. # Default: false # BK_HIDE_ALL_SETS=true -# Optional: Hide the 'Missing' entry from the menu. Does not disable the route. +# Optional: Hide the 'Storages' entry from the menu. Does not disable the route. # Default: false -# BK_HIDE_MISSING_PARTS=true +# BK_HIDE_ALL_STORAGES=true + +# Optional: Hide the 'Instructions' entry in a Set card +# Default: false +# BK_HIDE_SET_INSTRUCTIONS=true + +# Optional: Hide the 'Damaged' column from the parts table. +# Default: false +# BK_HIDE_TABLE_DAMAGED_PARTS=true + +# Optional: Hide the 'Missing' column from the parts table. +# Default: false +# BK_HIDE_TABLE_MISSING_PARTS=true # Optional: Hide the 'Wishlist' entry from the menu. Does not disable the route. # Default: false @@ -117,10 +139,11 @@ # Optional: Change the default order of minifigures. By default ordered by insertion order. # Useful column names for this option are: -# - "minifigures"."fig_num": minifigure ID (fig-xxxxx) -# - "minifigures"."name": minifigure name -# Default: "minifigures"."name" ASC -# BK_MINIFIGURES_DEFAULT_ORDER="minifigures"."name" ASC +# - "rebrickable_minifigures"."figure": minifigure ID (fig-xxxxx) +# - "rebrickable_minifigures"."number": minifigure ID as an integer (xxxxx) +# - "rebrickable_minifigures"."name": minifigure name +# Default: "rebrickable_minifigures"."name" ASC +# BK_MINIFIGURES_DEFAULT_ORDER="rebrickable_minifigures"."name" ASC # Optional: Folder where to store the minifigures images, relative to the '/app/static/' folder # Default: minifigs @@ -134,13 +157,13 @@ # Optional: Change the default order of parts. By default ordered by insertion order. # Useful column names for this option are: -# - "inventory"."part_num": part number -# - "inventory"."name": part name -# - "inventory"."color_name": part color name -# - "inventory"."is_spare": par is a spare part +# - "bricktracker_parts"."part": part number +# - "bricktracker_parts"."spare": part is a spare part +# - "rebrickable_parts"."name": part name +# - "rebrickable_parts"."color_name": part color name # - "total_missing": number of missing parts -# Default: "inventory"."name" ASC, "inventory"."color_name" ASC, "inventory"."is_spare" ASC -# BK_PARTS_DEFAULT_ORDER="total_missing" DESC, "inventory"."name" ASC +# Default: "rebrickable_parts"."name" ASC, "rebrickable_parts"."color_name" ASC, "bricktracker_parts"."spare" ASC +# BK_PARTS_DEFAULT_ORDER="total_missing" DESC, "rebrickable_parts"."name"."name" ASC # Optional: Folder where to store the parts images, relative to the '/app/static/' folder # Default: parts @@ -150,6 +173,21 @@ # Default: 3333 # BK_PORT=3333 +# Optional: Format of the timestamp for purchase dates +# Check https://docs.python.org/3/library/time.html#time.strftime for format details +# Default: %d/%m/%Y +# BK_PURCHASE_DATE_FORMAT=%m/%d/%Y + +# Optional: Currency to display for purchase prices. +# Default: € +# BK_PURCHASE_CURRENCY=£ + +# Optional: Change the default order of purchase locations. By default ordered by insertion order. +# Useful column names for this option are: +# - "bricktracker_metadata_purchase_locations"."name" ASC: storage name +# Default: "bricktracker_metadata_purchase_locations"."name" ASC +# BK_PURCHASE_LOCATION_DEFAULT_ORDER="bricktracker_metadata_purchase_locations"."name" ASC + # Optional: Shuffle the lists on the front page. # Default: false # Legacy name: RANDOM @@ -171,11 +209,11 @@ # BK_REBRICKABLE_IMAGE_NIL_MINIFIGURE= # Optional: Pattern of the link to Rebrickable for a minifigure. Will be passed to Python .format() -# Default: https://rebrickable.com/minifigs/{number} +# Default: https://rebrickable.com/minifigs/{figure} # BK_REBRICKABLE_LINK_MINIFIGURE_PATTERN= # Optional: Pattern of the link to Rebrickable for a part. Will be passed to Python .format() -# Default: https://rebrickable.com/parts/{number}/_/{color} +# Default: https://rebrickable.com/parts/{part}/_/{color} # BK_REBRICKABLE_LINK_PART_PATTERN= # Optional: Pattern of the link to Rebrickable for instructions. Will be passed to Python .format() @@ -221,6 +259,14 @@ # Default: sets # BK_SETS_FOLDER=sets +# Optional: Make the grid filters displayed by default, rather than collapsed +# Default: false +# BK_SHOW_GRID_FILTERS=true + +# Optional: Make the grid sort displayed by default, rather than collapsed +# Default: false +# BK_SHOW_GRID_SORT=true + # Optional: Skip saving or displaying spare parts # Default: false # BK_SKIP_SPARE_PARTS=true @@ -233,6 +279,12 @@ # Default: /bricksocket/ # BK_SOCKET_PATH=custompath +# Optional: Change the default order of storages. By default ordered by insertion order. +# Useful column names for this option are: +# - "bricktracker_metadata_storages"."name" ASC: storage name +# Default: "bricktracker_metadata_storages"."name" ASC +# BK_STORAGE_DEFAULT_ORDER="bricktracker_metadata_storages"."name" ASC + # Optional: URL to the themes.csv.gz on Rebrickable # Default: https://cdn.rebrickable.com/media/downloads/themes.csv.gz # BK_THEMES_FILE_URL= diff --git a/CHANGELOG.md b/CHANGELOG.md index b87c902..baad4bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,117 @@ # Changelog +## Unreleased + +> **Warning** +> "Missing" part has been renamed to "Problems" to accomodate for missing and damaged parts. +> The associated environment variables have changed named (the old names are still valid) + +### Environment + +- Renamed: `BK_HIDE_MISSING_PARTS` -> `BK_HIDE_ALL_PROBLEMS_PARTS` +- Added: `BK_HIDE_TABLE_MISSING_PARTS`, hide the Missing column in all tables +- Added: `BK_HIDE_TABLE_DAMAGED_PARTS`, hide the Damaged column in all tables +- Added: `BK_SHOW_GRID_SORT`, show the sort options on the grid by default +- Added: `BK_SHOW_GRID_FILTERS`, show the filter options on the grid by default +- Added: `BK_HIDE_ALL_STORAGES`, hide the "Storages" menu entry +- Added: `BK_STORAGE_DEFAULT_ORDER`, ordering of storages +- Added: `BK_PURCHASE_LOCATION_DEFAULT_ORDER`, ordering of purchase locations +- Added: `BK_PURCHASE_CURRENCY`, currency to display for purchase prices +- Added: `BK_PURCHASE_DATE_FORMAT`, date format for purchase dates +- Documented: `BK_FILE_DATETIME_FORMAT`, date format for files on disk (instructions, theme) + +### Code + +- Changer + - Revert the checked state of a checkbox if an error occured + +- Form + - Migrate missing input fields to BrickChanger + +- General cleanup + +- Metadata + - Underlying class to implement more metadata-like features + +- Minifigure + - Deduplicate + - Compute number of parts + +- Parts + - Damaged parts + +- Sets + - Refresh data from Rebrickable + - Fix missing @login_required for set deletion + - Ownership + - Tags + - Storage + - Purchase location, date, price + +- Storage + - Storage content and list + +- Socket + - Add decorator for rebrickable, authenticated and threaded socket actions + +- SQL + - Allow for advanced migration scenarios through companion python files + - Add a bunch of the requested fields into the database for future implementation + +- Wish + - Requester + +### UI + +- Add + - Allow adding or bulk adding by pressing Enter in the input field + +- Admin + - Grey out legacy tables in the database view + - Checkboxes renamed to Set statuses + - List of sets that may need to be refreshed + +- Cards + - Use macros for badge in the card header + +- Form + - Add a clear button for dynamic text inputs + - Add error message in a tooltip for dynamic inputs + +- Minifigure + - Display number of parts + +- Parts + - Use Rebrickable URL if stored (+ color code) + - Display color and transparency + - Display if print of another part + - Display prints using the same base + - Damaged parts + - Display same parts using a different color + +- Sets + - Add a flag to hide instructions in a set + - Make checkbox clickable on the whole width of the card + - Management + - Ownership + - Tags + - Refresh + - Storage + - Purchase location, date, price + +- Sets grid + - Collapsible controls depending on screen size + - Manually collapsible filters (with configuration variable for default state) + - Manually collapsible sort (with configuration variable for default state) + - Clear search bar + +- Storage + - Storage list + - Storage content + +- Wish + - Requester + ## 1.1.1: PDF Instructions Download ### Instructions diff --git a/bricktracker/app.py b/bricktracker/app.py index a55a9f7..b4aad9e 100644 --- a/bricktracker/app.py +++ b/bricktracker/app.py @@ -13,11 +13,16 @@ from bricktracker.sql import close from bricktracker.version import __version__ from bricktracker.views.add import add_page from bricktracker.views.admin.admin import admin_page -from bricktracker.views.admin.checkbox import admin_checkbox_page from bricktracker.views.admin.database import admin_database_page from bricktracker.views.admin.image import admin_image_page from bricktracker.views.admin.instructions import admin_instructions_page +from bricktracker.views.admin.owner import admin_owner_page +from bricktracker.views.admin.purchase_location import admin_purchase_location_page # noqa: E501 from bricktracker.views.admin.retired import admin_retired_page +from bricktracker.views.admin.set import admin_set_page +from bricktracker.views.admin.status import admin_status_page +from bricktracker.views.admin.storage import admin_storage_page +from bricktracker.views.admin.tag import admin_tag_page from bricktracker.views.admin.theme import admin_theme_page from bricktracker.views.error import error_404 from bricktracker.views.index import index_page @@ -26,6 +31,7 @@ from bricktracker.views.login import login_page from bricktracker.views.minifigure import minifigure_page from bricktracker.views.part import part_page from bricktracker.views.set import set_page +from bricktracker.views.storage import storage_page from bricktracker.views.wish import wish_page @@ -74,15 +80,21 @@ def setup_app(app: Flask) -> None: app.register_blueprint(minifigure_page) app.register_blueprint(part_page) app.register_blueprint(set_page) + app.register_blueprint(storage_page) app.register_blueprint(wish_page) # Register admin routes app.register_blueprint(admin_page) - app.register_blueprint(admin_checkbox_page) app.register_blueprint(admin_database_page) app.register_blueprint(admin_image_page) app.register_blueprint(admin_instructions_page) app.register_blueprint(admin_retired_page) + app.register_blueprint(admin_owner_page) + app.register_blueprint(admin_purchase_location_page) + app.register_blueprint(admin_set_page) + app.register_blueprint(admin_status_page) + app.register_blueprint(admin_storage_page) + app.register_blueprint(admin_tag_page) app.register_blueprint(admin_theme_page) # An helper to make global variables available to the diff --git a/bricktracker/config.py b/bricktracker/config.py index 08db61b..e8872e5 100644 --- a/bricktracker/config.py +++ b/bricktracker/config.py @@ -10,7 +10,7 @@ from typing import Any, Final CONFIG: Final[list[dict[str, Any]]] = [ {'n': 'AUTHENTICATION_PASSWORD', 'd': ''}, {'n': 'AUTHENTICATION_KEY', 'd': ''}, - {'n': 'BRICKLINK_LINK_PART_PATTERN', 'd': 'https://www.bricklink.com/v2/catalog/catalogitem.page?P={number}'}, # noqa: E501 + {'n': 'BRICKLINK_LINK_PART_PATTERN', 'd': 'https://www.bricklink.com/v2/catalog/catalogitem.page?P={part}'}, # noqa: E501 {'n': 'BRICKLINK_LINKS', 'c': bool}, {'n': 'DATABASE_PATH', 'd': './app.db'}, {'n': 'DATABASE_TIMESTAMP_FORMAT', 'd': '%Y-%m-%d-%H-%M-%S'}, @@ -28,21 +28,28 @@ CONFIG: Final[list[dict[str, Any]]] = [ {'n': 'HIDE_ALL_INSTRUCTIONS', 'c': bool}, {'n': 'HIDE_ALL_MINIFIGURES', 'c': bool}, {'n': 'HIDE_ALL_PARTS', 'c': bool}, + {'n': 'HIDE_ALL_PROBLEMS_PARTS', 'e': 'BK_HIDE_MISSING_PARTS', 'c': bool}, {'n': 'HIDE_ALL_SETS', 'c': bool}, - {'n': 'HIDE_MISSING_PARTS', 'c': bool}, + {'n': 'HIDE_ALL_STORAGES', 'c': bool}, + {'n': 'HIDE_SET_INSTRUCTIONS', 'c': bool}, + {'n': 'HIDE_TABLE_DAMAGED_PARTS', 'c': bool}, + {'n': 'HIDE_TABLE_MISSING_PARTS', 'c': bool}, {'n': 'HIDE_WISHES', 'c': bool}, - {'n': 'MINIFIGURES_DEFAULT_ORDER', 'd': '"minifigures"."name" ASC'}, + {'n': 'MINIFIGURES_DEFAULT_ORDER', 'd': '"rebrickable_minifigures"."name" ASC'}, # noqa: E501 {'n': 'MINIFIGURES_FOLDER', 'd': 'minifigs', 's': True}, {'n': 'NO_THREADED_SOCKET', 'c': bool}, - {'n': 'PARTS_DEFAULT_ORDER', 'd': '"inventory"."name" ASC, "inventory"."color_name" ASC, "inventory"."is_spare" ASC'}, # noqa: E501 + {'n': 'PARTS_DEFAULT_ORDER', 'd': '"rebrickable_parts"."name" ASC, "rebrickable_parts"."color_name" ASC, "bricktracker_parts"."spare" ASC'}, # noqa: E501 {'n': 'PARTS_FOLDER', 'd': 'parts', 's': True}, {'n': 'PORT', 'd': 3333, 'c': int}, + {'n': 'PURCHASE_DATE_FORMAT', 'd': '%d/%m/%Y'}, + {'n': 'PURCHASE_CURRENCY', 'd': '€'}, + {'n': 'PURCHASE_LOCATION_DEFAULT_ORDER', 'd': '"bricktracker_metadata_purchase_locations"."name" ASC'}, # noqa: E501 {'n': 'RANDOM', 'e': 'RANDOM', 'c': bool}, {'n': 'REBRICKABLE_API_KEY', 'e': 'REBRICKABLE_API_KEY', 'd': ''}, {'n': 'REBRICKABLE_IMAGE_NIL', 'd': 'https://rebrickable.com/static/img/nil.png'}, # noqa: E501 {'n': 'REBRICKABLE_IMAGE_NIL_MINIFIGURE', 'd': 'https://rebrickable.com/static/img/nil_mf.jpg'}, # noqa: E501 - {'n': 'REBRICKABLE_LINK_MINIFIGURE_PATTERN', 'd': 'https://rebrickable.com/minifigs/{number}'}, # noqa: E501 - {'n': 'REBRICKABLE_LINK_PART_PATTERN', 'd': 'https://rebrickable.com/parts/{number}/_/{color}'}, # noqa: E501 + {'n': 'REBRICKABLE_LINK_MINIFIGURE_PATTERN', 'd': 'https://rebrickable.com/minifigs/{figure}'}, # noqa: E501 + {'n': 'REBRICKABLE_LINK_PART_PATTERN', 'd': 'https://rebrickable.com/parts/{part}/_/{color}'}, # noqa: E501 {'n': 'REBRICKABLE_LINK_INSTRUCTIONS_PATTERN', 'd': 'https://rebrickable.com/instructions/{path}'}, # noqa: E501 {'n': 'REBRICKABLE_USER_AGENT', 'd': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'}, # noqa: E501 {'n': 'REBRICKABLE_LINKS', 'e': 'LINKS', 'c': bool}, @@ -51,9 +58,12 @@ CONFIG: Final[list[dict[str, Any]]] = [ {'n': 'RETIRED_SETS_PATH', 'd': './retired_sets.csv'}, {'n': 'SETS_DEFAULT_ORDER', 'd': '"rebrickable_sets"."number" DESC, "rebrickable_sets"."version" ASC'}, # noqa: E501 {'n': 'SETS_FOLDER', 'd': 'sets', 's': True}, + {'n': 'SHOW_GRID_FILTERS', 'c': bool}, + {'n': 'SHOW_GRID_SORT', 'c': bool}, {'n': 'SKIP_SPARE_PARTS', 'c': bool}, {'n': 'SOCKET_NAMESPACE', 'd': 'bricksocket'}, {'n': 'SOCKET_PATH', 'd': '/bricksocket/'}, + {'n': 'STORAGE_DEFAULT_ORDER', 'd': '"bricktracker_metadata_storages"."name" ASC'}, # noqa: E501 {'n': 'THEMES_FILE_URL', 'd': 'https://cdn.rebrickable.com/media/downloads/themes.csv.gz'}, # noqa: E501 {'n': 'THEMES_PATH', 'd': './themes.csv'}, {'n': 'TIMEZONE', 'd': 'Etc/UTC'}, diff --git a/bricktracker/metadata.py b/bricktracker/metadata.py new file mode 100644 index 0000000..88d2623 --- /dev/null +++ b/bricktracker/metadata.py @@ -0,0 +1,263 @@ +import logging +from sqlite3 import Row +from typing import Any, Self, TYPE_CHECKING +from uuid import uuid4 + +from flask import url_for + +from .exceptions import DatabaseException, ErrorException, NotFoundException +from .record import BrickRecord +from .sql import BrickSQL +if TYPE_CHECKING: + from .set import BrickSet + +logger = logging.getLogger(__name__) + + +# Lego set metadata (customizable list of entries that can be checked) +class BrickMetadata(BrickRecord): + kind: str + + # Set state endpoint + set_state_endpoint: str + + # Queries + delete_query: str + insert_query: str + select_query: str + update_field_query: str + update_set_state_query: str + update_set_value_query: str + + def __init__( + self, + /, + *, + record: Row | dict[str, Any] | None = None, + ): + super().__init__() + + # Defined an empty ID + self.fields.id = None + + # Ingest the record if it has one + if record is not None: + self.ingest(record) + + # SQL column name + def as_column(self, /) -> str: + return '{kind}_{id}'.format( + id=self.fields.id, + kind=self.kind.lower().replace(' ', '-') + ) + + # HTML dataset name + def as_dataset(self, /) -> str: + return self.as_column().replace('_', '-') + + # Delete from database + def delete(self, /) -> None: + BrickSQL().executescript( + self.delete_query, + id=self.fields.id, + ) + + # Grab data from a form + def from_form(self, form: dict[str, str], /) -> Self: + name = form.get('name', None) + + if name is None or name == '': + raise ErrorException('Status name cannot be empty') + + self.fields.name = name + + return self + + # Insert into database + def insert(self, /, **context) -> None: + self.safe() + + # Generate an ID for the metadata (with underscores to make it + # column name friendly) + self.fields.id = str(uuid4()).replace('-', '_') + + BrickSQL().executescript( + self.insert_query, + id=self.fields.id, + name=self.fields.safe_name, + **context + ) + + # Rename the entry + def rename(self, /) -> None: + self.update_field('name', value=self.fields.name) + + # Make the name "safe" + # Security: eh. + def safe(self, /) -> None: + # Prevent self-ownage with accidental quote escape + self.fields.safe_name = self.fields.name.replace("'", "''") + + # URL to change the selected state of this metadata item for a set + def url_for_set_state(self, id: str, /) -> str: + return url_for( + self.set_state_endpoint, + id=id, + metadata_id=self.fields.id + ) + + # Select a specific metadata (with an id) + def select_specific(self, id: str, /) -> Self: + # Save the parameters to the fields + self.fields.id = id + + # Load from database + if not self.select(): + raise NotFoundException( + '{kind} with ID {id} was not found in the database'.format( + kind=self.kind.capitalize(), + id=self.fields.id, + ), + ) + + return self + + # Update a field + def update_field( + self, + field: str, + /, + *, + json: Any | None = None, + value: Any | None = None + ) -> Any: + if value is None and json is not None: + value = json.get('value', None) + + if value is None: + raise ErrorException('"{field}" of a {kind} cannot be set to an empty value'.format( # noqa: E501 + field=field, + kind=self.kind + )) + + if field == 'id' or not hasattr(self.fields, field): + raise NotFoundException('"{field}" is not a field of a {kind}'.format( # noqa: E501 + kind=self.kind, + field=field + )) + + parameters = self.sql_parameters() + parameters['value'] = value + + # Update the status + rows, _ = BrickSQL().execute_and_commit( + self.update_field_query, + parameters=parameters, + field=field, + ) + + if rows != 1: + raise DatabaseException('Could not update the field "{field}" for {kind} "{name}" ({id})'.format( # noqa: E501 + field=field, + kind=self.kind, + name=self.fields.name, + id=self.fields.id, + )) + + # Info + logger.info('{kind} "{name}" ({id}): field "{field}" changed to "{value}"'.format( # noqa: E501 + kind=self.kind.capitalize(), + name=self.fields.name, + id=self.fields.id, + field=field, + value=value, + )) + + return value + + # Update the selected state of this metadata item for a set + def update_set_state( + self, + brickset: 'BrickSet', + /, + *, + json: Any | None = None, + state: Any | None = None + ) -> Any: + if state is None and json is not None: + state = json.get('value', False) + + parameters = self.sql_parameters() + parameters['set_id'] = brickset.fields.id + parameters['state'] = state + + rows, _ = BrickSQL().execute_and_commit( + self.update_set_state_query, + parameters=parameters, + name=self.as_column(), + ) + + if rows != 1: + raise DatabaseException('Could not update the {kind} "{name}" state for set {set} ({id})'.format( # noqa: E501 + kind=self.kind, + name=self.fields.name, + set=brickset.fields.set, + id=brickset.fields.id, + )) + + # Info + logger.info('{kind} "{name}" state changed to "{state}" for set {set} ({id})'.format( # noqa: E501 + kind=self.kind, + name=self.fields.name, + state=state, + set=brickset.fields.set, + id=brickset.fields.id, + )) + + return state + + # Update the selected value of this metadata item for a set + def update_set_value( + self, + brickset: 'BrickSet', + /, + *, + 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, + ) + + # Update the status + if value is None and not hasattr(self.fields, 'name'): + self.fields.name = 'None' + + if rows != 1: + raise DatabaseException('Could not update the {kind} value for set {set} ({id})'.format( # noqa: E501 + kind=self.kind, + set=brickset.fields.set, + id=brickset.fields.id, + )) + + # Info + logger.info('{kind} value changed to "{name}" ({value}) for set {set} ({id})'.format( # noqa: E501 + kind=self.kind, + name=self.fields.name, + value=value, + set=brickset.fields.set, + id=brickset.fields.id, + )) + + return value diff --git a/bricktracker/metadata_list.py b/bricktracker/metadata_list.py new file mode 100644 index 0000000..44bf18f --- /dev/null +++ b/bricktracker/metadata_list.py @@ -0,0 +1,171 @@ +import logging +from typing import List, overload, Self, Type, TypeVar + +from flask import url_for + +from .exceptions import NotFoundException +from .fields import BrickRecordFields +from .record_list import BrickRecordList +from .set_owner import BrickSetOwner +from .set_purchase_location import BrickSetPurchaseLocation +from .set_status import BrickSetStatus +from .set_storage import BrickSetStorage +from .set_tag import BrickSetTag +from .wish_owner import BrickWishOwner + +logger = logging.getLogger(__name__) + +T = TypeVar( + 'T', + BrickSetOwner, + BrickSetPurchaseLocation, + BrickSetStatus, + BrickSetStorage, + BrickSetTag, + BrickWishOwner +) + + +# Lego sets metadata list +class BrickMetadataList(BrickRecordList[T]): + kind: str + mapping: dict[str, T] + model: Type[T] + + # Database + table: str + order: str + + # Queries + select_query: str + + # Set endpoints + set_state_endpoint: str + set_value_endpoint: str + + def __init__( + self, + model: Type[T], + /, + *, + force: bool = False, + records: list[T] | None = None + ): + self.model = model + + # Records override (masking the class variables with instance ones) + if records is not None: + self.override() + + for metadata in records: + self.records.append(metadata) + self.mapping[metadata.fields.id] = metadata + else: + # Load metadata only if there is none already loaded + records = getattr(self, 'records', None) + + if records is None or force: + # Don't use super()__init__ as it would mask class variables + self.fields = BrickRecordFields() + + logger.info('Loading {kind} list'.format( + kind=self.kind + )) + + self.__class__.records = [] + self.__class__.mapping = {} + + # Load the metadata from the database + for record in self.select(order=self.order): + metadata = model(record=record) + + self.__class__.records.append(metadata) + self.__class__.mapping[metadata.fields.id] = metadata + + # HTML prefix name + def as_prefix(self, /) -> str: + return self.kind.replace(' ', '-') + + # Filter the list of records (this one does nothing) + def filter(self) -> list[T]: + return self.records + + # Add a layer of override data + def override(self) -> None: + self.fields = BrickRecordFields() + + self.records = [] + self.mapping = {} + + # Return the items as columns for a select + @classmethod + def as_columns(cls, /, **kwargs) -> str: + new = cls.new() + + return ', '.join([ + '"{table}"."{column}"'.format( + table=cls.table, + column=record.as_column(), + ) + for record + in new.filter(**kwargs) + ]) + + # Grab a specific status + @classmethod + def get(cls, id: str, /, *, allow_none: bool = False) -> T: + new = cls.new() + + if allow_none and id == '': + return new.model() + + if id not in new.mapping: + raise NotFoundException( + '{kind} with ID {id} was not found in the database'.format( + kind=new.kind.capitalize(), + id=id, + ), + ) + + return new.mapping[id] + + # Get the list of statuses depending on the context + @overload + @classmethod + def list(cls, /, **kwargs) -> List[T]: ... + + @overload + @classmethod + def list(cls, /, as_class: bool = False, **kwargs) -> Self: ... + + @classmethod + def list(cls, /, as_class: bool = False, **kwargs) -> List[T] | Self: + new = cls.new() + list = new.filter(**kwargs) + + if as_class: + # Return a copy of the metadata list with overriden records + return cls(new.model, records=list) + else: + return list + + # Instantiate the list with the proper class + @classmethod + def new(cls, /, *, force: bool = False) -> Self: + raise Exception('new() is not implemented for BrickMetadataList') + + # URL to change the selected state of this metadata item for a set + @classmethod + def url_for_set_state(cls, id: str, /) -> str: + return url_for( + cls.set_state_endpoint, + id=id, + ) + + # URL to change the selected value of this metadata item for a set + @classmethod + def url_for_set_value(cls, id: str, /) -> str: + return url_for( + cls.set_value_endpoint, + id=id, + ) diff --git a/bricktracker/migrations/0007.py b/bricktracker/migrations/0007.py new file mode 100644 index 0000000..81e4535 --- /dev/null +++ b/bricktracker/migrations/0007.py @@ -0,0 +1,29 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from ..sql import BrickSQL + + +# Grab the list of checkboxes to create a list of SQL columns +def migration_0007(sql: 'BrickSQL', /) -> dict[str, Any]: + # Don't realy on sql files as they could be removed in the future + sql.cursor.execute('SELECT "bricktracker_set_checkboxes"."id" FROM "bricktracker_set_checkboxes"') # noqa: E501 + records = sql.cursor.fetchall() + + return { + 'sources': ', '.join([ + '"bricktracker_set_statuses_old"."status_{id}"'.format(id=record['id']) # noqa: E501 + for record + in records + ]), + 'targets': ', '.join([ + '"status_{id}"'.format(id=record['id']) + for record + in records + ]), + 'structure': ', '.join([ + '"status_{id}" BOOLEAN NOT NULL DEFAULT 0'.format(id=record['id']) + for record + in records + ]) + } diff --git a/bricktracker/migrations/__init__.py b/bricktracker/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bricktracker/minifigure.py b/bricktracker/minifigure.py index 0ad55b1..c09589e 100644 --- a/bricktracker/minifigure.py +++ b/bricktracker/minifigure.py @@ -1,48 +1,68 @@ -from sqlite3 import Row -from typing import Any, Self, TYPE_CHECKING - -from flask import current_app, url_for +import logging +import traceback +from typing import Self, TYPE_CHECKING from .exceptions import ErrorException, NotFoundException from .part_list import BrickPartList -from .rebrickable_image import RebrickableImage -from .record import BrickRecord +from .rebrickable_minifigure import RebrickableMinifigure if TYPE_CHECKING: from .set import BrickSet + from .socket import BrickSocket + +logger = logging.getLogger(__name__) # Lego minifigure -class BrickMinifigure(BrickRecord): - brickset: 'BrickSet | None' - +class BrickMinifigure(RebrickableMinifigure): # Queries insert_query: str = 'minifigure/insert' generic_query: str = 'minifigure/select/generic' select_query: str = 'minifigure/select/specific' - def __init__( - self, - /, - *, - brickset: 'BrickSet | None' = None, - record: Row | dict[str, Any] | None = None, - ): - super().__init__() + # Import a minifigure into the database + def download(self, socket: 'BrickSocket', refresh: bool = False) -> bool: + if self.brickset is None: + raise ErrorException('Importing a minifigure from Rebrickable outside of a set is not supported') # noqa: E501 - # Save the brickset - self.brickset = brickset + try: + # Insert into the database + socket.auto_progress( + message='Set {set}: inserting minifigure {figure} into database'.format( # noqa: E501 + set=self.brickset.fields.set, + figure=self.fields.figure + ) + ) - # Ingest the record if it has one - if record is not None: - self.ingest(record) + if not refresh: + # Insert into database + self.insert(commit=False) - # Return the number just in digits format - def clean_number(self, /) -> str: - number: str = self.fields.fig_num - number = number.removeprefix('fig-') - number = number.lstrip('0') + # Load the inventory + if not BrickPartList.download( + socket, + self.brickset, + minifigure=self, + refresh=refresh + ): + return False - return number + # Insert the rebrickable set into database (after counting parts) + self.insert_rebrickable() + + except Exception as e: + socket.fail( + message='Error while importing minifigure {figure} from {set}: {error}'.format( # noqa: E501 + figure=self.fields.figure, + set=self.brickset.fields.set, + error=e, + ) + ) + + logger.debug(traceback.format_exc()) + + return False + + return True # Parts def generic_parts(self, /) -> BrickPartList: @@ -51,108 +71,38 @@ class BrickMinifigure(BrickRecord): # Parts def parts(self, /) -> BrickPartList: if self.brickset is None: - raise ErrorException('Part list for minifigure {number} requires a brickset'.format( # noqa: E501 - number=self.fields.fig_num, + raise ErrorException('Part list for minifigure {figure} requires a brickset'.format( # noqa: E501 + figure=self.fields.figure, )) - return BrickPartList().load(self.brickset, minifigure=self) + return BrickPartList().list_specific(self.brickset, minifigure=self) # Select a generic minifigure - def select_generic(self, fig_num: str, /) -> Self: + def select_generic(self, figure: str, /) -> Self: # Save the parameters to the fields - self.fields.fig_num = fig_num + self.fields.figure = figure if not self.select(override_query=self.generic_query): raise NotFoundException( - 'Minifigure with number {number} was not found in the database'.format( # noqa: E501 - number=self.fields.fig_num, + 'Minifigure with figure {figure} was not found in the database'.format( # noqa: E501 + figure=self.fields.figure, ), ) return self - # Select a specific minifigure (with a set and an number) - def select_specific(self, brickset: 'BrickSet', fig_num: str, /) -> Self: + # Select a specific minifigure (with a set and a figure) + def select_specific(self, brickset: 'BrickSet', figure: str, /) -> Self: # Save the parameters to the fields self.brickset = brickset - self.fields.fig_num = fig_num + self.fields.figure = figure if not self.select(): raise NotFoundException( - 'Minifigure with number {number} from set {set} was not found in the database'.format( # noqa: E501 - number=self.fields.fig_num, + 'Minifigure with figure {figure} from set {set} was not found in the database'.format( # noqa: E501 + figure=self.fields.figure, set=self.brickset.fields.set, ), ) return self - - # Return a dict with common SQL parameters for a minifigure - def sql_parameters(self, /) -> dict[str, Any]: - parameters = super().sql_parameters() - - # Supplement from the brickset - if self.brickset is not None: - if 'u_id' not in parameters: - parameters['u_id'] = self.brickset.fields.id - - if 'set_num' not in parameters: - parameters['set_num'] = self.brickset.fields.set - - return parameters - - # Self url - def url(self, /) -> str: - return url_for( - 'minifigure.details', - number=self.fields.fig_num, - ) - - # Compute the url for minifigure part image - def url_for_image(self, /) -> str: - if not current_app.config['USE_REMOTE_IMAGES']: - if self.fields.set_img_url is None: - file = RebrickableImage.nil_minifigure_name() - else: - file = self.fields.fig_num - - return RebrickableImage.static_url(file, 'MINIFIGURES_FOLDER') - else: - if self.fields.set_img_url is None: - return current_app.config['REBRICKABLE_IMAGE_NIL_MINIFIGURE'] - else: - return self.fields.set_img_url - - # Compute the url for the rebrickable page - def url_for_rebrickable(self, /) -> str: - if current_app.config['REBRICKABLE_LINKS']: - try: - return current_app.config['REBRICKABLE_LINK_MINIFIGURE_PATTERN'].format( # noqa: E501 - number=self.fields.fig_num.lower(), - ) - except Exception: - pass - - return '' - - # Normalize from Rebrickable - @staticmethod - def from_rebrickable( - data: dict[str, Any], - /, - *, - brickset: 'BrickSet | None' = None, - **_, - ) -> dict[str, Any]: - record = { - 'fig_num': data['set_num'], - 'name': data['set_name'], - 'quantity': data['quantity'], - 'set_img_url': data['set_img_url'], - } - - if brickset is not None: - record['set_num'] = brickset.fields.set - record['u_id'] = brickset.fields.id - - return record diff --git a/bricktracker/minifigure_list.py b/bricktracker/minifigure_list.py index 04ece73..fa73562 100644 --- a/bricktracker/minifigure_list.py +++ b/bricktracker/minifigure_list.py @@ -1,11 +1,17 @@ +import logging +import traceback from typing import Any, Self, TYPE_CHECKING from flask import current_app from .minifigure import BrickMinifigure +from .rebrickable import Rebrickable from .record_list import BrickRecordList if TYPE_CHECKING: from .set import BrickSet + from .socket import BrickSocket + +logger = logging.getLogger(__name__) # Lego minifigures @@ -15,10 +21,11 @@ class BrickMinifigureList(BrickRecordList[BrickMinifigure]): # Queries all_query: str = 'minifigure/list/all' + damaged_part_query: str = 'minifigure/list/damaged_part' last_query: str = 'minifigure/list/last' + missing_part_query: str = 'minifigure/list/missing_part' select_query: str = 'minifigure/list/from_set' using_part_query: str = 'minifigure/list/using_part' - missing_part_query: str = 'minifigure/list/missing_part' def __init__(self, /): super().__init__() @@ -31,13 +38,18 @@ class BrickMinifigureList(BrickRecordList[BrickMinifigure]): # Load all minifigures def all(self, /) -> Self: - for record in self.select( - override_query=self.all_query, - order=self.order - ): - minifigure = BrickMinifigure(record=record) + self.list(override_query=self.all_query) - self.records.append(minifigure) + return self + + # Minifigures with a part damaged part + def damaged_part(self, part: str, color: int, /) -> Self: + # Save the parameters to the fields + self.fields.part = part + self.fields.color = color + + # Load the minifigures from the database + self.list(override_query=self.damaged_part_query) return self @@ -47,29 +59,69 @@ class BrickMinifigureList(BrickRecordList[BrickMinifigure]): if current_app.config['RANDOM']: order = 'RANDOM()' else: - order = 'minifigures.rowid DESC' + order = '"bricktracker_minifigures"."rowid" DESC' - for record in self.select( - override_query=self.last_query, - order=order, - limit=limit - ): - minifigure = BrickMinifigure(record=record) - - self.records.append(minifigure) + self.list(override_query=self.last_query, order=order, limit=limit) return self + # Base minifigure list + def list( + self, + /, + *, + override_query: str | None = None, + order: str | None = None, + limit: int | None = None, + **context: Any, + ) -> None: + if order is None: + order = self.order + + if hasattr(self, 'brickset'): + brickset = self.brickset + else: + brickset = None + + # Load the sets from the database + for record in super().select( + override_query=override_query, + order=order, + limit=limit, + ): + minifigure = BrickMinifigure(brickset=brickset, record=record) + + self.records.append(minifigure) + # Load minifigures from a brickset - def load(self, brickset: 'BrickSet', /) -> Self: + def from_set(self, brickset: 'BrickSet', /) -> Self: # Save the brickset self.brickset = brickset # Load the minifigures from the database - for record in self.select(order=self.order): - minifigure = BrickMinifigure(brickset=self.brickset, record=record) + self.list() - self.records.append(minifigure) + return self + + # Minifigures missing a part + def missing_part(self, part: str, color: int, /) -> Self: + # Save the parameters to the fields + self.fields.part = part + self.fields.color = color + + # Load the minifigures from the database + self.list(override_query=self.missing_part_query) + + return self + + # Minifigure using a part + def using_part(self, part: str, color: int, /) -> Self: + # Save the parameters to the fields + self.fields.part = part + self.fields.color = color + + # Load the minifigures from the database + self.list(override_query=self.using_part_query) return self @@ -78,57 +130,54 @@ class BrickMinifigureList(BrickRecordList[BrickMinifigure]): parameters: dict[str, Any] = super().sql_parameters() if self.brickset is not None: - parameters['u_id'] = self.brickset.fields.id - parameters['set_num'] = self.brickset.fields.set + parameters['id'] = self.brickset.fields.id return parameters - # Minifigures missing a part - def missing_part( - self, - part_num: str, - color_id: int, + # Import the minifigures from Rebrickable + @staticmethod + def download( + socket: 'BrickSocket', + brickset: 'BrickSet', /, *, - element_id: int | None = None, - ) -> Self: - # Save the parameters to the fields - self.fields.part_num = part_num - self.fields.color_id = color_id - self.fields.element_id = element_id + refresh: bool = False + ) -> bool: + try: + socket.auto_progress( + message='Set {set}: loading minifigures from Rebrickable'.format( # noqa: E501 + set=brickset.fields.set, + ), + increment_total=True, + ) - # Load the minifigures from the database - for record in self.select( - override_query=self.missing_part_query, - order=self.order - ): - minifigure = BrickMinifigure(record=record) + logger.debug('rebrick.lego.get_set_minifigs("{set}")'.format( + set=brickset.fields.set, + )) - self.records.append(minifigure) + minifigures = Rebrickable[BrickMinifigure]( + 'get_set_minifigs', + brickset.fields.set, + BrickMinifigure, + socket=socket, + brickset=brickset, + ).list() - return self + # Process each minifigure + for minifigure in minifigures: + if not minifigure.download(socket, refresh=refresh): + return False - # Minifigure using a part - def using_part( - self, - part_num: str, - color_id: int, - /, - *, - element_id: int | None = None, - ) -> Self: - # Save the parameters to the fields - self.fields.part_num = part_num - self.fields.color_id = color_id - self.fields.element_id = element_id + return True - # Load the minifigures from the database - for record in self.select( - override_query=self.using_part_query, - order=self.order - ): - minifigure = BrickMinifigure(record=record) + except Exception as e: + socket.fail( + message='Error while importing set {set} minifigure list: {error}'.format( # noqa: E501 + set=brickset.fields.set, + error=e, + ) + ) - self.records.append(minifigure) + logger.debug(traceback.format_exc()) - return self + return False diff --git a/bricktracker/navbar.py b/bricktracker/navbar.py index 17853eb..20a2b29 100644 --- a/bricktracker/navbar.py +++ b/bricktracker/navbar.py @@ -11,9 +11,10 @@ NAVBAR: Final[list[dict[str, Any]]] = [ {'e': 'set.list', 't': 'Sets', 'i': 'grid-line', 'f': 'HIDE_ALL_SETS'}, # noqa: E501 {'e': 'add.add', 't': 'Add', 'i': 'add-circle-line', 'f': 'HIDE_ADD_SET'}, # noqa: E501 {'e': 'part.list', 't': 'Parts', 'i': 'shapes-line', 'f': 'HIDE_ALL_PARTS'}, # noqa: E501 - {'e': 'part.missing', 't': 'Missing', 'i': 'error-warning-line', 'f': 'HIDE_MISSING_PARTS'}, # noqa: E501 + {'e': 'part.problem', 't': 'Problems', 'i': 'error-warning-line', 'f': 'HIDE_ALL_PROBLEMS_PARTS'}, # noqa: E501 {'e': 'minifigure.list', 't': 'Minifigures', 'i': 'group-line', 'f': 'HIDE_ALL_MINIFIGURES'}, # noqa: E501 {'e': 'instructions.list', 't': 'Instructions', 'i': 'file-line', 'f': 'HIDE_ALL_INSTRUCTIONS'}, # noqa: E501 + {'e': 'storage.list', 't': 'Storages', 'i': 'archive-2-line', 'f': 'HIDE_ALL_STORAGES'}, # noqa: E501 {'e': 'wish.list', 't': 'Wishlist', 'i': 'gift-line', 'f': 'HIDE_WISHES'}, {'e': 'admin.admin', 't': 'Admin', 'i': 'settings-4-line', 'f': 'HIDE_ADMIN'}, # noqa: E501 ] diff --git a/bricktracker/part.py b/bricktracker/part.py index 80a51bd..12eab28 100644 --- a/bricktracker/part.py +++ b/bricktracker/part.py @@ -1,23 +1,25 @@ -import os +import logging from sqlite3 import Row from typing import Any, Self, TYPE_CHECKING -from urllib.parse import urlparse +import traceback -from flask import current_app, url_for +from flask import url_for -from .exceptions import DatabaseException, ErrorException, NotFoundException -from .rebrickable_image import RebrickableImage -from .record import BrickRecord +from .exceptions import ErrorException, NotFoundException +from .rebrickable_part import RebrickablePart from .sql import BrickSQL if TYPE_CHECKING: from .minifigure import BrickMinifigure from .set import BrickSet + from .socket import BrickSocket + +logger = logging.getLogger(__name__) # Lego set or minifig part -class BrickPart(BrickRecord): - brickset: 'BrickSet | None' - minifigure: 'BrickMinifigure | None' +class BrickPart(RebrickablePart): + identifier: str + kind: str # Queries insert_query: str = 'part/insert' @@ -30,74 +32,91 @@ class BrickPart(BrickRecord): *, brickset: 'BrickSet | None' = None, minifigure: 'BrickMinifigure | None' = None, - record: Row | dict[str, Any] | None = None, + record: Row | dict[str, Any] | None = None ): - super().__init__() - - # Save the brickset and minifigure - self.brickset = brickset - self.minifigure = minifigure - - # Ingest the record if it has one - if record is not None: - self.ingest(record) - - # Delete missing part - def delete_missing(self, /) -> None: - BrickSQL().execute_and_commit( - 'missing/delete/from_set', - parameters=self.sql_parameters() + super().__init__( + brickset=brickset, + minifigure=minifigure, + record=record ) - # Set missing part - def set_missing(self, quantity: int, /) -> None: - parameters = self.sql_parameters() - parameters['quantity'] = quantity + if self.minifigure is not None: + self.identifier = self.minifigure.fields.figure + self.kind = 'Minifigure' + elif self.brickset is not None: + self.identifier = self.brickset.fields.set + self.kind = 'Set' - # Can't use UPSERT because the database has no keys - # Try to update - database = BrickSQL() - rows, _ = database.execute( - 'missing/update/from_set', - parameters=parameters, - ) + # Import a part into the database + def download(self, socket: 'BrickSocket', refresh: bool = False) -> bool: + if self.brickset is None: + raise ErrorException('Importing a part from Rebrickable outside of a set is not supported') # noqa: E501 - # Insert if no row has been affected - if not rows: - rows, _ = database.execute( - 'missing/insert', - parameters=parameters, + try: + # Insert into the database + socket.auto_progress( + message='{kind} {identifier}: inserting part {part} into database'.format( # noqa: E501 + kind=self.kind, + identifier=self.identifier, + part=self.fields.part + ) ) - if rows != 1: - raise DatabaseException( - 'Could not update the missing quantity for part {id}'.format( # noqa: E501 - id=self.fields.id - ) - ) + if not refresh: + # Insert into database + self.insert(commit=False) - database.commit() + # Insert the rebrickable set into database + self.insert_rebrickable() + + except Exception as e: + socket.fail( + message='Error while importing part {part} from {kind} {identifier}: {error}'.format( # noqa: E501 + part=self.fields.part, + kind=self.kind, + identifier=self.identifier, + error=e, + ) + ) + + logger.debug(traceback.format_exc()) + + return False + + return True + + # A identifier for HTML component + def html_id(self, prefix: str | None = None, /) -> str: + components: list[str] = ['part'] + + if prefix is not None: + components.append(prefix) + + if self.fields.figure is not None: + components.append(self.fields.figure) + + components.append(self.fields.part) + components.append(str(self.fields.color)) + components.append(str(self.fields.spare)) + + return '-'.join(components) # Select a generic part def select_generic( self, - part_num: str, - color_id: int, + part: str, + color: int, /, - *, - element_id: int | None = None ) -> Self: # Save the parameters to the fields - self.fields.part_num = part_num - self.fields.color_id = color_id - self.fields.element_id = element_id + self.fields.part = part + self.fields.color = color if not self.select(override_query=self.generic_query): raise NotFoundException( - 'Part with number {number}, color ID {color} and element ID {element} was not found in the database'.format( # noqa: E501 - number=self.fields.part_num, - color=self.fields.color_id, - element=self.fields.element_id, + 'Part with number {number}, color ID {color} was not found in the database'.format( # noqa: E501 + number=self.fields.part, + color=self.fields.color, ), ) @@ -107,7 +126,9 @@ class BrickPart(BrickRecord): def select_specific( self, brickset: 'BrickSet', - id: str, + part: str, + color: int, + spare: int, /, *, minifigure: 'BrickMinifigure | None' = None, @@ -115,168 +136,73 @@ class BrickPart(BrickRecord): # Save the parameters to the fields self.brickset = brickset self.minifigure = minifigure - self.fields.id = id + self.fields.part = part + self.fields.color = color + self.fields.spare = spare if not self.select(): + if self.minifigure is not None: + figure = self.minifigure.fields.figure + else: + figure = None + raise NotFoundException( - 'Part with ID {id} from set {set} was not found in the database'.format( # noqa: E501 + 'Part {part} with color {color} (spare: {spare}) from set {set} ({id}) (minifigure: {figure}) was not found in the database'.format( # noqa: E501 + part=self.fields.part, + color=self.fields.color, + spare=self.fields.spare, id=self.fields.id, set=self.brickset.fields.set, + figure=figure, ), ) return self - # Return a dict with common SQL parameters for a part - def sql_parameters(self, /) -> dict[str, Any]: - parameters = super().sql_parameters() + # Update a problematic part + def update_problem(self, problem: str, json: Any | None, /) -> int: + amount: str | int = json.get('value', '') # type: ignore - # Supplement from the brickset - if 'u_id' not in parameters and self.brickset is not None: - parameters['u_id'] = self.brickset.fields.id + # We need a positive integer + try: + if amount == '': + amount = 0 - if 'set_num' not in parameters: - if self.minifigure is not None: - parameters['set_num'] = self.minifigure.fields.fig_num + amount = int(amount) - elif self.brickset is not None: - parameters['set_num'] = self.brickset.fields.set + if amount < 0: + amount = 0 + except Exception: + raise ErrorException('"{amount}" is not a valid integer'.format( + amount=amount + )) - return parameters + if amount < 0: + raise ErrorException('Cannot set a negative amount') - # Update the missing part - def update_missing(self, missing: Any, /) -> None: - # If empty, delete it - if missing == '': - self.delete_missing() + setattr(self.fields, problem, amount) - else: - # Try to understand it as a number - try: - missing = int(missing) - except Exception: - raise ErrorException('"{missing}" is not a valid integer'.format( # noqa: E501 - missing=missing - )) - - # If 0, delete it - if missing == 0: - self.delete_missing() - - else: - # If negative, it's an error - if missing < 0: - raise ErrorException('Cannot set a negative missing value') - - # Otherwise upsert it - # Not checking if it is too much, you do you - self.set_missing(missing) - - # Self url - def url(self, /) -> str: - return url_for( - 'part.details', - number=self.fields.part_num, - color=self.fields.color_id, - element=self.fields.element_id, + BrickSQL().execute_and_commit( + 'part/update/{problem}'.format(problem=problem), + parameters=self.sql_parameters() ) - # Compute the url for the bricklink page - def url_for_bricklink(self, /) -> str: - if current_app.config['BRICKLINK_LINKS']: - try: - return current_app.config['BRICKLINK_LINK_PART_PATTERN'].format( # noqa: E501 - number=self.fields.part_num, - ) - except Exception: - pass + return amount - return '' - - # Compute the url for the part image - def url_for_image(self, /) -> str: - if not current_app.config['USE_REMOTE_IMAGES']: - if self.fields.part_img_url is None: - file = RebrickableImage.nil_name() - else: - file = self.fields.part_img_url_id - - return RebrickableImage.static_url(file, 'PARTS_FOLDER') - else: - if self.fields.part_img_url is None: - return current_app.config['REBRICKABLE_IMAGE_NIL'] - else: - return self.fields.part_img_url - - # Compute the url for missing part - def url_for_missing(self, /) -> str: + # Compute the url for problematic part + def url_for_problem(self, problem: str, /) -> str: # Different URL for a minifigure part if self.minifigure is not None: - return url_for( - 'set.missing_minifigure_part', - id=self.fields.u_id, - minifigure_id=self.minifigure.fields.fig_num, - part_id=self.fields.id, - ) + figure = self.minifigure.fields.figure + else: + figure = None return url_for( - 'set.missing_part', - id=self.fields.u_id, - part_id=self.fields.id + 'set.problem_part', + id=self.fields.id, + figure=figure, + part=self.fields.part, + color=self.fields.color, + spare=self.fields.spare, + problem=problem, ) - - # Compute the url for the rebrickable page - def url_for_rebrickable(self, /) -> str: - if current_app.config['REBRICKABLE_LINKS']: - try: - return current_app.config['REBRICKABLE_LINK_PART_PATTERN'].format( # noqa: E501 - number=self.fields.part_num, - color=self.fields.color_id, - ) - except Exception: - pass - - return '' - - # Normalize from Rebrickable - @staticmethod - def from_rebrickable( - data: dict[str, Any], - /, - *, - brickset: 'BrickSet | None' = None, - minifigure: 'BrickMinifigure | None' = None, - **_, - ) -> dict[str, Any]: - record = { - 'set_num': data['set_num'], - 'id': data['id'], - 'part_num': data['part']['part_num'], - 'name': data['part']['name'], - 'part_img_url': data['part']['part_img_url'], - 'part_img_url_id': None, - 'color_id': data['color']['id'], - 'color_name': data['color']['name'], - 'quantity': data['quantity'], - 'is_spare': data['is_spare'], - 'element_id': data['element_id'], - } - - if brickset is not None: - record['u_id'] = brickset.fields.id - - if minifigure is not None: - record['set_num'] = data['fig_num'] - - # Extract the file name - if data['part']['part_img_url'] is not None: - part_img_url_file = os.path.basename( - urlparse(data['part']['part_img_url']).path - ) - - part_img_url_id, _ = os.path.splitext(part_img_url_file) - - if part_img_url_id is not None or part_img_url_id != '': - record['part_img_url_id'] = part_img_url_id - - return record diff --git a/bricktracker/part_list.py b/bricktracker/part_list.py index 93897f8..a12ef89 100644 --- a/bricktracker/part_list.py +++ b/bricktracker/part_list.py @@ -1,12 +1,18 @@ +import logging from typing import Any, Self, TYPE_CHECKING +import traceback from flask import current_app from .part import BrickPart +from .rebrickable import Rebrickable from .record_list import BrickRecordList if TYPE_CHECKING: from .minifigure import BrickMinifigure from .set import BrickSet + from .socket import BrickSocket + +logger = logging.getLogger(__name__) # Lego set or minifig parts @@ -17,10 +23,12 @@ class BrickPartList(BrickRecordList[BrickPart]): # Queries all_query: str = 'part/list/all' + different_color_query = 'part/list/with_different_color' last_query: str = 'part/list/last' minifigure_query: str = 'part/list/from_minifigure' - missing_query: str = 'part/list/missing' - select_query: str = 'part/list/from_set' + problem_query: str = 'part/list/problem' + print_query: str = 'part/list/from_print' + select_query: str = 'part/list/specific' def __init__(self, /): super().__init__() @@ -34,18 +42,52 @@ class BrickPartList(BrickRecordList[BrickPart]): # Load all parts def all(self, /) -> Self: - for record in self.select( - override_query=self.all_query, - order=self.order - ): - part = BrickPart(record=record) - - self.records.append(part) + self.list(override_query=self.all_query) return self - # Load parts from a brickset or minifigure - def load( + # Base part list + def list( + self, + /, + *, + override_query: str | None = None, + order: str | None = None, + limit: int | None = None, + **context: Any, + ) -> None: + if order is None: + order = self.order + + if hasattr(self, 'brickset'): + brickset = self.brickset + else: + brickset = None + + if hasattr(self, 'minifigure'): + minifigure = self.minifigure + else: + minifigure = None + + # Load the sets from the database + for record in super().select( + override_query=override_query, + order=order, + limit=limit, + ): + part = BrickPart( + brickset=brickset, + minifigure=minifigure, + record=record, + ) + + if current_app.config['SKIP_SPARE_PARTS'] and part.fields.spare: + continue + + self.records.append(part) + + # List specific parts from a brickset or minifigure + def list_specific( self, brickset: 'BrickSet', /, @@ -57,17 +99,7 @@ class BrickPartList(BrickRecordList[BrickPart]): self.minifigure = minifigure # Load the parts from the database - for record in self.select(order=self.order): - part = BrickPart( - brickset=self.brickset, - minifigure=minifigure, - record=record, - ) - - if current_app.config['SKIP_SPARE_PARTS'] and part.fields.is_spare: - continue - - self.records.append(part) + self.list() return self @@ -81,47 +113,133 @@ class BrickPartList(BrickRecordList[BrickPart]): self.minifigure = minifigure # Load the parts from the database - for record in self.select( - override_query=self.minifigure_query, - order=self.order - ): - part = BrickPart( - minifigure=minifigure, - record=record, - ) - - if current_app.config['SKIP_SPARE_PARTS'] and part.fields.is_spare: - continue - - self.records.append(part) + self.list(override_query=self.minifigure_query) return self - # Load missing parts - def missing(self, /) -> Self: - for record in self.select( - override_query=self.missing_query, - order=self.order - ): - part = BrickPart(record=record) + # Load generic parts from a print + def from_print( + self, + brickpart: BrickPart, + /, + ) -> Self: + # Save the part and print + if brickpart.fields.print is not None: + self.fields.print = brickpart.fields.print + else: + self.fields.print = brickpart.fields.part - self.records.append(part) + self.fields.part = brickpart.fields.part + self.fields.color = brickpart.fields.color + + # Load the parts from the database + self.list(override_query=self.print_query) + + return self + + # Load problematic parts + def problem(self, /) -> Self: + self.list(override_query=self.problem_query) return self # Return a dict with common SQL parameters for a parts list def sql_parameters(self, /) -> dict[str, Any]: - parameters: dict[str, Any] = {} + parameters: dict[str, Any] = super().sql_parameters() # Set id if self.brickset is not None: - parameters['u_id'] = self.brickset.fields.id + parameters['id'] = self.brickset.fields.id # Use the minifigure number if present, - # otherwise use the set number if self.minifigure is not None: - parameters['set_num'] = self.minifigure.fields.fig_num - elif self.brickset is not None: - parameters['set_num'] = self.brickset.fields.set + parameters['figure'] = self.minifigure.fields.figure + else: + parameters['figure'] = None return parameters + + # Load generic parts with same base but different color + def with_different_color( + self, + brickpart: BrickPart, + /, + ) -> Self: + # Save the part + self.fields.part = brickpart.fields.part + self.fields.color = brickpart.fields.color + + # Load the parts from the database + self.list(override_query=self.different_color_query) + + return self + + # Import the parts from Rebrickable + @staticmethod + def download( + socket: 'BrickSocket', + brickset: 'BrickSet', + /, + *, + minifigure: 'BrickMinifigure | None' = None, + refresh: bool = False + ) -> bool: + if minifigure is not None: + identifier = minifigure.fields.figure + kind = 'Minifigure' + method = 'get_minifig_elements' + else: + identifier = brickset.fields.set + kind = 'Set' + method = 'get_set_elements' + + try: + socket.auto_progress( + message='{kind} {identifier}: loading parts inventory from Rebrickable'.format( # noqa: E501 + kind=kind, + identifier=identifier, + ), + increment_total=True, + ) + + logger.debug('rebrick.lego.{method}("{identifier}")'.format( + method=method, + identifier=identifier, + )) + + inventory = Rebrickable[BrickPart]( + method, + identifier, + BrickPart, + socket=socket, + brickset=brickset, + minifigure=minifigure, + ).list() + + # Process each part + number_of_parts: int = 0 + for part in inventory: + # Count the number of parts for minifigures + if minifigure is not None: + number_of_parts += part.fields.quantity + + if not part.download(socket, refresh=refresh): + return False + + if minifigure is not None: + minifigure.fields.number_of_parts = number_of_parts + + except Exception as e: + socket.fail( + message='Error while importing {kind} {identifier} parts list: {error}'.format( # noqa: E501 + kind=kind, + identifier=identifier, + error=e, + ) + ) + + logger.debug(traceback.format_exc()) + + return False + + return True diff --git a/bricktracker/rebrickable_image.py b/bricktracker/rebrickable_image.py index 0a0d9f4..509e718 100644 --- a/bricktracker/rebrickable_image.py +++ b/bricktracker/rebrickable_image.py @@ -8,16 +8,16 @@ from shutil import copyfileobj from .exceptions import DownloadException if TYPE_CHECKING: - from .minifigure import BrickMinifigure - from .part import BrickPart + from .rebrickable_minifigure import RebrickableMinifigure + from .rebrickable_part import RebrickablePart from .rebrickable_set import RebrickableSet # A set, part or minifigure image from Rebrickable class RebrickableImage(object): set: 'RebrickableSet' - minifigure: 'BrickMinifigure | None' - part: 'BrickPart | None' + minifigure: 'RebrickableMinifigure | None' + part: 'RebrickablePart | None' extension: str | None @@ -26,8 +26,8 @@ class RebrickableImage(object): set: 'RebrickableSet', /, *, - minifigure: 'BrickMinifigure | None' = None, - part: 'BrickPart | None' = None, + minifigure: 'RebrickableMinifigure | None' = None, + part: 'RebrickablePart | None' = None, ): # Save all objects self.set = set @@ -81,16 +81,16 @@ class RebrickableImage(object): # Return the id depending on the objects provided def id(self, /) -> str: if self.part is not None: - if self.part.fields.part_img_url_id is None: + if self.part.fields.image_id is None: return RebrickableImage.nil_name() else: - return self.part.fields.part_img_url_id + return self.part.fields.image_id if self.minifigure is not None: - if self.minifigure.fields.set_img_url is None: + if self.minifigure.fields.image is None: return RebrickableImage.nil_minifigure_name() else: - return self.minifigure.fields.fig_num + return self.minifigure.fields.figure return self.set.fields.set @@ -105,16 +105,16 @@ class RebrickableImage(object): # Return the url depending on the objects provided def url(self, /) -> str: if self.part is not None: - if self.part.fields.part_img_url is None: + if self.part.fields.image is None: return current_app.config['REBRICKABLE_IMAGE_NIL'] else: - return self.part.fields.part_img_url + return self.part.fields.image if self.minifigure is not None: - if self.minifigure.fields.set_img_url is None: + if self.minifigure.fields.image is None: return current_app.config['REBRICKABLE_IMAGE_NIL_MINIFIGURE'] else: - return self.minifigure.fields.set_img_url + return self.minifigure.fields.image return self.set.fields.image diff --git a/bricktracker/rebrickable_minifigure.py b/bricktracker/rebrickable_minifigure.py new file mode 100644 index 0000000..0ef7d43 --- /dev/null +++ b/bricktracker/rebrickable_minifigure.py @@ -0,0 +1,111 @@ +from sqlite3 import Row +from typing import Any, TYPE_CHECKING + +from flask import current_app, url_for + +from .exceptions import ErrorException +from .rebrickable_image import RebrickableImage +from .record import BrickRecord +if TYPE_CHECKING: + from .set import BrickSet + + +# A minifigure from Rebrickable +class RebrickableMinifigure(BrickRecord): + brickset: 'BrickSet | None' + + # Queries + select_query: str = 'rebrickable/minifigure/select' + insert_query: str = 'rebrickable/minifigure/insert' + + def __init__( + self, + /, + *, + brickset: 'BrickSet | None' = None, + record: Row | dict[str, Any] | None = None + ): + super().__init__() + + # Save the brickset + self.brickset = brickset + + # Ingest the record if it has one + if record is not None: + self.ingest(record) + + # Insert the minifigure from Rebrickable + def insert_rebrickable(self, /) -> None: + if self.brickset is None: + raise ErrorException('Importing a minifigure from Rebrickable outside of a set is not supported') # noqa: E501 + + # Insert the Rebrickable minifigure to the database + self.insert( + commit=False, + no_defer=True, + override_query=RebrickableMinifigure.insert_query + ) + + if not current_app.config['USE_REMOTE_IMAGES']: + RebrickableImage( + self.brickset, + minifigure=self, + ).download() + + # Return a dict with common SQL parameters for a minifigure + def sql_parameters(self, /) -> dict[str, Any]: + parameters = super().sql_parameters() + + # Supplement from the brickset + if self.brickset is not None and 'id' not in parameters: + parameters['id'] = self.brickset.fields.id + + return parameters + + # Self url + def url(self, /) -> str: + return url_for( + 'minifigure.details', + figure=self.fields.figure, + ) + + # Compute the url for minifigure image + def url_for_image(self, /) -> str: + if not current_app.config['USE_REMOTE_IMAGES']: + if self.fields.image is None: + file = RebrickableImage.nil_minifigure_name() + else: + file = self.fields.figure + + return RebrickableImage.static_url(file, 'MINIFIGURES_FOLDER') + else: + if self.fields.image is None: + return current_app.config['REBRICKABLE_IMAGE_NIL_MINIFIGURE'] + else: + return self.fields.image + + # Compute the url for the rebrickable page + def url_for_rebrickable(self, /) -> str: + if current_app.config['REBRICKABLE_LINKS']: + try: + return current_app.config['REBRICKABLE_LINK_MINIFIGURE_PATTERN'].format( # noqa: E501 + number=self.fields.figure, + ) + except Exception: + pass + + return '' + + # Normalize from Rebrickable + @staticmethod + def from_rebrickable(data: dict[str, Any], /, **_) -> dict[str, Any]: + # Extracting number + number = int(str(data['set_num'])[5:]) + + return { + 'figure': str(data['set_num']), + 'number': int(number), + 'name': str(data['set_name']), + 'quantity': int(data['quantity']), + 'image': data['set_img_url'], + } diff --git a/bricktracker/rebrickable_minifigures.py b/bricktracker/rebrickable_minifigures.py deleted file mode 100644 index eb72e06..0000000 --- a/bricktracker/rebrickable_minifigures.py +++ /dev/null @@ -1,85 +0,0 @@ -import logging -from typing import TYPE_CHECKING - -from flask import current_app - -from .minifigure import BrickMinifigure -from .rebrickable import Rebrickable -from .rebrickable_image import RebrickableImage -from .rebrickable_parts import RebrickableParts -if TYPE_CHECKING: - from .set import BrickSet - from .socket import BrickSocket - -logger = logging.getLogger(__name__) - - -# Minifigures from Rebrickable -class RebrickableMinifigures(object): - socket: 'BrickSocket' - brickset: 'BrickSet' - - def __init__(self, socket: 'BrickSocket', brickset: 'BrickSet', /): - # Save the socket - self.socket = socket - - # Save the objects - self.brickset = brickset - - # Import the minifigures from Rebrickable - def download(self, /) -> None: - self.socket.auto_progress( - message='Set {number}: loading minifigures from Rebrickable'.format( # noqa: E501 - number=self.brickset.fields.set, - ), - increment_total=True, - ) - - logger.debug('rebrick.lego.get_set_minifigs("{set}")'.format( - set=self.brickset.fields.set, - )) - - minifigures = Rebrickable[BrickMinifigure]( - 'get_set_minifigs', - self.brickset.fields.set, - BrickMinifigure, - socket=self.socket, - brickset=self.brickset, - ).list() - - # Process each minifigure - total = len(minifigures) - for index, minifigure in enumerate(minifigures): - # Insert into the database - self.socket.auto_progress( - message='Set {number}: inserting minifigure {current}/{total} into database'.format( # noqa: E501 - number=self.brickset.fields.set, - current=index+1, - total=total, - ) - ) - - # Insert into database - minifigure.insert(commit=False) - - # Grab the image - self.socket.progress( - message='Set {number}: downloading minifigure {current}/{total} image'.format( # noqa: E501 - number=self.brickset.fields.set, - current=index+1, - total=total, - ) - ) - - if not current_app.config['USE_REMOTE_IMAGES']: - RebrickableImage( - self.brickset, - minifigure=minifigure - ).download() - - # Load the inventory - RebrickableParts( - self.socket, - self.brickset, - minifigure=minifigure, - ).download() diff --git a/bricktracker/rebrickable_part.py b/bricktracker/rebrickable_part.py new file mode 100644 index 0000000..ae34b3a --- /dev/null +++ b/bricktracker/rebrickable_part.py @@ -0,0 +1,196 @@ +import os +from sqlite3 import Row +from typing import Any, TYPE_CHECKING +from urllib.parse import urlparse + +from flask import current_app, url_for + +from .exceptions import ErrorException +from .rebrickable_image import RebrickableImage +from .record import BrickRecord +if TYPE_CHECKING: + from .minifigure import BrickMinifigure + from .set import BrickSet + from .socket import BrickSocket + + +# A part from Rebrickable +class RebrickablePart(BrickRecord): + socket: 'BrickSocket' + brickset: 'BrickSet | None' + minifigure: 'BrickMinifigure | None' + + # Queries + select_query: str = 'rebrickable/part/select' + insert_query: str = 'rebrickable/part/insert' + + def __init__( + self, + /, + *, + brickset: 'BrickSet | None' = None, + minifigure: 'BrickMinifigure | None' = None, + record: Row | dict[str, Any] | None = None + ): + super().__init__() + + # Save the brickset + self.brickset = brickset + + # Save the minifigure + self.minifigure = minifigure + + # Ingest the record if it has one + if record is not None: + self.ingest(record) + + # Insert the part from Rebrickable + def insert_rebrickable(self, /) -> None: + if self.brickset is None: + raise ErrorException('Importing a part from Rebrickable outside of a set is not supported') # noqa: E501 + + # Insert the Rebrickable part to the database + self.insert( + commit=False, + no_defer=True, + override_query=RebrickablePart.insert_query + ) + + if not current_app.config['USE_REMOTE_IMAGES']: + RebrickableImage( + self.brickset, + minifigure=self.minifigure, + part=self, + ).download() + + # Return a dict with common SQL parameters for a part + def sql_parameters(self, /) -> dict[str, Any]: + parameters = super().sql_parameters() + + # Set id + if self.brickset is not None: + parameters['id'] = self.brickset.fields.id + + # Use the minifigure number if present, + if self.minifigure is not None: + parameters['figure'] = self.minifigure.fields.figure + else: + parameters['figure'] = None + + return parameters + + # Self url + def url(self, /) -> str: + return url_for( + 'part.details', + part=self.fields.part, + color=self.fields.color, + ) + + # Compute the url for the bricklink page + def url_for_bricklink(self, /) -> str: + if current_app.config['BRICKLINK_LINKS']: + try: + return current_app.config['BRICKLINK_LINK_PART_PATTERN'].format( # noqa: E501 + part=self.fields.part, + ) + except Exception: + pass + + return '' + + # Compute the url for the part image + def url_for_image(self, /) -> str: + if not current_app.config['USE_REMOTE_IMAGES']: + if self.fields.image is None: + file = RebrickableImage.nil_name() + else: + file = self.fields.image_id + + return RebrickableImage.static_url(file, 'PARTS_FOLDER') + else: + if self.fields.image is None: + return current_app.config['REBRICKABLE_IMAGE_NIL'] + else: + return self.fields.image + + # Compute the url for the original of the printed part + def url_for_print(self, /) -> str: + if self.fields.print is not None: + return url_for( + 'part.details', + part=self.fields.print, + color=self.fields.color, + ) + else: + return '' + + # Compute the url for the rebrickable page + def url_for_rebrickable(self, /) -> str: + if current_app.config['REBRICKABLE_LINKS']: + try: + if self.fields.url is not None: + # The URL does not contain color info... + return '{url}{color}'.format( + url=self.fields.url, + color=self.fields.color + ) + else: + return current_app.config['REBRICKABLE_LINK_PART_PATTERN'].format( # noqa: E501 + part=self.fields.part, + color=self.fields.color, + ) + except Exception: + pass + + return '' + + # Normalize from Rebrickable + @staticmethod + def from_rebrickable( + data: dict[str, Any], + /, + *, + brickset: 'BrickSet | None' = None, + minifigure: 'BrickMinifigure | None' = None, + **_, + ) -> dict[str, Any]: + record = { + 'id': None, + 'figure': None, + 'part': data['part']['part_num'], + 'color': data['color']['id'], + 'spare': data['is_spare'], + 'quantity': data['quantity'], + 'rebrickable_inventory': data['id'], + 'element': data['element_id'], + 'color_id': data['color']['id'], + 'color_name': data['color']['name'], + 'color_rgb': data['color']['rgb'], + 'color_transparent': data['color']['is_trans'], + 'name': data['part']['name'], + 'category': data['part']['part_cat_id'], + 'image': data['part']['part_img_url'], + 'image_id': None, + 'url': data['part']['part_url'], + 'print': data['part']['print_of'] + } + + if brickset is not None: + record['id'] = brickset.fields.id + + if minifigure is not None: + record['figure'] = minifigure.fields.figure + + # Extract the file name + if record['image'] is not None: + image_id, _ = os.path.splitext( + os.path.basename( + urlparse(record['image']).path + ) + ) + + if image_id is not None or image_id != '': + record['image_id'] = image_id + + return record diff --git a/bricktracker/rebrickable_parts.py b/bricktracker/rebrickable_parts.py deleted file mode 100644 index 69c42dc..0000000 --- a/bricktracker/rebrickable_parts.py +++ /dev/null @@ -1,113 +0,0 @@ -import logging -from typing import TYPE_CHECKING - -from flask import current_app - -from .part import BrickPart -from .rebrickable import Rebrickable -from .rebrickable_image import RebrickableImage -if TYPE_CHECKING: - from .minifigure import BrickMinifigure - from .set import BrickSet - from .socket import BrickSocket - -logger = logging.getLogger(__name__) - - -# A list of parts from Rebrickable -class RebrickableParts(object): - socket: 'BrickSocket' - brickset: 'BrickSet' - minifigure: 'BrickMinifigure | None' - - number: str - kind: str - method: str - - def __init__( - self, - socket: 'BrickSocket', - brickset: 'BrickSet', - /, - *, - minifigure: 'BrickMinifigure | None' = None, - ): - # Save the socket - self.socket = socket - - # Save the objects - self.brickset = brickset - self.minifigure = minifigure - - if self.minifigure is not None: - self.number = self.minifigure.fields.fig_num - self.kind = 'Minifigure' - self.method = 'get_minifig_elements' - else: - self.number = self.brickset.fields.set - self.kind = 'Set' - self.method = 'get_set_elements' - - # Import the parts from Rebrickable - def download(self, /) -> None: - self.socket.auto_progress( - message='{kind} {number}: loading parts inventory from Rebrickable'.format( # noqa: E501 - kind=self.kind, - number=self.number, - ), - increment_total=True, - ) - - logger.debug('rebrick.lego.{method}("{number}")'.format( - method=self.method, - number=self.number, - )) - - inventory = Rebrickable[BrickPart]( - self.method, - self.number, - BrickPart, - socket=self.socket, - brickset=self.brickset, - minifigure=self.minifigure, - ).list() - - # Process each part - total = len(inventory) - for index, part in enumerate(inventory): - # Skip spare parts - if ( - current_app.config['SKIP_SPARE_PARTS'] and - part.fields.is_spare - ): - continue - - # Insert into the database - self.socket.auto_progress( - message='{kind} {number}: inserting part {current}/{total} into database'.format( # noqa: E501 - kind=self.kind, - number=self.number, - current=index+1, - total=total, - ) - ) - - # Insert into database - part.insert(commit=False) - - # Grab the image - self.socket.progress( - message='{kind} {number}: downloading part {current}/{total} image'.format( # noqa: E501 - kind=self.kind, - number=self.number, - current=index+1, - total=total, - ) - ) - - if not current_app.config['USE_REMOTE_IMAGES']: - RebrickableImage( - self.brickset, - minifigure=self.minifigure, - part=part, - ).download() diff --git a/bricktracker/rebrickable_set.py b/bricktracker/rebrickable_set.py index 5a1c41f..6beffc2 100644 --- a/bricktracker/rebrickable_set.py +++ b/bricktracker/rebrickable_set.py @@ -1,9 +1,9 @@ import logging from sqlite3 import Row import traceback -from typing import Any, TYPE_CHECKING +from typing import Any, Self, TYPE_CHECKING -from flask import current_app +from flask import current_app, url_for from .exceptions import ErrorException, NotFoundException from .instructions import BrickInstructions @@ -21,7 +21,6 @@ logger = logging.getLogger(__name__) # A set from Rebrickable class RebrickableSet(BrickRecord): - socket: 'BrickSocket' theme: 'BrickTheme' instructions: list[BrickInstructions] @@ -36,7 +35,6 @@ class RebrickableSet(BrickRecord): self, /, *, - socket: 'BrickSocket | None' = None, record: Row | dict[str, Any] | None = None ): super().__init__() @@ -44,26 +42,21 @@ class RebrickableSet(BrickRecord): # Placeholders self.instructions = [] - # Save the socket - if socket is not None: - self.socket = socket - # Ingest the record if it has one if record is not None: self.ingest(record) - # Import the set from Rebrickable - def download_rebrickable(self, /) -> None: + # Insert the set from Rebrickable + def insert_rebrickable(self, /) -> None: # Insert the Rebrickable set to the database - rows, _ = self.insert( + self.insert( commit=False, no_defer=True, override_query=RebrickableSet.insert_query ) - if rows > 0: - if not current_app.config['USE_REMOTE_IMAGES']: - RebrickableImage(self).download() + if not current_app.config['USE_REMOTE_IMAGES']: + RebrickableImage(self).download() # Ingest a set def ingest(self, record: Row | dict[str, Any], /): @@ -88,20 +81,21 @@ class RebrickableSet(BrickRecord): # Load the set from Rebrickable def load( self, + socket: 'BrickSocket', data: dict[str, Any], /, *, from_download=False, ) -> bool: # Reset the progress - self.socket.progress_count = 0 - self.socket.progress_total = 2 + socket.progress_count = 0 + socket.progress_total = 2 try: - self.socket.auto_progress(message='Parsing set number') + socket.auto_progress(message='Parsing set number') set = parse_set(str(data['set'])) - self.socket.auto_progress( + socket.auto_progress( message='Set {set}: loading from Rebrickable'.format( set=set, ), @@ -118,12 +112,12 @@ class RebrickableSet(BrickRecord): instance=self, ).get() - self.socket.emit('SET_LOADED', self.short( + socket.emit('SET_LOADED', self.short( from_download=from_download )) if not from_download: - self.socket.complete( + socket.complete( message='Set {set}: loaded from Rebrickable'.format( set=self.fields.set ) @@ -132,7 +126,7 @@ class RebrickableSet(BrickRecord): return True except Exception as e: - self.socket.fail( + socket.fail( message='Could not load the set from Rebrickable: {error}. Data: {data}'.format( # noqa: E501 error=str(e), data=data, @@ -144,6 +138,21 @@ class RebrickableSet(BrickRecord): return False + # Select a specific set (with a set) + def select_specific(self, set: str, /) -> Self: + # Save the parameters to the fields + self.fields.set = set + + # Load from database + if not self.select(): + raise NotFoundException( + 'Set with set {set} was not found in the database'.format( + set=self.fields.set, + ), + ) + + return self + # Return a short form of the Rebrickable set def short(self, /, *, from_download: bool = False) -> dict[str, Any]: return { @@ -170,6 +179,10 @@ class RebrickableSet(BrickRecord): return '' + # Compute the url for the refresh button + def url_for_refresh(self, /) -> str: + return url_for('set.refresh', set=self.fields.set) + # Normalize from Rebrickable @staticmethod def from_rebrickable(data: dict[str, Any], /, **_) -> dict[str, Any]: diff --git a/bricktracker/rebrickable_set_list.py b/bricktracker/rebrickable_set_list.py index 8fe7ee9..0db84b7 100644 --- a/bricktracker/rebrickable_set_list.py +++ b/bricktracker/rebrickable_set_list.py @@ -9,6 +9,7 @@ class RebrickableSetList(BrickRecordList[RebrickableSet]): # Queries select_query: str = 'rebrickable/set/list' + refresh_query: str = 'rebrickable/set/need_refresh' # All the sets def all(self, /) -> Self: @@ -19,3 +20,15 @@ class RebrickableSetList(BrickRecordList[RebrickableSet]): self.records.append(rebrickable_set) return self + + # Sets needing refresh + def need_refresh(self, /) -> Self: + # Load the sets from the database + for record in self.select( + override_query=self.refresh_query + ): + rebrickable_set = RebrickableSet(record=record) + + self.records.append(rebrickable_set) + + return self diff --git a/bricktracker/record.py b/bricktracker/record.py index 08651d2..f7cc889 100644 --- a/bricktracker/record.py +++ b/bricktracker/record.py @@ -1,5 +1,5 @@ from sqlite3 import Row -from typing import Any, ItemsView, Tuple +from typing import Any, ItemsView from .fields import BrickRecordFields from .sql import BrickSQL @@ -31,14 +31,14 @@ class BrickRecord(object): commit=True, no_defer=False, override_query: str | None = None - ) -> Tuple[int, str]: + ) -> None: if override_query: query = override_query else: query = self.insert_query database = BrickSQL() - rows, q = database.execute( + database.execute( query, parameters=self.sql_parameters(), defer=not commit and not no_defer, @@ -47,8 +47,6 @@ class BrickRecord(object): if commit: database.commit() - return rows, q - # Shorthand to field items def items(self, /) -> ItemsView[str, Any]: return self.fields.__dict__.items() diff --git a/bricktracker/record_list.py b/bricktracker/record_list.py index 0798991..8d862ed 100644 --- a/bricktracker/record_list.py +++ b/bricktracker/record_list.py @@ -8,16 +8,26 @@ if TYPE_CHECKING: from .part import BrickPart from .rebrickable_set import RebrickableSet from .set import BrickSet - from .set_checkbox import BrickSetCheckbox + from .set_owner import BrickSetOwner + from .set_purchase_location import BrickSetPurchaseLocation + from .set_status import BrickSetStatus + from .set_storage import BrickSetStorage + from .set_tag import BrickSetTag from .wish import BrickWish + from .wish_owner import BrickWishOwner T = TypeVar( 'T', - 'BrickSet', - 'BrickSetCheckbox', - 'BrickPart', 'BrickMinifigure', + 'BrickPart', + 'BrickSet', + 'BrickSetOwner', + 'BrickSetPurchaseLocation', + 'BrickSetStatus', + 'BrickSetStorage', + 'BrickSetTag', 'BrickWish', + 'BrickWishOwner', 'RebrickableSet' ) diff --git a/bricktracker/reload.py b/bricktracker/reload.py index 62564df..99d95bb 100644 --- a/bricktracker/reload.py +++ b/bricktracker/reload.py @@ -1,7 +1,12 @@ from .instructions_list import BrickInstructionsList from .retired_list import BrickRetiredList -from .set_checkbox_list import BrickSetCheckboxList +from .set_owner_list import BrickSetOwnerList +from .set_purchase_location_list import BrickSetPurchaseLocationList +from .set_status_list import BrickSetStatusList +from .set_storage_list import BrickSetStorageList +from .set_tag_list import BrickSetTagList from .theme_list import BrickThemeList +from .wish_owner_list import BrickWishOwnerList # Reload everything related to a database after an operation @@ -11,13 +16,28 @@ def reload() -> None: # Reload the instructions BrickInstructionsList(force=True) - # Reload the checkboxes - BrickSetCheckboxList(force=True) + # Reload the set owners + BrickSetOwnerList.new(force=True) + + # Reload the set purchase locations + BrickSetPurchaseLocationList.new(force=True) + + # Reload the set statuses + BrickSetStatusList.new(force=True) + + # Reload the set storages + BrickSetStorageList.new(force=True) + + # Reload the set tags + BrickSetTagList.new(force=True) # Reload retired sets BrickRetiredList(force=True) # Reload themes BrickThemeList(force=True) + + # Reload the wish owners + BrickWishOwnerList.new(force=True) except Exception: pass diff --git a/bricktracker/set.py b/bricktracker/set.py index aa536b8..c397b13 100644 --- a/bricktracker/set.py +++ b/bricktracker/set.py @@ -1,19 +1,23 @@ +from datetime import datetime import logging import traceback -from typing import Any, Self +from typing import Any, Self, TYPE_CHECKING from uuid import uuid4 -from flask import url_for +from flask import current_app, url_for -from .exceptions import DatabaseException, NotFoundException +from .exceptions import NotFoundException, DatabaseException, ErrorException from .minifigure_list import BrickMinifigureList from .part_list import BrickPartList -from .rebrickable_minifigures import RebrickableMinifigures -from .rebrickable_parts import RebrickableParts from .rebrickable_set import RebrickableSet -from .set_checkbox import BrickSetCheckbox -from .set_checkbox_list import BrickSetCheckboxList +from .set_owner_list import BrickSetOwnerList +from .set_purchase_location_list import BrickSetPurchaseLocationList +from .set_status_list import BrickSetStatusList +from .set_storage_list import BrickSetStorageList +from .set_tag_list import BrickSetTagList from .sql import BrickSQL +if TYPE_CHECKING: + from .socket import BrickSocket logger = logging.getLogger(__name__) @@ -24,6 +28,8 @@ class BrickSet(RebrickableSet): select_query: str = 'set/select/full' light_query: str = 'set/select/light' insert_query: str = 'set/insert' + update_purchase_date_query: str = 'set/update/purchase_date' + update_purchase_price_query: str = 'set/update/purchase_price' # Delete a set def delete(self, /) -> None: @@ -33,81 +39,153 @@ class BrickSet(RebrickableSet): ) # Import a set into the database - def download(self, data: dict[str, Any], /) -> None: + def download(self, socket: 'BrickSocket', data: dict[str, Any], /) -> bool: # Load the set - if not self.load(data, from_download=True): - return + if not self.load(socket, data, from_download=True): + return False try: # Insert into the database - self.socket.auto_progress( - message='Set {number}: inserting into database'.format( - number=self.fields.set + socket.auto_progress( + message='Set {set}: inserting into database'.format( + set=self.fields.set ), increment_total=True, ) + # Grabbing the refresh flag + refresh: bool = bool(data.get('refresh', False)) + # Generate an UUID for self self.fields.id = str(uuid4()) - # Insert into database - self.insert(commit=False) + if not refresh: + # Save the storage + storage = BrickSetStorageList.get( + data.get('storage', ''), + allow_none=True + ) + self.fields.storage = storage.fields.id - # Execute the parent download method - self.download_rebrickable() + # Save the purchase location + purchase_location = BrickSetPurchaseLocationList.get( + data.get('purchase_location', ''), + allow_none=True + ) + self.fields.purchase_location = purchase_location.fields.id + + # Insert into database + self.insert(commit=False) + + # Save the owners + owners: list[str] = list(data.get('owners', [])) + + for id in owners: + owner = BrickSetOwnerList.get(id) + owner.update_set_state(self, state=True) + + # Save the tags + tags: list[str] = list(data.get('tags', [])) + + for id in tags: + tag = BrickSetTagList.get(id) + tag.update_set_state(self, state=True) + + # Insert the rebrickable set into database + self.insert_rebrickable() # Load the inventory - RebrickableParts(self.socket, self).download() + if not BrickPartList.download(socket, self, refresh=refresh): + return False # Load the minifigures - RebrickableMinifigures(self.socket, self).download() + if not BrickMinifigureList.download(socket, self, refresh=refresh): + return False # Commit the transaction to the database - self.socket.auto_progress( - message='Set {number}: writing to the database'.format( - number=self.fields.set + socket.auto_progress( + message='Set {set}: writing to the database'.format( + set=self.fields.set ), increment_total=True, ) BrickSQL().commit() - # Info - logger.info('Set {number}: imported (id: {id})'.format( - number=self.fields.set, - id=self.fields.id, - )) + if refresh: + # Info + logger.info('Set {set}: imported (id: {id})'.format( + set=self.fields.set, + id=self.fields.id, + )) - # Complete - self.socket.complete( - message='Set {number}: imported (Go to the set)'.format( # noqa: E501 - number=self.fields.set, - url=self.url() - ), - download=True - ) + # Complete + socket.complete( + message='Set {set}: refreshed'.format( # noqa: E501 + set=self.fields.set, + ), + download=True + ) + else: + # Info + logger.info('Set {set}: refreshed'.format( + set=self.fields.set, + )) + + # Complete + socket.complete( + message='Set {set}: imported (Go to the set)'.format( # noqa: E501 + set=self.fields.set, + url=self.url() + ), + download=True + ) except Exception as e: - self.socket.fail( - message='Error while importing set {number}: {error}'.format( - number=self.fields.set, + socket.fail( + message='Error while importing set {set}: {error}'.format( + set=self.fields.set, error=e, ) ) logger.debug(traceback.format_exc()) - # Insert a Rebrickable set - def insert_rebrickable(self, /) -> None: - self.insert() + return False + + return True + + # Purchase date + def purchase_date(self, /, *, standard: bool = False) -> str: + if self.fields.purchase_date is not None: + time = datetime.fromtimestamp(self.fields.purchase_date) + + if standard: + return time.strftime('%Y/%m/%d') + else: + return time.strftime( + current_app.config['PURCHASE_DATE_FORMAT'] + ) + else: + return '' + + # Purchase price with currency + def purchase_price(self, /) -> str: + if self.fields.purchase_price is not None: + return '{price}{currency}'.format( + price=self.fields.purchase_price, + currency=current_app.config['PURCHASE_CURRENCY'] + ) + else: + return '' # Minifigures def minifigures(self, /) -> BrickMinifigureList: - return BrickMinifigureList().load(self) + return BrickMinifigureList().from_set(self) # Parts def parts(self, /) -> BrickPartList: - return BrickPartList().load(self) + return BrickPartList().list_specific(self) # Select a light set (with an id) def select_light(self, id: str, /) -> Self: @@ -131,7 +209,9 @@ class BrickSet(RebrickableSet): # Load from database if not self.select( - statuses=BrickSetCheckboxList().as_columns(solo=True) + owners=BrickSetOwnerList.as_columns(), + statuses=BrickSetStatusList.as_columns(all=True), + tags=BrickSetTagList.as_columns(), ): raise NotFoundException( 'Set with ID {id} was not found in the database'.format( @@ -141,30 +221,80 @@ class BrickSet(RebrickableSet): return self - # Update a status - def update_status( - self, - checkbox: BrickSetCheckbox, - status: bool, - / - ) -> None: - parameters = self.sql_parameters() - parameters['status'] = status + # Update the purchase date + def update_purchase_date(self, json: Any | None, /) -> Any: + value = json.get('value', None) # type: ignore + + try: + if value == '': + value = None + + if value is not None: + value = datetime.strptime(value, '%Y/%m/%d').timestamp() + except Exception: + raise ErrorException('{value} is not a date'.format( + value=value, + )) + + self.fields.purchase_date = value - # Update the status rows, _ = BrickSQL().execute_and_commit( - 'set/update/status', - parameters=parameters, - name=checkbox.as_column(), + self.update_purchase_date_query, + parameters=self.sql_parameters() ) if rows != 1: - raise DatabaseException('Could not update the status "{status}" for set {number} ({id})'.format( # noqa: E501 - status=checkbox.fields.name, - number=self.fields.set, + raise DatabaseException('Could not update the purchase date for set {set} ({id})'.format( # noqa: E501 + set=self.fields.set, id=self.fields.id, )) + # Info + logger.info('Purchase date changed to "{value}" for set {set} ({id})'.format( # noqa: E501 + value=value, + set=self.fields.set, + id=self.fields.id, + )) + + return value + + # Update the purchase price + def update_purchase_price(self, json: Any | None, /) -> Any: + value = json.get('value', None) # type: ignore + + try: + if value == '': + value = None + + if value is not None: + value = float(value) + except Exception: + raise ErrorException('{value} is not a number or empty'.format( + value=value, + )) + + self.fields.purchase_price = value + + rows, _ = BrickSQL().execute_and_commit( + self.update_purchase_price_query, + parameters=self.sql_parameters() + ) + + if rows != 1: + raise DatabaseException('Could not update the purchase price for set {set} ({id})'.format( # noqa: E501 + set=self.fields.set, + id=self.fields.id, + )) + + # Info + logger.info('Purchase price changed to "{value}" for set {set} ({id})'.format( # noqa: E501 + value=value, + set=self.fields.set, + id=self.fields.id, + )) + + return value + # Self url def url(self, /) -> str: return url_for('set.details', id=self.fields.id) @@ -179,7 +309,10 @@ class BrickSet(RebrickableSet): # Compute the url for the set instructions def url_for_instructions(self, /) -> str: - if len(self.instructions): + if ( + not current_app.config['HIDE_SET_INSTRUCTIONS'] and + len(self.instructions) + ): return url_for( 'set.details', id=self.fields.id, @@ -187,3 +320,22 @@ class BrickSet(RebrickableSet): ) else: return '' + + # Compute the url for the refresh button + def url_for_refresh(self, /) -> str: + return url_for('set.refresh', id=self.fields.id) + + # Compute the url for the set storage + def url_for_storage(self, /) -> str: + if self.fields.storage is not None: + return url_for('storage.details', id=self.fields.storage) + else: + return '' + + # Update purchase date url + def url_for_purchase_date(self, /) -> str: + return url_for('set.update_purchase_date', id=self.fields.id) + + # Update purchase price url + def url_for_purchase_price(self, /) -> str: + return url_for('set.update_purchase_price', id=self.fields.id) diff --git a/bricktracker/set_checkbox.py b/bricktracker/set_checkbox.py deleted file mode 100644 index ea6d6d2..0000000 --- a/bricktracker/set_checkbox.py +++ /dev/null @@ -1,142 +0,0 @@ -from sqlite3 import Row -from typing import Any, Self, Tuple -from uuid import uuid4 - -from flask import url_for - -from .exceptions import DatabaseException, ErrorException, NotFoundException -from .record import BrickRecord -from .sql import BrickSQL - - -# Lego set checkbox -class BrickSetCheckbox(BrickRecord): - # Queries - select_query: str = 'checkbox/select' - - def __init__( - self, - /, - *, - record: Row | dict[str, Any] | None = None, - ): - super().__init__() - - # Ingest the record if it has one - if record is not None: - self.ingest(record) - - # SQL column name - def as_column(self) -> str: - return 'status_{id}'.format(id=self.fields.id) - - # HTML dataset name - def as_dataset(self) -> str: - return '{id}'.format( - id=self.as_column().replace('_', '-') - ) - - # Delete from database - def delete(self) -> None: - BrickSQL().executescript( - 'checkbox/delete', - id=self.fields.id, - ) - - # Grab data from a form - def from_form(self, form: dict[str, str]) -> Self: - name = form.get('name', None) - grid = form.get('grid', None) - - if name is None or name == '': - raise ErrorException('Checkbox name cannot be empty') - - # Security: eh. - # Prevent self-ownage with accidental quote escape - self.fields.name = name - self.fields.safe_name = self.fields.name.replace("'", "''") - self.fields.displayed_on_grid = grid == 'on' - - return self - - # Insert into database - def insert(self, **_) -> Tuple[int, str]: - # Generate an ID for the checkbox (with underscores to make it - # column name friendly) - self.fields.id = str(uuid4()).replace('-', '_') - - BrickSQL().executescript( - 'checkbox/add', - id=self.fields.id, - name=self.fields.safe_name, - displayed_on_grid=self.fields.displayed_on_grid - ) - - # To accomodate the parent().insert we have overriden - return 0, '' - - # Rename the checkbox - def rename(self, /) -> None: - # Update the name - rows, _ = BrickSQL().execute_and_commit( - 'checkbox/update/name', - parameters=self.sql_parameters(), - ) - - if rows != 1: - raise DatabaseException('Could not update the name for checkbox {name} ({id})'.format( # noqa: E501 - name=self.fields.name, - id=self.fields.id, - )) - - # URL to change the status - def status_url(self, id: str) -> str: - return url_for( - 'set.update_status', - id=id, - checkbox_id=self.fields.id - ) - - # Select a specific checkbox (with an id) - def select_specific(self, id: str, /) -> Self: - # Save the parameters to the fields - self.fields.id = id - - # Load from database - if not self.select(): - raise NotFoundException( - 'Checkbox with ID {id} was not found in the database'.format( - id=self.fields.id, - ), - ) - - return self - - # Update a status - def update_status( - self, - name: str, - status: bool, - / - ) -> None: - if not hasattr(self.fields, name) or name in ['id', 'name']: - raise NotFoundException('{name} is not a field of a checkbox'.format( # noqa: E501 - name=name - )) - - parameters = self.sql_parameters() - parameters['status'] = status - - # Update the status - rows, _ = BrickSQL().execute_and_commit( - 'checkbox/update/status', - parameters=parameters, - name=name, - ) - - if rows != 1: - raise DatabaseException('Could not update the status "{status}" for checkbox {name} ({id})'.format( # noqa: E501 - status=name, - name=self.fields.name, - id=self.fields.id, - )) diff --git a/bricktracker/set_checkbox_list.py b/bricktracker/set_checkbox_list.py deleted file mode 100644 index 0f32240..0000000 --- a/bricktracker/set_checkbox_list.py +++ /dev/null @@ -1,74 +0,0 @@ -import logging - -from .exceptions import NotFoundException -from .fields import BrickRecordFields -from .record_list import BrickRecordList -from .set_checkbox import BrickSetCheckbox - -logger = logging.getLogger(__name__) - - -# Lego sets checkbox list -class BrickSetCheckboxList(BrickRecordList[BrickSetCheckbox]): - checkboxes: dict[str, BrickSetCheckbox] - - # Queries - select_query = 'checkbox/list' - - def __init__(self, /, *, force: bool = False): - # Load checkboxes only if there is none already loaded - records = getattr(self, 'records', None) - - if records is None or force: - # Don't use super()__init__ as it would mask class variables - self.fields = BrickRecordFields() - - logger.info('Loading set checkboxes list') - - BrickSetCheckboxList.records = [] - BrickSetCheckboxList.checkboxes = {} - - # Load the checkboxes from the database - for record in self.select(): - checkbox = BrickSetCheckbox(record=record) - - BrickSetCheckboxList.records.append(checkbox) - BrickSetCheckboxList.checkboxes[checkbox.fields.id] = checkbox - - # Return the checkboxes as columns for a select - def as_columns( - self, - /, - *, - solo: bool = False, - table: str = 'bricktracker_set_statuses' - ) -> str: - return ', '.join([ - '"{table}"."{column}"'.format( - table=table, - column=record.as_column(), - ) - for record - in self.records - if solo or record.fields.displayed_on_grid - ]) - - # Grab a specific checkbox - def get(self, id: str, /) -> BrickSetCheckbox: - if id not in self.checkboxes: - raise NotFoundException( - 'Checkbox with ID {id} was not found in the database'.format( - id=self.fields.id, - ), - ) - - return self.checkboxes[id] - - # Get the list of checkboxes depending on the context - def list(self, /, *, all: bool = False) -> list[BrickSetCheckbox]: - return [ - record - for record - in self.records - if all or record.fields.displayed_on_grid - ] diff --git a/bricktracker/set_list.py b/bricktracker/set_list.py index 3b229e8..a8f5faa 100644 --- a/bricktracker/set_list.py +++ b/bricktracker/set_list.py @@ -1,9 +1,17 @@ -from typing import Self +from typing import Any, Self, Union from flask import current_app from .record_list import BrickRecordList -from .set_checkbox_list import BrickSetCheckboxList +from .set_owner import BrickSetOwner +from .set_owner_list import BrickSetOwnerList +from .set_purchase_location import BrickSetPurchaseLocation +from .set_purchase_location_list import BrickSetPurchaseLocationList +from .set_status_list import BrickSetStatusList +from .set_storage import BrickSetStorage +from .set_storage_list import BrickSetStorageList +from .set_tag import BrickSetTag +from .set_tag_list import BrickSetTagList from .set import BrickSet @@ -13,6 +21,8 @@ class BrickSetList(BrickRecordList[BrickSet]): order: str # Queries + damaged_minifigure_query: str = 'set/list/damaged_minifigure' + damaged_part_query: str = 'set/list/damaged_part' generic_query: str = 'set/list/generic' light_query: str = 'set/list/light' missing_minifigure_query: str = 'set/list/missing_minifigure' @@ -20,6 +30,7 @@ class BrickSetList(BrickRecordList[BrickSet]): select_query: str = 'set/list/all' using_minifigure_query: str = 'set/list/using_minifigure' using_part_query: str = 'set/list/using_part' + using_storage_query: str = 'set/list/using_storage' def __init__(self, /): super().__init__() @@ -32,33 +43,29 @@ class BrickSetList(BrickRecordList[BrickSet]): # All the sets def all(self, /) -> Self: - themes = set() - # Load the sets from the database - for record in self.select( - order=self.order, - statuses=BrickSetCheckboxList().as_columns() - ): - brickset = BrickSet(record=record) - - self.records.append(brickset) - themes.add(brickset.theme.name) - - # Convert the set into a list and sort it - self.themes = list(themes) - self.themes.sort() + self.list(do_theme=True) return self - # A generic list of the different sets - def generic(self, /) -> Self: - for record in self.select( - override_query=self.generic_query, - order=self.order - ): - brickset = BrickSet(record=record) + # Sets with a minifigure part damaged + def damaged_minifigure(self, figure: str, /) -> Self: + # Save the parameters to the fields + self.fields.figure = figure - self.records.append(brickset) + # Load the sets from the database + self.list(override_query=self.damaged_minifigure_query) + + return self + + # Sets with a part damaged + def damaged_part(self, part: str, color: int, /) -> Self: + # Save the parameters to the fields + self.fields.part = part + self.fields.color = color + + # Load the sets from the database + self.list(override_query=self.damaged_part_query) return self @@ -70,103 +77,116 @@ class BrickSetList(BrickRecordList[BrickSet]): else: order = '"bricktracker_sets"."rowid" DESC' - for record in self.select( - order=order, - limit=limit, - statuses=BrickSetCheckboxList().as_columns() - ): - brickset = BrickSet(record=record) - - self.records.append(brickset) + self.list(order=order, limit=limit) return self - # Sets missing a minifigure - def missing_minifigure( + # Base set list + def list( self, - fig_num: str, - / - ) -> Self: - # Save the parameters to the fields - self.fields.fig_num = fig_num + /, + *, + override_query: str | None = None, + order: str | None = None, + limit: int | None = None, + do_theme: bool = False, + **context: Any, + ) -> None: + themes = set() + + if order is None: + order = self.order # Load the sets from the database - for record in self.select( - override_query=self.missing_minifigure_query, - order=self.order + for record in super().select( + override_query=override_query, + order=order, + limit=limit, + owners=BrickSetOwnerList.as_columns(), + statuses=BrickSetStatusList.as_columns(), + tags=BrickSetTagList.as_columns(), ): brickset = BrickSet(record=record) self.records.append(brickset) + if do_theme: + themes.add(brickset.theme.name) + + # Convert the set into a list and sort it + if do_theme: + self.themes = list(themes) + self.themes.sort() + + # Sets missing a minifigure part + def missing_minifigure(self, figure: str, /) -> Self: + # Save the parameters to the fields + self.fields.figure = figure + + # Load the sets from the database + self.list(override_query=self.missing_minifigure_query) return self # Sets missing a part - def missing_part( - self, - part_num: str, - color_id: int, - /, - *, - element_id: int | None = None, - ) -> Self: + def missing_part(self, part: str, color: int, /) -> Self: # Save the parameters to the fields - self.fields.part_num = part_num - self.fields.color_id = color_id - self.fields.element_id = element_id + self.fields.part = part + self.fields.color = color # Load the sets from the database - for record in self.select( - override_query=self.missing_part_query, - order=self.order - ): - brickset = BrickSet(record=record) - - self.records.append(brickset) + self.list(override_query=self.missing_part_query) return self # Sets using a minifigure - def using_minifigure( - self, - fig_num: str, - / - ) -> Self: + def using_minifigure(self, figure: str, /) -> Self: # Save the parameters to the fields - self.fields.fig_num = fig_num + self.fields.figure = figure # Load the sets from the database - for record in self.select( - override_query=self.using_minifigure_query, - order=self.order - ): - brickset = BrickSet(record=record) - - self.records.append(brickset) + self.list(override_query=self.using_minifigure_query) return self # Sets using a part - def using_part( - self, - part_num: str, - color_id: int, - /, - *, - element_id: int | None = None, - ) -> Self: + def using_part(self, part: str, color: int, /) -> Self: # Save the parameters to the fields - self.fields.part_num = part_num - self.fields.color_id = color_id - self.fields.element_id = element_id + self.fields.part = part + self.fields.color = color # Load the sets from the database - for record in self.select( - override_query=self.using_part_query, - order=self.order - ): - brickset = BrickSet(record=record) - - self.records.append(brickset) + self.list(override_query=self.using_part_query) return self + + # Sets using a storage + def using_storage(self, storage: BrickSetStorage, /) -> Self: + # Save the parameters to the fields + self.fields.storage = storage.fields.id + + # Load the sets from the database + self.list(override_query=self.using_storage_query) + + return self + + +# Helper to build the metadata lists +def set_metadata_lists( + as_class: bool = False +) -> dict[ + str, + Union[ + list[BrickSetOwner], + list[BrickSetPurchaseLocation], + BrickSetPurchaseLocation, + list[BrickSetStorage], + BrickSetStorageList, + list[BrickSetTag] + ] +]: + return { + 'brickset_owners': BrickSetOwnerList.list(), + 'brickset_purchase_locations': BrickSetPurchaseLocationList.list(as_class=as_class), # noqa: E501 + 'brickset_storages': BrickSetStorageList.list(as_class=as_class), + 'brickset_tags': BrickSetTagList.list(), + } diff --git a/bricktracker/set_owner.py b/bricktracker/set_owner.py new file mode 100644 index 0000000..3c07647 --- /dev/null +++ b/bricktracker/set_owner.py @@ -0,0 +1,16 @@ +from .metadata import BrickMetadata + + +# Lego set owner metadata +class BrickSetOwner(BrickMetadata): + kind: str = 'owner' + + # Set state endpoint + set_state_endpoint: str = 'set.update_owner' + + # Queries + delete_query: str = 'set/metadata/owner/delete' + insert_query: str = 'set/metadata/owner/insert' + select_query: str = 'set/metadata/owner/select' + update_field_query: str = 'set/metadata/owner/update/field' + update_set_state_query: str = 'set/metadata/owner/update/state' diff --git a/bricktracker/set_owner_list.py b/bricktracker/set_owner_list.py new file mode 100644 index 0000000..ec2af9d --- /dev/null +++ b/bricktracker/set_owner_list.py @@ -0,0 +1,21 @@ +from typing import Self + +from .metadata_list import BrickMetadataList +from .set_owner import BrickSetOwner + + +# Lego sets owner list +class BrickSetOwnerList(BrickMetadataList[BrickSetOwner]): + kind: str = 'set owners' + + # Database + table: str = 'bricktracker_set_owners' + order: str = '"bricktracker_metadata_owners"."name"' + + # Queries + select_query = 'set/metadata/owner/list' + + # Instantiate the list with the proper class + @classmethod + def new(cls, /, *, force: bool = False) -> Self: + return cls(BrickSetOwner, force=force) diff --git a/bricktracker/set_purchase_location.py b/bricktracker/set_purchase_location.py new file mode 100644 index 0000000..801ccf8 --- /dev/null +++ b/bricktracker/set_purchase_location.py @@ -0,0 +1,13 @@ +from .metadata import BrickMetadata + + +# Lego set purchase location metadata +class BrickSetPurchaseLocation(BrickMetadata): + kind: str = 'purchase location' + + # Queries + delete_query: str = 'set/metadata/purchase_location/delete' + insert_query: str = 'set/metadata/purchase_location/insert' + select_query: str = 'set/metadata/purchase_location/select' + update_field_query: str = 'set/metadata/purchase_location/update/field' + update_set_value_query: str = 'set/metadata/purchase_location/update/value' diff --git a/bricktracker/set_purchase_location_list.py b/bricktracker/set_purchase_location_list.py new file mode 100644 index 0000000..65e5f1b --- /dev/null +++ b/bricktracker/set_purchase_location_list.py @@ -0,0 +1,42 @@ +from typing import Self + +from flask import current_app + +from .metadata_list import BrickMetadataList +from .set_purchase_location import BrickSetPurchaseLocation + + +# Lego sets purchase location list +class BrickSetPurchaseLocationList( + BrickMetadataList[BrickSetPurchaseLocation] +): + kind: str = 'set purchase locations' + + # Order + order: str = '"bricktracker_metadata_purchase_locations"."name"' + + # Queries + select_query: str = 'set/metadata/purchase_location/list' + all_query: str = 'set/metadata/purchase_location/all' + + # Set value endpoint + set_value_endpoint: str = 'set.update_purchase_location' + + # Load all purchase locations + @classmethod + def all(cls, /) -> Self: + new = cls.new() + new.override() + + for record in new.select( + override_query=cls.all_query, + order=current_app.config['PURCHASE_LOCATION_DEFAULT_ORDER'] + ): + new.records.append(new.model(record=record)) + + return new + + # Instantiate the list with the proper class + @classmethod + def new(cls, /, *, force: bool = False) -> Self: + return cls(BrickSetPurchaseLocation, force=force) diff --git a/bricktracker/set_status.py b/bricktracker/set_status.py new file mode 100644 index 0000000..d114d65 --- /dev/null +++ b/bricktracker/set_status.py @@ -0,0 +1,34 @@ +from typing import Self + +from .metadata import BrickMetadata + + +# Lego set status metadata +class BrickSetStatus(BrickMetadata): + kind: str = 'status' + + # Set state endpoint + set_state_endpoint: str = 'set.update_status' + + # Queries + delete_query: str = 'set/metadata/status/delete' + insert_query: str = 'set/metadata/status/insert' + select_query: str = 'set/metadata/status/select' + update_field_query: str = 'set/metadata/status/update/field' + update_set_state_query: str = 'set/metadata/status/update/state' + + # Grab data from a form + def from_form(self, form: dict[str, str], /) -> Self: + super().from_form(form) + + grid = form.get('grid', None) + + self.fields.displayed_on_grid = grid == 'on' + + return self + + # Insert into database + def insert(self, /, **_) -> None: + super().insert( + displayed_on_grid=self.fields.displayed_on_grid + ) diff --git a/bricktracker/set_status_list.py b/bricktracker/set_status_list.py new file mode 100644 index 0000000..ff4603b --- /dev/null +++ b/bricktracker/set_status_list.py @@ -0,0 +1,30 @@ +from typing import Self + +from .metadata_list import BrickMetadataList +from .set_status import BrickSetStatus + + +# Lego sets status list +class BrickSetStatusList(BrickMetadataList[BrickSetStatus]): + kind: str = 'set statuses' + + # Database + table: str = 'bricktracker_set_statuses' + order: str = '"bricktracker_metadata_statuses"."name"' + + # Queries + select_query = 'set/metadata/status/list' + + # Filter the list of set status + def filter(self, all: bool = False) -> list[BrickSetStatus]: + return [ + record + for record + in self.records + if all or record.fields.displayed_on_grid + ] + + # Instantiate the list with the proper class + @classmethod + def new(cls, /, *, force: bool = False) -> Self: + return cls(BrickSetStatus, force=force) diff --git a/bricktracker/set_storage.py b/bricktracker/set_storage.py new file mode 100644 index 0000000..08c2429 --- /dev/null +++ b/bricktracker/set_storage.py @@ -0,0 +1,22 @@ +from .metadata import BrickMetadata + +from flask import url_for + + +# Lego set storage metadata +class BrickSetStorage(BrickMetadata): + kind: str = 'storage' + + # Queries + delete_query: str = 'set/metadata/storage/delete' + insert_query: str = 'set/metadata/storage/insert' + select_query: str = 'set/metadata/storage/select' + update_field_query: str = 'set/metadata/storage/update/field' + update_set_value_query: str = 'set/metadata/storage/update/value' + + # Self url + def url(self, /) -> str: + return url_for( + 'storage.details', + id=self.fields.id, + ) diff --git a/bricktracker/set_storage_list.py b/bricktracker/set_storage_list.py new file mode 100644 index 0000000..7cd9e13 --- /dev/null +++ b/bricktracker/set_storage_list.py @@ -0,0 +1,40 @@ +from typing import Self + +from flask import current_app + +from .metadata_list import BrickMetadataList +from .set_storage import BrickSetStorage + + +# Lego sets storage list +class BrickSetStorageList(BrickMetadataList[BrickSetStorage]): + kind: str = 'set storages' + + # Order + order: str = '"bricktracker_metadata_storages"."name"' + + # Queries + select_query: str = 'set/metadata/storage/list' + all_query: str = 'set/metadata/storage/all' + + # Set value endpoint + set_value_endpoint: str = 'set.update_storage' + + # Load all storages + @classmethod + def all(cls, /) -> Self: + new = cls.new() + new.override() + + for record in new.select( + override_query=cls.all_query, + order=current_app.config['STORAGE_DEFAULT_ORDER'] + ): + new.records.append(new.model(record=record)) + + return new + + # Instantiate the list with the proper class + @classmethod + def new(cls, /, *, force: bool = False) -> Self: + return cls(BrickSetStorage, force=force) diff --git a/bricktracker/set_tag.py b/bricktracker/set_tag.py new file mode 100644 index 0000000..6d81c18 --- /dev/null +++ b/bricktracker/set_tag.py @@ -0,0 +1,16 @@ +from .metadata import BrickMetadata + + +# Lego set tag metadata +class BrickSetTag(BrickMetadata): + kind: str = 'tag' + + # Set state endpoint + set_state_endpoint: str = 'set.update_tag' + + # Queries + delete_query: str = 'set/metadata/tag/delete' + insert_query: str = 'set/metadata/tag/insert' + select_query: str = 'set/metadata/tag/select' + update_field_query: str = 'set/metadata/tag/update/field' + update_set_state_query: str = 'set/metadata/tag/update/state' diff --git a/bricktracker/set_tag_list.py b/bricktracker/set_tag_list.py new file mode 100644 index 0000000..93817ba --- /dev/null +++ b/bricktracker/set_tag_list.py @@ -0,0 +1,21 @@ +from typing import Self + +from .metadata_list import BrickMetadataList +from .set_tag import BrickSetTag + + +# Lego sets tag list +class BrickSetTagList(BrickMetadataList[BrickSetTag]): + kind: str = 'set tags' + + # Database + table: str = 'bricktracker_set_tags' + order: str = '"bricktracker_metadata_tags"."name"' + + # Queries + select_query: str = 'set/metadata/tag/list' + + # Instantiate the list with the proper class + @classmethod + def new(cls, /, *, force: bool = False) -> Self: + return cls(BrickSetTag, force=force) diff --git a/bricktracker/socket.py b/bricktracker/socket.py index c7215ae..99cd625 100644 --- a/bricktracker/socket.py +++ b/bricktracker/socket.py @@ -1,14 +1,13 @@ import logging from typing import Any, Final, Tuple -from flask import copy_current_request_context, Flask, request +from flask import Flask, request from flask_socketio import SocketIO -from .configuration_list import BrickConfigurationList from .instructions import BrickInstructions from .instructions_list import BrickInstructionsList -from .login import LoginManager from .set import BrickSet +from .socket_decorator import authenticated_socket, rebrickable_socket from .sql import close as sql_close logger = logging.getLogger(__name__) @@ -87,12 +86,8 @@ class BrickSocket(object): self.disconnected() @self.socket.on(MESSAGES['DOWNLOAD_INSTRUCTIONS'], namespace=self.namespace) # noqa: E501 + @authenticated_socket(self) def download_instructions(data: dict[str, Any], /) -> None: - # Needs to be authenticated - if LoginManager.is_not_authenticated(): - self.fail(message='You need to be authenticated') - return - instructions = BrickInstructions( '{name}.pdf'.format(name=data.get('alt', '')), socket=self @@ -107,71 +102,28 @@ class BrickSocket(object): except Exception: pass - # Start it in a thread if requested - if self.threaded: - @copy_current_request_context - def do_download() -> None: - instructions.download(path) + instructions.download(path) - BrickInstructionsList(force=True) - - self.socket.start_background_task(do_download) - else: - instructions.download(path) - - BrickInstructionsList(force=True) + BrickInstructionsList(force=True) @self.socket.on(MESSAGES['IMPORT_SET'], namespace=self.namespace) + @rebrickable_socket(self) def import_set(data: dict[str, Any], /) -> None: - # Needs to be authenticated - if LoginManager.is_not_authenticated(): - self.fail(message='You need to be authenticated') - return + logger.debug('Socket: IMPORT_SET={data} (from: {fr})'.format( + data=data, + fr=request.sid, # type: ignore + )) - # Needs the Rebrickable API key - try: - BrickConfigurationList.error_unless_is_set('REBRICKABLE_API_KEY') # noqa: E501 - except Exception as e: - self.fail(message=str(e)) - return - - brickset = BrickSet(socket=self) - - # Start it in a thread if requested - if self.threaded: - @copy_current_request_context - def do_download() -> None: - brickset.download(data) - - self.socket.start_background_task(do_download) - else: - brickset.download(data) + BrickSet().download(self, data) @self.socket.on(MESSAGES['LOAD_SET'], namespace=self.namespace) def load_set(data: dict[str, Any], /) -> None: - # Needs to be authenticated - if LoginManager.is_not_authenticated(): - self.fail(message='You need to be authenticated') - return + logger.debug('Socket: LOAD_SET={data} (from: {fr})'.format( + data=data, + fr=request.sid, # type: ignore + )) - # Needs the Rebrickable API key - try: - BrickConfigurationList.error_unless_is_set('REBRICKABLE_API_KEY') # noqa: E501 - except Exception as e: - self.fail(message=str(e)) - return - - brickset = BrickSet(socket=self) - - # Start it in a thread if requested - if self.threaded: - @copy_current_request_context - def do_load() -> None: - brickset.load(data) - - self.socket.start_background_task(do_load) - else: - brickset.load(data) + BrickSet().load(self, data) # Update the progress auto-incrementing def auto_progress( diff --git a/bricktracker/socket_decorator.py b/bricktracker/socket_decorator.py new file mode 100644 index 0000000..331b457 --- /dev/null +++ b/bricktracker/socket_decorator.py @@ -0,0 +1,93 @@ +from functools import wraps +from threading import Thread +from typing import Callable, ParamSpec, TYPE_CHECKING, Union + +from flask import copy_current_request_context + +from .configuration_list import BrickConfigurationList +from .login import LoginManager +if TYPE_CHECKING: + from .socket import BrickSocket + +# What a threaded function can return (None or Thread) +SocketReturn = Union[None, Thread] + +# Threaded signature (*arg, **kwargs -> (None or Thread) +P = ParamSpec('P') +SocketCallable = Callable[P, SocketReturn] + + +# Fail if not authenticated +def authenticated_socket( + self: 'BrickSocket', + /, + *, + threaded: bool = True, +) -> Callable[[SocketCallable], SocketCallable]: + def outer(function: SocketCallable, /) -> SocketCallable: + @wraps(function) + def wrapper(*args, **kwargs) -> SocketReturn: + # Needs to be authenticated + if LoginManager.is_not_authenticated(): + self.fail(message='You need to be authenticated') + return + + # Apply threading + if threaded: + return threaded_socket(self)(function)(*args, **kwargs) + else: + return function(*args, **kwargs) + + return wrapper + return outer + + +# Fail if not ready for Rebrickable (authenticated, API key) +# Automatically makes it threaded +def rebrickable_socket( + self: 'BrickSocket', + /, + *, + threaded: bool = True, +) -> Callable[[SocketCallable], SocketCallable]: + def outer(function: SocketCallable, /) -> SocketCallable: + @wraps(function) + # Automatically authenticated + @authenticated_socket(self, threaded=False) + def wrapper(*args, **kwargs) -> SocketReturn: + # Needs the Rebrickable API key + try: + BrickConfigurationList.error_unless_is_set('REBRICKABLE_API_KEY') # noqa: E501 + except Exception as e: + self.fail(message=str(e)) + return + + # Apply threading + if threaded: + return threaded_socket(self)(function)(*args, **kwargs) + else: + return function(*args, **kwargs) + + return wrapper + return outer + + +# Start the function in a thread if the socket is threaded +def threaded_socket( + self: 'BrickSocket', + / +) -> Callable[[SocketCallable], SocketCallable]: + def outer(function: SocketCallable, /) -> SocketCallable: + @wraps(function) + def wrapper(*args, **kwargs) -> SocketReturn: + # Start it in a thread if requested + if self.threaded: + @copy_current_request_context + def do_function() -> None: + function(*args, **kwargs) + + return self.socket.start_background_task(do_function) + else: + return function(*args, **kwargs) + return wrapper + return outer diff --git a/bricktracker/sql.py b/bricktracker/sql.py index 07811d9..18f7e99 100644 --- a/bricktracker/sql.py +++ b/bricktracker/sql.py @@ -1,3 +1,4 @@ +from importlib import import_module import logging import os import sqlite3 @@ -301,7 +302,37 @@ class BrickSQL(object): version=pending.version) ) - self.executescript(pending.get_query()) + # Load context from the migrations if it exists + # It looks for a file in migrations/ named after the SQL file + # and containing one function named migration_xxxx, also named + # after the SQL file, returning a context dict. + # + # For instance: + # - sql/migrations/0007.sql + # - migrations/0007.py + # - def migration_0007(BrickSQL) -> dict[str, Any] + try: + module = import_module( + '.migrations.{name}'.format( + name=pending.name + ), + package='bricktracker' + ) + except Exception: + module = None + + # If a module has been loaded, we need to fail if an error + # occured while executing the migration function + if module is not None: + function = getattr(module, 'migration_{name}'.format( + name=pending.name + )) + + context: dict[str, Any] = function(self) + else: + context: dict[str, Any] = {} + + self.executescript(pending.get_query(), **context) self.execute('schema/set_version', version=pending.version) # Tells whether the database needs upgrade diff --git a/bricktracker/sql/checkbox/base.sql b/bricktracker/sql/checkbox/base.sql deleted file mode 100644 index 9726a6c..0000000 --- a/bricktracker/sql/checkbox/base.sql +++ /dev/null @@ -1,7 +0,0 @@ -SELECT - "bricktracker_set_checkboxes"."id", - "bricktracker_set_checkboxes"."name", - "bricktracker_set_checkboxes"."displayed_on_grid" -FROM "bricktracker_set_checkboxes" - -{% block where %}{% endblock %} \ No newline at end of file diff --git a/bricktracker/sql/checkbox/delete.sql b/bricktracker/sql/checkbox/delete.sql deleted file mode 100644 index 6eae9d0..0000000 --- a/bricktracker/sql/checkbox/delete.sql +++ /dev/null @@ -1,9 +0,0 @@ -BEGIN TRANSACTION; - -ALTER TABLE "bricktracker_set_statuses" -DROP COLUMN "status_{{ id }}"; - -DELETE FROM "bricktracker_set_checkboxes" -WHERE "bricktracker_set_checkboxes"."id" IS NOT DISTINCT FROM '{{ id }}'; - -COMMIT; \ No newline at end of file diff --git a/bricktracker/sql/checkbox/list.sql b/bricktracker/sql/checkbox/list.sql deleted file mode 100644 index 7420eb3..0000000 --- a/bricktracker/sql/checkbox/list.sql +++ /dev/null @@ -1 +0,0 @@ -{% extends 'checkbox/base.sql' %} diff --git a/bricktracker/sql/checkbox/select.sql b/bricktracker/sql/checkbox/select.sql deleted file mode 100644 index 76557a8..0000000 --- a/bricktracker/sql/checkbox/select.sql +++ /dev/null @@ -1,5 +0,0 @@ -{% extends 'checkbox/base.sql' %} - -{% block where %} -WHERE "bricktracker_set_checkboxes"."id" IS NOT DISTINCT FROM :id -{% endblock %} \ No newline at end of file diff --git a/bricktracker/sql/checkbox/update/name.sql b/bricktracker/sql/checkbox/update/name.sql deleted file mode 100644 index 19fccc0..0000000 --- a/bricktracker/sql/checkbox/update/name.sql +++ /dev/null @@ -1,3 +0,0 @@ -UPDATE "bricktracker_set_checkboxes" -SET "name" = :safe_name -WHERE "bricktracker_set_checkboxes"."id" IS NOT DISTINCT FROM :id diff --git a/bricktracker/sql/checkbox/update/status.sql b/bricktracker/sql/checkbox/update/status.sql deleted file mode 100644 index 3c04c22..0000000 --- a/bricktracker/sql/checkbox/update/status.sql +++ /dev/null @@ -1,3 +0,0 @@ -UPDATE "bricktracker_set_checkboxes" -SET "{{name}}" = :status -WHERE "bricktracker_set_checkboxes"."id" IS NOT DISTINCT FROM :id diff --git a/bricktracker/sql/migrations/0004.sql b/bricktracker/sql/migrations/0004.sql index 3828204..ac7aa37 100644 --- a/bricktracker/sql/migrations/0004.sql +++ b/bricktracker/sql/migrations/0004.sql @@ -4,7 +4,7 @@ PRAGMA foreign_keys = ON; BEGIN TRANSACTION; --- Create a Bricktable set table: with their unique IDs, and a reference to the Rebrickable set +-- Create a Bricktracker set table: with their unique IDs, and a reference to the Rebrickable set CREATE TABLE "bricktracker_sets" ( "id" TEXT NOT NULL, "rebrickable_set" TEXT NOT NULL, diff --git a/bricktracker/sql/migrations/0007.sql b/bricktracker/sql/migrations/0007.sql new file mode 100644 index 0000000..720be76 --- /dev/null +++ b/bricktracker/sql/migrations/0007.sql @@ -0,0 +1,74 @@ +-- description: Renaming various complicated field names to something simpler, and add a bunch of extra fields for later + +PRAGMA foreign_keys = ON; + +BEGIN TRANSACTION; + +-- Rename sets table +ALTER TABLE "bricktracker_sets" RENAME TO "bricktracker_sets_old"; + +-- Create a Bricktracker metadata storage table for later +CREATE TABLE "bricktracker_metadata_storages" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + PRIMARY KEY("id") +); + +-- Create a Bricktracker metadata purchase location table for later +CREATE TABLE "bricktracker_metadata_purchase_locations" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + PRIMARY KEY("id") +); + +-- Re-Create a Bricktracker set table with the simplified name +CREATE TABLE "bricktracker_sets" ( + "id" TEXT NOT NULL, + "set" TEXT NOT NULL, + "description" TEXT, + "storage" TEXT, -- Storage bin location + "purchase_date" REAL, -- Purchase data + "purchase_location" TEXT, -- Purchase location + "purchase_price" REAL, -- Purchase price + PRIMARY KEY("id"), + FOREIGN KEY("set") REFERENCES "rebrickable_sets"("set"), + FOREIGN KEY("storage") REFERENCES "bricktracker_metadata_storages"("id"), + FOREIGN KEY("purchase_location") REFERENCES "bricktracker_metadata_purchase_locations"("id") +); + +-- Insert existing sets into the new table +INSERT INTO "bricktracker_sets" ( + "id", + "set" +) +SELECT + "bricktracker_sets_old"."id", + "bricktracker_sets_old"."rebrickable_set" +FROM "bricktracker_sets_old"; + +-- Rename status table +ALTER TABLE "bricktracker_set_statuses" RENAME TO "bricktracker_set_statuses_old"; + +-- Re-create a table for the status of each checkbox +CREATE TABLE "bricktracker_set_statuses" ( + "id" TEXT NOT NULL, + {% if structure %}{{ structure }},{% endif %} + PRIMARY KEY("id"), + FOREIGN KEY("id") REFERENCES "bricktracker_sets"("id") +); + +-- Insert existing status into the new table +INSERT INTO "bricktracker_set_statuses" ( + {% if targets %}{{ targets }},{% endif %} + "id" +) +SELECT + {% if sources %}{{ sources }},{% endif %} + "bricktracker_set_statuses_old"."bricktracker_set_id" +FROM "bricktracker_set_statuses_old"; + +-- Delete the original tables +DROP TABLE "bricktracker_set_statuses_old"; +DROP TABLE "bricktracker_sets_old"; + +COMMIT; \ No newline at end of file diff --git a/bricktracker/sql/migrations/0008.sql b/bricktracker/sql/migrations/0008.sql new file mode 100644 index 0000000..09830c4 --- /dev/null +++ b/bricktracker/sql/migrations/0008.sql @@ -0,0 +1,30 @@ +-- description: Creation of the deduplicated table of Rebrickable minifigures + +BEGIN TRANSACTION; + +-- Create a Rebrickable minifigures table: each unique minifigure imported from Rebrickable +CREATE TABLE "rebrickable_minifigures" ( + "figure" TEXT NOT NULL, + "number" INTEGER NOT NULL, + "name" TEXT NOT NULL, + "image" TEXT, + PRIMARY KEY("figure") +); + +-- Insert existing sets into the new table +INSERT INTO "rebrickable_minifigures" ( + "figure", + "number", + "name", + "image" +) +SELECT + "minifigures"."fig_num", + CAST(SUBSTR("minifigures"."fig_num", 5) AS INTEGER), + "minifigures"."name", + "minifigures"."set_img_url" +FROM "minifigures" +GROUP BY + "minifigures"."fig_num"; + +COMMIT; \ No newline at end of file diff --git a/bricktracker/sql/migrations/0009.sql b/bricktracker/sql/migrations/0009.sql new file mode 100644 index 0000000..679a761 --- /dev/null +++ b/bricktracker/sql/migrations/0009.sql @@ -0,0 +1,32 @@ +-- description: Migrate the Bricktracker minifigures + +PRAGMA foreign_keys = ON; + +BEGIN TRANSACTION; + +-- Create a Bricktracker minifigures table: an amount of minifigures linked to a Bricktracker set +CREATE TABLE "bricktracker_minifigures" ( + "id" TEXT NOT NULL, + "figure" TEXT NOT NULL, + "quantity" INTEGER NOT NULL, + PRIMARY KEY("id", "figure"), + FOREIGN KEY("id") REFERENCES "bricktracker_sets"("id"), + FOREIGN KEY("figure") REFERENCES "rebrickable_minifigures"("figure") +); + +-- Insert existing sets into the new table +INSERT INTO "bricktracker_minifigures" ( + "id", + "figure", + "quantity" +) +SELECT + "minifigures"."u_id", + "minifigures"."fig_num", + "minifigures"."quantity" +FROM "minifigures"; + +-- Rename the original table (don't delete it yet?) +ALTER TABLE "minifigures" RENAME TO "minifigures_old"; + +COMMIT; \ No newline at end of file diff --git a/bricktracker/sql/migrations/0010.sql b/bricktracker/sql/migrations/0010.sql new file mode 100644 index 0000000..8b4b6e6 --- /dev/null +++ b/bricktracker/sql/migrations/0010.sql @@ -0,0 +1,42 @@ +-- description: Creation of the deduplicated table of Rebrickable parts, and add a bunch of extra fields for later + +BEGIN TRANSACTION; + +-- Create a Rebrickable parts table: each unique part imported from Rebrickable +CREATE TABLE "rebrickable_parts" ( + "part" TEXT NOT NULL, + "color_id" INTEGER NOT NULL, + "color_name" TEXT NOT NULL, + "color_rgb" TEXT, -- can be NULL because it was not saved before + "color_transparent" BOOLEAN, -- can be NULL because it was not saved before + "name" TEXT NOT NULL, + "category" INTEGER, -- can be NULL because it was not saved before + "image" TEXT, + "image_id" TEXT, + "url" TEXT, -- can be NULL because it was not saved before + "print" INTEGER, -- can be NULL, was not saved before + PRIMARY KEY("part", "color_id") +); + +-- Insert existing parts into the new table +INSERT INTO "rebrickable_parts" ( + "part", + "color_id", + "color_name", + "name", + "image", + "image_id" +) +SELECT + "inventory"."part_num", + "inventory"."color_id", + "inventory"."color_name", + "inventory"."name", + "inventory"."part_img_url", + "inventory"."part_img_url_id" +FROM "inventory" +GROUP BY + "inventory"."part_num", + "inventory"."color_id"; + +COMMIT; \ No newline at end of file diff --git a/bricktracker/sql/migrations/0011.sql b/bricktracker/sql/migrations/0011.sql new file mode 100644 index 0000000..54962f3 --- /dev/null +++ b/bricktracker/sql/migrations/0011.sql @@ -0,0 +1,60 @@ +-- description: Migrate the Bricktracker parts (and missing parts), and add a bunch of extra fields for later + +PRAGMA foreign_keys = ON; + +BEGIN TRANSACTION; + +-- Create a Bricktracker parts table: an amount of parts linked to a Bricktracker set +CREATE TABLE "bricktracker_parts" ( + "id" TEXT NOT NULL, + "figure" TEXT, + "part" TEXT NOT NULL, + "color" INTEGER NOT NULL, + "spare" BOOLEAN NOT NULL, + "quantity" INTEGER NOT NULL, + "element" INTEGER, + "rebrickable_inventory" INTEGER NOT NULL, + "missing" INTEGER NOT NULL DEFAULT 0, + "damaged" INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY("id", "figure", "part", "color", "spare"), + FOREIGN KEY("id") REFERENCES "bricktracker_sets"("id"), + FOREIGN KEY("figure") REFERENCES "rebrickable_minifigures"("figure"), + FOREIGN KEY("part", "color") REFERENCES "rebrickable_parts"("part", "color_id") +); + +-- Insert existing parts into the new table +INSERT INTO "bricktracker_parts" ( + "id", + "figure", + "part", + "color", + "spare", + "quantity", + "element", + "rebrickable_inventory", + "missing" +) +SELECT + "inventory"."u_id", + CASE WHEN SUBSTR("inventory"."set_num", 0, 5) = 'fig-' THEN "inventory"."set_num" ELSE NULL END, + "inventory"."part_num", + "inventory"."color_id", + "inventory"."is_spare", + "inventory"."quantity", + "inventory"."element_id", + "inventory"."id", + IFNULL("missing"."quantity", 0) +FROM "inventory" +LEFT JOIN "missing" +ON "inventory"."set_num" IS NOT DISTINCT FROM "missing"."set_num" +AND "inventory"."id" IS NOT DISTINCT FROM "missing"."id" +AND "inventory"."part_num" IS NOT DISTINCT FROM "missing"."part_num" +AND "inventory"."color_id" IS NOT DISTINCT FROM "missing"."color_id" +AND "inventory"."element_id" IS NOT DISTINCT FROM "missing"."element_id" +AND "inventory"."u_id" IS NOT DISTINCT FROM "missing"."u_id"; + +-- Rename the original table (don't delete it yet?) +ALTER TABLE "inventory" RENAME TO "inventory_old"; +ALTER TABLE "missing" RENAME TO "missing_old"; + +COMMIT; \ No newline at end of file diff --git a/bricktracker/sql/migrations/0012.sql b/bricktracker/sql/migrations/0012.sql new file mode 100644 index 0000000..d656e29 --- /dev/null +++ b/bricktracker/sql/migrations/0012.sql @@ -0,0 +1,7 @@ +-- description: Rename checkboxes to status metadata + +BEGIN TRANSACTION; + +ALTER TABLE "bricktracker_set_checkboxes" RENAME TO "bricktracker_metadata_statuses"; + +COMMIT; \ No newline at end of file diff --git a/bricktracker/sql/migrations/0013.sql b/bricktracker/sql/migrations/0013.sql new file mode 100644 index 0000000..469649d --- /dev/null +++ b/bricktracker/sql/migrations/0013.sql @@ -0,0 +1,26 @@ +-- description: Add set owners + +BEGIN TRANSACTION; + +-- Create a table to define each set owners: an id and a name +CREATE TABLE "bricktracker_metadata_owners" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + PRIMARY KEY("id") +); + +-- Create a table for the set owners +CREATE TABLE "bricktracker_set_owners" ( + "id" TEXT NOT NULL, + PRIMARY KEY("id"), + FOREIGN KEY("id") REFERENCES "bricktracker_sets"("id") +); + +-- Create a table for the wish owners +CREATE TABLE "bricktracker_wish_owners" ( + "set" TEXT NOT NULL, + PRIMARY KEY("set"), + FOREIGN KEY("set") REFERENCES "bricktracker_wishes"("set") +); + +COMMIT; diff --git a/bricktracker/sql/migrations/0014.sql b/bricktracker/sql/migrations/0014.sql new file mode 100644 index 0000000..37c655e --- /dev/null +++ b/bricktracker/sql/migrations/0014.sql @@ -0,0 +1,19 @@ +-- description: Add set tags + +BEGIN TRANSACTION; + +-- Create a table to define each set tags: an id and a name +CREATE TABLE "bricktracker_metadata_tags" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + PRIMARY KEY("id") +); + +-- Create a table for the set tags +CREATE TABLE "bricktracker_set_tags" ( + "id" TEXT NOT NULL, + PRIMARY KEY("id"), + FOREIGN KEY("id") REFERENCES "bricktracker_sets"("id") +); + +COMMIT; diff --git a/bricktracker/sql/migrations/0015.sql b/bricktracker/sql/migrations/0015.sql new file mode 100644 index 0000000..de2d6ec --- /dev/null +++ b/bricktracker/sql/migrations/0015.sql @@ -0,0 +1,32 @@ +-- description: Add number of parts for minifigures + +BEGIN TRANSACTION; + +-- Add the number_of_parts column to the minifigures +ALTER TABLE "rebrickable_minifigures" +ADD COLUMN "number_of_parts" INTEGER NOT NULL DEFAULT 0; + +-- Update the number of parts for each minifigure +UPDATE "rebrickable_minifigures" +SET "number_of_parts" = "parts_sum"."number_of_parts" +FROM ( + SELECT + "parts"."figure", + SUM("parts"."quantity") as "number_of_parts" + FROM ( + SELECT + "bricktracker_parts"."figure", + "bricktracker_parts"."quantity" + FROM "bricktracker_parts" + WHERE "bricktracker_parts"."figure" IS NOT NULL + GROUP BY + "bricktracker_parts"."figure", + "bricktracker_parts"."part", + "bricktracker_parts"."color", + "bricktracker_parts"."spare" + ) "parts" + GROUP BY "parts"."figure" +) "parts_sum" +WHERE "rebrickable_minifigures"."figure" = "parts_sum"."figure"; + +COMMIT; diff --git a/bricktracker/sql/minifigure/base/select.sql b/bricktracker/sql/minifigure/base/base.sql similarity index 50% rename from bricktracker/sql/minifigure/base/select.sql rename to bricktracker/sql/minifigure/base/base.sql index 8182998..a3a30a7 100644 --- a/bricktracker/sql/minifigure/base/select.sql +++ b/bricktracker/sql/minifigure/base/base.sql @@ -1,20 +1,26 @@ SELECT - "minifigures"."fig_num", - "minifigures"."set_num", - "minifigures"."name", - "minifigures"."quantity", - "minifigures"."set_img_url", - "minifigures"."u_id", + "bricktracker_minifigures"."quantity", + "rebrickable_minifigures"."figure", + "rebrickable_minifigures"."number", + "rebrickable_minifigures"."number_of_parts", + "rebrickable_minifigures"."name", + "rebrickable_minifigures"."image", {% block total_missing %} NULL AS "total_missing", -- dummy for order: total_missing {% endblock %} + {% block total_damaged %} + NULL AS "total_damaged", -- dummy for order: total_damaged + {% endblock %} {% block total_quantity %} NULL AS "total_quantity", -- dummy for order: total_quantity {% endblock %} {% block total_sets %} NULL AS "total_sets" -- dummy for order: total_sets {% endblock %} -FROM "minifigures" +FROM "bricktracker_minifigures" + +INNER JOIN "rebrickable_minifigures" +ON "bricktracker_minifigures"."figure" IS NOT DISTINCT FROM "rebrickable_minifigures"."figure" {% block join %}{% endblock %} diff --git a/bricktracker/sql/minifigure/insert.sql b/bricktracker/sql/minifigure/insert.sql index d72a2a3..0a2679e 100644 --- a/bricktracker/sql/minifigure/insert.sql +++ b/bricktracker/sql/minifigure/insert.sql @@ -1,15 +1,9 @@ -INSERT INTO "minifigures" ( - "fig_num", - "set_num", - "name", - "quantity", - "set_img_url", - "u_id" +INSERT INTO "bricktracker_minifigures" ( + "id", + "figure", + "quantity" ) VALUES ( - :fig_num, - :set_num, - :name, - :quantity, - :set_img_url, - :u_id + :id, + :figure, + :quantity ) diff --git a/bricktracker/sql/minifigure/list/all.sql b/bricktracker/sql/minifigure/list/all.sql index a00f474..904e818 100644 --- a/bricktracker/sql/minifigure/list/all.sql +++ b/bricktracker/sql/minifigure/list/all.sql @@ -1,34 +1,40 @@ -{% extends 'minifigure/base/select.sql' %} +{% extends 'minifigure/base/base.sql' %} {% block total_missing %} -SUM(IFNULL("missing_join"."total", 0)) AS "total_missing", +SUM(IFNULL("problem_join"."total_missing", 0)) AS "total_missing", +{% endblock %} + +{% block total_damaged %} +SUM(IFNULL("problem_join"."total_damaged", 0)) AS "total_damaged", {% endblock %} {% block total_quantity %} -SUM(IFNULL("minifigures"."quantity", 0)) AS "total_quantity", +SUM(IFNULL("bricktracker_minifigures"."quantity", 0)) AS "total_quantity", {% endblock %} {% block total_sets %} -COUNT("minifigures"."set_num") AS "total_sets" +IFNULL(COUNT("bricktracker_minifigures"."id"), 0) AS "total_sets" {% endblock %} {% block join %} -- LEFT JOIN + SELECT to avoid messing the total LEFT JOIN ( SELECT - "missing"."set_num", - "missing"."u_id", - SUM("missing"."quantity") AS total - FROM "missing" + "bricktracker_parts"."id", + "bricktracker_parts"."figure", + SUM("bricktracker_parts"."missing") AS "total_missing", + SUM("bricktracker_parts"."damaged") AS "total_damaged" + FROM "bricktracker_parts" + WHERE "bricktracker_parts"."figure" IS NOT NULL GROUP BY - "missing"."set_num", - "missing"."u_id" -) missing_join -ON "minifigures"."u_id" IS NOT DISTINCT FROM "missing_join"."u_id" -AND "minifigures"."fig_num" IS NOT DISTINCT FROM "missing_join"."set_num" + "bricktracker_parts"."id", + "bricktracker_parts"."figure" +) "problem_join" +ON "bricktracker_minifigures"."id" IS NOT DISTINCT FROM "problem_join"."id" +AND "rebrickable_minifigures"."figure" IS NOT DISTINCT FROM "problem_join"."figure" {% endblock %} {% block group %} GROUP BY - "minifigures"."fig_num" + "rebrickable_minifigures"."figure" {% endblock %} diff --git a/bricktracker/sql/minifigure/list/damaged_part.sql b/bricktracker/sql/minifigure/list/damaged_part.sql new file mode 100644 index 0000000..5cd18db --- /dev/null +++ b/bricktracker/sql/minifigure/list/damaged_part.sql @@ -0,0 +1,28 @@ +{% extends 'minifigure/base/base.sql' %} + +{% block total_damaged %} +SUM("bricktracker_parts"."damaged") AS "total_damaged", +{% endblock %} + +{% block join %} +LEFT JOIN "bricktracker_parts" +ON "bricktracker_minifigures"."id" IS NOT DISTINCT FROM "bricktracker_parts"."id" +AND "rebrickable_minifigures"."figure" IS NOT DISTINCT FROM "bricktracker_parts"."figure" +{% endblock %} + +{% block where %} +WHERE "rebrickable_minifigures"."figure" IN ( + SELECT "bricktracker_parts"."figure" + FROM "bricktracker_parts" + WHERE "bricktracker_parts"."part" IS NOT DISTINCT FROM :part + AND "bricktracker_parts"."color" IS NOT DISTINCT FROM :color + AND "bricktracker_parts"."figure" IS NOT NULL + AND "bricktracker_parts"."damaged" > 0 + GROUP BY "bricktracker_parts"."figure" +) +{% endblock %} + +{% block group %} +GROUP BY + "rebrickable_minifigures"."figure" +{% endblock %} diff --git a/bricktracker/sql/minifigure/list/from_set.sql b/bricktracker/sql/minifigure/list/from_set.sql index ea2dcbe..e22ee95 100644 --- a/bricktracker/sql/minifigure/list/from_set.sql +++ b/bricktracker/sql/minifigure/list/from_set.sql @@ -1,6 +1,5 @@ -{% extends 'minifigure/base/select.sql' %} +{% extends 'minifigure/base/base.sql' %} {% block where %} -WHERE "minifigures"."u_id" IS NOT DISTINCT FROM :u_id -AND "minifigures"."set_num" IS NOT DISTINCT FROM :set_num +WHERE "bricktracker_minifigures"."id" IS NOT DISTINCT FROM :id {% endblock %} diff --git a/bricktracker/sql/minifigure/list/last.sql b/bricktracker/sql/minifigure/list/last.sql index faf3f40..ddae212 100644 --- a/bricktracker/sql/minifigure/list/last.sql +++ b/bricktracker/sql/minifigure/list/last.sql @@ -1,17 +1,21 @@ -{% extends 'minifigure/base/select.sql' %} +{% extends 'minifigure/base/base.sql' %} {% block total_missing %} -SUM(IFNULL("missing"."quantity", 0)) AS "total_missing", +SUM("bricktracker_parts"."missing") AS "total_missing", +{% endblock %} + +{% block total_damaged %} +SUM("bricktracker_parts"."damaged") AS "total_damaged", {% endblock %} {% block join %} -LEFT JOIN "missing" -ON "minifigures"."fig_num" IS NOT DISTINCT FROM "missing"."set_num" -AND "minifigures"."u_id" IS NOT DISTINCT FROM "missing"."u_id" +LEFT JOIN "bricktracker_parts" +ON "bricktracker_minifigures"."id" IS NOT DISTINCT FROM "bricktracker_parts"."id" +AND "rebrickable_minifigures"."figure" IS NOT DISTINCT FROM "bricktracker_parts"."figure" {% endblock %} {% block group %} GROUP BY - "minifigures"."fig_num", - "minifigures"."u_id" + "rebrickable_minifigures"."figure", + "bricktracker_minifigures"."id" {% endblock %} diff --git a/bricktracker/sql/minifigure/list/missing_part.sql b/bricktracker/sql/minifigure/list/missing_part.sql index e0bc54d..32144bd 100644 --- a/bricktracker/sql/minifigure/list/missing_part.sql +++ b/bricktracker/sql/minifigure/list/missing_part.sql @@ -1,30 +1,28 @@ -{% extends 'minifigure/base/select.sql' %} +{% extends 'minifigure/base/base.sql' %} {% block total_missing %} -SUM(IFNULL("missing"."quantity", 0)) AS "total_missing", +SUM("bricktracker_parts"."missing") AS "total_missing", {% endblock %} {% block join %} -LEFT JOIN "missing" -ON "minifigures"."fig_num" IS NOT DISTINCT FROM "missing"."set_num" -AND "minifigures"."u_id" IS NOT DISTINCT FROM "missing"."u_id" +LEFT JOIN "bricktracker_parts" +ON "bricktracker_minifigures"."id" IS NOT DISTINCT FROM "bricktracker_parts"."id" +AND "rebrickable_minifigures"."figure" IS NOT DISTINCT FROM "bricktracker_parts"."figure" {% endblock %} {% block where %} -WHERE "minifigures"."fig_num" IN ( - SELECT - "missing"."set_num" - FROM "missing" - - WHERE "missing"."color_id" IS NOT DISTINCT FROM :color_id - AND "missing"."element_id" IS NOT DISTINCT FROM :element_id - AND "missing"."part_num" IS NOT DISTINCT FROM :part_num - - GROUP BY "missing"."set_num" +WHERE "rebrickable_minifigures"."figure" IN ( + SELECT "bricktracker_parts"."figure" + FROM "bricktracker_parts" + WHERE "bricktracker_parts"."part" IS NOT DISTINCT FROM :part + AND "bricktracker_parts"."color" IS NOT DISTINCT FROM :color + AND "bricktracker_parts"."figure" IS NOT NULL + AND "bricktracker_parts"."missing" > 0 + GROUP BY "bricktracker_parts"."figure" ) {% endblock %} {% block group %} GROUP BY - "minifigures"."fig_num" + "rebrickable_minifigures"."figure" {% endblock %} diff --git a/bricktracker/sql/minifigure/list/using_part.sql b/bricktracker/sql/minifigure/list/using_part.sql index c40d379..d6ea0d1 100644 --- a/bricktracker/sql/minifigure/list/using_part.sql +++ b/bricktracker/sql/minifigure/list/using_part.sql @@ -1,24 +1,21 @@ -{% extends 'minifigure/base/select.sql' %} +{% extends 'minifigure/base/base.sql' %} {% block total_quantity %} -SUM("minifigures"."quantity") AS "total_quantity", +SUM("bricktracker_minifigures"."quantity") AS "total_quantity", {% endblock %} {% block where %} -WHERE "minifigures"."fig_num" IN ( - SELECT - "inventory"."set_num" - FROM "inventory" - - WHERE "inventory"."color_id" IS NOT DISTINCT FROM :color_id - AND "inventory"."element_id" IS NOT DISTINCT FROM :element_id - AND "inventory"."part_num" IS NOT DISTINCT FROM :part_num - - GROUP BY "inventory"."set_num" +WHERE "rebrickable_minifigures"."figure" IN ( + SELECT "bricktracker_parts"."figure" + FROM "bricktracker_parts" + WHERE "bricktracker_parts"."part" IS NOT DISTINCT FROM :part + AND "bricktracker_parts"."color" IS NOT DISTINCT FROM :color + AND "bricktracker_parts"."figure" IS NOT NULL + GROUP BY "bricktracker_parts"."figure" ) {% endblock %} {% block group %} GROUP BY - "minifigures"."fig_num" + "rebrickable_minifigures"."figure" {% endblock %} diff --git a/bricktracker/sql/minifigure/select/generic.sql b/bricktracker/sql/minifigure/select/generic.sql index 114810d..6301550 100644 --- a/bricktracker/sql/minifigure/select/generic.sql +++ b/bricktracker/sql/minifigure/select/generic.sql @@ -1,38 +1,40 @@ -{% extends 'minifigure/base/select.sql' %} +{% extends 'minifigure/base/base.sql' %} {% block total_missing %} -SUM(IFNULL("missing_join"."total", 0)) AS "total_missing", +IFNULL("problem_join"."total_missing", 0) AS "total_missing", +{% endblock %} + +{% block total_damaged %} +IFNULL("problem_join"."total_damaged", 0) AS "total_damaged", {% endblock %} {% block total_quantity %} -SUM(IFNULL("minifigures"."quantity", 0)) AS "total_quantity", +SUM(IFNULL("bricktracker_minifigures"."quantity", 0)) AS "total_quantity", {% endblock %} {% block total_sets %} -COUNT("minifigures"."set_num") AS "total_sets" +IFNULL(COUNT(DISTINCT "bricktracker_minifigures"."id"), 0) AS "total_sets" {% endblock %} {% block join %} -- LEFT JOIN + SELECT to avoid messing the total LEFT JOIN ( SELECT - "missing"."set_num", - "missing"."u_id", - SUM("missing"."quantity") AS "total" - FROM "missing" - GROUP BY - "missing"."set_num", - "missing"."u_id" -) "missing_join" -ON "minifigures"."u_id" IS NOT DISTINCT FROM "missing_join"."u_id" -AND "minifigures"."fig_num" IS NOT DISTINCT FROM "missing_join"."set_num" + "bricktracker_parts"."figure", + SUM("bricktracker_parts"."missing") AS "total_missing", + SUM("bricktracker_parts"."damaged") AS "total_damaged" + FROM "bricktracker_parts" + WHERE "bricktracker_parts"."figure" IS NOT DISTINCT FROM :figure + GROUP BY "bricktracker_parts"."figure" +) "problem_join" +ON "rebrickable_minifigures"."figure" IS NOT DISTINCT FROM "problem_join"."figure" {% endblock %} {% block where %} -WHERE "minifigures"."fig_num" IS NOT DISTINCT FROM :fig_num +WHERE "rebrickable_minifigures"."figure" IS NOT DISTINCT FROM :figure {% endblock %} {% block group %} GROUP BY - "minifigures"."fig_num" + "rebrickable_minifigures"."figure" {% endblock %} diff --git a/bricktracker/sql/minifigure/select/specific.sql b/bricktracker/sql/minifigure/select/specific.sql index 34a8b3d..970494f 100644 --- a/bricktracker/sql/minifigure/select/specific.sql +++ b/bricktracker/sql/minifigure/select/specific.sql @@ -1,7 +1,6 @@ -{% extends 'minifigure/base/select.sql' %} +{% extends 'minifigure/base/base.sql' %} {% block where %} -WHERE "minifigures"."fig_num" IS NOT DISTINCT FROM :fig_num -AND "minifigures"."u_id" IS NOT DISTINCT FROM :u_id -AND "minifigures"."set_num" IS NOT DISTINCT FROM :set_num +WHERE "bricktracker_minifigures"."id" IS NOT DISTINCT FROM :id +AND "rebrickable_minifigures"."figure" IS NOT DISTINCT FROM :figure {% endblock %} diff --git a/bricktracker/sql/missing/delete/from_set.sql b/bricktracker/sql/missing/delete/from_set.sql deleted file mode 100644 index ceedc78..0000000 --- a/bricktracker/sql/missing/delete/from_set.sql +++ /dev/null @@ -1,4 +0,0 @@ -DELETE FROM "missing" -WHERE "missing"."set_num" IS NOT DISTINCT FROM :set_num -AND "missing"."id" IS NOT DISTINCT FROM :id -AND "missing"."u_id" IS NOT DISTINCT FROM :u_id diff --git a/bricktracker/sql/missing/insert.sql b/bricktracker/sql/missing/insert.sql deleted file mode 100644 index c645028..0000000 --- a/bricktracker/sql/missing/insert.sql +++ /dev/null @@ -1,20 +0,0 @@ -INSERT INTO "missing" ( - "set_num", - "id", - "part_num", - "part_img_url_id", - "color_id", - "quantity", - "element_id", - "u_id" -) -VALUES( - :set_num, - :id, - :part_num, - :part_img_url_id, - :color_id, - :quantity, - :element_id, - :u_id -) diff --git a/bricktracker/sql/missing/update/from_set.sql b/bricktracker/sql/missing/update/from_set.sql deleted file mode 100644 index 335dd06..0000000 --- a/bricktracker/sql/missing/update/from_set.sql +++ /dev/null @@ -1,5 +0,0 @@ -UPDATE "missing" -SET "quantity" = :quantity -WHERE "missing"."set_num" IS NOT DISTINCT FROM :set_num -AND "missing"."id" IS NOT DISTINCT FROM :id -AND "missing"."u_id" IS NOT DISTINCT FROM :u_id diff --git a/bricktracker/sql/part/base/base.sql b/bricktracker/sql/part/base/base.sql new file mode 100644 index 0000000..24c1c56 --- /dev/null +++ b/bricktracker/sql/part/base/base.sql @@ -0,0 +1,59 @@ +SELECT + "bricktracker_parts"."id", + "bricktracker_parts"."figure", + "bricktracker_parts"."part", + "bricktracker_parts"."color", + "bricktracker_parts"."spare", + "bricktracker_parts"."quantity", + "bricktracker_parts"."element", + --"bricktracker_parts"."rebrickable_inventory", + "bricktracker_parts"."missing", + "bricktracker_parts"."damaged", + --"rebrickable_parts"."part", + --"rebrickable_parts"."color_id", + "rebrickable_parts"."color_name", + "rebrickable_parts"."color_rgb", + "rebrickable_parts"."color_transparent", + "rebrickable_parts"."name", + --"rebrickable_parts"."category", + "rebrickable_parts"."image", + "rebrickable_parts"."image_id", + "rebrickable_parts"."url", + "rebrickable_parts"."print", + {% block total_missing %} + NULL AS "total_missing", -- dummy for order: total_missing + {% endblock %} + {% block total_damaged %} + NULL AS "total_damaged", -- dummy for order: total_damaged + {% endblock %} + {% block total_quantity %} + NULL AS "total_quantity", -- dummy for order: total_quantity + {% endblock %} + {% block total_spare %} + NULL AS "total_spare", -- dummy for order: total_spare + {% endblock %} + {% block total_sets %} + NULL AS "total_sets", -- dummy for order: total_sets + {% endblock %} + {% block total_minifigures %} + NULL AS "total_minifigures" -- dummy for order: total_minifigures + {% endblock %} +FROM "bricktracker_parts" + +INNER JOIN "rebrickable_parts" +ON "bricktracker_parts"."part" IS NOT DISTINCT FROM "rebrickable_parts"."part" +AND "bricktracker_parts"."color" IS NOT DISTINCT FROM "rebrickable_parts"."color_id" + +{% block join %}{% endblock %} + +{% block where %}{% endblock %} + +{% block group %}{% endblock %} + +{% if order %} +ORDER BY {{ order }} +{% endif %} + +{% if limit %} +LIMIT {{ limit }} +{% endif %} diff --git a/bricktracker/sql/part/base/select.sql b/bricktracker/sql/part/base/select.sql deleted file mode 100644 index 3966a75..0000000 --- a/bricktracker/sql/part/base/select.sql +++ /dev/null @@ -1,43 +0,0 @@ -SELECT - "inventory"."set_num", - "inventory"."id", - "inventory"."part_num", - "inventory"."name", - "inventory"."part_img_url", - "inventory"."part_img_url_id", - "inventory"."color_id", - "inventory"."color_name", - "inventory"."quantity", - "inventory"."is_spare", - "inventory"."element_id", - "inventory"."u_id", - {% block total_missing %} - NULL AS "total_missing", -- dummy for order: total_missing - {% endblock %} - {% block total_quantity %} - NULL AS "total_quantity", -- dummy for order: total_quantity - {% endblock %} - {% block total_spare %} - NULL AS "total_spare", -- dummy for order: total_spare - {% endblock %} - {% block total_sets %} - NULL AS "total_sets", -- dummy for order: total_sets - {% endblock %} - {% block total_minifigures %} - NULL AS "total_minifigures" -- dummy for order: total_minifigures - {% endblock %} -FROM "inventory" - -{% block join %}{% endblock %} - -{% block where %}{% endblock %} - -{% block group %}{% endblock %} - -{% if order %} -ORDER BY {{ order }} -{% endif %} - -{% if limit %} -LIMIT {{ limit }} -{% endif %} diff --git a/bricktracker/sql/part/insert.sql b/bricktracker/sql/part/insert.sql index 39b2d14..e127386 100644 --- a/bricktracker/sql/part/insert.sql +++ b/bricktracker/sql/part/insert.sql @@ -1,27 +1,19 @@ -INSERT INTO inventory ( - "set_num", +INSERT INTO "bricktracker_parts" ( "id", - "part_num", - "name", - "part_img_url", - "part_img_url_id", - "color_id", - "color_name", + "figure", + "part", + "color", + "spare", "quantity", - "is_spare", - "element_id", - "u_id" + "element", + "rebrickable_inventory" ) VALUES ( - :set_num, :id, - :part_num, - :name, - :part_img_url, - :part_img_url_id, - :color_id, - :color_name, + :figure, + :part, + :color, + :spare, :quantity, - :is_spare, - :element_id, - :u_id + :element, + :rebrickable_inventory ) diff --git a/bricktracker/sql/part/list/all.sql b/bricktracker/sql/part/list/all.sql index b1ff2ac..8bb8dcb 100644 --- a/bricktracker/sql/part/list/all.sql +++ b/bricktracker/sql/part/list/all.sql @@ -1,43 +1,34 @@ -{% extends 'part/base/select.sql' %} +{% extends 'part/base/base.sql' %} {% block total_missing %} -SUM(IFNULL("missing"."quantity", 0)) AS "total_missing", +SUM("bricktracker_parts"."missing") AS "total_missing", +{% endblock %} + +{% block total_damaged %} +SUM("bricktracker_parts"."damaged") AS "total_damaged", {% endblock %} {% block total_quantity %} -SUM("inventory"."quantity" * IFNULL("minifigures"."quantity", 1)) AS "total_quantity", +SUM("bricktracker_parts"."quantity" * IFNULL("bricktracker_minifigures"."quantity", 1)) AS "total_quantity", {% endblock %} {% block total_sets %} -COUNT(DISTINCT "bricktracker_sets"."id") AS "total_sets", +IFNULL(COUNT(DISTINCT "bricktracker_parts"."id"), 0) AS "total_sets", {% endblock %} {% block total_minifigures %} -SUM(IFNULL("minifigures"."quantity", 0)) AS "total_minifigures" +SUM(IFNULL("bricktracker_minifigures"."quantity", 0)) AS "total_minifigures" {% endblock %} {% block join %} -LEFT JOIN "missing" -ON "inventory"."set_num" IS NOT DISTINCT FROM "missing"."set_num" -AND "inventory"."id" IS NOT DISTINCT FROM "missing"."id" -AND "inventory"."part_num" IS NOT DISTINCT FROM "missing"."part_num" -AND "inventory"."color_id" IS NOT DISTINCT FROM "missing"."color_id" -AND "inventory"."element_id" IS NOT DISTINCT FROM "missing"."element_id" -AND "inventory"."u_id" IS NOT DISTINCT FROM "missing"."u_id" - -LEFT JOIN "minifigures" -ON "inventory"."set_num" IS NOT DISTINCT FROM "minifigures"."fig_num" -AND "inventory"."u_id" IS NOT DISTINCT FROM "minifigures"."u_id" - -LEFT JOIN "bricktracker_sets" -ON "inventory"."u_id" IS NOT DISTINCT FROM "bricktracker_sets"."id" +LEFT JOIN "bricktracker_minifigures" +ON "bricktracker_parts"."id" IS NOT DISTINCT FROM "bricktracker_minifigures"."id" +AND "bricktracker_parts"."figure" IS NOT DISTINCT FROM "bricktracker_minifigures"."figure" {% endblock %} {% block group %} GROUP BY - "inventory"."part_num", - "inventory"."name", - "inventory"."color_id", - "inventory"."is_spare", - "inventory"."element_id" + "bricktracker_parts"."part", + "bricktracker_parts"."color", + "bricktracker_parts"."spare" {% endblock %} diff --git a/bricktracker/sql/part/list/from_minifigure.sql b/bricktracker/sql/part/list/from_minifigure.sql index cf4135f..115b791 100644 --- a/bricktracker/sql/part/list/from_minifigure.sql +++ b/bricktracker/sql/part/list/from_minifigure.sql @@ -1,28 +1,21 @@ -{% extends 'part/base/select.sql' %} +{% extends 'part/base/base.sql' %} {% block total_missing %} -SUM(IFNULL("missing"."quantity", 0)) AS "total_missing", +SUM("bricktracker_parts"."missing") AS "total_missing", {% endblock %} -{% block join %} -LEFT JOIN "missing" -ON "missing"."set_num" IS NOT DISTINCT FROM "inventory"."set_num" -AND "missing"."id" IS NOT DISTINCT FROM "inventory"."id" -AND "missing"."part_num" IS NOT DISTINCT FROM "inventory"."part_num" -AND "missing"."color_id" IS NOT DISTINCT FROM "inventory"."color_id" -AND "missing"."element_id" IS NOT DISTINCT FROM "inventory"."element_id" +{% block total_damaged %} +SUM("bricktracker_parts"."damaged") AS "total_damaged", {% endblock %} {% block where %} -WHERE "inventory"."set_num" IS NOT DISTINCT FROM :set_num +WHERE "bricktracker_parts"."figure" IS NOT DISTINCT FROM :figure {% endblock %} {% block group %} GROUP BY - "inventory"."part_num", - "inventory"."name", - "inventory"."color_id", - "inventory"."is_spare", - "inventory"."element_id" + "bricktracker_parts"."part", + "bricktracker_parts"."color", + "bricktracker_parts"."spare" {% endblock %} diff --git a/bricktracker/sql/part/list/from_print.sql b/bricktracker/sql/part/list/from_print.sql new file mode 100644 index 0000000..fe1198c --- /dev/null +++ b/bricktracker/sql/part/list/from_print.sql @@ -0,0 +1,18 @@ + +{% extends 'part/base/base.sql' %} + +{% block total_missing %}{% endblock %} + +{% block total_damaged %}{% endblock %} + +{% block where %} +WHERE "rebrickable_parts"."print" IS NOT DISTINCT FROM :print +AND "bricktracker_parts"."color" IS NOT DISTINCT FROM :color +AND "bricktracker_parts"."part" IS DISTINCT FROM :part +{% endblock %} + +{% block group %} +GROUP BY + "bricktracker_parts"."part", + "bricktracker_parts"."color" +{% endblock %} diff --git a/bricktracker/sql/part/list/from_set.sql b/bricktracker/sql/part/list/from_set.sql deleted file mode 100644 index 2646eeb..0000000 --- a/bricktracker/sql/part/list/from_set.sql +++ /dev/null @@ -1,21 +0,0 @@ - -{% extends 'part/base/select.sql' %} - -{% block total_missing %} -IFNULL("missing"."quantity", 0) AS "total_missing", -{% endblock %} - -{% block join %} -LEFT JOIN "missing" -ON "inventory"."set_num" IS NOT DISTINCT FROM "missing"."set_num" -AND "inventory"."id" IS NOT DISTINCT FROM "missing"."id" -AND "inventory"."part_num" IS NOT DISTINCT FROM "missing"."part_num" -AND "inventory"."color_id" IS NOT DISTINCT FROM "missing"."color_id" -AND "inventory"."element_id" IS NOT DISTINCT FROM "missing"."element_id" -AND "inventory"."u_id" IS NOT DISTINCT FROM "missing"."u_id" -{% endblock %} - -{% block where %} -WHERE "inventory"."u_id" IS NOT DISTINCT FROM :u_id -AND "inventory"."set_num" IS NOT DISTINCT FROM :set_num -{% endblock %} diff --git a/bricktracker/sql/part/list/missing.sql b/bricktracker/sql/part/list/missing.sql deleted file mode 100644 index 555916f..0000000 --- a/bricktracker/sql/part/list/missing.sql +++ /dev/null @@ -1,36 +0,0 @@ -{% extends 'part/base/select.sql' %} - -{% block total_missing %} -SUM(IFNULL("missing"."quantity", 0)) AS "total_missing", -{% endblock %} - -{% block total_sets %} -COUNT("inventory"."u_id") - COUNT("minifigures"."u_id") AS "total_sets", -{% endblock %} - -{% block total_minifigures %} -SUM(IFNULL("minifigures"."quantity", 0)) AS "total_minifigures" -{% endblock %} - -{% block join %} -INNER JOIN "missing" -ON "missing"."set_num" IS NOT DISTINCT FROM "inventory"."set_num" -AND "missing"."id" IS NOT DISTINCT FROM "inventory"."id" -AND "missing"."part_num" IS NOT DISTINCT FROM "inventory"."part_num" -AND "missing"."color_id" IS NOT DISTINCT FROM "inventory"."color_id" -AND "missing"."element_id" IS NOT DISTINCT FROM "inventory"."element_id" -AND "missing"."u_id" IS NOT DISTINCT FROM "inventory"."u_id" - -LEFT JOIN "minifigures" -ON "missing"."set_num" IS NOT DISTINCT FROM "minifigures"."fig_num" -AND "missing"."u_id" IS NOT DISTINCT FROM "minifigures"."u_id" -{% endblock %} - -{% block group %} -GROUP BY - "inventory"."part_num", - "inventory"."name", - "inventory"."color_id", - "inventory"."is_spare", - "inventory"."element_id" -{% endblock %} diff --git a/bricktracker/sql/part/list/problem.sql b/bricktracker/sql/part/list/problem.sql new file mode 100644 index 0000000..068b8d8 --- /dev/null +++ b/bricktracker/sql/part/list/problem.sql @@ -0,0 +1,35 @@ +{% extends 'part/base/base.sql' %} + +{% block total_missing %} +SUM("bricktracker_parts"."missing") AS "total_missing", +{% endblock %} + +{% block total_damaged %} +SUM("bricktracker_parts"."damaged") AS "total_damaged", +{% endblock %} + +{% block total_sets %} +IFNULL(COUNT("bricktracker_parts"."id"), 0) - IFNULL(COUNT("bricktracker_parts"."figure"), 0) AS "total_sets", +{% endblock %} + +{% block total_minifigures %} +SUM(IFNULL("bricktracker_minifigures"."quantity", 0)) AS "total_minifigures" +{% endblock %} + +{% block join %} +LEFT JOIN "bricktracker_minifigures" +ON "bricktracker_parts"."id" IS NOT DISTINCT FROM "bricktracker_minifigures"."id" +AND "bricktracker_parts"."figure" IS NOT DISTINCT FROM "bricktracker_minifigures"."figure" +{% endblock %} + +{% block where %} +WHERE "bricktracker_parts"."missing" > 0 +OR "bricktracker_parts"."damaged" > 0 +{% endblock %} + +{% block group %} +GROUP BY + "bricktracker_parts"."part", + "bricktracker_parts"."color", + "bricktracker_parts"."spare" +{% endblock %} diff --git a/bricktracker/sql/part/list/specific.sql b/bricktracker/sql/part/list/specific.sql new file mode 100644 index 0000000..7c62c68 --- /dev/null +++ b/bricktracker/sql/part/list/specific.sql @@ -0,0 +1,15 @@ + +{% extends 'part/base/base.sql' %} + +{% block total_missing %} +IFNULL("bricktracker_parts"."missing", 0) AS "total_missing", +{% endblock %} + +{% block total_damaged %} +IFNULL("bricktracker_parts"."damaged", 0) AS "total_damaged", +{% endblock %} + +{% block where %} +WHERE "bricktracker_parts"."id" IS NOT DISTINCT FROM :id +AND "bricktracker_parts"."figure" IS NOT DISTINCT FROM :figure +{% endblock %} diff --git a/bricktracker/sql/part/list/with_different_color.sql b/bricktracker/sql/part/list/with_different_color.sql new file mode 100644 index 0000000..d75501d --- /dev/null +++ b/bricktracker/sql/part/list/with_different_color.sql @@ -0,0 +1,17 @@ + +{% extends 'part/base/base.sql' %} + +{% block total_missing %}{% endblock %} + +{% block total_damaged %}{% endblock %} + +{% block where %} +WHERE "bricktracker_parts"."color" IS DISTINCT FROM :color +AND "bricktracker_parts"."part" IS NOT DISTINCT FROM :part +{% endblock %} + +{% block group %} +GROUP BY + "bricktracker_parts"."part", + "bricktracker_parts"."color" +{% endblock %} diff --git a/bricktracker/sql/part/select/generic.sql b/bricktracker/sql/part/select/generic.sql index 4a75b4c..43a26da 100644 --- a/bricktracker/sql/part/select/generic.sql +++ b/bricktracker/sql/part/select/generic.sql @@ -1,40 +1,34 @@ -{% extends 'part/base/select.sql' %} +{% extends 'part/base/base.sql' %} {% block total_missing %} -SUM(IFNULL("missing"."quantity", 0)) AS "total_missing", +SUM("bricktracker_parts"."missing") AS "total_missing", +{% endblock %} + +{% block total_damaged %} +SUM("bricktracker_parts"."damaged") AS "total_damaged", {% endblock %} {% block total_quantity %} -SUM((NOT "inventory"."is_spare") * "inventory"."quantity" * IFNULL("minifigures"."quantity", 1)) AS "total_quantity", +SUM((NOT "bricktracker_parts"."spare") * "bricktracker_parts"."quantity" * IFNULL("bricktracker_minifigures"."quantity", 1)) AS "total_quantity", {% endblock %} {% block total_spare %} -SUM("inventory"."is_spare" * "inventory"."quantity" * IFNULL("minifigures"."quantity", 1)) AS "total_spare", +SUM("bricktracker_parts"."spare" * "bricktracker_parts"."quantity" * IFNULL("bricktracker_minifigures"."quantity", 1)) AS "total_spare", {% endblock %} {% block join %} -LEFT JOIN "missing" -ON "inventory"."set_num" IS NOT DISTINCT FROM "missing"."set_num" -AND "inventory"."id" IS NOT DISTINCT FROM "missing"."id" -AND "inventory"."part_num" IS NOT DISTINCT FROM "missing"."part_num" -AND "inventory"."color_id" IS NOT DISTINCT FROM "missing"."color_id" -AND "inventory"."element_id" IS NOT DISTINCT FROM "missing"."element_id" -AND "inventory"."u_id" IS NOT DISTINCT FROM "missing"."u_id" - -LEFT JOIN "minifigures" -ON "inventory"."set_num" IS NOT DISTINCT FROM "minifigures"."fig_num" -AND "inventory"."u_id" IS NOT DISTINCT FROM "minifigures"."u_id" +LEFT JOIN "bricktracker_minifigures" +ON "bricktracker_parts"."id" IS NOT DISTINCT FROM "bricktracker_minifigures"."id" +AND "bricktracker_parts"."figure" IS NOT DISTINCT FROM "bricktracker_minifigures"."figure" {% endblock %} {% block where %} -WHERE "inventory"."part_num" IS NOT DISTINCT FROM :part_num -AND "inventory"."color_id" IS NOT DISTINCT FROM :color_id -AND "inventory"."element_id" IS NOT DISTINCT FROM :element_id +WHERE "bricktracker_parts"."part" IS NOT DISTINCT FROM :part +AND "bricktracker_parts"."color" IS NOT DISTINCT FROM :color {% endblock %} {% block group %} GROUP BY - "inventory"."part_num", - "inventory"."color_id", - "inventory"."element_id" + "bricktracker_parts"."part", + "bricktracker_parts"."color" {% endblock %} diff --git a/bricktracker/sql/part/select/specific.sql b/bricktracker/sql/part/select/specific.sql index ebdd5f5..c74a535 100644 --- a/bricktracker/sql/part/select/specific.sql +++ b/bricktracker/sql/part/select/specific.sql @@ -1,24 +1,18 @@ -{% extends 'part/base/select.sql' %} - -{% block join %} -LEFT JOIN "missing" -ON "inventory"."set_num" IS NOT DISTINCT FROM "missing"."set_num" -AND "inventory"."id" IS NOT DISTINCT FROM "missing"."id" -AND "inventory"."u_id" IS NOT DISTINCT FROM "missing"."u_id" -{% endblock %} +{% extends 'part/base/base.sql' %} {% block where %} -WHERE "inventory"."u_id" IS NOT DISTINCT FROM :u_id -AND "inventory"."set_num" IS NOT DISTINCT FROM :set_num -AND "inventory"."id" IS NOT DISTINCT FROM :id +WHERE "bricktracker_parts"."id" IS NOT DISTINCT FROM :id +AND "bricktracker_parts"."figure" IS NOT DISTINCT FROM :figure +AND "bricktracker_parts"."part" IS NOT DISTINCT FROM :part +AND "bricktracker_parts"."color" IS NOT DISTINCT FROM :color +AND "bricktracker_parts"."spare" IS NOT DISTINCT FROM :spare {% endblock %} {% block group %} GROUP BY - "inventory"."set_num", - "inventory"."id", - "inventory"."part_num", - "inventory"."color_id", - "inventory"."element_id", - "inventory"."u_id" + "bricktracker_parts"."id", + "bricktracker_parts"."figure", + "bricktracker_parts"."part", + "bricktracker_parts"."color", + "bricktracker_parts"."spare" {% endblock %} diff --git a/bricktracker/sql/part/update/damaged.sql b/bricktracker/sql/part/update/damaged.sql new file mode 100644 index 0000000..d4bbabd --- /dev/null +++ b/bricktracker/sql/part/update/damaged.sql @@ -0,0 +1,7 @@ +UPDATE "bricktracker_parts" +SET "damaged" = :damaged +WHERE "bricktracker_parts"."id" IS NOT DISTINCT FROM :id +AND "bricktracker_parts"."figure" IS NOT DISTINCT FROM :figure +AND "bricktracker_parts"."part" IS NOT DISTINCT FROM :part +AND "bricktracker_parts"."color" IS NOT DISTINCT FROM :color +AND "bricktracker_parts"."spare" IS NOT DISTINCT FROM :spare diff --git a/bricktracker/sql/part/update/missing.sql b/bricktracker/sql/part/update/missing.sql new file mode 100644 index 0000000..c1dd368 --- /dev/null +++ b/bricktracker/sql/part/update/missing.sql @@ -0,0 +1,7 @@ +UPDATE "bricktracker_parts" +SET "missing" = :missing +WHERE "bricktracker_parts"."id" IS NOT DISTINCT FROM :id +AND "bricktracker_parts"."figure" IS NOT DISTINCT FROM :figure +AND "bricktracker_parts"."part" IS NOT DISTINCT FROM :part +AND "bricktracker_parts"."color" IS NOT DISTINCT FROM :color +AND "bricktracker_parts"."spare" IS NOT DISTINCT FROM :spare diff --git a/bricktracker/sql/rebrickable/minifigure/insert.sql b/bricktracker/sql/rebrickable/minifigure/insert.sql new file mode 100644 index 0000000..3db1680 --- /dev/null +++ b/bricktracker/sql/rebrickable/minifigure/insert.sql @@ -0,0 +1,20 @@ +INSERT OR IGNORE INTO "rebrickable_minifigures" ( + "figure", + "number", + "name", + "image", + "number_of_parts" +) VALUES ( + :figure, + :number, + :name, + :image, + :number_of_parts +) +ON CONFLICT("figure") +DO UPDATE SET +"number" = :number, +"name" = :name, +"image" = :image, +"number_of_parts" = :number_of_parts +WHERE "rebrickable_minifigures"."figure" IS NOT DISTINCT FROM :figure diff --git a/bricktracker/sql/rebrickable/minifigure/list.sql b/bricktracker/sql/rebrickable/minifigure/list.sql new file mode 100644 index 0000000..ec379d8 --- /dev/null +++ b/bricktracker/sql/rebrickable/minifigure/list.sql @@ -0,0 +1,6 @@ +SELECT + "rebrickable_minifigures"."figure", + "rebrickable_minifigures"."number", + "rebrickable_minifigures"."name", + "rebrickable_minifigures"."image" +FROM "rebrickable_minifigures" diff --git a/bricktracker/sql/rebrickable/minifigure/select.sql b/bricktracker/sql/rebrickable/minifigure/select.sql new file mode 100644 index 0000000..f1c68c1 --- /dev/null +++ b/bricktracker/sql/rebrickable/minifigure/select.sql @@ -0,0 +1,8 @@ +SELECT + "rebrickable_minifigures"."figure", + "rebrickable_minifigures"."number", + "rebrickable_minifigures"."name", + "rebrickable_minifigures"."image" +FROM "rebrickable_minifigures" + +WHERE "rebrickable_minifigures"."figure" IS NOT DISTINCT FROM :figure diff --git a/bricktracker/sql/rebrickable/part/insert.sql b/bricktracker/sql/rebrickable/part/insert.sql new file mode 100644 index 0000000..fcec4ef --- /dev/null +++ b/bricktracker/sql/rebrickable/part/insert.sql @@ -0,0 +1,38 @@ +INSERT OR IGNORE INTO "rebrickable_parts" ( + "part", + "color_id", + "color_name", + "color_rgb", + "color_transparent", + "name", + "category", + "image", + "image_id", + "url", + "print" +) VALUES ( + :part, + :color_id, + :color_name, + :color_rgb, + :color_transparent, + :name, + :category, + :image, + :image_id, + :url, + :print +) +ON CONFLICT("part", "color_id") +DO UPDATE SET +"color_name" = :color_name, +"color_rgb" = :color_rgb, +"color_transparent" = :color_transparent, +"name" = :name, +"category" = :category, +"image" = :image, +"image_id" = :image_id, +"url" = :url, +"print" = :print +WHERE "rebrickable_parts"."part" IS NOT DISTINCT FROM :part +AND "rebrickable_parts"."color_id" IS NOT DISTINCT FROM :color_id \ No newline at end of file diff --git a/bricktracker/sql/rebrickable/part/list.sql b/bricktracker/sql/rebrickable/part/list.sql new file mode 100644 index 0000000..026f465 --- /dev/null +++ b/bricktracker/sql/rebrickable/part/list.sql @@ -0,0 +1,13 @@ +SELECT + "rebrickable_parts"."part", + "rebrickable_parts"."color_id", + "rebrickable_parts"."color_name", + "rebrickable_parts"."color_rgb", + "rebrickable_parts"."color_transparent", + "rebrickable_parts"."name", + "rebrickable_parts"."category", + "rebrickable_parts"."image", + "rebrickable_parts"."image_id", + "rebrickable_parts"."url", + "rebrickable_parts"."print" +FROM "rebrickable_parts" diff --git a/bricktracker/sql/rebrickable/part/select.sql b/bricktracker/sql/rebrickable/part/select.sql new file mode 100644 index 0000000..54f6305 --- /dev/null +++ b/bricktracker/sql/rebrickable/part/select.sql @@ -0,0 +1,16 @@ +SELECT + "rebrickable_parts"."part", + "rebrickable_parts"."color_id", + "rebrickable_parts"."color_name", + "rebrickable_parts"."color_rgb", + "rebrickable_parts"."color_transparent", + "rebrickable_parts"."name", + "rebrickable_parts"."category", + "rebrickable_parts"."image", + "rebrickable_parts"."image_id", + "rebrickable_parts"."url", + "rebrickable_parts"."print" +FROM "rebrickable_parts" + +WHERE "rebrickable_minifigures"."part" IS NOT DISTINCT FROM :figure +AND "rebrickable_minifigures"."color_id" IS NOT DISTINCT FROM :color diff --git a/bricktracker/sql/rebrickable/set/insert.sql b/bricktracker/sql/rebrickable/set/insert.sql index 88b2b44..39e6964 100644 --- a/bricktracker/sql/rebrickable/set/insert.sql +++ b/bricktracker/sql/rebrickable/set/insert.sql @@ -21,3 +21,15 @@ INSERT OR IGNORE INTO "rebrickable_sets" ( :url, :last_modified ) +ON CONFLICT("set") +DO UPDATE SET + "number" = :number, + "version" = :version, + "name" = :name, + "year" = :year, + "theme_id" = :theme_id, + "number_of_parts" = :number_of_parts, + "image" = :image, + "url" = :url, + "last_modified" = :last_modified +WHERE "rebrickable_sets"."set" IS NOT DISTINCT FROM :set diff --git a/bricktracker/sql/rebrickable/set/need_refresh.sql b/bricktracker/sql/rebrickable/set/need_refresh.sql new file mode 100644 index 0000000..8060a60 --- /dev/null +++ b/bricktracker/sql/rebrickable/set/need_refresh.sql @@ -0,0 +1,53 @@ +SELECT + "rebrickable_sets"."set", + "rebrickable_sets"."name", + "rebrickable_sets"."number_of_parts", + "rebrickable_sets"."image", + "rebrickable_sets"."url", + "null_join"."null_rgb", + "null_join"."null_transparent", + "null_join"."null_url" +FROM "rebrickable_sets" + +INNER JOIN ( + SELECT + "null_sums"."set", + "null_sums"."null_rgb", + "null_sums"."null_transparent", + "null_sums"."null_url" + FROM ( + SELECT + "unique_set_parts"."set", + SUM(CASE WHEN "unique_set_parts"."color_rgb" IS NULL THEN 1 ELSE 0 END) AS "null_rgb", + SUM(CASE WHEN "unique_set_parts"."color_transparent" IS NULL THEN 1 ELSE 0 END) AS "null_transparent", + SUM(CASE WHEN "unique_set_parts"."url" IS NULL THEN 1 ELSE 0 END) AS "null_url" + FROM ( + SELECT + "bricktracker_sets"."set", + "rebrickable_parts"."color_rgb", + "rebrickable_parts"."color_transparent", + "rebrickable_parts"."url" + FROM "bricktracker_sets" + + INNER JOIN "bricktracker_parts" + ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "bricktracker_parts"."id" + + LEFT JOIN "rebrickable_parts" + ON "bricktracker_parts"."part" IS NOT DISTINCT FROM "rebrickable_parts"."part" + AND "bricktracker_parts"."color" IS NOT DISTINCT FROM "rebrickable_parts"."color_id" + + GROUP BY + "bricktracker_sets"."set", + "bricktracker_parts"."part", + "bricktracker_parts"."color" + ) "unique_set_parts" + + GROUP BY "unique_set_parts"."set" + + ) "null_sums" + + WHERE "null_rgb" > 0 + OR "null_transparent" > 0 + OR "null_url" > 0 +) "null_join" +ON "rebrickable_sets"."set" IS NOT DISTINCT FROM "null_join"."set" diff --git a/bricktracker/sql/schema/drop.sql b/bricktracker/sql/schema/drop.sql index b961b28..8c4cedb 100644 --- a/bricktracker/sql/schema/drop.sql +++ b/bricktracker/sql/schema/drop.sql @@ -1,12 +1,25 @@ BEGIN transaction; +DROP TABLE IF EXISTS "bricktracker_metadata_owners"; +DROP TABLE IF EXISTS "bricktracker_metadata_statuses"; +DROP TABLE IF EXISTS "bricktracker_metadata_tags"; +DROP TABLE IF EXISTS "bricktracker_minifigures"; +DROP TABLE IF EXISTS "bricktracker_parts"; DROP TABLE IF EXISTS "bricktracker_sets"; DROP TABLE IF EXISTS "bricktracker_set_checkboxes"; +DROP TABLE IF EXISTS "bricktracker_set_owners"; DROP TABLE IF EXISTS "bricktracker_set_statuses"; +DROP TABLE IF EXISTS "bricktracker_set_storages"; +DROP TABLE IF EXISTS "bricktracker_set_tags"; DROP TABLE IF EXISTS "bricktracker_wishes"; DROP TABLE IF EXISTS "inventory"; +DROP TABLE IF EXISTS "inventory_old"; DROP TABLE IF EXISTS "minifigures"; +DROP TABLE IF EXISTS "minifigures_old"; DROP TABLE IF EXISTS "missing"; +DROP TABLE IF EXISTS "missing_old"; +DROP TABLE IF EXISTS "rebrickable_minifigures"; +DROP TABLE IF EXISTS "rebrickable_parts"; DROP TABLE IF EXISTS "rebrickable_sets"; DROP TABLE IF EXISTS "sets"; DROP TABLE IF EXISTS "sets_old"; diff --git a/bricktracker/sql/set/base/base.sql b/bricktracker/sql/set/base/base.sql index 2f4d683..c19ffc8 100644 --- a/bricktracker/sql/set/base/base.sql +++ b/bricktracker/sql/set/base/base.sql @@ -1,5 +1,9 @@ SELECT {% block id %}{% endblock %} + "bricktracker_sets"."storage", + "bricktracker_sets"."purchase_date", + "bricktracker_sets"."purchase_location", + "bricktracker_sets"."purchase_price", "rebrickable_sets"."set", "rebrickable_sets"."number", "rebrickable_sets"."version", @@ -9,19 +13,28 @@ SELECT "rebrickable_sets"."number_of_parts", "rebrickable_sets"."image", "rebrickable_sets"."url", + {% block owners %} + {% if owners %}{{ owners }},{% endif %} + {% endblock %} + {% block tags %} + {% if tags %}{{ tags }},{% endif %} + {% endblock %} {% block statuses %} {% if statuses %}{{ statuses }},{% endif %} {% endblock %} {% block total_missing %} NULL AS "total_missing", -- dummy for order: total_missing {% endblock %} + {% block total_damaged %} + NULL AS "total_damaged", -- dummy for order: total_damaged + {% endblock %} {% block total_quantity %} - NULL AS "total_quantity", -- dummy for order: total_quantity + NULL AS "total_quantity" -- dummy for order: total_quantity {% endblock %} FROM "bricktracker_sets" INNER JOIN "rebrickable_sets" -ON "bricktracker_sets"."rebrickable_set" IS NOT DISTINCT FROM "rebrickable_sets"."set" +ON "bricktracker_sets"."set" IS NOT DISTINCT FROM "rebrickable_sets"."set" {% block join %}{% endblock %} diff --git a/bricktracker/sql/set/base/full.sql b/bricktracker/sql/set/base/full.sql index c169c7a..92612df 100644 --- a/bricktracker/sql/set/base/full.sql +++ b/bricktracker/sql/set/base/full.sql @@ -5,7 +5,11 @@ {% endblock %} {% block total_missing %} -IFNULL("missing_join"."total", 0) AS "total_missing", +IFNULL("problem_join"."total_missing", 0) AS "total_missing", +{% endblock %} + +{% block total_damaged %} +IFNULL("problem_join"."total_damaged", 0) AS "total_damaged", {% endblock %} {% block total_quantity %} @@ -13,30 +17,41 @@ IFNULL("minifigures_join"."total", 0) AS "total_minifigures" {% endblock %} {% block join %} +{% if owners %} +LEFT JOIN "bricktracker_set_owners" +ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "bricktracker_set_owners"."id" +{% endif %} + {% if statuses %} LEFT JOIN "bricktracker_set_statuses" -ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "bricktracker_set_statuses"."bricktracker_set_id" +ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "bricktracker_set_statuses"."id" +{% endif %} + +{% if tags %} +LEFT JOIN "bricktracker_set_tags" +ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "bricktracker_set_tags"."id" {% endif %} -- LEFT JOIN + SELECT to avoid messing the total LEFT JOIN ( SELECT - "missing"."u_id", - SUM("missing"."quantity") AS "total" - FROM "missing" + "bricktracker_parts"."id", + SUM("bricktracker_parts"."missing") AS "total_missing", + SUM("bricktracker_parts"."damaged") AS "total_damaged" + FROM "bricktracker_parts" {% block where_missing %}{% endblock %} - GROUP BY "u_id" -) "missing_join" -ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "missing_join"."u_id" + GROUP BY "bricktracker_parts"."id" +) "problem_join" +ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "problem_join"."id" -- LEFT JOIN + SELECT to avoid messing the total LEFT JOIN ( SELECT - "minifigures"."u_id", - SUM("minifigures"."quantity") AS "total" - FROM "minifigures" + "bricktracker_minifigures"."id", + SUM("bricktracker_minifigures"."quantity") AS "total" + FROM "bricktracker_minifigures" {% block where_minifigures %}{% endblock %} - GROUP BY "u_id" + GROUP BY "bricktracker_minifigures"."id" ) "minifigures_join" -ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "minifigures_join"."u_id" +ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "minifigures_join"."id" {% endblock %} \ No newline at end of file diff --git a/bricktracker/sql/set/base/light.sql b/bricktracker/sql/set/base/light.sql index b599a87..12df8e2 100644 --- a/bricktracker/sql/set/base/light.sql +++ b/bricktracker/sql/set/base/light.sql @@ -1,6 +1,6 @@ SELECT "bricktracker_sets"."id", - "bricktracker_sets"."rebrickable_set" AS "set" + "bricktracker_sets"."set" FROM "bricktracker_sets" {% block join %}{% endblock %} diff --git a/bricktracker/sql/set/delete/set.sql b/bricktracker/sql/set/delete/set.sql index dd2c856..4eca845 100644 --- a/bricktracker/sql/set/delete/set.sql +++ b/bricktracker/sql/set/delete/set.sql @@ -6,16 +6,19 @@ BEGIN TRANSACTION; DELETE FROM "bricktracker_sets" WHERE "bricktracker_sets"."id" IS NOT DISTINCT FROM '{{ id }}'; +DELETE FROM "bricktracker_set_owners" +WHERE "bricktracker_set_owners"."id" IS NOT DISTINCT FROM '{{ id }}'; + DELETE FROM "bricktracker_set_statuses" -WHERE "bricktracker_set_statuses"."bricktracker_set_id" IS NOT DISTINCT FROM '{{ id }}'; +WHERE "bricktracker_set_statuses"."id" IS NOT DISTINCT FROM '{{ id }}'; -DELETE FROM "minifigures" -WHERE "minifigures"."u_id" IS NOT DISTINCT FROM '{{ id }}'; +DELETE FROM "bricktracker_set_tags" +WHERE "bricktracker_set_tags"."id" IS NOT DISTINCT FROM '{{ id }}'; -DELETE FROM "missing" -WHERE "missing"."u_id" IS NOT DISTINCT FROM '{{ id }}'; +DELETE FROM "bricktracker_minifigures" +WHERE "bricktracker_minifigures"."id" IS NOT DISTINCT FROM '{{ id }}'; -DELETE FROM "inventory" -WHERE "inventory"."u_id" IS NOT DISTINCT FROM '{{ id }}'; +DELETE FROM "bricktracker_parts" +WHERE "bricktracker_parts"."id" IS NOT DISTINCT FROM '{{ id }}'; COMMIT; \ No newline at end of file diff --git a/bricktracker/sql/set/insert.sql b/bricktracker/sql/set/insert.sql index 2462ac5..bc933c3 100644 --- a/bricktracker/sql/set/insert.sql +++ b/bricktracker/sql/set/insert.sql @@ -1,7 +1,11 @@ INSERT OR IGNORE INTO "bricktracker_sets" ( "id", - "rebrickable_set" + "set", + "storage", + "purchase_location" ) VALUES ( :id, - :set + :set, + :storage, + :purchase_location ) diff --git a/bricktracker/sql/set/list/damaged_minifigure.sql b/bricktracker/sql/set/list/damaged_minifigure.sql new file mode 100644 index 0000000..51a615d --- /dev/null +++ b/bricktracker/sql/set/list/damaged_minifigure.sql @@ -0,0 +1,11 @@ +{% extends 'set/base/full.sql' %} + +{% block where %} +WHERE "bricktracker_sets"."id" IN ( + SELECT "bricktracker_parts"."id" + FROM "bricktracker_parts" + WHERE "bricktracker_parts"."figure" IS NOT DISTINCT FROM :figure + AND "bricktracker_parts"."missing" > 0 + GROUP BY "bricktracker_parts"."id" +) +{% endblock %} diff --git a/bricktracker/sql/set/list/damaged_part.sql b/bricktracker/sql/set/list/damaged_part.sql new file mode 100644 index 0000000..128931f --- /dev/null +++ b/bricktracker/sql/set/list/damaged_part.sql @@ -0,0 +1,12 @@ +{% extends 'set/base/full.sql' %} + +{% block where %} +WHERE "bricktracker_sets"."id" IN ( + SELECT "bricktracker_parts"."id" + FROM "bricktracker_parts" + WHERE "bricktracker_parts"."part" IS NOT DISTINCT FROM :part + AND "bricktracker_parts"."color" IS NOT DISTINCT FROM :color + AND "bricktracker_parts"."damaged" > 0 + GROUP BY "bricktracker_parts"."id" +) +{% endblock %} diff --git a/bricktracker/sql/set/list/generic.sql b/bricktracker/sql/set/list/generic.sql index 0177c2b..d5b2da4 100644 --- a/bricktracker/sql/set/list/generic.sql +++ b/bricktracker/sql/set/list/generic.sql @@ -2,5 +2,5 @@ {% block group %} GROUP BY - "bricktracker_sets"."rebrickable_set" + "bricktracker_sets"."set" {% endblock %} diff --git a/bricktracker/sql/set/list/missing_minifigure.sql b/bricktracker/sql/set/list/missing_minifigure.sql index 5f27088..51a615d 100644 --- a/bricktracker/sql/set/list/missing_minifigure.sql +++ b/bricktracker/sql/set/list/missing_minifigure.sql @@ -2,12 +2,10 @@ {% block where %} WHERE "bricktracker_sets"."id" IN ( - SELECT - "missing"."u_id" - FROM "missing" - - WHERE "missing"."set_num" IS NOT DISTINCT FROM :fig_num - - GROUP BY "missing"."u_id" + SELECT "bricktracker_parts"."id" + FROM "bricktracker_parts" + WHERE "bricktracker_parts"."figure" IS NOT DISTINCT FROM :figure + AND "bricktracker_parts"."missing" > 0 + GROUP BY "bricktracker_parts"."id" ) {% endblock %} diff --git a/bricktracker/sql/set/list/missing_part.sql b/bricktracker/sql/set/list/missing_part.sql index 781754c..9438b67 100644 --- a/bricktracker/sql/set/list/missing_part.sql +++ b/bricktracker/sql/set/list/missing_part.sql @@ -2,14 +2,11 @@ {% block where %} WHERE "bricktracker_sets"."id" IN ( - SELECT - "missing"."u_id" - FROM "missing" - - WHERE "missing"."color_id" IS NOT DISTINCT FROM :color_id - AND "missing"."element_id" IS NOT DISTINCT FROM :element_id - AND "missing"."part_num" IS NOT DISTINCT FROM :part_num - - GROUP BY "missing"."u_id" + SELECT "bricktracker_parts"."id" + FROM "bricktracker_parts" + WHERE "bricktracker_parts"."part" IS NOT DISTINCT FROM :part + AND "bricktracker_parts"."color" IS NOT DISTINCT FROM :color + AND "bricktracker_parts"."missing" > 0 + GROUP BY "bricktracker_parts"."id" ) {% endblock %} diff --git a/bricktracker/sql/set/list/using_minifigure.sql b/bricktracker/sql/set/list/using_minifigure.sql index f08a5d7..00a1fb0 100644 --- a/bricktracker/sql/set/list/using_minifigure.sql +++ b/bricktracker/sql/set/list/using_minifigure.sql @@ -2,12 +2,9 @@ {% block where %} WHERE "bricktracker_sets"."id" IN ( - SELECT - "inventory"."u_id" - FROM "inventory" - - WHERE "inventory"."set_num" IS NOT DISTINCT FROM :fig_num - - GROUP BY "inventory"."u_id" + SELECT "bricktracker_parts"."id" + FROM "bricktracker_parts" + WHERE "bricktracker_parts"."figure" IS NOT DISTINCT FROM :figure + GROUP BY "bricktracker_parts"."id" ) {% endblock %} diff --git a/bricktracker/sql/set/list/using_part.sql b/bricktracker/sql/set/list/using_part.sql index 8877cff..a037173 100644 --- a/bricktracker/sql/set/list/using_part.sql +++ b/bricktracker/sql/set/list/using_part.sql @@ -2,14 +2,10 @@ {% block where %} WHERE "bricktracker_sets"."id" IN ( - SELECT - "inventory"."u_id" - FROM "inventory" - - WHERE "inventory"."color_id" IS NOT DISTINCT FROM :color_id - AND "inventory"."element_id" IS NOT DISTINCT FROM :element_id - AND "inventory"."part_num" IS NOT DISTINCT FROM :part_num - - GROUP BY "inventory"."u_id" + SELECT "bricktracker_parts"."id" + FROM "bricktracker_parts" + WHERE "bricktracker_parts"."part" IS NOT DISTINCT FROM :part + AND "bricktracker_parts"."color" IS NOT DISTINCT FROM :color + GROUP BY "bricktracker_parts"."id" ) {% endblock %} diff --git a/bricktracker/sql/set/list/using_storage.sql b/bricktracker/sql/set/list/using_storage.sql new file mode 100644 index 0000000..0dc0f14 --- /dev/null +++ b/bricktracker/sql/set/list/using_storage.sql @@ -0,0 +1,5 @@ +{% extends 'set/base/full.sql' %} + +{% block where %} +WHERE "bricktracker_sets"."storage" IS NOT DISTINCT FROM :storage +{% endblock %} diff --git a/bricktracker/sql/set/metadata/owner/base.sql b/bricktracker/sql/set/metadata/owner/base.sql new file mode 100644 index 0000000..f36d023 --- /dev/null +++ b/bricktracker/sql/set/metadata/owner/base.sql @@ -0,0 +1,10 @@ +SELECT + "bricktracker_metadata_owners"."id", + "bricktracker_metadata_owners"."name" +FROM "bricktracker_metadata_owners" + +{% block where %}{% endblock %} + +{% if order %} +ORDER BY {{ order }} +{% endif %} diff --git a/bricktracker/sql/set/metadata/owner/delete.sql b/bricktracker/sql/set/metadata/owner/delete.sql new file mode 100644 index 0000000..5927bfd --- /dev/null +++ b/bricktracker/sql/set/metadata/owner/delete.sql @@ -0,0 +1,13 @@ +BEGIN TRANSACTION; + +ALTER TABLE "bricktracker_set_owners" +DROP COLUMN "owner_{{ id }}"; + +-- Also drop from wishes +ALTER TABLE "bricktracker_wish_owners" +DROP COLUMN "owner_{{ id }}"; + +DELETE FROM "bricktracker_metadata_owners" +WHERE "bricktracker_metadata_owners"."id" IS NOT DISTINCT FROM '{{ id }}'; + +COMMIT; \ No newline at end of file diff --git a/bricktracker/sql/set/metadata/owner/insert.sql b/bricktracker/sql/set/metadata/owner/insert.sql new file mode 100644 index 0000000..6b2de77 --- /dev/null +++ b/bricktracker/sql/set/metadata/owner/insert.sql @@ -0,0 +1,18 @@ +BEGIN TRANSACTION; + +ALTER TABLE "bricktracker_set_owners" +ADD COLUMN "owner_{{ id }}" BOOLEAN NOT NULL DEFAULT 0; + +-- Also inject into wishes +ALTER TABLE "bricktracker_wish_owners" +ADD COLUMN "owner_{{ id }}" BOOLEAN NOT NULL DEFAULT 0; + +INSERT INTO "bricktracker_metadata_owners" ( + "id", + "name" +) VALUES ( + '{{ id }}', + '{{ name }}' +); + +COMMIT; \ No newline at end of file diff --git a/bricktracker/sql/set/metadata/owner/list.sql b/bricktracker/sql/set/metadata/owner/list.sql new file mode 100644 index 0000000..e970cf9 --- /dev/null +++ b/bricktracker/sql/set/metadata/owner/list.sql @@ -0,0 +1 @@ +{% extends 'set/metadata/owner/base.sql' %} diff --git a/bricktracker/sql/set/metadata/owner/select.sql b/bricktracker/sql/set/metadata/owner/select.sql new file mode 100644 index 0000000..8224565 --- /dev/null +++ b/bricktracker/sql/set/metadata/owner/select.sql @@ -0,0 +1,5 @@ +{% extends 'set/metadata/owner/base.sql' %} + +{% block where %} +WHERE "bricktracker_metadata_owners"."id" IS NOT DISTINCT FROM :id +{% endblock %} \ No newline at end of file diff --git a/bricktracker/sql/set/metadata/owner/update/field.sql b/bricktracker/sql/set/metadata/owner/update/field.sql new file mode 100644 index 0000000..5f047a3 --- /dev/null +++ b/bricktracker/sql/set/metadata/owner/update/field.sql @@ -0,0 +1,3 @@ +UPDATE "bricktracker_metadata_owners" +SET "{{field}}" = :value +WHERE "bricktracker_metadata_owners"."id" IS NOT DISTINCT FROM :id diff --git a/bricktracker/sql/set/metadata/owner/update/state.sql b/bricktracker/sql/set/metadata/owner/update/state.sql new file mode 100644 index 0000000..2469207 --- /dev/null +++ b/bricktracker/sql/set/metadata/owner/update/state.sql @@ -0,0 +1,10 @@ +INSERT INTO "bricktracker_set_owners" ( + "id", + "{{name}}" +) VALUES ( + :set_id, + :state +) +ON CONFLICT("id") +DO UPDATE SET "{{name}}" = :state +WHERE "bricktracker_set_owners"."id" IS NOT DISTINCT FROM :set_id diff --git a/bricktracker/sql/set/metadata/purchase_location/base.sql b/bricktracker/sql/set/metadata/purchase_location/base.sql new file mode 100644 index 0000000..c0a0ce0 --- /dev/null +++ b/bricktracker/sql/set/metadata/purchase_location/base.sql @@ -0,0 +1,10 @@ +SELECT + "bricktracker_metadata_purchase_locations"."id", + "bricktracker_metadata_purchase_locations"."name" +FROM "bricktracker_metadata_purchase_locations" + +{% block where %}{% endblock %} + +{% if order %} +ORDER BY {{ order }} +{% endif %} diff --git a/bricktracker/sql/set/metadata/purchase_location/delete.sql b/bricktracker/sql/set/metadata/purchase_location/delete.sql new file mode 100644 index 0000000..489dfd0 --- /dev/null +++ b/bricktracker/sql/set/metadata/purchase_location/delete.sql @@ -0,0 +1,10 @@ +BEGIN TRANSACTION; + +DELETE FROM "bricktracker_metadata_purchase_locations" +WHERE "bricktracker_metadata_purchase_locations"."id" IS NOT DISTINCT FROM '{{ id }}'; + +UPDATE "bricktracker_sets" +SET "purchase_location" = NULL +WHERE "bricktracker_sets"."purchase_location" IS NOT DISTINCT FROM '{{ id }}'; + +COMMIT; \ No newline at end of file diff --git a/bricktracker/sql/set/metadata/purchase_location/insert.sql b/bricktracker/sql/set/metadata/purchase_location/insert.sql new file mode 100644 index 0000000..22fc587 --- /dev/null +++ b/bricktracker/sql/set/metadata/purchase_location/insert.sql @@ -0,0 +1,11 @@ +BEGIN TRANSACTION; + +INSERT INTO "bricktracker_metadata_purchase_locations" ( + "id", + "name" +) VALUES ( + '{{ id }}', + '{{ name }}' +); + +COMMIT; \ No newline at end of file diff --git a/bricktracker/sql/set/metadata/purchase_location/list.sql b/bricktracker/sql/set/metadata/purchase_location/list.sql new file mode 100644 index 0000000..2a0813b --- /dev/null +++ b/bricktracker/sql/set/metadata/purchase_location/list.sql @@ -0,0 +1 @@ +{% extends 'set/metadata/purchase_location/base.sql' %} diff --git a/bricktracker/sql/set/metadata/purchase_location/select.sql b/bricktracker/sql/set/metadata/purchase_location/select.sql new file mode 100644 index 0000000..a9e6161 --- /dev/null +++ b/bricktracker/sql/set/metadata/purchase_location/select.sql @@ -0,0 +1,5 @@ +{% extends 'set/metadata/purchase_location/base.sql' %} + +{% block where %} +WHERE "bricktracker_metadata_purchase_locations"."id" IS NOT DISTINCT FROM :id +{% endblock %} \ No newline at end of file diff --git a/bricktracker/sql/set/metadata/purchase_location/update/field.sql b/bricktracker/sql/set/metadata/purchase_location/update/field.sql new file mode 100644 index 0000000..323d98d --- /dev/null +++ b/bricktracker/sql/set/metadata/purchase_location/update/field.sql @@ -0,0 +1,3 @@ +UPDATE "bricktracker_metadata_purchase_locations" +SET "{{field}}" = :value +WHERE "bricktracker_metadata_purchase_locations"."id" IS NOT DISTINCT FROM :id diff --git a/bricktracker/sql/set/metadata/purchase_location/update/value.sql b/bricktracker/sql/set/metadata/purchase_location/update/value.sql new file mode 100644 index 0000000..d27469e --- /dev/null +++ b/bricktracker/sql/set/metadata/purchase_location/update/value.sql @@ -0,0 +1,3 @@ +UPDATE "bricktracker_sets" +SET "purchase_location" = :value +WHERE "bricktracker_sets"."id" IS NOT DISTINCT FROM :set_id diff --git a/bricktracker/sql/set/metadata/status/base.sql b/bricktracker/sql/set/metadata/status/base.sql new file mode 100644 index 0000000..d962cc2 --- /dev/null +++ b/bricktracker/sql/set/metadata/status/base.sql @@ -0,0 +1,11 @@ +SELECT + "bricktracker_metadata_statuses"."id", + "bricktracker_metadata_statuses"."name", + "bricktracker_metadata_statuses"."displayed_on_grid" +FROM "bricktracker_metadata_statuses" + +{% block where %}{% endblock %} + +{% if order %} +ORDER BY {{ order }} +{% endif %} diff --git a/bricktracker/sql/set/metadata/status/delete.sql b/bricktracker/sql/set/metadata/status/delete.sql new file mode 100644 index 0000000..cac284e --- /dev/null +++ b/bricktracker/sql/set/metadata/status/delete.sql @@ -0,0 +1,9 @@ +BEGIN TRANSACTION; + +ALTER TABLE "bricktracker_set_statuses" +DROP COLUMN "status_{{ id }}"; + +DELETE FROM "bricktracker_metadata_statuses" +WHERE "bricktracker_metadata_statuses"."id" IS NOT DISTINCT FROM '{{ id }}'; + +COMMIT; \ No newline at end of file diff --git a/bricktracker/sql/checkbox/add.sql b/bricktracker/sql/set/metadata/status/insert.sql similarity index 84% rename from bricktracker/sql/checkbox/add.sql rename to bricktracker/sql/set/metadata/status/insert.sql index 5de9c17..2704d72 100644 --- a/bricktracker/sql/checkbox/add.sql +++ b/bricktracker/sql/set/metadata/status/insert.sql @@ -3,7 +3,7 @@ BEGIN TRANSACTION; ALTER TABLE "bricktracker_set_statuses" ADD COLUMN "status_{{ id }}" BOOLEAN NOT NULL DEFAULT 0; -INSERT INTO "bricktracker_set_checkboxes" ( +INSERT INTO "bricktracker_metadata_statuses" ( "id", "name", "displayed_on_grid" diff --git a/bricktracker/sql/set/metadata/status/list.sql b/bricktracker/sql/set/metadata/status/list.sql new file mode 100644 index 0000000..2fe9994 --- /dev/null +++ b/bricktracker/sql/set/metadata/status/list.sql @@ -0,0 +1 @@ +{% extends 'set/metadata/status/base.sql' %} diff --git a/bricktracker/sql/set/metadata/status/select.sql b/bricktracker/sql/set/metadata/status/select.sql new file mode 100644 index 0000000..09afe87 --- /dev/null +++ b/bricktracker/sql/set/metadata/status/select.sql @@ -0,0 +1,5 @@ +{% extends 'set/metadata/status/base.sql' %} + +{% block where %} +WHERE "bricktracker_metadata_statuses"."id" IS NOT DISTINCT FROM :id +{% endblock %} \ No newline at end of file diff --git a/bricktracker/sql/set/metadata/status/update/field.sql b/bricktracker/sql/set/metadata/status/update/field.sql new file mode 100644 index 0000000..d17681e --- /dev/null +++ b/bricktracker/sql/set/metadata/status/update/field.sql @@ -0,0 +1,3 @@ +UPDATE "bricktracker_metadata_statuses" +SET "{{field}}" = :value +WHERE "bricktracker_metadata_statuses"."id" IS NOT DISTINCT FROM :id diff --git a/bricktracker/sql/set/metadata/status/update/state.sql b/bricktracker/sql/set/metadata/status/update/state.sql new file mode 100644 index 0000000..7697ca5 --- /dev/null +++ b/bricktracker/sql/set/metadata/status/update/state.sql @@ -0,0 +1,10 @@ +INSERT INTO "bricktracker_set_statuses" ( + "id", + "{{name}}" +) VALUES ( + :set_id, + :state +) +ON CONFLICT("id") +DO UPDATE SET "{{name}}" = :state +WHERE "bricktracker_set_statuses"."id" IS NOT DISTINCT FROM :set_id diff --git a/bricktracker/sql/set/metadata/storage/all.sql b/bricktracker/sql/set/metadata/storage/all.sql new file mode 100644 index 0000000..0bd8b8e --- /dev/null +++ b/bricktracker/sql/set/metadata/storage/all.sql @@ -0,0 +1,14 @@ +{% extends 'set/metadata/storage/base.sql' %} + +{% block total_sets %} +IFNULL(COUNT("bricktracker_sets"."id"), 0) AS "total_sets" +{% endblock %} + +{% block join %} +LEFT JOIN "bricktracker_sets" +ON "bricktracker_metadata_storages"."id" IS NOT DISTINCT FROM "bricktracker_sets"."storage" +{% endblock %} + +{% block group %} +GROUP BY "bricktracker_metadata_storages"."id" +{% endblock %} \ No newline at end of file diff --git a/bricktracker/sql/set/metadata/storage/base.sql b/bricktracker/sql/set/metadata/storage/base.sql new file mode 100644 index 0000000..bf616ed --- /dev/null +++ b/bricktracker/sql/set/metadata/storage/base.sql @@ -0,0 +1,17 @@ +SELECT + "bricktracker_metadata_storages"."id", + "bricktracker_metadata_storages"."name", + {% block total_sets %} + NULL as "total_sets" -- dummy for order: total_sets + {% endblock %} +FROM "bricktracker_metadata_storages" + +{% block join %}{% endblock %} + +{% block where %}{% endblock %} + +{% block group %}{% endblock %} + +{% if order %} +ORDER BY {{ order }} +{% endif %} diff --git a/bricktracker/sql/set/metadata/storage/delete.sql b/bricktracker/sql/set/metadata/storage/delete.sql new file mode 100644 index 0000000..e1c5d22 --- /dev/null +++ b/bricktracker/sql/set/metadata/storage/delete.sql @@ -0,0 +1,10 @@ +BEGIN TRANSACTION; + +DELETE FROM "bricktracker_metadata_storages" +WHERE "bricktracker_metadata_storages"."id" IS NOT DISTINCT FROM '{{ id }}'; + +UPDATE "bricktracker_sets" +SET "storage" = NULL +WHERE "bricktracker_sets"."storage" IS NOT DISTINCT FROM '{{ id }}'; + +COMMIT; \ No newline at end of file diff --git a/bricktracker/sql/set/metadata/storage/insert.sql b/bricktracker/sql/set/metadata/storage/insert.sql new file mode 100644 index 0000000..262c651 --- /dev/null +++ b/bricktracker/sql/set/metadata/storage/insert.sql @@ -0,0 +1,11 @@ +BEGIN TRANSACTION; + +INSERT INTO "bricktracker_metadata_storages" ( + "id", + "name" +) VALUES ( + '{{ id }}', + '{{ name }}' +); + +COMMIT; \ No newline at end of file diff --git a/bricktracker/sql/set/metadata/storage/list.sql b/bricktracker/sql/set/metadata/storage/list.sql new file mode 100644 index 0000000..87ac7a4 --- /dev/null +++ b/bricktracker/sql/set/metadata/storage/list.sql @@ -0,0 +1 @@ +{% extends 'set/metadata/storage/base.sql' %} diff --git a/bricktracker/sql/set/metadata/storage/select.sql b/bricktracker/sql/set/metadata/storage/select.sql new file mode 100644 index 0000000..b37a7e8 --- /dev/null +++ b/bricktracker/sql/set/metadata/storage/select.sql @@ -0,0 +1,5 @@ +{% extends 'set/metadata/storage/base.sql' %} + +{% block where %} +WHERE "bricktracker_metadata_storages"."id" IS NOT DISTINCT FROM :id +{% endblock %} \ No newline at end of file diff --git a/bricktracker/sql/set/metadata/storage/update/field.sql b/bricktracker/sql/set/metadata/storage/update/field.sql new file mode 100644 index 0000000..d27d27c --- /dev/null +++ b/bricktracker/sql/set/metadata/storage/update/field.sql @@ -0,0 +1,3 @@ +UPDATE "bricktracker_metadata_storages" +SET "{{field}}" = :value +WHERE "bricktracker_metadata_storages"."id" IS NOT DISTINCT FROM :id diff --git a/bricktracker/sql/set/metadata/storage/update/value.sql b/bricktracker/sql/set/metadata/storage/update/value.sql new file mode 100644 index 0000000..b758f08 --- /dev/null +++ b/bricktracker/sql/set/metadata/storage/update/value.sql @@ -0,0 +1,3 @@ +UPDATE "bricktracker_sets" +SET "storage" = :value +WHERE "bricktracker_sets"."id" IS NOT DISTINCT FROM :set_id diff --git a/bricktracker/sql/set/metadata/tag/base.sql b/bricktracker/sql/set/metadata/tag/base.sql new file mode 100644 index 0000000..7e6f03b --- /dev/null +++ b/bricktracker/sql/set/metadata/tag/base.sql @@ -0,0 +1,10 @@ +SELECT + "bricktracker_metadata_tags"."id", + "bricktracker_metadata_tags"."name" +FROM "bricktracker_metadata_tags" + +{% block where %}{% endblock %} + +{% if order %} +ORDER BY {{ order }} +{% endif %} diff --git a/bricktracker/sql/set/metadata/tag/delete.sql b/bricktracker/sql/set/metadata/tag/delete.sql new file mode 100644 index 0000000..a80bb8f --- /dev/null +++ b/bricktracker/sql/set/metadata/tag/delete.sql @@ -0,0 +1,9 @@ +BEGIN TRANSACTION; + +ALTER TABLE "bricktracker_set_tags" +DROP COLUMN "tag_{{ id }}"; + +DELETE FROM "bricktracker_metadata_tags" +WHERE "bricktracker_metadata_tags"."id" IS NOT DISTINCT FROM '{{ id }}'; + +COMMIT; \ No newline at end of file diff --git a/bricktracker/sql/set/metadata/tag/insert.sql b/bricktracker/sql/set/metadata/tag/insert.sql new file mode 100644 index 0000000..7a62866 --- /dev/null +++ b/bricktracker/sql/set/metadata/tag/insert.sql @@ -0,0 +1,14 @@ +BEGIN TRANSACTION; + +ALTER TABLE "bricktracker_set_tags" +ADD COLUMN "tag_{{ id }}" BOOLEAN NOT NULL DEFAULT 0; + +INSERT INTO "bricktracker_metadata_tags" ( + "id", + "name" +) VALUES ( + '{{ id }}', + '{{ name }}' +); + +COMMIT; \ No newline at end of file diff --git a/bricktracker/sql/set/metadata/tag/list.sql b/bricktracker/sql/set/metadata/tag/list.sql new file mode 100644 index 0000000..fe44b5f --- /dev/null +++ b/bricktracker/sql/set/metadata/tag/list.sql @@ -0,0 +1 @@ +{% extends 'set/metadata/tag/base.sql' %} diff --git a/bricktracker/sql/set/metadata/tag/select.sql b/bricktracker/sql/set/metadata/tag/select.sql new file mode 100644 index 0000000..2d52076 --- /dev/null +++ b/bricktracker/sql/set/metadata/tag/select.sql @@ -0,0 +1,5 @@ +{% extends 'set/metadata/tag/base.sql' %} + +{% block where %} +WHERE "bricktracker_metadata_tags"."id" IS NOT DISTINCT FROM :id +{% endblock %} \ No newline at end of file diff --git a/bricktracker/sql/set/metadata/tag/update/field.sql b/bricktracker/sql/set/metadata/tag/update/field.sql new file mode 100644 index 0000000..629a9e8 --- /dev/null +++ b/bricktracker/sql/set/metadata/tag/update/field.sql @@ -0,0 +1,3 @@ +UPDATE "bricktracker_metadata_tags" +SET "{{field}}" = :value +WHERE "bricktracker_metadata_tags"."id" IS NOT DISTINCT FROM :id diff --git a/bricktracker/sql/set/metadata/tag/update/state.sql b/bricktracker/sql/set/metadata/tag/update/state.sql new file mode 100644 index 0000000..18de40a --- /dev/null +++ b/bricktracker/sql/set/metadata/tag/update/state.sql @@ -0,0 +1,10 @@ +INSERT INTO "bricktracker_set_tags" ( + "id", + "{{name}}" +) VALUES ( + :set_id, + :state +) +ON CONFLICT("id") +DO UPDATE SET "{{name}}" = :state +WHERE "bricktracker_set_tags"."id" IS NOT DISTINCT FROM :set_id diff --git a/bricktracker/sql/set/select/full.sql b/bricktracker/sql/set/select/full.sql index 4b19136..0d12ae8 100644 --- a/bricktracker/sql/set/select/full.sql +++ b/bricktracker/sql/set/select/full.sql @@ -1,11 +1,11 @@ {% extends 'set/base/full.sql' %} {% block where_missing %} -WHERE "missing"."u_id" IS NOT DISTINCT FROM :id +WHERE "bricktracker_parts"."id" IS NOT DISTINCT FROM :id {% endblock %} {% block where_minifigures %} -WHERE "minifigures"."u_id" IS NOT DISTINCT FROM :id +WHERE "bricktracker_minifigures"."id" IS NOT DISTINCT FROM :id {% endblock %} {% block where %} diff --git a/bricktracker/sql/set/update/purchase_date.sql b/bricktracker/sql/set/update/purchase_date.sql new file mode 100644 index 0000000..cb8516e --- /dev/null +++ b/bricktracker/sql/set/update/purchase_date.sql @@ -0,0 +1,3 @@ +UPDATE "bricktracker_sets" +SET "purchase_date" = :purchase_date +WHERE "bricktracker_sets"."id" IS NOT DISTINCT FROM :id diff --git a/bricktracker/sql/set/update/purchase_price.sql b/bricktracker/sql/set/update/purchase_price.sql new file mode 100644 index 0000000..f742a8f --- /dev/null +++ b/bricktracker/sql/set/update/purchase_price.sql @@ -0,0 +1,3 @@ +UPDATE "bricktracker_sets" +SET "purchase_price" = :purchase_price +WHERE "bricktracker_sets"."id" IS NOT DISTINCT FROM :id diff --git a/bricktracker/sql/set/update/status.sql b/bricktracker/sql/set/update/status.sql deleted file mode 100644 index d72616e..0000000 --- a/bricktracker/sql/set/update/status.sql +++ /dev/null @@ -1,10 +0,0 @@ -INSERT INTO "bricktracker_set_statuses" ( - "bricktracker_set_id", - "{{name}}" -) VALUES ( - :id, - :status -) -ON CONFLICT("bricktracker_set_id") -DO UPDATE SET "{{name}}" = :status -WHERE "bricktracker_set_statuses"."bricktracker_set_id" IS NOT DISTINCT FROM :id diff --git a/bricktracker/sql/wish/base/base.sql b/bricktracker/sql/wish/base/base.sql index b06c66f..62f5a7c 100644 --- a/bricktracker/sql/wish/base/base.sql +++ b/bricktracker/sql/wish/base/base.sql @@ -5,9 +5,17 @@ SELECT "bricktracker_wishes"."theme_id", "bricktracker_wishes"."number_of_parts", "bricktracker_wishes"."image", + {% block owners %} + {% if owners %}{{ owners }},{% endif %} + {% endblock %} "bricktracker_wishes"."url" FROM "bricktracker_wishes" +{% if owners %} +LEFT JOIN "bricktracker_wish_owners" +ON "bricktracker_wishes"."set" IS NOT DISTINCT FROM "bricktracker_wish_owners"."set" +{% endif %} + {% block where %}{% endblock %} {% if order %} diff --git a/bricktracker/sql/wish/delete/wish.sql b/bricktracker/sql/wish/delete/wish.sql index e60b2e4..1adcfc1 100644 --- a/bricktracker/sql/wish/delete/wish.sql +++ b/bricktracker/sql/wish/delete/wish.sql @@ -1,2 +1,12 @@ +-- A bit unsafe as it does not use a prepared statement but it +-- should not be possible to inject anything through the {{ set }} context + +BEGIN TRANSACTION; + DELETE FROM "bricktracker_wishes" -WHERE "bricktracker_wishes"."set" IS NOT DISTINCT FROM :set \ No newline at end of file +WHERE "bricktracker_wishes"."set" IS NOT DISTINCT FROM '{{ set }}'; + +DELETE FROM "bricktracker_wish_owners" +WHERE "bricktracker_wish_owners"."set" IS NOT DISTINCT FROM '{{ set }}'; + +COMMIT; \ No newline at end of file diff --git a/bricktracker/sql/wish/metadata/owner/update/state.sql b/bricktracker/sql/wish/metadata/owner/update/state.sql new file mode 100644 index 0000000..9191ca8 --- /dev/null +++ b/bricktracker/sql/wish/metadata/owner/update/state.sql @@ -0,0 +1,10 @@ +INSERT INTO "bricktracker_wish_owners" ( + "set", + "{{name}}" +) VALUES ( + :set, + :state +) +ON CONFLICT("set") +DO UPDATE SET "{{name}}" = :state +WHERE "bricktracker_wish_owners"."set" IS NOT DISTINCT FROM :set diff --git a/bricktracker/sql_counter.py b/bricktracker/sql_counter.py index d104269..4d7a61e 100644 --- a/bricktracker/sql_counter.py +++ b/bricktracker/sql_counter.py @@ -2,13 +2,28 @@ from typing import Tuple # Some table aliases to make it look cleaner (id: (name, icon)) ALIASES: dict[str, Tuple[str, str]] = { - 'bricktracker_set_checkboxes': ('Checkboxes', 'checkbox-line'), - 'bricktracker_set_statuses': ('Bricktracker sets status', 'checkbox-line'), + 'bricktracker_metadata_owners': ('Bricktracker set owners metadata', 'user-line'), # noqa: E501 + 'bricktracker_metadata_purchase_locations': ('Bricktracker set purchase locations metadata', 'building-line'), # noqa: E501 + 'bricktracker_metadata_statuses': ('Bricktracker set status metadata', 'checkbox-line'), # noqa: E501 + 'bricktracker_metadata_storages': ('Bricktracker set storages metadata', 'archive-2-line'), # noqa: E501 + 'bricktracker_metadata_tags': ('Bricktracker set tags metadata', 'price-tag-2-line'), # noqa: E501 + 'bricktracker_minifigures': ('Bricktracker minifigures', 'group-line'), + 'bricktracker_parts': ('Bricktracker parts', 'shapes-line'), + 'bricktracker_set_checkboxes': ('Bricktracker set checkboxes (legacy)', 'checkbox-line'), # noqa: E501 + 'bricktracker_set_owners': ('Bricktracker set owners', 'checkbox-line'), + 'bricktracker_set_statuses': ('Bricktracker set statuses', 'user-line'), + 'bricktracker_set_tags': ('Bricktracker set tags', 'price-tag-2-line'), 'bricktracker_sets': ('Bricktracker sets', 'hashtag'), 'bricktracker_wishes': ('Bricktracker wishes', 'gift-line'), + 'bricktracker_wish_owners': ('Bricktracker wish owners', 'checkbox-line'), 'inventory': ('Parts', 'shapes-line'), + 'inventory_old': ('Parts (legacy)', 'shapes-line'), 'minifigures': ('Minifigures', 'group-line'), + 'minifigures_old': ('Minifigures (legacy)', 'group-line'), 'missing': ('Missing', 'error-warning-line'), + 'missing_old': ('Missing (legacy)', 'error-warning-line'), + 'rebrickable_minifigures': ('Rebrickable minifigures', 'group-line'), + 'rebrickable_parts': ('Rebrickable parts', 'shapes-line'), 'rebrickable_sets': ('Rebrickable sets', 'hashtag'), 'sets': ('Sets', 'hashtag'), 'sets_old': ('Sets (legacy)', 'hashtag'), @@ -22,6 +37,7 @@ class BrickCounter(object): table: str icon: str count: int + legacy: bool def __init__( self, @@ -44,3 +60,5 @@ class BrickCounter(object): self.name = name self.icon = icon + + self.legacy = '(legacy)' in self.name diff --git a/bricktracker/version.py b/bricktracker/version.py index b055c6b..4efb1e6 100644 --- a/bricktracker/version.py +++ b/bricktracker/version.py @@ -1,4 +1,4 @@ from typing import Final -__version__: Final[str] = '1.1.1' -__database_version__: Final[int] = 6 +__version__: Final[str] = '1.2.0' +__database_version__: Final[int] = 15 diff --git a/bricktracker/views/add.py b/bricktracker/views/add.py index 218a0bf..db4671e 100644 --- a/bricktracker/views/add.py +++ b/bricktracker/views/add.py @@ -3,6 +3,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 ..socket import MESSAGES add_page = Blueprint('add', __name__, url_prefix='/add') @@ -19,7 +20,8 @@ def add() -> str: 'add.html', path=current_app.config['SOCKET_PATH'], namespace=current_app.config['SOCKET_NAMESPACE'], - messages=MESSAGES + messages=MESSAGES, + **set_metadata_lists() ) @@ -31,8 +33,10 @@ def bulk() -> str: BrickConfigurationList.error_unless_is_set('REBRICKABLE_API_KEY') return render_template( - 'bulk.html', + 'add.html', path=current_app.config['SOCKET_PATH'], namespace=current_app.config['SOCKET_NAMESPACE'], - messages=MESSAGES + messages=MESSAGES, + bulk=True, + **set_metadata_lists() ) diff --git a/bricktracker/views/admin/admin.py b/bricktracker/views/admin/admin.py index 847e42a..749b3df 100644 --- a/bricktracker/views/admin/admin.py +++ b/bricktracker/views/admin/admin.py @@ -8,8 +8,16 @@ from ..exceptions import exception_handler from ...instructions_list import BrickInstructionsList from ...rebrickable_image import RebrickableImage from ...retired_list import BrickRetiredList -from ...set_checkbox import BrickSetCheckbox -from ...set_checkbox_list import BrickSetCheckboxList +from ...set_owner import BrickSetOwner +from ...set_owner_list import BrickSetOwnerList +from ...set_purchase_location import BrickSetPurchaseLocation +from ...set_purchase_location_list import BrickSetPurchaseLocationList +from ...set_storage import BrickSetStorage +from ...set_storage_list import BrickSetStorageList +from ...set_status import BrickSetStatus +from ...set_status_list import BrickSetStatusList +from ...set_tag import BrickSetTag +from ...set_tag_list import BrickSetTagList from ...sql_counter import BrickCounter from ...sql import BrickSQL from ...theme_list import BrickThemeList @@ -24,11 +32,16 @@ admin_page = Blueprint('admin', __name__, url_prefix='/admin') @login_required @exception_handler(__file__) def admin() -> str: - brickset_checkboxes: list[BrickSetCheckbox] = [] database_counters: list[BrickCounter] = [] database_exception: Exception | None = None database_upgrade_needed: bool = False database_version: int = -1 + instructions: BrickInstructionsList | None = None + metadata_owners: list[BrickSetOwner] = [] + metadata_purchase_locations: list[BrickSetPurchaseLocation] = [] + metadata_statuses: list[BrickSetStatus] = [] + metadata_storages: list[BrickSetStorage] = [] + metadata_tags: list[BrickSetTag] = [] nil_minifigure_name: str = '' nil_minifigure_url: str = '' nil_part_name: str = '' @@ -41,7 +54,13 @@ def admin() -> str: database_version = database.version database_counters = BrickSQL().count_records() - brickset_checkboxes = BrickSetCheckboxList().list(all=True) + instructions = BrickInstructionsList() + + metadata_owners = BrickSetOwnerList.list() + metadata_purchase_locations = BrickSetPurchaseLocationList.list() + metadata_statuses = BrickSetStatusList.list(all=True) + metadata_storages = BrickSetStorageList.list() + metadata_tags = BrickSetTagList.list() except Exception as e: database_exception = e @@ -62,18 +81,30 @@ def admin() -> str: 'PARTS_FOLDER' ) - open_checkbox = request.args.get('open_checkbox', None) 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_owner = request.args.get('open_owner', None) + open_purchase_location = request.args.get('open_purchase_location', None) open_retired = request.args.get('open_retired', None) + open_status = request.args.get('open_status', None) + open_storage = request.args.get('open_storage', None) + open_tag = request.args.get('open_tag', None) open_theme = request.args.get('open_theme', None) + open_metadata = ( + open_owner or + open_purchase_location or + open_status or + open_storage or + open_tag + ) + open_database = ( - open_checkbox is None and open_image is None and open_instructions is None and open_logout is None and + not open_metadata and open_retired is None and open_theme is None ) @@ -81,24 +112,38 @@ def admin() -> str: return render_template( 'admin.html', configuration=BrickConfigurationList.list(), - brickset_checkboxes=brickset_checkboxes, database_counters=database_counters, - database_error=request.args.get('error'), + database_error=request.args.get('database_error'), database_exception=database_exception, database_upgrade_needed=database_upgrade_needed, database_version=database_version, - instructions=BrickInstructionsList(), + instructions=instructions, + metadata_owners=metadata_owners, + metadata_purchase_locations=metadata_purchase_locations, + metadata_statuses=metadata_statuses, + metadata_storages=metadata_storages, + metadata_tags=metadata_tags, nil_minifigure_name=nil_minifigure_name, nil_minifigure_url=nil_minifigure_url, nil_part_name=nil_part_name, nil_part_url=nil_part_url, - open_checkbox=open_checkbox, open_database=open_database, open_image=open_image, open_instructions=open_instructions, open_logout=open_logout, + open_metadata=open_metadata, + open_owner=open_owner, + open_purchase_location=open_purchase_location, open_retired=open_retired, + open_status=open_status, + open_storage=open_storage, + open_tag=open_tag, open_theme=open_theme, + owner_error=request.args.get('owner_error'), + purchase_location_error=request.args.get('purchase_location_error'), retired=BrickRetiredList(), + status_error=request.args.get('status_error'), + storage_error=request.args.get('storage_error'), + tag_error=request.args.get('tag_error'), theme=BrickThemeList(), ) diff --git a/bricktracker/views/admin/checkbox.py b/bricktracker/views/admin/checkbox.py deleted file mode 100644 index 1dd58bb..0000000 --- a/bricktracker/views/admin/checkbox.py +++ /dev/null @@ -1,98 +0,0 @@ -import logging - -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_checkbox import BrickSetCheckbox - -logger = logging.getLogger(__name__) - -admin_checkbox_page = Blueprint( - 'admin_checkbox', - __name__, - url_prefix='/admin/checkbox' -) - - -# Add a checkbox -@admin_checkbox_page.route('/add', methods=['POST']) -@login_required -@exception_handler(__file__, post_redirect='admin.admin', open_checkbox=True) -def add() -> Response: - BrickSetCheckbox().from_form(request.form).insert() - - reload() - - return redirect(url_for('admin.admin', open_checkbox=True)) - - -# Delete the checkbox -@admin_checkbox_page.route('/delete', methods=['GET']) -@login_required -@exception_handler(__file__) -def delete(*, id: str) -> str: - return render_template( - 'admin.html', - delete_checkbox=True, - checkbox=BrickSetCheckbox().select_specific(id), - error=request.args.get('error') - ) - - -# Actually delete the checkbox -@admin_checkbox_page.route('/delete', methods=['POST']) -@login_required -@exception_handler(__file__, post_redirect='admin_checkbox.delete') -def do_delete(*, id: str) -> Response: - checkbox = BrickSetCheckbox().select_specific(id) - checkbox.delete() - - reload() - - return redirect(url_for('admin.admin', open_checkbox=True)) - - -# Change the status of a checkbox -@admin_checkbox_page.route('//status/', methods=['POST']) -@login_required -@exception_handler(__file__, json=True) -def update_status(*, id: str, name: str) -> Response: - value: bool = request.json.get('value', False) # type: ignore - - checkbox = BrickSetCheckbox().select_specific(id) - checkbox.update_status(name, value) - - # Info - logger.info('Checkbox {name} ({id}): status "{status}" changed to "{state}"'.format( # noqa: E501 - name=checkbox.fields.name, - id=checkbox.fields.id, - status=name, - state=value, - )) - - reload() - - return jsonify({'value': value}) - - -# Rename the checkbox -@admin_checkbox_page.route('/rename', methods=['POST']) -@login_required -@exception_handler(__file__, post_redirect='admin.admin', open_checkbox=True) -def rename(*, id: str) -> Response: - checkbox = BrickSetCheckbox().select_specific(id) - checkbox.from_form(request.form).rename() - - reload() - - return redirect(url_for('admin.admin', open_checkbox=True)) diff --git a/bricktracker/views/admin/database.py b/bricktracker/views/admin/database.py index bd0f213..e2fc4bc 100644 --- a/bricktracker/views/admin/database.py +++ b/bricktracker/views/admin/database.py @@ -38,14 +38,18 @@ def delete() -> str: return render_template( 'admin.html', delete_database=True, - error=request.args.get('error') + database_error=request.args.get('database_error') ) # Actually delete the database @admin_database_page.route('/delete', methods=['POST']) @login_required -@exception_handler(__file__, post_redirect='admin_database.delete') +@exception_handler( + __file__, + post_redirect='admin_database.delete', + error_name='database_error' +) def do_delete() -> Response: BrickSQL.delete() @@ -89,14 +93,18 @@ def drop() -> str: return render_template( 'admin.html', drop_database=True, - error=request.args.get('error') + database_error=request.args.get('database_error') ) # Actually drop the database @admin_database_page.route('/drop', methods=['POST']) @login_required -@exception_handler(__file__, post_redirect='admin_database.drop') +@exception_handler( + __file__, + post_redirect='admin_database.drop', + error_name='database_error' +) def do_drop() -> Response: BrickSQL.drop() @@ -108,7 +116,11 @@ def do_drop() -> Response: # Actually upgrade the database @admin_database_page.route('/upgrade', methods=['POST']) @login_required -@exception_handler(__file__, post_redirect='admin_database.upgrade') +@exception_handler( + __file__, + post_redirect='admin_database.upgrade', + error_name='database_error' +) def do_upgrade() -> Response: BrickSQL(failsafe=True).upgrade() @@ -125,14 +137,18 @@ def upload() -> str: return render_template( 'admin.html', import_database=True, - error=request.args.get('error') + database_error=request.args.get('database_error') ) # Actually import a database @admin_database_page.route('/import', methods=['POST']) @login_required -@exception_handler(__file__, post_redirect='admin_database.upload') +@exception_handler( + __file__, + post_redirect='admin_database.upload', + error_name='database_error' +) def do_upload() -> Response: file = upload_helper( 'database', @@ -153,7 +169,7 @@ def do_upload() -> Response: # Upgrade the database @admin_database_page.route('/upgrade', methods=['GET']) @login_required -@exception_handler(__file__, post_redirect='admin.admin') +@exception_handler(__file__) def upgrade() -> str | Response: database = BrickSQL(failsafe=True) @@ -166,5 +182,5 @@ def upgrade() -> str | Response: migrations=BrickSQLMigrationList().pending( database.version ), - error=request.args.get('error') + database_error=request.args.get('database_error') ) diff --git a/bricktracker/views/admin/image.py b/bricktracker/views/admin/image.py index 30dce28..85b995d 100644 --- a/bricktracker/views/admin/image.py +++ b/bricktracker/views/admin/image.py @@ -1,5 +1,3 @@ -import logging - from flask import Blueprint, redirect, url_for from flask_login import login_required from werkzeug.wrappers.response import Response @@ -10,8 +8,6 @@ from ...part import BrickPart from ...rebrickable_image import RebrickableImage from ...set import BrickSet -logger = logging.getLogger(__name__) - admin_image_page = Blueprint( 'admin_image', __name__, diff --git a/bricktracker/views/admin/instructions.py b/bricktracker/views/admin/instructions.py index 354782d..90ac201 100644 --- a/bricktracker/views/admin/instructions.py +++ b/bricktracker/views/admin/instructions.py @@ -1,5 +1,3 @@ -import logging - from flask import Blueprint, redirect, url_for from flask_login import login_required from werkzeug.wrappers.response import Response @@ -7,8 +5,6 @@ from werkzeug.wrappers.response import Response from ..exceptions import exception_handler from ...instructions_list import BrickInstructionsList -logger = logging.getLogger(__name__) - admin_instructions_page = Blueprint( 'admin_instructions', __name__, diff --git a/bricktracker/views/admin/owner.py b/bricktracker/views/admin/owner.py new file mode 100644 index 0000000..bfa799e --- /dev/null +++ b/bricktracker/views/admin/owner.py @@ -0,0 +1,84 @@ +from flask import ( + Blueprint, + 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_owner import BrickSetOwner + +admin_owner_page = Blueprint( + 'admin_owner', + __name__, + url_prefix='/admin/owner' +) + + +# Add a metadata owner +@admin_owner_page.route('/add', methods=['POST']) +@login_required +@exception_handler( + __file__, + post_redirect='admin.admin', + error_name='owner_error', + open_owner=True +) +def add() -> Response: + BrickSetOwner().from_form(request.form).insert() + + reload() + + return redirect(url_for('admin.admin', open_owner=True)) + + +# Delete the metadata owner +@admin_owner_page.route('/delete', methods=['GET']) +@login_required +@exception_handler(__file__) +def delete(*, id: str) -> str: + return render_template( + 'admin.html', + delete_owner=True, + owner=BrickSetOwner().select_specific(id), + error=request.args.get('owner_error') + ) + + +# Actually delete the metadata owner +@admin_owner_page.route('/delete', methods=['POST']) +@login_required +@exception_handler( + __file__, + post_redirect='admin_owner.delete', + error_name='owner_error' +) +def do_delete(*, id: str) -> Response: + owner = BrickSetOwner().select_specific(id) + owner.delete() + + reload() + + return redirect(url_for('admin.admin', open_owner=True)) + + +# Rename the metadata owner +@admin_owner_page.route('/rename', methods=['POST']) +@login_required +@exception_handler( + __file__, + post_redirect='admin.admin', + error_name='owner_error', + open_owner=True +) +def rename(*, id: str) -> Response: + owner = BrickSetOwner().select_specific(id) + owner.from_form(request.form).rename() + + reload() + + return redirect(url_for('admin.admin', open_owner=True)) diff --git a/bricktracker/views/admin/purchase_location.py b/bricktracker/views/admin/purchase_location.py new file mode 100644 index 0000000..48e7b7d --- /dev/null +++ b/bricktracker/views/admin/purchase_location.py @@ -0,0 +1,84 @@ +from flask import ( + Blueprint, + 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_purchase_location import BrickSetPurchaseLocation + +admin_purchase_location_page = Blueprint( + 'admin_purchase_location', + __name__, + url_prefix='/admin/purchase_location' +) + + +# Add a metadata purchase location +@admin_purchase_location_page.route('/add', methods=['POST']) +@login_required +@exception_handler( + __file__, + post_redirect='admin.admin', + error_name='purchase_location_error', + open_purchase_location=True +) +def add() -> Response: + BrickSetPurchaseLocation().from_form(request.form).insert() + + reload() + + return redirect(url_for('admin.admin', open_purchase_location=True)) + + +# Delete the metadata purchase location +@admin_purchase_location_page.route('/delete', methods=['GET']) +@login_required +@exception_handler(__file__) +def delete(*, id: str) -> str: + return render_template( + 'admin.html', + delete_purchase_location=True, + purchase_location=BrickSetPurchaseLocation().select_specific(id), + error=request.args.get('purchase_location_error') + ) + + +# Actually delete the metadata purchase location +@admin_purchase_location_page.route('/delete', methods=['POST']) +@login_required +@exception_handler( + __file__, + post_redirect='admin_purchase_location.delete', + error_name='purchase_location_error' +) +def do_delete(*, id: str) -> Response: + purchase_location = BrickSetPurchaseLocation().select_specific(id) + purchase_location.delete() + + reload() + + return redirect(url_for('admin.admin', open_purchase_location=True)) + + +# Rename the metadata purchase location +@admin_purchase_location_page.route('/rename', methods=['POST']) +@login_required +@exception_handler( + __file__, + post_redirect='admin.admin', + error_name='purchase_location_error', + open_purchase_location=True +) +def rename(*, id: str) -> Response: + purchase_location = BrickSetPurchaseLocation().select_specific(id) + purchase_location.from_form(request.form).rename() + + reload() + + return redirect(url_for('admin.admin', open_purchase_location=True)) diff --git a/bricktracker/views/admin/retired.py b/bricktracker/views/admin/retired.py index c3aa2f2..17ae4f0 100644 --- a/bricktracker/views/admin/retired.py +++ b/bricktracker/views/admin/retired.py @@ -1,5 +1,3 @@ -import logging - from flask import Blueprint, redirect, url_for from flask_login import login_required from werkzeug.wrappers.response import Response @@ -7,8 +5,6 @@ from werkzeug.wrappers.response import Response from ..exceptions import exception_handler from ...retired_list import BrickRetiredList -logger = logging.getLogger(__name__) - admin_retired_page = Blueprint( 'admin_retired', __name__, diff --git a/bricktracker/views/admin/set.py b/bricktracker/views/admin/set.py new file mode 100644 index 0000000..6f00910 --- /dev/null +++ b/bricktracker/views/admin/set.py @@ -0,0 +1,20 @@ +from flask import Blueprint, render_template, request +from flask_login import login_required + +from ..exceptions import exception_handler +from ...rebrickable_set_list import RebrickableSetList + +admin_set_page = Blueprint('admin_set', __name__, url_prefix='/admin/set') + + +# Sets that need o be refreshed +@admin_set_page.route('/refresh', methods=['GET']) +@login_required +@exception_handler(__file__) +def refresh() -> str: + return render_template( + 'admin.html', + refresh_set=True, + table_collection=RebrickableSetList().need_refresh(), + set_error=request.args.get('set_error') + ) diff --git a/bricktracker/views/admin/status.py b/bricktracker/views/admin/status.py new file mode 100644 index 0000000..49037f3 --- /dev/null +++ b/bricktracker/views/admin/status.py @@ -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_status import BrickSetStatus + +admin_status_page = Blueprint( + 'admin_status', + __name__, + url_prefix='/admin/status' +) + + +# Add a metadata status +@admin_status_page.route('/add', methods=['POST']) +@login_required +@exception_handler( + __file__, + post_redirect='admin.admin', + error_name='status_error', + open_status=True +) +def add() -> Response: + BrickSetStatus().from_form(request.form).insert() + + reload() + + return redirect(url_for('admin.admin', open_status=True)) + + +# Delete the metadata status +@admin_status_page.route('/delete', methods=['GET']) +@login_required +@exception_handler(__file__) +def delete(*, id: str) -> str: + return render_template( + 'admin.html', + delete_status=True, + status=BrickSetStatus().select_specific(id), + status_error=request.args.get('status_error') + ) + + +# Actually delete the metadata status +@admin_status_page.route('/delete', methods=['POST']) +@login_required +@exception_handler( + __file__, + post_redirect='admin_status.delete', + error_name='status_error' +) +def do_delete(*, id: str) -> Response: + status = BrickSetStatus().select_specific(id) + status.delete() + + reload() + + return redirect(url_for('admin.admin', open_status=True)) + + +# Change the field of a metadata status +@admin_status_page.route('//field/', methods=['POST']) +@login_required +@exception_handler(__file__, json=True) +def update_field(*, id: str, name: str) -> Response: + status = BrickSetStatus().select_specific(id) + value = status.update_field(name, json=request.json) + + reload() + + return jsonify({'value': value}) + + +# Rename the metadata status +@admin_status_page.route('/rename', methods=['POST']) +@login_required +@exception_handler( + __file__, + post_redirect='admin.admin', + error_name='status_error', + open_status=True +) +def rename(*, id: str) -> Response: + status = BrickSetStatus().select_specific(id) + status.from_form(request.form).rename() + + reload() + + return redirect(url_for('admin.admin', open_status=True)) diff --git a/bricktracker/views/admin/storage.py b/bricktracker/views/admin/storage.py new file mode 100644 index 0000000..7c686bf --- /dev/null +++ b/bricktracker/views/admin/storage.py @@ -0,0 +1,84 @@ +from flask import ( + Blueprint, + 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_storage import BrickSetStorage + +admin_storage_page = Blueprint( + 'admin_storage', + __name__, + url_prefix='/admin/storage' +) + + +# Add a metadata storage +@admin_storage_page.route('/add', methods=['POST']) +@login_required +@exception_handler( + __file__, + post_redirect='admin.admin', + error_name='storage_error', + open_storage=True +) +def add() -> Response: + BrickSetStorage().from_form(request.form).insert() + + reload() + + return redirect(url_for('admin.admin', open_storage=True)) + + +# Delete the metadata storage +@admin_storage_page.route('/delete', methods=['GET']) +@login_required +@exception_handler(__file__) +def delete(*, id: str) -> str: + return render_template( + 'admin.html', + delete_storage=True, + storage=BrickSetStorage().select_specific(id), + error=request.args.get('storage_error') + ) + + +# Actually delete the metadata storage +@admin_storage_page.route('/delete', methods=['POST']) +@login_required +@exception_handler( + __file__, + post_redirect='admin_storage.delete', + error_name='storage_error' +) +def do_delete(*, id: str) -> Response: + storage = BrickSetStorage().select_specific(id) + storage.delete() + + reload() + + return redirect(url_for('admin.admin', open_storage=True)) + + +# Rename the metadata storage +@admin_storage_page.route('/rename', methods=['POST']) +@login_required +@exception_handler( + __file__, + post_redirect='admin.admin', + error_name='storage_error', + open_storage=True +) +def rename(*, id: str) -> Response: + storage = BrickSetStorage().select_specific(id) + storage.from_form(request.form).rename() + + reload() + + return redirect(url_for('admin.admin', open_storage=True)) diff --git a/bricktracker/views/admin/tag.py b/bricktracker/views/admin/tag.py new file mode 100644 index 0000000..d31bc49 --- /dev/null +++ b/bricktracker/views/admin/tag.py @@ -0,0 +1,84 @@ +from flask import ( + Blueprint, + 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_tag import BrickSetTag + +admin_tag_page = Blueprint( + 'admin_tag', + __name__, + url_prefix='/admin/tag' +) + + +# Add a metadata tag +@admin_tag_page.route('/add', methods=['POST']) +@login_required +@exception_handler( + __file__, + post_redirect='admin.admin', + error_name='tag_error', + open_tag=True +) +def add() -> Response: + BrickSetTag().from_form(request.form).insert() + + reload() + + return redirect(url_for('admin.admin', open_tag=True)) + + +# Delete the metadata tag +@admin_tag_page.route('/delete', methods=['GET']) +@login_required +@exception_handler(__file__) +def delete(*, id: str) -> str: + return render_template( + 'admin.html', + delete_tag=True, + tag=BrickSetTag().select_specific(id), + error=request.args.get('tag_error') + ) + + +# Actually delete the metadata tag +@admin_tag_page.route('/delete', methods=['POST']) +@login_required +@exception_handler( + __file__, + post_redirect='admin_tag.delete', + error_name='tag_error' +) +def do_delete(*, id: str) -> Response: + tag = BrickSetTag().select_specific(id) + tag.delete() + + reload() + + return redirect(url_for('admin.admin', open_tag=True)) + + +# Rename the metadata tag +@admin_tag_page.route('/rename', methods=['POST']) +@login_required +@exception_handler( + __file__, + post_redirect='admin.admin', + error_name='tag_error', + open_tag=True +) +def rename(*, id: str) -> Response: + tag = BrickSetTag().select_specific(id) + tag.from_form(request.form).rename() + + reload() + + return redirect(url_for('admin.admin', open_tag=True)) diff --git a/bricktracker/views/admin/theme.py b/bricktracker/views/admin/theme.py index d5f15bb..ca9511a 100644 --- a/bricktracker/views/admin/theme.py +++ b/bricktracker/views/admin/theme.py @@ -1,5 +1,3 @@ -import logging - from flask import Blueprint, redirect, url_for from flask_login import login_required from werkzeug.wrappers.response import Response @@ -7,8 +5,6 @@ from werkzeug.wrappers.response import Response from ..exceptions import exception_handler from ...theme_list import BrickThemeList -logger = logging.getLogger(__name__) - admin_theme_page = Blueprint( 'admin_theme', __name__, diff --git a/bricktracker/views/error.py b/bricktracker/views/error.py index c034ea8..3a1c742 100644 --- a/bricktracker/views/error.py +++ b/bricktracker/views/error.py @@ -1,7 +1,7 @@ import logging from sqlite3 import Error, OperationalError import traceback -from typing import Tuple +from typing import Any, Tuple from flask import jsonify, redirect, request, render_template, url_for from werkzeug.wrappers.response import Response @@ -33,12 +33,16 @@ def error( *, json: bool = False, post_redirect: str | None = None, + error_name: str = 'error', **kwargs, ) -> str | Tuple[str | Response, int] | Response: # Back to the index if no error (not sure if this can happen) if error is None: if json: - return jsonify({'error': 'error() called without an error'}) + return json_error( + 'error() called without an error', + error_name=error_name + ) else: return redirect(url_for('index.index')) @@ -56,6 +60,7 @@ def error( error, json=json, post_redirect=post_redirect, + error_name=error_name, **kwargs ) @@ -71,13 +76,17 @@ def error( logger.debug(cleaned_exception(error)) if json: - return jsonify({'error': str(error)}) + return json_error( + str(error), + error_name=error_name + ) elif post_redirect is not None: - return redirect(url_for( + return redirect_error( post_redirect, error=str(error), + error_name=error_name, **kwargs, - )) + ) else: return render_template( 'error.html', @@ -96,18 +105,20 @@ def error( line = None if json: - return jsonify({ - 'error': 'Exception: {error}'.format(error=str(error)), - 'name': type(error).__name__, - 'line': line, - 'file': file, - }), 500 + return json_error( + 'Exception: {error}'.format(error=str(error)), + error_name=error_name, + name=type(error).__name__, + line=line, + file=file + ), 500 elif post_redirect is not None: - return redirect(url_for( + return redirect_error( post_redirect, error=str(error), + error_name=error_name, **kwargs, - )) + ) else: return render_template( 'exception.html', @@ -125,8 +136,9 @@ def error_404( *, json: bool = False, post_redirect: str | None = None, + error_name: str = 'error', **kwargs, -) -> Tuple[str | Response, int]: +) -> Response | Tuple[str | Response, int]: # Warning logger.warning('Not found: {error} (path: {path})'.format( error=str(error), @@ -134,14 +146,44 @@ def error_404( )) if json: - return jsonify({ - 'error': 'Not found: {error}'.format(error=str(error)) - }), 404 + return json_error( + 'Not found: {error}'.format(error=str(error)), + error_name=error_name + ), 404 elif post_redirect is not None: - return redirect(url_for( + return redirect_error( post_redirect, error=str(error), - **kwargs - )), 404 + error_name=error_name, + **kwargs, + ) else: return render_template('404.html', error=str(error)), 404 + + +# JSON error with parametric error name +def json_error( + error: str, + error_name: str = 'error', + **parameters: Any +) -> Response: + parameters[error_name] = error + + return jsonify(parameters) + + +# Redirect error with parametric error name +def redirect_error( + url: str, + error: str, + error_name: str = 'error', + **kwargs +) -> Response: + error_parameter: dict[str, str] = {} + error_parameter[error_name] = str(error) + + return redirect(url_for( + url, + **error_parameter, + **kwargs + )) diff --git a/bricktracker/views/exceptions.py b/bricktracker/views/exceptions.py index b78c390..e51b66b 100644 --- a/bricktracker/views/exceptions.py +++ b/bricktracker/views/exceptions.py @@ -1,13 +1,10 @@ from functools import wraps -import logging from typing import Callable, ParamSpec, Tuple, Union from werkzeug.wrappers.response import Response from .error import error -logger = logging.getLogger(__name__) - # Decorator type hinting is hard. # What a view can return (str or Response or (Response, xxx)) ViewReturn = Union[ @@ -28,6 +25,7 @@ def exception_handler( *, json: bool = False, post_redirect: str | None = None, + error_name: str = 'error', **superkwargs, ) -> Callable[[ViewCallable], ViewCallable]: def outer(function: ViewCallable, /) -> ViewCallable: @@ -42,6 +40,7 @@ def exception_handler( file, json=json, post_redirect=post_redirect, + error_name=error_name, **kwargs, **superkwargs, ) diff --git a/bricktracker/views/index.py b/bricktracker/views/index.py index c1f0811..1bf7c22 100644 --- a/bricktracker/views/index.py +++ b/bricktracker/views/index.py @@ -2,8 +2,8 @@ from flask import Blueprint, render_template from .exceptions import exception_handler from ..minifigure_list import BrickMinifigureList -from ..set_checkbox_list import BrickSetCheckboxList -from ..set_list import BrickSetList +from ..set_status_list import BrickSetStatusList +from ..set_list import BrickSetList, set_metadata_lists index_page = Blueprint('index', __name__) @@ -15,6 +15,7 @@ def index() -> str: return render_template( 'index.html', brickset_collection=BrickSetList().last(), + brickset_statuses=BrickSetStatusList.list(), minifigure_collection=BrickMinifigureList().last(), - brickset_checkboxes=BrickSetCheckboxList().list(), + **set_metadata_lists(as_class=True) ) diff --git a/bricktracker/views/minifigure.py b/bricktracker/views/minifigure.py index 29c5822..7123e4a 100644 --- a/bricktracker/views/minifigure.py +++ b/bricktracker/views/minifigure.py @@ -3,7 +3,7 @@ from flask import Blueprint, render_template from .exceptions import exception_handler from ..minifigure import BrickMinifigure from ..minifigure_list import BrickMinifigureList -from ..set_list import BrickSetList +from ..set_list import BrickSetList, set_metadata_lists minifigure_page = Blueprint('minifigure', __name__, url_prefix='/minifigures') @@ -19,12 +19,14 @@ def list() -> str: # Minifigure details -@minifigure_page.route('//details') +@minifigure_page.route('/
/details') @exception_handler(__file__) -def details(*, number: str) -> str: +def details(*, figure: str) -> str: return render_template( 'minifigure.html', - item=BrickMinifigure().select_generic(number), - using=BrickSetList().using_minifigure(number), - missing=BrickSetList().missing_minifigure(number), + item=BrickMinifigure().select_generic(figure), + using=BrickSetList().using_minifigure(figure), + missing=BrickSetList().missing_minifigure(figure), + damaged=BrickSetList().damaged_minifigure(figure), + **set_metadata_lists(as_class=True) ) diff --git a/bricktracker/views/part.py b/bricktracker/views/part.py index 2505122..fc800c4 100644 --- a/bricktracker/views/part.py +++ b/bricktracker/views/part.py @@ -4,7 +4,7 @@ from .exceptions import exception_handler from ..minifigure_list import BrickMinifigureList from ..part import BrickPart from ..part_list import BrickPartList -from ..set_list import BrickSetList +from ..set_list import BrickSetList, set_metadata_lists part_page = Blueprint('part', __name__, url_prefix='/parts') @@ -19,42 +19,50 @@ def list() -> str: ) -# Missing -@part_page.route('/missing', methods=['GET']) +# Problem +@part_page.route('/problem', methods=['GET']) @exception_handler(__file__) -def missing() -> str: +def problem() -> str: return render_template( - 'missing.html', - table_collection=BrickPartList().missing() + 'problem.html', + table_collection=BrickPartList().problem() ) # Part details -@part_page.route('///details', defaults={'element': None}, methods=['GET']) # noqa: E501 -@part_page.route('////details', methods=['GET']) # noqa: E501 +@part_page.route('///details', methods=['GET']) # noqa: E501 @exception_handler(__file__) -def details(*, number: str, color: int, element: int | None) -> str: +def details(*, part: str, color: int) -> str: + brickpart = BrickPart().select_generic(part, color) + return render_template( 'part.html', - item=BrickPart().select_generic(number, color, element_id=element), + item=brickpart, sets_using=BrickSetList().using_part( - number, - color, - element_id=element + part, + color ), sets_missing=BrickSetList().missing_part( - number, - color, - element_id=element + part, + color + ), + sets_damaged=BrickSetList().damaged_part( + part, + color ), minifigures_using=BrickMinifigureList().using_part( - number, - color, - element_id=element + part, + color ), minifigures_missing=BrickMinifigureList().missing_part( - number, - color, - element_id=element + part, + color ), + minifigures_damaged=BrickMinifigureList().damaged_part( + part, + color + ), + different_color=BrickPartList().with_different_color(brickpart), + similar_prints=BrickPartList().from_print(brickpart), + **set_metadata_lists(as_class=True) ) diff --git a/bricktracker/views/set.py b/bricktracker/views/set.py index b36f7e1..1ffec55 100644 --- a/bricktracker/views/set.py +++ b/bricktracker/views/set.py @@ -2,6 +2,7 @@ import logging from flask import ( Blueprint, + current_app, jsonify, render_template, redirect, @@ -12,11 +13,18 @@ from flask_login import login_required from werkzeug.wrappers.response import Response from .exceptions import exception_handler +from ..exceptions import ErrorException from ..minifigure import BrickMinifigure from ..part import BrickPart +from ..rebrickable_set import RebrickableSet from ..set import BrickSet -from ..set_checkbox_list import BrickSetCheckboxList -from ..set_list import BrickSetList +from ..set_list import BrickSetList, set_metadata_lists +from ..set_owner_list import BrickSetOwnerList +from ..set_purchase_location_list import BrickSetPurchaseLocationList +from ..set_status_list import BrickSetStatusList +from ..set_storage_list import BrickSetStorageList +from ..set_tag_list import BrickSetTagList +from ..socket import MESSAGES logger = logging.getLogger(__name__) @@ -30,55 +38,134 @@ def list() -> str: return render_template( 'sets.html', collection=BrickSetList().all(), - brickset_checkboxes=BrickSetCheckboxList().list(), + brickset_statuses=BrickSetStatusList.list(), + **set_metadata_lists(as_class=True) ) -# Change the status of a checkbox -@set_page.route('//status/', methods=['POST']) +# Change the value of purchase date +@set_page.route('//purchase_date', methods=['POST']) @login_required @exception_handler(__file__, json=True) -def update_status(*, id: str, checkbox_id: str) -> Response: - value: bool = request.json.get('value', False) # type: ignore - +def update_purchase_date(*, id: str) -> Response: brickset = BrickSet().select_light(id) - checkbox = BrickSetCheckboxList().get(checkbox_id) - brickset.update_status(checkbox, value) - - # Info - logger.info('Set {number} ({id}): status "{status}" changed to "{state}"'.format( # noqa: E501 - number=brickset.fields.set, - id=brickset.fields.id, - status=checkbox.fields.name, - state=value, - )) + value = brickset.update_purchase_date(request.json) return jsonify({'value': value}) +# Change the value of purchase location +@set_page.route('//purchase_location', methods=['POST']) +@login_required +@exception_handler(__file__, json=True) +def update_purchase_location(*, id: str) -> Response: + brickset = BrickSet().select_light(id) + purchase_location = BrickSetPurchaseLocationList.get( + request.json.get('value', ''), # type: ignore + allow_none=True + ) + + value = purchase_location.update_set_value( + brickset, + value=purchase_location.fields.id + ) + + return jsonify({'value': value}) + + +# Change the value of purchase price +@set_page.route('//purchase_price', methods=['POST']) +@login_required +@exception_handler(__file__, json=True) +def update_purchase_price(*, id: str) -> Response: + brickset = BrickSet().select_light(id) + + value = brickset.update_purchase_price(request.json) + + return jsonify({'value': value}) + + +# Change the state of a owner +@set_page.route('//owner/', methods=['POST']) +@login_required +@exception_handler(__file__, json=True) +def update_owner(*, id: str, metadata_id: str) -> Response: + brickset = BrickSet().select_light(id) + owner = BrickSetOwnerList.get(metadata_id) + + state = owner.update_set_state(brickset, json=request.json) + + return jsonify({'value': state}) + + +# Change the state of a status +@set_page.route('//status/', methods=['POST']) +@login_required +@exception_handler(__file__, json=True) +def update_status(*, id: str, metadata_id: str) -> Response: + brickset = BrickSet().select_light(id) + status = BrickSetStatusList.get(metadata_id) + + state = status.update_set_state(brickset, json=request.json) + + return jsonify({'value': state}) + + +# Change the value of storage +@set_page.route('//storage', methods=['POST']) +@login_required +@exception_handler(__file__, json=True) +def update_storage(*, id: str) -> Response: + brickset = BrickSet().select_light(id) + storage = BrickSetStorageList.get( + request.json.get('value', ''), # type: ignore + allow_none=True + ) + + value = storage.update_set_value(brickset, value=storage.fields.id) + + return jsonify({'value': value}) + + +# Change the state of a tag +@set_page.route('//tag/', methods=['POST']) +@login_required +@exception_handler(__file__, json=True) +def update_tag(*, id: str, metadata_id: str) -> Response: + brickset = BrickSet().select_light(id) + tag = BrickSetTagList.get(metadata_id) + + state = tag.update_set_state(brickset, json=request.json) + + return jsonify({'value': state}) + + # Ask for deletion of a set @set_page.route('//delete', methods=['GET']) @login_required @exception_handler(__file__) def delete(*, id: str) -> str: return render_template( - 'delete.html', + 'set.html', + delete=True, item=BrickSet().select_specific(id), error=request.args.get('error'), + **set_metadata_lists(as_class=True) ) # Actually delete of a set @set_page.route('//delete', methods=['POST']) +@login_required @exception_handler(__file__, post_redirect='set.delete') def do_delete(*, id: str) -> Response: brickset = BrickSet().select_light(id) brickset.delete() # Info - logger.info('Set {number} ({id}): deleted'.format( - number=brickset.fields.set, + logger.info('Set {set} ({id}): deleted'.format( + set=brickset.fields.set, id=brickset.fields.id, )) @@ -87,6 +174,7 @@ def do_delete(*, id: str) -> Response: # Set is deleted @set_page.route('//deleted', methods=['GET']) +@login_required @exception_handler(__file__) def deleted(*, id: str) -> str: return render_template( @@ -103,62 +191,75 @@ def details(*, id: str) -> str: 'set.html', item=BrickSet().select_specific(id), open_instructions=request.args.get('open_instructions'), - brickset_checkboxes=BrickSetCheckboxList().list(all=True), + brickset_statuses=BrickSetStatusList.list(all=True), + **set_metadata_lists(as_class=True) ) -# Update the missing pieces of a minifig part -@set_page.route('//minifigures//parts//missing', methods=['POST']) # noqa: E501 +# Update problematic pieces of a set +@set_page.route('//parts////', defaults={'figure': None}, methods=['POST']) # noqa: E501 +@set_page.route('//minifigures/
/parts////', methods=['POST']) # noqa: E501 @login_required @exception_handler(__file__, json=True) -def missing_minifigure_part( +def problem_part( *, id: str, - minifigure_id: str, - part_id: str + figure: str | None, + part: str, + color: int, + spare: int, + problem: str, ) -> Response: brickset = BrickSet().select_specific(id) - minifigure = BrickMinifigure().select_specific(brickset, minifigure_id) - part = BrickPart().select_specific( + + if figure is not None: + brickminifigure = BrickMinifigure().select_specific(brickset, figure) + else: + brickminifigure = None + + brickpart = BrickPart().select_specific( brickset, - part_id, - minifigure=minifigure, + part, + color, + spare, + minifigure=brickminifigure, ) - missing = request.json.get('missing', '') # type: ignore - - part.update_missing(missing) + amount = brickpart.update_problem(problem, request.json) # Info - logger.info('Set {number} ({id}): updated minifigure ({minifigure}) part ({part}) missing count to {missing}'.format( # noqa: E501 - number=brickset.fields.set, + logger.info('Set {set} ({id}): updated part ({part} color: {color}, spare: {spare}, minifigure: {figure}) {problem} count to {amount}'.format( # noqa: E501 + set=brickset.fields.set, id=brickset.fields.id, - minifigure=minifigure.fields.fig_num, - part=part.fields.id, - missing=missing, + figure=figure, + part=brickpart.fields.part, + color=brickpart.fields.color, + spare=brickpart.fields.spare, + problem=problem, + amount=amount )) - return jsonify({'missing': missing}) + return jsonify({problem: amount}) -# Update the missing pieces of a part -@set_page.route('//parts//missing', methods=['POST']) +# Refresh a set +@set_page.route('/refresh//', methods=['GET']) +@set_page.route('//refresh', methods=['GET']) @login_required -@exception_handler(__file__, json=True) -def missing_part(*, id: str, part_id: str) -> Response: - brickset = BrickSet().select_specific(id) - part = BrickPart().select_specific(brickset, part_id) +@exception_handler(__file__) +def refresh(*, id: str | None = None, set: str | None = None) -> str: + if id is not None: + item = BrickSet().select_specific(id) + elif set is not None: + item = RebrickableSet().select_specific(set) + else: + raise ErrorException('Could not load any set to refresh') - missing = request.json.get('missing', '') # type: ignore - - part.update_missing(missing) - - # Info - logger.info('Set {number} ({id}): updated part ({part}) missing count to {missing}'.format( # noqa: E501 - number=brickset.fields.set, - id=brickset.fields.id, - part=part.fields.id, - missing=missing, - )) - - return jsonify({'missing': missing}) + return render_template( + 'refresh.html', + id=id, + item=item, + path=current_app.config['SOCKET_PATH'], + namespace=current_app.config['SOCKET_NAMESPACE'], + messages=MESSAGES + ) diff --git a/bricktracker/views/storage.py b/bricktracker/views/storage.py new file mode 100644 index 0000000..7d5ba3f --- /dev/null +++ b/bricktracker/views/storage.py @@ -0,0 +1,32 @@ +from flask import Blueprint, render_template + +from .exceptions import exception_handler +from ..set_list import BrickSetList, set_metadata_lists +from ..set_storage import BrickSetStorage +from ..set_storage_list import BrickSetStorageList + +storage_page = Blueprint('storage', __name__, url_prefix='/storages') + + +# Index +@storage_page.route('/', methods=['GET']) +@exception_handler(__file__) +def list() -> str: + return render_template( + 'storages.html', + table_collection=BrickSetStorageList.all(), + ) + + +# Storage details +@storage_page.route('//details') +@exception_handler(__file__) +def details(*, id: str) -> str: + storage = BrickSetStorage().select_specific(id) + + return render_template( + 'storage.html', + item=storage, + sets=BrickSetList().using_storage(storage), + **set_metadata_lists(as_class=True) + ) diff --git a/bricktracker/views/wish.py b/bricktracker/views/wish.py index b0c763b..416b900 100644 --- a/bricktracker/views/wish.py +++ b/bricktracker/views/wish.py @@ -1,4 +1,11 @@ -from flask import Blueprint, redirect, render_template, request, url_for +from flask import ( + Blueprint, + jsonify, + redirect, + render_template, + request, + url_for +) from flask_login import login_required from werkzeug.wrappers.response import Response @@ -6,8 +13,10 @@ from .exceptions import exception_handler from ..retired_list import BrickRetiredList from ..wish import BrickWish from ..wish_list import BrickWishList +from ..wish_owner_list import BrickWishOwnerList -wish_page = Blueprint('wish', __name__, url_prefix='/wishlist') + +wish_page = Blueprint('wish', __name__, url_prefix='/wishes') # Index @@ -18,7 +27,8 @@ def list() -> str: 'wishes.html', table_collection=BrickWishList().all(), retired=BrickRetiredList(), - error=request.args.get('error') + error=request.args.get('error'), + owners=BrickWishOwnerList.list(), ) @@ -27,21 +37,60 @@ def list() -> str: @login_required @exception_handler(__file__, post_redirect='wish.list') def add() -> Response: - # Grab the set number - number: str = request.form.get('number', '') + # Grab the set + set: str = request.form.get('set', '') - if number != '': - BrickWishList.add(number) + if set != '': + BrickWishList.add(set) return redirect(url_for('wish.list')) -# Delete a set from the wishlit -@wish_page.route('/delete/', methods=['POST']) +# Ask for deletion of a wish +@wish_page.route('//delete', methods=['GET']) +@login_required +@exception_handler(__file__) +def delete(*, set: str) -> str: + return render_template( + 'wish.html', + delete=True, + item=BrickWish().select_specific(set), + error=request.args.get('error'), + owners=BrickWishOwnerList.list(), + ) + + +# Actually delete of a set +@wish_page.route('//delete', methods=['POST']) @login_required @exception_handler(__file__, post_redirect='wish.list') -def delete(*, number: str) -> Response: - brickwish = BrickWish().select_specific(number) +def do_delete(*, set: str) -> Response: + brickwish = BrickWish().select_specific(set) brickwish.delete() return redirect(url_for('wish.list')) + + +# Details +@wish_page.route('//details', methods=['GET']) +@exception_handler(__file__) +def details(*, set: str) -> str: + return render_template( + 'wish.html', + item=BrickWish().select_specific(set), + retired=BrickRetiredList(), + owners=BrickWishOwnerList.list(), + ) + + +# Change the state of a owner +@wish_page.route('//owner/', methods=['POST']) +@login_required +@exception_handler(__file__, json=True) +def update_owner(*, set: str, metadata_id: str) -> Response: + brickwish = BrickWish().select_specific(set) + owner = BrickWishOwnerList.get(metadata_id) + + state = owner.update_wish_state(brickwish, json=request.json) + + return jsonify({'value': state}) diff --git a/bricktracker/wish.py b/bricktracker/wish.py index 1e301fa..502792f 100644 --- a/bricktracker/wish.py +++ b/bricktracker/wish.py @@ -5,6 +5,7 @@ from flask import url_for from .exceptions import NotFoundException from .rebrickable_set import RebrickableSet from .sql import BrickSQL +from .wish_owner_list import BrickWishOwnerList # Lego brick wished set @@ -16,11 +17,11 @@ class BrickWish(RebrickableSet): select_query: str = 'wish/select' insert_query: str = 'wish/insert' - # Delete a wished set + # Delete a wish def delete(self, /) -> None: - BrickSQL().execute_and_commit( + BrickSQL().executescript( 'wish/delete/wish', - parameters=self.sql_parameters() + set=self.fields.set ) # Select a specific part (with a set and an id) @@ -29,15 +30,23 @@ class BrickWish(RebrickableSet): self.fields.set = set # Load from database - if not self.select(): + if not self.select(owners=BrickWishOwnerList.as_columns()): raise NotFoundException( - 'Wish with number {number} was not found in the database'.format( # noqa: E501 - number=self.fields.set, + 'Wish for set {set} was not found in the database'.format( # noqa: E501 + set=self.fields.set, ), ) return self + # Self url + def url(self, /) -> str: + return url_for('wish.details', set=self.fields.set) + # Deletion url def url_for_delete(self, /) -> str: - return url_for('wish.delete', number=self.fields.set) + return url_for('wish.delete', set=self.fields.set) + + # Actual deletion url + def url_for_do_delete(self, /) -> str: + return url_for('wish.do_delete', set=self.fields.set) diff --git a/bricktracker/wish_list.py b/bricktracker/wish_list.py index 880021b..d3038b8 100644 --- a/bricktracker/wish_list.py +++ b/bricktracker/wish_list.py @@ -9,6 +9,7 @@ from .rebrickable import Rebrickable from .rebrickable_image import RebrickableImage from .record_list import BrickRecordList from .wish import BrickWish +from .wish_owner_list import BrickWishOwnerList logger = logging.getLogger(__name__) @@ -22,7 +23,8 @@ class BrickWishList(BrickRecordList[BrickWish]): def all(self, /) -> Self: # Load the wished sets from the database for record in self.select( - order=current_app.config['WISHES_DEFAULT_ORDER'] + order=current_app.config['WISHES_DEFAULT_ORDER'], + owners=BrickWishOwnerList.as_columns(), ): brickwish = BrickWish(record=record) diff --git a/bricktracker/wish_owner.py b/bricktracker/wish_owner.py new file mode 100644 index 0000000..c31d2c9 --- /dev/null +++ b/bricktracker/wish_owner.py @@ -0,0 +1,70 @@ +import logging +from typing import Any, TYPE_CHECKING + +from flask import url_for + +from .exceptions import DatabaseException +from .metadata import BrickMetadata +from .sql import BrickSQL +if TYPE_CHECKING: + from .wish import BrickWish + +logger = logging.getLogger(__name__) + + +# Lego wish owner metadata +class BrickWishOwner(BrickMetadata): + kind: str = 'owner' + + # Wish state endpoint + wish_state_endpoint: str = 'wish.update_owner' + + # Queries + update_wish_state_query: str = 'wish/metadata/owner/update/state' + + # Update the selected state of this metadata item for a wish + def update_wish_state( + self, + brickset: 'BrickWish', + /, + *, + json: Any | None = None, + state: Any | None = None + ) -> Any: + if state is None and json is not None: + state = json.get('value', False) + + parameters = self.sql_parameters() + parameters['set'] = brickset.fields.set + parameters['state'] = state + + rows, _ = BrickSQL().execute_and_commit( + self.update_wish_state_query, + parameters=parameters, + name=self.as_column(), + ) + + if rows != 1: + raise DatabaseException('Could not update the {kind} "{name}" state for wish {set}'.format( # noqa: E501 + kind=self.kind, + name=self.fields.name, + set=brickset.fields.set, + )) + + # Info + logger.info('{kind} "{name}" state changed to "{state}" for wish {set}'.format( # noqa: E501 + kind=self.kind, + name=self.fields.name, + state=state, + set=brickset.fields.set, + )) + + return state + + # URL to change the selected state of this metadata item for a wish + def url_for_wish_state(self, set: str, /) -> str: + return url_for( + self.wish_state_endpoint, + set=set, + metadata_id=self.fields.id + ) diff --git a/bricktracker/wish_owner_list.py b/bricktracker/wish_owner_list.py new file mode 100644 index 0000000..719fc6f --- /dev/null +++ b/bricktracker/wish_owner_list.py @@ -0,0 +1,21 @@ +from typing import Self + +from .metadata_list import BrickMetadataList +from .wish_owner import BrickWishOwner + + +# Lego sets owner list +class BrickWishOwnerList(BrickMetadataList[BrickWishOwner]): + kind: str = 'wish owners' + + # Database + table: str = 'bricktracker_wish_owners' + order: str = '"bricktracker_metadata_owners"."name"' + + # Queries + select_query = 'set/metadata/owner/list' + + # Instantiate the list with the proper class + @classmethod + def new(cls, /, *, force: bool = False) -> Self: + return cls(BrickWishOwner, force=force) diff --git a/compose.legacy.yml b/compose.legacy.yml index 0647bcb..2d48e3b 100644 --- a/compose.legacy.yml +++ b/compose.legacy.yml @@ -2,7 +2,7 @@ services: bricktracker: container_name: BrickTracker restart: unless-stopped - image: gitea.baerentsen.space/frederikbaerentsen/bricktracker:1.1.1 + image: gitea.baerentsen.space/frederikbaerentsen/bricktracker:1.2.0 ports: - "3333:3333" volumes: diff --git a/compose.yaml b/compose.yaml index 861b8a9..6c38ed9 100644 --- a/compose.yaml +++ b/compose.yaml @@ -2,7 +2,7 @@ services: bricktracker: container_name: BrickTracker restart: unless-stopped - image: gitea.baerentsen.space/frederikbaerentsen/bricktracker:1.1.1 + image: gitea.baerentsen.space/frederikbaerentsen/bricktracker:1.2.0 ports: - "3333:3333" volumes: diff --git a/docs/DOCS.md b/docs/DOCS.md index 28deffe..f13165d 100644 --- a/docs/DOCS.md +++ b/docs/DOCS.md @@ -15,7 +15,7 @@ This page helps you navigate the documentation of BrickTracker. - [First steps](first-steps.md) - [Managing your sets](set.md) -- [Managing your set checkboxes](checkbox.md) +- [Managing your set statuses](set-statuses.md) ## Specific procedures diff --git a/docs/checkbox.md b/docs/checkbox.md deleted file mode 100644 index 3071e67..0000000 --- a/docs/checkbox.md +++ /dev/null @@ -1,58 +0,0 @@ -# Manage your set chechboxes - -> **Note** -> The following page is based on version `1.1.0` of BrickTracker. - -They are useful to store "yes/no" info about a set and quickly set it. Once clicked the change is immediatly stored in the database. A visual indicator tells you the change was succesful. - -![](images/checkbox-01.png) - -## Default checkboxes - -The original version of BrickTracker defined the following checkboxes - -- Minifigures are collected -- Set is checked -- Set is collected and boxed - -## Visibility - -The checkboxes are **never visible** on the front page. The display here tries to be as minimalistic as possible. - -Prior to version `1.1.0`, the checkboxes were visible both on the Grid view (**Sets**) and the details of a set. - -![](images/checkbox-02.png) -![](images/checkbox-03.png) - -From version `1.1.0`, it is possible to decide if a checkbox is visible from the Grid or not. It will always be visible in a set details. - -### Change the visibility of a checkbox - -To change the visibility of a checkbox, head to the **Admin page** and open the **Checkboxes** section. - -![](images/checkbox-04.png) - -Simply click on the **Displayed on the Set Grid** checkbox to select whether it is displayed or not. The change is immediately saved to the database. - -![](images/checkbox-05.png) - -In this example, we have decided to have no checkbox visible on the Grid view. - -![](images/checkbox-06.png) - -## Management - -Starting version `1.1.0`, you can manage the checkboxes for the **Checkboxes** section of the **Admin page**. - -![](images/checkbox-04.png) - -From there you can do the following: - -- Add a new checkbox: use the last line of the list and press the **Add** button -- Rename an existing checkbox: use the **Name** field to change the name and press the **Rename** button -- Change the Grid display of an existing checkbox: tick or untick the **Displayed on the Set Grid** checkbox -- Delete an existing checkbox: use the **Delete** button and confirm on the following screen - -It is possible to delete all the checkboxes, they are an optional component of a set. - -![](images/checkbox-07.png) diff --git a/docs/development.md b/docs/development.md index 6799be0..dd3beee 100644 --- a/docs/development.md +++ b/docs/development.md @@ -16,10 +16,12 @@ It uses the following Python/pip packages: It also uses the following libraries and frameworks: - Boostrap (https://getbootstrap.com/) +- Remixicon (https://remixicon.com/) - `baguettebox` (https://github.com/feimosi/baguetteBox.js) - `tinysort` (https://github.com/Sjeiti/TinySort) - `sortable` (https://github.com/tofsjonas/sortable) - `simple-datatables` (https://github.com/fiduswriter/simple-datatables) +- `vanillajs-datepicker` (https://github.com/mymth/vanillajs-datepicker) The BrickTracker brick logo is part of the Small n' Flat Icons set designed by [Arnaud Chesne](https://iconduck.com/designers/arnaud-chesne). diff --git a/docs/images/checkbox-01.png b/docs/images/status-01.png similarity index 100% rename from docs/images/checkbox-01.png rename to docs/images/status-01.png diff --git a/docs/images/checkbox-02.png b/docs/images/status-02.png similarity index 100% rename from docs/images/checkbox-02.png rename to docs/images/status-02.png diff --git a/docs/images/checkbox-03.png b/docs/images/status-03.png similarity index 100% rename from docs/images/checkbox-03.png rename to docs/images/status-03.png diff --git a/docs/images/checkbox-04.png b/docs/images/status-04.png similarity index 100% rename from docs/images/checkbox-04.png rename to docs/images/status-04.png diff --git a/docs/images/checkbox-05.png b/docs/images/status-05.png similarity index 100% rename from docs/images/checkbox-05.png rename to docs/images/status-05.png diff --git a/docs/images/checkbox-06.png b/docs/images/status-06.png similarity index 100% rename from docs/images/checkbox-06.png rename to docs/images/status-06.png diff --git a/docs/images/checkbox-07.png b/docs/images/status-07.png similarity index 100% rename from docs/images/checkbox-07.png rename to docs/images/status-07.png diff --git a/docs/set-statuses.md b/docs/set-statuses.md new file mode 100644 index 0000000..8739b41 --- /dev/null +++ b/docs/set-statuses.md @@ -0,0 +1,67 @@ +# Manage your set statuses + +> **Note** +> The following page is based on version `1.1.0` of BrickTracker. + +> **Note** +> On version `1.2.0`, this feature has been renommed from `Checkboxes` to `Set statuses`. It works exactly the same. + +They are useful to store "yes/no" info about a set and quickly set it. Once clicked the change is immediatly stored in the database. A visual indicator tells you the change was succesful. + +![](images/status-01.png) + +## Default statuses + +The original version of BrickTracker defined the following statuses + +- Minifigures are collected +- Set is checked +- Set is collected and boxed + +## Visibility + +The statuses are **never visible** on the front page. The display here tries to be as minimalistic as possible. + +Prior to version `1.1.0`, the statuses were visible both on the Grid view (**Sets**) and the details of a set. + +![](images/status-02.png) +![](images/status-03.png) + +From version `1.1.0`, it is possible to decide if a status is visible from the Grid or not. It will always be visible in a set details. + +### Change the visibility of a status + +> **Note** +> On version `1.2.0`, the Admin page section has been renamed from `Checkboxes` to `Set statuses`. It works exactly the same. + +To change the visibility of a status, head to the **Admin page** and open the **Set statuses** section. + +![](images/status-04.png) + +Simply click on the **Displayed on the Set Grid** status to select whether it is displayed or not. The change is immediately saved to the database. + +![](images/status-05.png) + +In this example, we have decided to have no status visible on the Grid view. + +![](images/status-06.png) + +## Management + +> **Note** +> On version `1.2.0`, the Admin page section has been renamed from `Checkboxes` to `Set statuses`. It works exactly the same. + +Starting version `1.1.0`, you can manage the set statuses for the **Set statuses** section of the **Admin page**. + +![](images/status-04.png) + +From there you can do the following: + +- Add a new set status: use the last line of the list and press the **Add** button +- Rename an existing set status: use the **Name** field to change the name and press the **Rename** button +- Change the Grid display of an existing status: tick or untick the **Displayed on the Set Grid** checkbox +- Delete an existing set status: use the **Delete** button and confirm on the following screen + +It is possible to delete all the set statuses, they are an optional component of a set. + +![](images/status-07.png) diff --git a/static/scripts/changer.js b/static/scripts/changer.js index 224e24b..a32af76 100644 --- a/static/scripts/changer.js +++ b/static/scripts/changer.js @@ -1,10 +1,14 @@ // Generic state changer with visual feedback +// Tooltips requires boostrap.Tooltip +// Date requires vanillajs-datepicker class BrickChanger { constructor(prefix, id, url, parent = undefined) { this.prefix = prefix this.html_element = document.getElementById(`${prefix}-${id}`); + this.html_clear = document.getElementById(`clear-${prefix}-${id}`); this.html_status = document.getElementById(`status-${prefix}-${id}`); - this.html_type = this.html_element.getAttribute("type"); + this.html_status_tooltip = undefined; + this.html_type = undefined; this.url = url; if (parent) { @@ -13,15 +17,55 @@ class BrickChanger { } // Register an event depending on the type - if (this.html_type == "checkbox") { - var listener = "change"; - } else { - var listener = "click"; + let listener = undefined; + switch (this.html_element.tagName) { + case "INPUT": + this.html_type = this.html_element.getAttribute("type"); + + switch (this.html_type) { + case "checkbox": + case "text": + listener = "change"; + break; + + default: + throw Error(`Unsupported input type for BrickChanger: ${this.html_type}`); + } + break; + + case "SELECT": + this.html_type = "select"; + listener = "change"; + break; + + default: + throw Error(`Unsupported HTML tag type for BrickChanger: ${this.html_element.tagName}`); } this.html_element.addEventListener(listener, ((changer) => (e) => { changer.change(); })(this)); + + if (this.html_clear) { + this.html_clear.addEventListener("click", ((changer) => (e) => { + changer.html_element.value = ""; + changer.change(); + })(this)); + } + + // Date picker + this.picker = undefined; + if (this.html_element.dataset.changerDate == "true") { + this.picker = new Datepicker(this.html_element, { + buttonClass: 'btn', + format: 'yyyy/mm/dd', + }); + + // Picker fires a custom "changeDate" event + this.html_element.addEventListener("changeDate", ((changer) => (e) => { + changer.change(); + })(this)); + } } // Clean the status @@ -36,14 +80,24 @@ class BrickChanger { if (to_remove.length) { this.html_status.classList.remove(...to_remove); } + + if (this.html_status_tooltip) { + this.html_status_tooltip.dispose(); + this.html_status_tooltip = undefined; + } } } // Set the status to Error - status_error() { + status_error(message) { if (this.html_status) { this.status_clean(); this.html_status.classList.add("ri-alert-line", "text-danger"); + + this.html_status_tooltip = new bootstrap.Tooltip(this.html_status, { + "title": message, + }) + this.html_status_tooltip.show(); } } @@ -68,10 +122,20 @@ class BrickChanger { this.status_unknown(); // Grab the value depending on the type - if (this.html_type == "checkbox") { - var value = this.html_element.checked; - } else { - var value = this.html_element.value; + let value = undefined; + + switch(this.html_type) { + case "checkbox": + value = this.html_element.checked; + break; + + case "text": + case "select": + value = this.html_element.value; + break; + + default: + throw Error("Unsupported input type for BrickChanger"); } const response = await fetch(this.url, { @@ -86,7 +150,7 @@ class BrickChanger { }); if (!response.ok) { - throw new Error(`Response status: ${response.status}`); + throw new Error(`Response status: ${response.status} (${response.statusText})`); } const json = await response.json(); @@ -109,7 +173,12 @@ class BrickChanger { } catch (error) { console.log(error.message); - this.status_error(); + this.status_error(error.message); + + // Reverse the checked state + if (this.html_type == "checkbox") { + this.html_element.checked = !this.html_element.checked; + } } } } diff --git a/static/scripts/grid.js b/static/scripts/grid.js deleted file mode 100644 index 42b8ac3..0000000 --- a/static/scripts/grid.js +++ /dev/null @@ -1,258 +0,0 @@ -// Sort button -class BrickGridSortButton { - constructor(button, grid) { - this.button = button; - this.grid = grid; - this.data = this.button.dataset; - - // Setup - button.addEventListener("click", ((grid, button) => (e) => { - grid.sort(button); - })(grid, this)); - } - - // Active - active() { - this.button.classList.remove("btn-outline-primary"); - this.button.classList.add("btn-primary"); - } - - // Inactive - inactive() { - delete this.button.dataset.sortOrder; - this.button.classList.remove("btn-primary"); - this.button.classList.add("btn-outline-primary"); - } - - // Toggle sorting - toggle(order) { - // Cleanup - delete this.button.dataset.sortOrder; - - let icon = this.button.querySelector("i.ri"); - if (icon) { - this.button.removeChild(icon); - } - - // Set order - if (order) { - this.active(); - - this.button.dataset.sortOrder = order; - - icon = document.createElement("i"); - icon.classList.add("ri", "ms-1", `ri-sort-${order}`); - - this.button.append(icon); - } - } -} - -// Grid class -class BrickGrid { - constructor(grid) { - this.id = grid.id; - - // Grid elements (built based on the initial id) - this.html_grid = document.getElementById(this.id); - this.html_sort = document.getElementById(`${this.id}-sort`); - this.html_search = document.getElementById(`${this.id}-search`); - this.html_filter = document.getElementById(`${this.id}-filter`); - this.html_theme = document.getElementById(`${this.id}-theme`); - - // Sort buttons - this.html_sort_buttons = {}; - if (this.html_sort) { - this.html_sort.querySelectorAll("button[data-sort-attribute]").forEach(button => { - this.html_sort_buttons[button.id] = new BrickGridSortButton(button, this); - }); - } - - // Clear button - this.html_clear = document.querySelector("button[data-sort-clear]") - if (this.html_clear) { - this.html_clear.addEventListener("click", ((grid) => (e) => { - grid.clear(e.currentTarget) - })(this)) - } - - // Filter setup - if (this.html_search) { - this.html_search.addEventListener("keyup", ((grid) => () => { - grid.filter(); - })(this)); - } - - if (this.html_filter) { - this.html_filter.addEventListener("change", ((grid) => () => { - grid.filter(); - })(this)); - } - - if (this.html_theme) { - this.html_theme.addEventListener("change", ((grid) => () => { - grid.filter(); - })(this)); - } - - // Cookie setup - const cookies = document.cookie.split(";").reduce((acc, cookieString) => { - const [key, value] = cookieString.split("=").map(s => s.trim().replace(/^"|"$/g, "")); - if (key && value) { - acc[key] = decodeURIComponent(value); - } - return acc; - }, {}); - - // Initial sort - if ("sort-id" in cookies && cookies["sort-id"] in this.html_sort_buttons) { - const current = this.html_sort_buttons[cookies["sort-id"]]; - - if("sort-order" in cookies) { - current.button.setAttribute("data-sort-order", cookies["sort-order"]); - } - - this.sort(current, true); - } - } - - // Clear - clear(current) { - // Cleanup all - for (const [id, button] of Object.entries(this.html_sort_buttons)) { - button.toggle(); - button.inactive(); - } - - // Clear cookies - document.cookie = `sort-id=""; Path=/; SameSite=strict`; - document.cookie = `sort-order=""; Path=/; SameSite=strict`; - - // Reset sorting - tinysort(current.dataset.sortTarget, { - selector: "div", - attr: "data-index", - order: "asc", - }); - - } - - // Filter - filter() { - var filters = {}; - - // Check if there is a search filter - if (this.html_search && this.html_search.value != "") { - filters["search"] = this.html_search.value.toLowerCase(); - } - - // Check if there is a set filter - if (this.html_filter && this.html_filter.value != "") { - if (this.html_filter.value.startsWith("-")) { - filters["filter"] = this.html_filter.value.substring(1); - filters["filter-target"] = "0"; - } else { - filters["filter"] = this.html_filter.value; - filters["filter-target"] = "1"; - } - } - - // Check if there is a theme filter - if (this.html_theme && this.html_theme.value != "") { - filters["theme"] = this.html_theme.value; - } - - // Filter all cards - if (this.html_grid) { - const cards = this.html_grid.querySelectorAll("div > div.card"); - cards.forEach(current => { - // Set filter - if ("filter" in filters) { - if (current.getAttribute("data-" + filters["filter"]) != filters["filter-target"]) { - current.parentElement.classList.add("d-none"); - return; - } - } - - // Theme filter - if ("theme" in filters) { - if (current.getAttribute("data-theme") != filters["theme"]) { - current.parentElement.classList.add("d-none"); - return; - } - } - - // Check all searchable fields for a match - if ("search" in filters) { - for (let attribute of ["data-name", "data-number", "data-parts", "data-theme", "data-year"]) { - if (current.getAttribute(attribute).includes(filters["search"])) { - current.parentElement.classList.remove("d-none"); - return; - } - } - - // If no match, we need to hide it - current.parentElement.classList.add("d-none"); - return; - } - - // If we passed all filters, we need to display it - current.parentElement.classList.remove("d-none"); - }) - } - } - - // Sort - sort(current, no_flip=false) { - const target = current.data.sortTarget; - const attribute = current.data.sortAttribute; - const natural = current.data.sortNatural; - - // Cleanup all - for (const [id, button] of Object.entries(this.html_sort_buttons)) { - if (button != current) { - button.toggle(); - button.inactive(); - } - } - - // Sort - if (target && attribute) { - let order = current.data.sortOrder; - - // First ordering - if (!no_flip) { - if(!order) { - if (current.data.sortDesc) { - order = "desc" - } else { - order = "asc" - } - } else { - // Flip the sorting order - order = (order == "desc") ? "asc" : "desc"; - } - } - - // Toggle the ordering - current.toggle(order); - - // Store cookies - document.cookie = `sort-id="${encodeURIComponent(current.button.id)}"; Path=/; SameSite=strict`; - document.cookie = `sort-order="${encodeURIComponent(order)}"; Path=/; SameSite=strict`; - - // Do the sorting - tinysort(target, { - selector: "div", - attr: "data-" + attribute, - natural: natural == "true", - order: order, - }); - } - } -} - -// Helper to setup the grids -const setup_grids = () => document.querySelectorAll('*[data-grid="true"]').forEach( - el => new BrickGrid(el) -); diff --git a/static/scripts/grid/filter.js b/static/scripts/grid/filter.js new file mode 100644 index 0000000..6de37a8 --- /dev/null +++ b/static/scripts/grid/filter.js @@ -0,0 +1,157 @@ +// Grid filter +class BrickGridFilter { + constructor(grid) { + this.grid = grid; + + // Grid sort elements (built based on the initial id) + this.html_search = document.getElementById(`${this.grid.id}-search`); + this.html_search_clear = document.getElementById(`${this.grid.id}-search-clear`); + this.html_filter = document.getElementById(`${this.grid.id}-filter`); + + // Search setup + if (this.html_search) { + // Exact attributes + if (this.html_search.dataset.searchExact) { + this.search_exact = new Set(this.html_search.dataset.searchExact.split(",").map(el => el.trim())); + } else { + this.search_exact = new Set(); + } + + // List attributes + this.search_list = []; + if (this.html_search.dataset.searchList) { + this.search_list = this.html_search.dataset.searchList.split(",").map(el => el.trim()); + } + + this.html_search.addEventListener("keyup", ((gridfilter) => () => { + gridfilter.filter(); + })(this)); + + if (this.html_search_clear) { + this.html_search_clear.addEventListener("click", ((gridfilter) => () => { + this.html_search.value = ''; + gridfilter.filter(); + })(this)); + } + } + + // Filters setup + this.selects = []; + if (this.html_filter) { + this.html_filter.querySelectorAll("select[data-filter]").forEach(select => { + select.addEventListener("change", ((gridfilter) => () => { + gridfilter.filter(); + })(this)); + this.selects.push(select); + }); + } + + if (this.html_theme) { + this.html_theme.addEventListener("change", ((grid) => () => { + grid.filter(); + })(this)); + } + } + + // Filter + filter() { + let options = { + search: undefined, + filters: [], + }; + + // Check if there is a search filter + if (this.html_search && this.html_search.value != "") { + options.search = this.html_search.value.toLowerCase(); + } + + // Build filters + for (const select of this.selects) { + if (select.value != "") { + // Multi-attribute filter + switch (select.dataset.filter) { + // List contains values + case "value": + options.filters.push({ + attribute: select.dataset.filterAttribute, + value: select.value, + }) + break; + + // List contains metadata attribute name, looking for true/false + case "metadata": + if (select.value.startsWith("-")) { + options.filters.push({ + attribute: select.value.substring(1), + bool: true, + value: "0" + }) + } else { + options.filters.push({ + attribute: select.value, + bool: true, + value: "1" + }) + } + break; + } + } + } + + // Filter all cards + const cards = this.grid.html_grid.querySelectorAll(`${this.grid.target} > .card`); + cards.forEach(current => { + // Process all filters + for (const filter of options.filters) { + const attribute = current.getAttribute(`data-${filter.attribute}`); + + // Bool check + // Attribute not equal value, or undefined and value is truthy + if (filter.bool) { + if ((attribute != null && attribute != filter.value) || (attribute == null && filter.value == "1")) { + current.parentElement.classList.add("d-none"); + return; + } + } + + // Value check + // Attribute not equal value, or attribute undefined + else if ((attribute != null && attribute != filter.value) || attribute == null) { + current.parentElement.classList.add("d-none"); + return; + } + } + + // Check all searchable fields for a match + if (options.search) { + // Browse the whole dataset + for (const set in current.dataset) { + // Exact attribute + if (this.search_exact.has(set)) { + if (current.dataset[set].includes(options.search)) { + current.parentElement.classList.remove("d-none"); + return; + } + } else { + // List search + for (const list of this.search_list) { + if (set.startsWith(list)) { + if (current.dataset[set].includes(options.search)) { + current.parentElement.classList.remove("d-none"); + return; + } + } + } + } + } + + // If no match, we need to hide it + current.parentElement.classList.add("d-none"); + return; + } + + // If we passed all filters, we need to display it + current.parentElement.classList.remove("d-none"); + }); + } +} diff --git a/static/scripts/grid/grid.js b/static/scripts/grid/grid.js new file mode 100644 index 0000000..d0b9dab --- /dev/null +++ b/static/scripts/grid/grid.js @@ -0,0 +1,23 @@ +// Grid class +class BrickGrid { + constructor(grid, target = "div#grid>div") { + this.id = grid.id; + this.target = target; + + // Grid elements (built based on the initial id) + this.html_grid = document.getElementById(this.id); + + if (this.html_grid) { + // Sort setup + this.sort = new BrickGridSort(this); + + // Filter setup + this.filter = new BrickGridFilter(this); + } + } +} + +// Helper to setup the grids +const setup_grids = () => document.querySelectorAll('*[data-grid="true"]').forEach( + el => new BrickGrid(el) +); diff --git a/static/scripts/grid/sort.js b/static/scripts/grid/sort.js new file mode 100644 index 0000000..93602fe --- /dev/null +++ b/static/scripts/grid/sort.js @@ -0,0 +1,118 @@ +// Grid sort +class BrickGridSort { + constructor(grid) { + this.grid = grid; + + // Grid sort elements (built based on the initial id) + this.html_sort = document.getElementById(`${this.grid.id}-sort`); + + if (this.html_sort) { + // Cookie names + this.cookie_id = `${this.grid.id}-sort-id`; + this.cookie_order = `${this.grid.id}-sort-order`; + + // Sort buttons + this.html_sort_buttons = {}; + this.html_sort.querySelectorAll("button[data-sort-attribute]").forEach(button => { + this.html_sort_buttons[button.id] = new BrickGridSortButton(button, this); + }); + + // Clear button + this.html_clear = this.html_sort.querySelector("button[data-sort-clear]") + if (this.html_clear) { + this.html_clear.addEventListener("click", ((gridsort) => () => { + gridsort.clear(); + })(this)) + } + + // Cookie setup + const cookies = document.cookie.split(";").reduce((acc, cookieString) => { + const [key, value] = cookieString.split("=").map(s => s.trim().replace(/^"|"$/g, "")); + if (key && value) { + acc[key] = decodeURIComponent(value); + } + return acc; + }, {}); + + // Initial sort + if (this.cookie_id in cookies && cookies[this.cookie_id] in this.html_sort_buttons) { + const current = this.html_sort_buttons[cookies[this.cookie_id]]; + + if(this.cookie_order in cookies) { + current.button.setAttribute("data-sort-order", cookies[this.cookie_order]); + } + + this.sort(current, true); + } + } + } + + // Clear sort + clear() { + // Cleanup all + for (const [id, button] of Object.entries(this.html_sort_buttons)) { + button.toggle(); + button.inactive(); + } + + // Clear cookies + document.cookie = `${this.cookie_id}=""; Path=/; SameSite=strict`; + document.cookie = `${this.cookie_order}=""; Path=/; SameSite=strict`; + + // Reset sorting + tinysort(this.grid.target, { + selector: "div", + attr: "data-index", + order: "asc", + }); + + } + + // Sort + sort(current, no_flip=false) { + const attribute = current.data.sortAttribute; + const natural = current.data.sortNatural; + + // Cleanup all + for (const [id, button] of Object.entries(this.html_sort_buttons)) { + if (button != current) { + button.toggle(); + button.inactive(); + } + } + + // Sort + if (attribute) { + let order = current.data.sortOrder; + + // First ordering + if (!no_flip) { + if(!order) { + if (current.data.sortDesc) { + order = "desc"; + } else { + order = "asc"; + } + } else { + // Flip the sorting order + order = (order == "desc") ? "asc" : "desc"; + } + } + + // Toggle the ordering + current.toggle(order); + + // Store cookies + document.cookie = `${this.cookie_id}="${encodeURIComponent(current.button.id)}"; Path=/; SameSite=strict`; + document.cookie = `${this.cookie_order}="${encodeURIComponent(order)}"; Path=/; SameSite=strict`; + + // Do the sorting + tinysort(this.grid.target, { + selector: "div", + attr: "data-" + attribute, + natural: natural == "true", + order: order, + }); + } + } +} diff --git a/static/scripts/grid/sort_button.js b/static/scripts/grid/sort_button.js new file mode 100644 index 0000000..7d28742 --- /dev/null +++ b/static/scripts/grid/sort_button.js @@ -0,0 +1,49 @@ +// Grid sort button +class BrickGridSortButton { + constructor(button, grid) { + this.button = button; + this.grid = grid; + this.data = this.button.dataset; + + // Setup + button.addEventListener("click", ((grid, button) => (e) => { + grid.sort(button); + })(grid, this)); + } + + // Active + active() { + this.button.classList.remove("btn-outline-primary"); + this.button.classList.add("btn-primary"); + } + + // Inactive + inactive() { + delete this.button.dataset.sortOrder; + this.button.classList.remove("btn-primary"); + this.button.classList.add("btn-outline-primary"); + } + + // Toggle sorting + toggle(order) { + // Cleanup + delete this.button.dataset.sortOrder; + + let icon = this.button.querySelector("i.ri"); + if (icon) { + this.button.removeChild(icon); + } + + // Set order + if (order) { + this.active(); + + this.button.dataset.sortOrder = order; + + icon = document.createElement("i"); + icon.classList.add("ri", "ms-1", `ri-sort-${order}`); + + this.button.append(icon); + } + } +} diff --git a/static/scripts/socket/instructions.js b/static/scripts/socket/instructions.js index 271c0c3..fb19224 100644 --- a/static/scripts/socket/instructions.js +++ b/static/scripts/socket/instructions.js @@ -11,15 +11,9 @@ class BrickInstructionsSocket extends BrickSocket { this.html_files = document.getElementById(`${id}-files`); if (this.html_button) { - this.download_listener = ((bricksocket) => (e) => { - if (!bricksocket.disabled && bricksocket.socket !== undefined && bricksocket.socket.connected) { - bricksocket.toggle(false); - - bricksocket.download_instructions(); - } - })(this); - - this.html_button.addEventListener("click", this.download_listener); + this.download_listener = this.html_button.addEventListener("click", ((bricksocket) => (e) => { + bricksocket.execute(); + })(this)); } if (this.html_card_dismiss && this.html_card) { @@ -43,6 +37,15 @@ class BrickInstructionsSocket extends BrickSocket { this.download_instructions(true); } + // Execute the action + execute() { + if (!this.disabled && this.socket !== undefined && this.socket.connected) { + this.toggle(false); + + this.download_instructions(); + } + } + // Get the list of checkboxes describing files get_files(checked=false) { let files = []; diff --git a/static/scripts/socket/set.js b/static/scripts/socket/set.js index 60a2244..8a4e5bb 100644 --- a/static/scripts/socket/set.js +++ b/static/scripts/socket/set.js @@ -1,16 +1,24 @@ // Set Socket class class BrickSetSocket extends BrickSocket { - constructor(id, path, namespace, messages, bulk=false) { + constructor(id, path, namespace, messages, bulk=false, refresh=false) { super(id, path, namespace, messages, bulk); + // Refresh mode + this.refresh = refresh + // Listeners this.add_listener = undefined; + this.input_listener = undefined; this.confirm_listener = undefined; // Form elements (built based on the initial id) this.html_button = document.getElementById(id); 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_location = document.getElementById(`${id}-purchase-location`); + this.html_storage = document.getElementById(`${id}-storage`); + this.html_tags = document.getElementById(`${id}-tags`); // Card elements this.html_card = document.getElementById(`${id}-card`); @@ -23,24 +31,15 @@ class BrickSetSocket extends BrickSocket { this.html_card_dismiss = document.getElementById(`${id}-card-dismiss`); if (this.html_button) { - this.add_listener = ((bricksocket) => (e) => { - if (!bricksocket.disabled && bricksocket.socket !== undefined && bricksocket.socket.connected) { - bricksocket.toggle(false); + this.add_listener = this.html_button.addEventListener("click", ((bricksocket) => (e) => { + bricksocket.execute(); + })(this)); - // Split and save the list if bulk - if (bricksocket.bulk) { - bricksocket.read_set_list() - } - - if (bricksocket.bulk || (bricksocket.html_no_confim && bricksocket.html_no_confim.checked)) { - bricksocket.import_set(true); - } else { - bricksocket.load_set(); - } + this.input_listener = this.html_input.addEventListener("keyup", ((bricksocket) => (e) => { + if (e.key === 'Enter') { + bricksocket.execute(); } - })(this); - - this.html_button.addEventListener("click", this.add_listener); + })(this)) } if (this.html_card_dismiss && this.html_card) { @@ -80,6 +79,24 @@ class BrickSetSocket extends BrickSocket { } } + // Execute the action + execute() { + if (!this.disabled && this.socket !== undefined && this.socket.connected) { + this.toggle(false); + + // Split and save the list if bulk + if (this.bulk) { + this.read_set_list(); + } + + if (this.bulk || this.refresh || (this.html_no_confim && this.html_no_confim.checked)) { + this.import_set(true); + } else { + this.load_set(); + } + } + } + // Upon receiving a fail message fail(data) { super.fail(data); @@ -126,10 +143,51 @@ class BrickSetSocket extends BrickSocket { this.set_list_last_set = set; } + // Grab the owners + const owners = []; + if (this.html_owners) { + this.html_owners.querySelectorAll('input').forEach(input => { + if (input.checked) { + owners.push(input.value); + } + }); + } + + // Grab the purchase location + let purchase_location = null; + if (this.html_purchase_location) { + purchase_location = this.html_purchase_location.value; + } + + // Grab the storage + let storage = null; + if (this.html_storage) { + storage = this.html_storage.value; + } + + // Grab the tags + const tags = []; + if (this.html_tags) { + this.html_tags.querySelectorAll('input').forEach(input => { + if (input.checked) { + tags.push(input.value); + } + }); + } + this.spinner(true); + if (this.html_progress_bar) { + this.html_progress_bar.scrollIntoView(); + } + this.socket.emit(this.messages.IMPORT_SET, { set: (set !== undefined) ? set : this.html_input.value, + owners: owners, + purchase_location: purchase_location, + storage: storage, + tags: tags, + refresh: this.refresh }); } else { this.fail("Could not find the input field for the set number"); @@ -202,6 +260,8 @@ class BrickSetSocket extends BrickSocket { })(this, data["set"]); this.html_card_confirm.addEventListener("click", this.confirm_listener); + + this.html_card_confirm.scrollIntoView(); } } } @@ -233,6 +293,26 @@ class BrickSetSocket extends BrickSocket { this.html_input.disabled = !enabled; } + if (!this.bulk && this.html_no_confim) { + this.html_no_confim.disabled = !enabled; + } + + if (this.html_owners) { + this.html_owners.querySelectorAll('input').forEach(input => input.disabled = !enabled); + } + + if (this.html_purchase_location) { + this.html_purchase_location.disabled = !enabled; + } + + if (this.html_storage) { + this.html_storage.disabled = !enabled; + } + + if (this.html_tags) { + this.html_tags.querySelectorAll('input').forEach(input => input.disabled = !enabled); + } + if (this.html_card_confirm) { this.html_card_confirm.disabled = !enabled; } diff --git a/static/scripts/table.js b/static/scripts/table.js index 669afc5..f96f10b 100644 --- a/static/scripts/table.js +++ b/static/scripts/table.js @@ -1,11 +1,16 @@ class BrickTable { constructor(table, per_page) { - const columns = [] + const columns = []; + const no_sort_and_filter = []; const no_sort = []; const number = []; // Read the table header for parameters table.querySelectorAll('th').forEach((th, index) => { + if (th.dataset.tableNoSortAndFilter) { + no_sort_and_filter.push(index); + } + if (th.dataset.tableNoSort) { no_sort.push(index); } @@ -15,8 +20,12 @@ class BrickTable { } }); + if (no_sort_and_filter.length) { + columns.push({ select: no_sort_and_filter, sortable: false, searchable: false }); + } + if (no_sort.length) { - columns.push({ select: no_sort, sortable: false, searchable: false }); + columns.push({ select: no_sort, sortable: false }); } if (number.length) { diff --git a/static/styles.css b/static/styles.css index 9ac95de..0651e20 100644 --- a/static/styles.css +++ b/static/styles.css @@ -46,7 +46,7 @@ object-fit:contain; } -.table-td-missing { +.table-td-input { max-width: 150px; } @@ -59,3 +59,22 @@ .sortable thead th { font-weight: bold !important; } + +.color-rgb { + display: inline-block; + width: 15px; + height: 15px; +} + +.color-rgb-table { + width: 20px !important; + height: 20px !important; +} + +.color-any { + background: + linear-gradient(217deg, rgb(255 0 0 / 80%), rgb(255 0 0 / 0%) 70.71%), + linear-gradient(127deg, rgb(0 255 0 / 80%), rgb(0 255 0 / 0%) 70.71%), + linear-gradient(336deg, rgb(0 0 255 / 80%), rgb(0 0 255 / 0%) 70.71%) + ; +} \ No newline at end of file diff --git a/templates/add.html b/templates/add.html index 140eec6..d9a9462 100644 --- a/templates/add.html +++ b/templates/add.html @@ -1,10 +1,12 @@ +{% import 'macro/accordion.html' as accordion %} + {% extends 'base.html' %} -{% block title %} - Add a set{% endblock %} +{% block title %} - {% if not bulk %}Add a set{% else %}Bulk add sets{% endif %}{% endblock %} {% block main %}
- {% if not config['HIDE_ADD_BULK_SET'] %} + {% if not bulk and not config['HIDE_ADD_BULK_SET'] %}
-{% include 'set/socket.html' %} +{% with id='add', bulk=bulk %} + {% include 'set/socket.html' %} +{% endwith %} {% endblock %} diff --git a/templates/admin.html b/templates/admin.html index dc87256..2da5000 100644 --- a/templates/admin.html +++ b/templates/admin.html @@ -1,3 +1,5 @@ +{% import 'macro/accordion.html' as accordion %} + {% extends 'base.html' %} {% block title %} - Administration{% endblock %} @@ -12,16 +14,26 @@
Administration
- {% if delete_checkbox %} - {% include 'admin/checkbox/delete.html' %} - {% elif delete_database %} + {% if delete_database %} {% include 'admin/database/delete.html' %} + {% elif delete_owner %} + {% include 'admin/owner/delete.html' %} + {% elif delete_purchase_location %} + {% include 'admin/purchase_location/delete.html' %} + {% elif delete_status %} + {% include 'admin/status/delete.html' %} + {% elif delete_storage %} + {% include 'admin/storage/delete.html' %} + {% elif delete_tag %} + {% include 'admin/tag/delete.html' %} {% elif drop_database %} {% include 'admin/database/drop.html' %} {% elif import_database %} {% include 'admin/database/import.html' %} {% elif upgrade_database %} {% include 'admin/database/upgrade.html' %} + {% elif refresh_set %} + {% include 'admin/set/refresh.html' %} {% else %} {% include 'admin/logout.html' %} {% include 'admin/instructions.html' %} @@ -30,7 +42,14 @@ {% endif %} {% include 'admin/theme.html' %} {% include 'admin/retired.html' %} - {% include 'admin/checkbox.html' %} + {{ accordion.header('Set metadata', 'metadata', 'admin', expanded=open_metadata, icon='profile-line', class='p-0') }} + {% include 'admin/owner.html' %} + {% include 'admin/purchase_location.html' %} + {% include 'admin/status.html' %} + {% include 'admin/storage.html' %} + {% include 'admin/tag.html' %} + {{ accordion.footer() }} + {% include 'admin/refresh.html' %} {% include 'admin/database.html' %} {% include 'admin/configuration.html' %} {% endif %} diff --git a/templates/admin/checkbox.html b/templates/admin/checkbox.html deleted file mode 100644 index b71cc2a..0000000 --- a/templates/admin/checkbox.html +++ /dev/null @@ -1,62 +0,0 @@ -{% import 'macro/accordion.html' as accordion %} - -{{ accordion.header('Checkboxes', 'checkbox', 'admin', expanded=open_checkbox, icon='checkbox-line', class='p-0') }} -{% if error %}{% endif %} -{% if database_error %}{% endif %} -
    - {% if brickset_checkboxes | length %} - {% for checkbox in brickset_checkboxes %} -
  • -
    -
    - -
    -
    Name
    - - -
    -
    -
    -
    - - -
    -
    -
    - Delete -
    - -
  • - {% endfor %} - {% else %} -
  • No checkbox found.
  • - {% endif %} -
  • -
    -
    - -
    -
    Name
    - -
    -
    -
    -
    - - -
    -
    -
    - -
    - -
  • -
-{{ accordion.footer() }} diff --git a/templates/admin/checkbox/delete.html b/templates/admin/checkbox/delete.html deleted file mode 100644 index 49d507a..0000000 --- a/templates/admin/checkbox/delete.html +++ /dev/null @@ -1,25 +0,0 @@ -{% import 'macro/accordion.html' as accordion %} - -{{ accordion.header('Checkbox danger zone', 'checkbox-danger', 'admin', expanded=true, danger=true, class='text-end') }} -
- {% if error %}{% endif %} - -
-
-
-
Name
- -
-
-
-
- - Displayed on the Set Grid -
-
-
-
- Back to the admin - - -{{ accordion.footer() }} diff --git a/templates/admin/database.html b/templates/admin/database.html index 36d2b0d..a10fbe3 100644 --- a/templates/admin/database.html +++ b/templates/admin/database.html @@ -19,15 +19,15 @@

{% if database_counters %}
Records
-
-
    +
    +
      {% for counter in database_counters %} -
    • - {{ counter.name }} {{ counter.count }} +
    • + {{ counter.name }} {{ counter.count }}
    • {% if not (loop.index % 5) %}
    -
      +
        {% endif %} {% endfor %}
      @@ -36,7 +36,7 @@ {{ accordion.footer() }} {{ accordion.header('Database danger zone', 'database-danger', 'admin', danger=true, class='text-end') }} -{% if error %}{% endif %} +{% if database_error %}{% endif %} Import a database file Drop the database Delete the database file diff --git a/templates/admin/database/delete.html b/templates/admin/database/delete.html index 9a2d286..9bbbf2f 100644 --- a/templates/admin/database/delete.html +++ b/templates/admin/database/delete.html @@ -2,7 +2,7 @@ {{ accordion.header('Database danger zone', 'database-danger', 'admin', expanded=true, danger=true, class='text-end') }}
      - {% if error %}{% endif %} + {% if database_error %}{% endif %} Back to the admin diff --git a/templates/admin/database/drop.html b/templates/admin/database/drop.html index e0f5b2e..1ebe0d5 100644 --- a/templates/admin/database/drop.html +++ b/templates/admin/database/drop.html @@ -2,7 +2,7 @@ {{ accordion.header('Database danger zone', 'database-danger', 'admin', expanded=true, danger=true, class='text-end') }} - {% if error %}{% endif %} + {% if database_error %}{% endif %} Back to the admin diff --git a/templates/admin/database/import.html b/templates/admin/database/import.html index f7a7763..a6d5e9c 100644 --- a/templates/admin/database/import.html +++ b/templates/admin/database/import.html @@ -2,7 +2,7 @@ {{ accordion.header('Database danger zone', 'database-danger', 'admin', expanded=true, danger=true) }} - {% if error %}{% endif %} + {% if database_error %}{% endif %}
      diff --git a/templates/admin/database/upgrade.html b/templates/admin/database/upgrade.html index 2739c79..29e59ef 100644 --- a/templates/admin/database/upgrade.html +++ b/templates/admin/database/upgrade.html @@ -2,7 +2,7 @@ {{ accordion.header('Database', 'database', 'admin', expanded=true, icon='database-2-line') }} - {% if error %}{% endif %} + {% if database_error %}{% endif %}
+ + + + + + + + + + + + + + {% for item in table_collection %} + + {{ table.image(item.url_for_image(), caption=item.fields.name, alt=item.fields.set) }} + + + + + + + + + {% endfor %} + +
Image Set Name Parts Empty RGB Empty transparent Empty URL Actions
{{ item.fields.set }} {{ table.rebrickable(item) }}{{ item.fields.name }}{{ item.fields.number_of_parts }}{{ item.fields.null_rgb }}{{ item.fields.null_transparent }}{{ item.fields.null_url }} Refresh
+ \ No newline at end of file diff --git a/templates/admin/status.html b/templates/admin/status.html new file mode 100644 index 0000000..0377a4c --- /dev/null +++ b/templates/admin/status.html @@ -0,0 +1,61 @@ +{% import 'macro/accordion.html' as accordion %} + +{{ accordion.header('Set statuses', 'status', 'metadata', expanded=open_status, icon='checkbox-line', class='p-0') }} +{% if status_error %}{% endif %} + +{{ accordion.footer() }} diff --git a/templates/admin/status/delete.html b/templates/admin/status/delete.html new file mode 100644 index 0000000..76e2f71 --- /dev/null +++ b/templates/admin/status/delete.html @@ -0,0 +1,25 @@ +{% import 'macro/accordion.html' as accordion %} + +{{ accordion.header('Set statuses danger zone', 'status-danger', 'admin', expanded=true, danger=true, class='text-end') }} +
+ {% if status_error %}{% endif %} + +
+
+
+
Name
+ +
+
+
+
+ + Displayed on the Set Grid +
+
+
+
+ Back to the admin + +
+{{ accordion.footer() }} diff --git a/templates/admin/storage.html b/templates/admin/storage.html new file mode 100644 index 0000000..ddd3e4b --- /dev/null +++ b/templates/admin/storage.html @@ -0,0 +1,42 @@ +{% import 'macro/accordion.html' as accordion %} + +{{ accordion.header('Set storages', 'storage', 'metadata', expanded=open_storage, icon='archive-2-line', class='p-0') }} +{% if storage_error %}{% endif %} + +{{ accordion.footer() }} diff --git a/templates/admin/storage/delete.html b/templates/admin/storage/delete.html new file mode 100644 index 0000000..b3eb990 --- /dev/null +++ b/templates/admin/storage/delete.html @@ -0,0 +1,19 @@ +{% import 'macro/accordion.html' as accordion %} + +{{ accordion.header('Set storages danger zone', 'storage-danger', 'admin', expanded=true, danger=true, class='text-end') }} +
+ {% if storage_error %}{% endif %} + +
+
+
+
Name
+ +
+
+
+
+ Back to the admin + +
+{{ accordion.footer() }} diff --git a/templates/admin/tag.html b/templates/admin/tag.html new file mode 100644 index 0000000..30bfa71 --- /dev/null +++ b/templates/admin/tag.html @@ -0,0 +1,42 @@ +{% import 'macro/accordion.html' as accordion %} + +{{ accordion.header('Set tags', 'tag', 'metadata', expanded=open_tag, icon='price-tag-2-line', class='p-0') }} +{% if tag_error %}{% endif %} + +{{ accordion.footer() }} diff --git a/templates/admin/tag/delete.html b/templates/admin/tag/delete.html new file mode 100644 index 0000000..69dd334 --- /dev/null +++ b/templates/admin/tag/delete.html @@ -0,0 +1,19 @@ +{% import 'macro/accordion.html' as accordion %} + +{{ accordion.header('Set tags danger zone', 'tag-danger', 'admin', expanded=true, danger=true, class='text-end') }} +
+ {% if tag_error %}{% endif %} + +
+
+
+
Name
+ +
+
+
+
+ Back to the admin + +
+{{ accordion.footer() }} diff --git a/templates/base.html b/templates/base.html index 6d89d3d..658ef37 100644 --- a/templates/base.html +++ b/templates/base.html @@ -9,6 +9,7 @@ + @@ -78,9 +79,13 @@ + - + + + + diff --git a/templates/bulk.html b/templates/bulk.html deleted file mode 100644 index 6e6e5d8..0000000 --- a/templates/bulk.html +++ /dev/null @@ -1,64 +0,0 @@ -{% extends 'base.html' %} - -{% block title %} - Bulk add sets{% endblock %} - -{% block main %} -
-
-
-
-
-
Bulk add sets
-
-
- -
-
- - -
-
- - -
-
-
-

- Progress - - - Loading... - -

-
-
-
-

-
-
-
-
-
- - -
-
-
- -
-
-
-
- -
-
-
-
-{% with bulk=true %} - {% include 'set/socket.html' %} -{% endwith %} -{% endblock %} diff --git a/templates/instructions/table.html b/templates/instructions/table.html index 8ad9f3c..b2c8007 100644 --- a/templates/instructions/table.html +++ b/templates/instructions/table.html @@ -6,9 +6,9 @@ Filename Set - Image + Image {% if g.login.is_authenticated() %} - Actions + Actions {% endif %} diff --git a/templates/macro/accordion.html b/templates/macro/accordion.html index fbfd01b..c5c8954 100644 --- a/templates/macro/accordion.html +++ b/templates/macro/accordion.html @@ -16,7 +16,7 @@ {% endif %} -
+
{% endmacro %} @@ -26,10 +26,10 @@
{% endmacro %} -{% macro cards(card_collection, title, id, parent, target, icon=none) %} +{% macro cards(card_collection, title, id, parent, target, expanded=false, icon=none) %} {% set size=card_collection | length %} {% if size %} - {{ header(title, id, parent, icon=icon) }} + {{ header(title, id, parent, expanded=expanded, icon=icon) }}
{% for item in card_collection %}
@@ -43,7 +43,7 @@ {% endif %} {% endmacro %} -{% macro table(table_collection, title, id, parent, target, quantity=none, icon=none, image=none, alt=none, details=none, no_missing=none, read_only_missing=none) %} +{% macro table(table_collection, title, id, parent, target, quantity=none, icon=none, image=none, alt=none, details=none, read_only=none) %} {% set size=table_collection | length %} {% if size %} {{ header(title, id, parent, quantity=quantity, icon=icon, class='p-0', image=image, alt=alt) }} diff --git a/templates/macro/badge.html b/templates/macro/badge.html index 70b10f1..ee68a69 100644 --- a/templates/macro/badge.html +++ b/templates/macro/badge.html @@ -1,14 +1,15 @@ -{% macro badge(check=none, url=none, solo=false, last=false, color='primary', blank=none, icon=none, alt=none, collapsible=none, text=none, tooltip=none) %} +{% macro badge(check=none, url=none, solo=false, last=false, header=false, color='primary', rgb=none, blank=none, icon=none, alt=none, collapsible=none, text=none, tooltip=none) %} {% if check or url %} {% if url %} {% if icon %}{% endif %} + {% if rgb %}{% endif %} {% if collapsible and not last %} {{ collapsible }} {% endif %} {% if text %}{{ text }}{% endif %} {% if url %} @@ -23,6 +24,20 @@ {{ badge(url=item.url_for_bricklink(), solo=solo, last=last, blank=true, color='light border', icon='external-link-line', collapsible='Bricklink', alt='Bricklink') }} {% endmacro %} +{% macro color(item, icon=none, solo=false, last=false, header=false) %} + {% if item.fields.color == 9999 %} + {% set rgb = 'any' %} + {% else %} + {% set rgb = item.fields.color_rgb %} + {% endif %} + {{ badge(check=item.fields.color_name, solo=solo, last=last, header=header, color=' bg-white text-black border', rgb=rgb, icon='palette-line', collapsible=item.fields.color_name) }} + {{ badge(check=item.fields.color_transparent, solo=solo, last=last, header=header, color='light border', icon='blur-off-line', collapsible='Transparent') }} +{% endmacro %} + +{% macro identifier(id, icon=none, solo=false, last=false, header=false) %} + {{ badge(check=id, solo=solo, last=last, header=header, color='secondary', icon=icon, text=id) }} +{% endmacro %} + {% macro instructions(item, solo=false, last=false) %} {{ badge(url=item.url_for_instructions(), solo=solo, last=last, blank=true, color='light border', icon='file-line', collapsible='Instructions:', text=item.instructions | length, alt='Instructions') }} {% endmacro %} @@ -35,6 +50,51 @@ {{ badge(check=quantity, solo=solo, last=last, color='success', icon='close-line', collapsible='Quantity:', text=quantity, alt='Quantity') }} {% endmacro %} +{% macro owner(item, owner, solo=false, last=false) %} + {% if last %} + {% set tooltip=owner.fields.name %} + {% else %} + {% set text=owner.fields.name %} + {% endif %} + {{ badge(check=item.fields[owner.as_column()], solo=solo, last=last, color='light text-success-emphasis bg-success-subtle border border-success-subtle', icon='user-line', text=text, alt='Owner', tooltip=tooltip) }} +{% endmacro %} + +{% macro print(item, solo=false, last=false, header=false) %} + {% if item.fields.print %} + {{ badge(url=item.url_for_print(), solo=solo, last=last, color='light border', icon='paint-brush-line', collapsible='Print') }} + {% endif %} +{% endmacro %} + +{% macro purchase_date(date, solo=false, last=false) %} + {% if last %} + {% set tooltip=date %} + {% else %} + {% set text=date %} + {% endif %} + {{ badge(check=date, solo=solo, last=last, color='light border', icon='calendar-line', text=text, tooltip=tooltip, collapsible='Date:') }} +{% endmacro %} + +{% macro purchase_location(item, purchase_locations, solo=false, last=false) %} + {% if purchase_locations and item.fields.purchase_location in purchase_locations.mapping %} + {% set purchase_location = purchase_locations.mapping[item.fields.purchase_location] %} + {% if last %} + {% set tooltip=purchase_location.fields.name %} + {% else %} + {% set text=purchase_location.fields.name %} + {% endif %} + {{ badge(check=purchase_location, solo=solo, last=last, color='light border', icon='building-line', text=text, tooltip=tooltip, collapsible='Location:') }} + {% endif %} +{% endmacro %} + +{% macro purchase_price(price, solo=false, last=false) %} + {% if last %} + {% set tooltip=price %} + {% else %} + {% set text=price %} + {% endif %} + {{ badge(check=price, solo=solo, last=last, color='light border', icon='wallet-3-line', text=text, tooltip=tooltip, collapsible='Price:') }} +{% endmacro %} + {% macro set(set, solo=false, last=false, url=None, id=None) %} {% if id %} {% set url=url_for('set.details', id=id) %} @@ -42,8 +102,29 @@ {{ badge(check=set, url=url, solo=solo, last=last, color='secondary', icon='hashtag', collapsible='Set:', text=set, alt='Set') }} {% endmacro %} +{% macro storage(item, storages, solo=false, last=false) %} + {% if storages and item.fields.storage in storages.mapping %} + {% set storage = storages.mapping[item.fields.storage] %} + {% if last %} + {% set tooltip=storage.fields.name %} + {% else %} + {% set text=storage.fields.name %} + {% endif %} + {{ badge(url=item.url_for_storage(), solo=solo, last=last, color='light text-warning-emphasis bg-warning-subtle border border-warning-subtle', icon='archive-2-line', text=text, alt='Storage', tooltip=tooltip) }} + {% endif %} +{% endmacro %} + +{% macro tag(item, tag, solo=false, last=false) %} + {% if last %} + {% set tooltip=tag.fields.name %} + {% else %} + {% set text=tag.fields.name %} + {% endif %} + {{ badge(check=item.fields[tag.as_column()], solo=solo, last=last, color='light text-primary-emphasis bg-primary-subtle border border-primary-subtle', icon='price-tag-2-line', text=text, alt='Tag', tooltip=tooltip) }} +{% endmacro %} + {% macro theme(theme, solo=false, last=false) %} - {% if last %} + {% if last %} {% set tooltip=theme %} {% else %} {% set text=theme %} @@ -51,6 +132,12 @@ {{ badge(check=theme, solo=solo, last=last, color='primary', icon='price-tag-3-line', text=text, alt='Theme', tooltip=tooltip) }} {% endmacro %} +{% macro total_damaged(damaged, solo=false, last=false) %} + {% if not config['HIDE_TABLE_DAMAGED_PARTS'] %} + {{ badge(check=damaged, solo=solo, last=last, color='danger', icon='error-warning-line', collapsible='Damaged:', text=damaged, alt='Damaged') }} + {% endif %} +{% endmacro %} + {% macro total_quantity(quantity, solo=false, last=false) %} {{ badge(check=quantity, solo=solo, last=last, color='success', icon='functions', collapsible='Quantity:', text=quantity, alt='Quantity') }} {% endmacro %} @@ -60,7 +147,9 @@ {% endmacro %} {% macro total_missing(missing, solo=false, last=false) %} - {{ badge(check=missing, solo=solo, last=last, color='danger', icon='error-warning-line', collapsible='Missing:', text=missing, alt='Missing') }} + {% if not config['HIDE_TABLE_MISSING_PARTS'] %} + {{ badge(check=missing, solo=solo, last=last, color='light text-danger-emphasis bg-danger-subtle border border-danger-subtle', icon='question-line', collapsible='Missing:', text=missing, alt='Missing') }} + {% endif %} {% endmacro %} {% macro total_sets(sets, solo=false, last=false) %} diff --git a/templates/macro/card.html b/templates/macro/card.html index 3b52220..be6ad74 100644 --- a/templates/macro/card.html +++ b/templates/macro/card.html @@ -1,11 +1,16 @@ -{% macro header(item, name, solo=false, number=none, color=none, icon='hashtag') %} +{% import 'macro/badge.html' as badge %} + +{% macro header(item, name, solo=false, identifier=none, icon='hashtag') %}
{% if not solo %} {% endif %}
- {% if number %}{{ number }}{% endif %} - {% if color %} {{ color }}{% endif %} + {{ badge.identifier(identifier, icon=icon, solo=solo, header=true) }} + {% if solo %} + {{ badge.color(item, header=true) }} + {{ badge.print(item, header=true) }} + {% endif %} {{ name }}
{% if not solo %} diff --git a/templates/macro/form.html b/templates/macro/form.html index f9c5a5d..7f7952b 100644 --- a/templates/macro/form.html +++ b/templates/macro/form.html @@ -1,17 +1,66 @@ -{% macro checkbox(prefix, id, text, url, checked, delete=false) %} +{% macro checkbox(name, id, prefix, url, checked, parent=none, delete=false) %} {% if g.login.is_authenticated() %} -