diff --git a/.dockerignore b/.dockerignore index 82afab6..fa88ec8 100644 --- a/.dockerignore +++ b/.dockerignore @@ -19,6 +19,8 @@ LICENSE # Database *.db +*.db-shm +*.db-wal # Python **/__pycache__ diff --git a/.env.sample b/.env.sample index d3a6c5d..27de81a 100644 --- a/.env.sample +++ b/.env.sample @@ -4,13 +4,13 @@ # 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 # in the source to see all column names. -# The usual syntax for those variables is . [ASC|DESC]. +# The usual syntax for those variables is "
"."" [ASC|DESC]. # For composite fields (CASE, SUM, COUNT) the syntax is , there is no
name. # For instance: -# - table.name (by table.name, default order) -# - table.name ASC (by table.name, ascending) -# - table.name DESC (by table.name, descending) -# - field (by field, default order) +# - "table"."name" (by "table"."name", default order) +# - "table"."name" ASC (by "table"."name", ascending) +# - "table"."name" DESC (by "table"."name", descending) +# - "field" (by "field", default order) # - ... # You can combine the ordering options. # You can use the special column name 'rowid' to order by insertion order. @@ -117,10 +117,10 @@ # 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 +# - "minifigures"."fig_num": minifigure ID (fig-xxxxx) +# - "minifigures"."name": minifigure name +# Default: "minifigures"."name" ASC +# BK_MINIFIGURES_DEFAULT_ORDER="minifigures"."name" ASC # Optional: Folder where to store the minifigures images, relative to the '/app/static/' folder # Default: minifigs @@ -134,12 +134,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: par color name -# - total_missing: number of missing parts -# Default: inventory.name ASC, inventory.color_name ASC, is_spare ASC -# BK_PARTS_DEFAULT_ORDER=total_missing DESC, inventory.name ASC +# - "inventory"."part_num": part number +# - "inventory"."name": part name +# - "inventory"."color_name": part color name +# - "inventory"."is_spare": par is a spare part +# - "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 # Optional: Folder where to store the parts images, relative to the '/app/static/' folder # Default: parts @@ -177,10 +178,6 @@ # Default: https://rebrickable.com/parts/{number}/_/{color} # BK_REBRICKABLE_LINK_PART_PATTERN= -# Optional: Pattern of the link to Rebrickable for a set. Will be passed to Python .format() -# Default: https://rebrickable.com/sets/{number} -# BK_REBRICKABLE_LINK_SET_PATTERN= - # Optional: Display Rebrickable links wherever applicable # Default: false # Legacy name: LINKS @@ -201,16 +198,16 @@ # Optional: Change the default order of sets. By default ordered by insertion order. # Useful column names for this option are: -# - sets.set_num: set number as a string -# - sets.name: set name -# - sets.year: set release year -# - sets.num_parts: set number of parts -# - set_number: the number part of set_num as an integer -# - set_version: the version part of set_num as an integer -# - total_missing: number of missing parts -# - total_minifigures: number of minifigures -# Default: set_number DESC, set_version ASC -# BK_SETS_DEFAULT_ORDER=sets.year ASC +# - "rebrickable_sets"."set": set number as a string +# - "rebrickable_sets"."number": the number part of set as an integer +# - "rebrickable_sets"."version": the version part of set as an integer +# - "rebrickable_sets"."name": set name +# - "rebrickable_sets"."year": set release year +# - "rebrickable_sets"."number_of_parts": set number of parts +# - "total_missing": number of missing parts +# - "total_minifigures": number of minifigures +# Default: "rebrickable_sets"."number" DESC, "rebrickable_sets"."version" ASC +# BK_SETS_DEFAULT_ORDER="rebrickable_sets"."year" ASC # Optional: Folder where to store the sets images, relative to the '/app/static/' folder # Default: sets @@ -249,9 +246,9 @@ # Optional: Change the default order of sets. By default ordered by insertion order. # Useful column names for this option are: -# - wishlist.set_num: set number as a string -# - wishlist.name: set name -# - wishlist.year: set release year -# - wishlist.num_parts: set number of parts -# Default: wishlist.rowid DESC -# BK_WISHES_DEFAULT_ORDER=set_number DESC, set_version ASC +# - "bricktracker_wishes"."set": set number as a string +# - "bricktracker_wishes"."name": set name +# - "bricktracker_wishes"."year": set release year +# - "bricktracker_wishes"."number_of_parts": set number of parts +# Default: "bricktracker_wishes"."rowid" DESC +# BK_WISHES_DEFAULT_ORDER="bricktracker_wishes"."set" DESC diff --git a/.gitignore b/.gitignore index 0d192d5..6c9e2d0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ # Application .env *.db +*.db-shm +*.db-wal # Python specifics __pycache__/ @@ -9,6 +11,7 @@ __pycache__/ # Static folders static/instructions/ static/minifigs/ +static/minifigures/ static/parts/ static/sets/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..571c522 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,196 @@ +# Changelog + +## 1.1.0: Deduped sets, custom checkboxes and database upgrade + +### Database + +- Sets + - Deduplicating rebrickable sets (unique) and bricktracker sets (can be n bricktracker sets for one rebrickable set) + +### Docs + +- Removed extra `
` to accomodate Gitea Markdown +- Add an organized DOCS.md documentation page +- Database upgrade/migration +- Checkboxes + +### Code + +- Admin + - Split the views before admin because an unmanageable monster view + +- Checkboxes + - Customizable checkboxes for set (amount and names, displayed on the grid or not) + - Replaced the 3 original routes to update the status with a generic route to accomodate any custom status + +- Instructions + - Base instructions on RebrickableSet (the generic one) rather than BrickSet (the specific one) + - Refine set number detection in file name by making sure each first items is an integer + +- Python + - Make stricter function definition with no "arg_or_keyword" parameters + +- Records + - Consolidate the select() -> not None or Exception -> ingest() process duplicated in every child class + +- SQL + - Forward-only migration mechanism + - Check for database too far in version + - Inject the database version in the file when downloading it + - Quote all indentifiers as best practice + - Allow insert query to be overriden + - Allow insert query to force not being deferred even if not committed + - Allow select query to push context in BrickRecord and BrickRecordList + - Make SQL record counters failsafe as they are used in the admin and it should always work + - Remove BrickSQL.initialize() as it is replaced by upgrade() + +- Sets + - Now that it is deduplicated, adding the same set more than once will not pull it fully from the Rebrickable API (minifigures and parts) + - Make RebrickableSet extend BrickRecord since it is now an item in database + - Make BrickSet extend RebrickableSet now that RebrickableSet is a proper database item + +### UI + +- Checkboxes + - Possibility to hide the checkbox in the grid ("Sets") but sill have all them in the set details + - Management + +- Database + - Migration tool + +- Javascript + - Generic BrickChanger class to handle quick modification through a JSON request with a visual feedback indicator + - Simplify the way javascript scripts are loaded and instantiated + +- Set grid + - Filter by checkboxes and NOT checkboxes + +- Tables + - Fix table search looking inside links pills + +- Wishlist + - Add Rebrickable link badge for sets (@matthew) + +## 1.0.0: New Year revamp + +### Code + +- Authentication + - Basic authentication mechanism with ONE password to protect admin and writes +- CSV + - Remove dependencies to numpy and panda for simpler built-in csv +- Code + - Refactored the Python code + - Modularity (more functions, splitting files) + - Type hinting whenever possible + - Flake8 linter + - Retained most of the original behaviour (with its quirks) +- Colors + - Remove dependency on color.csv +- Configuration + - Moved all the hard-coded parameters into configuration variables + - Most of the variables are configuration through environment variables + - Force instruction, sets, etc path to be relative to static +- Docker + - Added an entrypoint to grab PORT / HOST from the environment if set + - Remove the need to seed the container with files (*.csv, nil files) +- Flask + - Fix improper socketio.run(app.run()) call which lead to hard crash on ^C + - Make use of url_for to create URLs + - Use blueprints to implement routes + - Move views into their own files + - Split GET and POST methods into two different routes for clarity +- Images + - Add an option to use remote images from the Rebrickable CDN rather than downloading everything locally + - Handle nil.png and nil_mf.jpg as true images in /static/sets/ so that they are downloaded whenever necessary when importing a se with missing images +- Instructions + - Scan the files once for the whole app, and re-use the data + - Refresh the instructions from the admin + - More lenient set number detection + - Update when uploading a new one + - Basic file management +- Logs + - Added log lines for change actions (add, check, missing, delete) so that the server is not silent when DEBUG=false +- Minifigures + - Added a variable to control default ordering +- Part(s) + - Added a variable to control default ordering of listing +- Retired sets + - Open the themes once for the whole app, and re-use the data + - Do not hard fail if themes.csv is missing, simply display the IDs + - Light management: resync, download +- Set(s) + - Reworked the set checkboxes with a dedicated route per status + - Switch from homemade ID generator to proven UUID4 for sets ID + - Does not interfere with previously created sets + - Do not rely on sets.csv to check if the set exists + - When adding, commit the set to database only once everything has been processed + - Added a bulk add page + - Keep spare parts when importing + - Added a variable to control default ordering of listing +- Socket + - Make use of socket.io rooms to avoid broadcasting messages to all clients +- SQLite + - Do not hard fail if the database is not present or not initialized + - Open the database once for the context, and re-use the connection + - Move queries to .sql files and load them as Jinja templates + - Use named arguments rather than sets for SQLite queries + - Allow execute() to be deferred to the commit() call to avoid locking the database for long period while importing (locked while downloading images) +- Themes + - Open the themes once for the whole app, and re-use the data + - Do not hard fail if themes.csv is missing, simply display the IDs + - Light management: resync, download + +### UI + +- Admin + - Initialize the database from the web interface + - Reset the database + - Delete the database + - Download the database + - Import the database + - Display the configuration variables + - Many things +- Accordions + - Added a flag to make the accordion items independent +- Branding: + - Add a brick as a logo (CC0 image from: https://iconduck.com/icons/71631/brick) +- Global + - Redesign of the whole app + - Sticky menu bar on top of the page + - Execution time and SQL stats for fun +- Libraries + - Switch from Bulma to Bootstrap, arbitrarily :D + - Use of baguettebox for images (https://github.com/feimosi/baguetteBox.js) + - Use of tinysort to sort and filter the grid (https://github.com/Sjeiti/TinySort) + - Use of sortable for set card tables (https://github.com/tofsjonas/sortable) + - Use of simple-datatables for big tables (https://github.com/fiduswriter/simple-datatables) +- Minifigures + - Added a detail view for a minifigure + - Display which sets are using a minifigure + - Display which sets are missing a minifigure +- Parts + - Added a detail view for a part + - Display which sets are using a part + - Display which sets are missing a part +- Templates + - Use a common base template + - Use HTML fragments/macros for repeted or parametrics items + - a 404 page for wrong URLs + - an error page for expected error messages + - an exception page for unexpected error messages +- Set add + - Two-tiered (with override) import where you see what you will import before importing it + - Add a visual indicator that the socket is connected +- Set card + - Badges to display info like theme, year, parts, etc + - Set image on top of the card, filling the space + - Trick to have a blurry background image fill the void in the card + - Save missing parts on input change rather than by clicking + - Visual feedback of success + - Parts and minifigure in accordions + - Instructions file list +- Set grid + - 4-2-1 card distribution depending on screen size + - Display the index with no set added, rather than redirecting + - Keep last sort in a cookie, and trigger it on page load (can be cleared) diff --git a/README.md b/README.md index 5b24ce5..291d8b5 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,6 @@ See [first steps](docs/first-steps.md). ## Documentation Most of the pages should be self explanatory to use. -However, you can find more specific documentation in the [docs](docs/) folder. +However, you can find more specific documentation in the [documentation](docs/DOCS.md). -You can find screenshots of the application in the [bricktracker](docs/bricktracker.md) documentation file. +You can find screenshots of the application in the [overview](docs/overview.md) documentation file. diff --git a/app.py b/app.py index 2077083..990974b 100644 --- a/app.py +++ b/app.py @@ -11,28 +11,44 @@ from bricktracker.socket import BrickSocket # noqa: E402 logger = logging.getLogger(__name__) -# Create the Flask app -app = Flask(__name__) -# Setup the app -setup_app(app) +# Create the app +# Using 'app' globally interferse with the teardown handlers of Flask +def create_app(main: bool = False, /) -> Flask | BrickSocket: + # Create the Flask app + app = Flask(__name__) -# Create the socket -s = BrickSocket( - app, - threaded=not app.config['NO_THREADED_SOCKET'].value, -) + # Setup the app + setup_app(app) + + # Create the socket + s = BrickSocket( + app, + threaded=not app.config['NO_THREADED_SOCKET'], + ) + + if main: + return s + else: + return app if __name__ == '__main__': + s = create_app(True) + + # This never happens, but makes the linter happy + if isinstance(s, Flask): + logger.critical('Cannot run locally with a Flask object, needs a BrickSocket. Use create_app(True) to return a BrickSocket') # noqa: E501 + exit(1) + # Run the application logger.info('Starting BrickTracker on {host}:{port}'.format( - host=app.config['HOST'].value, - port=app.config['PORT'].value, + host=s.app.config['HOST'], + port=s.app.config['PORT'], )) s.socket.run( - app, - host=app.config['HOST'].value, - debug=app.config['DEBUG'].value, - port=app.config['PORT'].value, + s.app, + host=s.app.config['HOST'], + debug=s.app.config['DEBUG'], + port=s.app.config['PORT'], ) diff --git a/bricktracker/app.py b/bricktracker/app.py index 5ba4414..a55a9f7 100644 --- a/bricktracker/app.py +++ b/bricktracker/app.py @@ -12,7 +12,13 @@ from bricktracker.navbar import Navbar from bricktracker.sql import close from bricktracker.version import __version__ from bricktracker.views.add import add_page -from bricktracker.views.admin import admin_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.retired import admin_retired_page +from bricktracker.views.admin.theme import admin_theme_page from bricktracker.views.error import error_404 from bricktracker.views.index import index_page from bricktracker.views.instructions import instructions_page @@ -28,7 +34,7 @@ def setup_app(app: Flask) -> None: BrickConfigurationList(app) # Set the logging level - if app.config['DEBUG'].value: + if app.config['DEBUG']: logging.basicConfig( stream=sys.stdout, level=logging.DEBUG, @@ -60,9 +66,8 @@ def setup_app(app: Flask) -> None: # Register errors app.register_error_handler(404, error_404) - # Register routes + # Register app routes app.register_blueprint(add_page) - app.register_blueprint(admin_page) app.register_blueprint(index_page) app.register_blueprint(instructions_page) app.register_blueprint(login_page) @@ -71,6 +76,15 @@ def setup_app(app: Flask) -> None: app.register_blueprint(set_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_theme_page) + # An helper to make global variables available to the # request @app.before_request @@ -90,12 +104,12 @@ def setup_app(app: Flask) -> None: g.request_time = request_time # Register the timezone - g.timezone = ZoneInfo(current_app.config['TIMEZONE'].value) + g.timezone = ZoneInfo(current_app.config['TIMEZONE']) # Version g.version = __version__ # Make sure all connections are closed at the end - @app.teardown_appcontext - def close_connections(exception, /) -> None: + @app.teardown_request + def teardown_request(_: BaseException | None) -> None: close() diff --git a/bricktracker/config.py b/bricktracker/config.py index 740f7b4..8cfdbba 100644 --- a/bricktracker/config.py +++ b/bricktracker/config.py @@ -31,10 +31,10 @@ CONFIG: Final[list[dict[str, Any]]] = [ {'n': 'HIDE_ALL_SETS', 'c': bool}, {'n': 'HIDE_MISSING_PARTS', 'c': bool}, {'n': 'HIDE_WISHES', 'c': bool}, - {'n': 'MINIFIGURES_DEFAULT_ORDER', 'd': 'minifigures.name ASC'}, + {'n': 'MINIFIGURES_DEFAULT_ORDER', 'd': '"minifigures"."name" ASC'}, {'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, is_spare ASC'}, # noqa: E501 + {'n': 'PARTS_DEFAULT_ORDER', 'd': '"inventory"."name" ASC, "inventory"."color_name" ASC, "inventory"."is_spare" ASC'}, # noqa: E501 {'n': 'PARTS_FOLDER', 'd': 'parts', 's': True}, {'n': 'PORT', 'd': 3333, 'c': int}, {'n': 'RANDOM', 'e': 'RANDOM', 'c': bool}, @@ -43,12 +43,11 @@ CONFIG: Final[list[dict[str, Any]]] = [ {'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_SET_PATTERN', 'd': 'https://rebrickable.com/sets/{number}'}, # noqa: E501 {'n': 'REBRICKABLE_LINKS', 'e': 'LINKS', 'c': bool}, {'n': 'REBRICKABLE_PAGE_SIZE', 'd': 100, 'c': int}, {'n': 'RETIRED_SETS_FILE_URL', 'd': 'https://docs.google.com/spreadsheets/d/1rlYfEXtNKxUOZt2Mfv0H17DvK7bj6Pe0CuYwq6ay8WA/gviz/tq?tqx=out:csv&sheet=Sorted%20by%20Retirement%20Date'}, # noqa: E501 {'n': 'RETIRED_SETS_PATH', 'd': './retired_sets.csv'}, - {'n': 'SETS_DEFAULT_ORDER', 'd': 'set_number DESC, set_version ASC'}, + {'n': 'SETS_DEFAULT_ORDER', 'd': '"rebrickable_sets"."number" DESC, "rebrickable_sets"."version" ASC'}, # noqa: E501 {'n': 'SETS_FOLDER', 'd': 'sets', 's': True}, {'n': 'SKIP_SPARE_PARTS', 'c': bool}, {'n': 'SOCKET_NAMESPACE', 'd': 'bricksocket'}, @@ -57,5 +56,5 @@ CONFIG: Final[list[dict[str, Any]]] = [ {'n': 'THEMES_PATH', 'd': './themes.csv'}, {'n': 'TIMEZONE', 'd': 'Etc/UTC'}, {'n': 'USE_REMOTE_IMAGES', 'c': bool}, - {'n': 'WISHES_DEFAULT_ORDER', 'd': 'wishlist.rowid DESC'}, + {'n': 'WISHES_DEFAULT_ORDER', 'd': '"bricktracker_wishes"."rowid" DESC'}, ] diff --git a/bricktracker/configuration.py b/bricktracker/configuration.py index b9c3559..6542b57 100644 --- a/bricktracker/configuration.py +++ b/bricktracker/configuration.py @@ -16,6 +16,7 @@ class BrickConfiguration(object): def __init__( self, /, + *, n: str, e: str | None = None, d: Any = None, @@ -69,6 +70,7 @@ class BrickConfiguration(object): # Remove static prefix value = value.removeprefix('static/') + # Type casting if self.cast is not None: self.value = self.cast(value) else: diff --git a/bricktracker/configuration_list.py b/bricktracker/configuration_list.py index 0e93a88..3293d85 100644 --- a/bricktracker/configuration_list.py +++ b/bricktracker/configuration_list.py @@ -1,46 +1,60 @@ +import logging from typing import Generator -from flask import current_app, Flask +from flask import Flask from .config import CONFIG from .configuration import BrickConfiguration from .exceptions import ConfigurationMissingException +logger = logging.getLogger(__name__) + # Application configuration class BrickConfigurationList(object): app: Flask + configurations: dict[str, BrickConfiguration] # Load configuration def __init__(self, app: Flask, /): self.app = app - # Process all configuration items - for config in CONFIG: - item = BrickConfiguration(**config) - self.app.config[item.name] = item + # Load the configurations only there is none already loaded + configurations = getattr(self, 'configurations', None) + + if configurations is None: + logger.info('Loading configuration variables') + + BrickConfigurationList.configurations = {} + + # Process all configuration items + for config in CONFIG: + item = BrickConfiguration(**config) + + # Store in the list + BrickConfigurationList.configurations[item.name] = item + + # Only store the value in the app to avoid breaking any + # existing variables + self.app.config[item.name] = item.value # Check whether a str configuration is set @staticmethod def error_unless_is_set(name: str): - config: BrickConfiguration = current_app.config[name] + configuration = BrickConfigurationList.configurations[name] - if config.value is None or config.value == '': + if configuration.value is None or configuration.value == '': raise ConfigurationMissingException( '{name} must be defined (using the {environ} environment variable)'.format( # noqa: E501 - name=config.name, - environ=config.env_name + name=name, + environ=configuration.env_name ), ) # Get all the configuration items from the app config @staticmethod def list() -> Generator[BrickConfiguration, None, None]: - keys = list(current_app.config.keys()) - keys.sort() + keys = sorted(BrickConfigurationList.configurations.keys()) for name in keys: - config = current_app.config[name] - - if isinstance(config, BrickConfiguration): - yield config + yield BrickConfigurationList.configurations[name] diff --git a/bricktracker/fields.py b/bricktracker/fields.py index 37d5a21..fc5100b 100644 --- a/bricktracker/fields.py +++ b/bricktracker/fields.py @@ -4,6 +4,9 @@ from typing import Any # SQLite record fields class BrickRecordFields(object): def __getattr__(self, name: str, /) -> Any: + if name not in self.__dict__: + raise AttributeError(name) + return self.__dict__[name] def __setattr__(self, name: str, value: Any, /) -> None: diff --git a/bricktracker/instructions.py b/bricktracker/instructions.py index d776db5..1924aba 100644 --- a/bricktracker/instructions.py +++ b/bricktracker/instructions.py @@ -13,18 +13,18 @@ from bs4 import BeautifulSoup from .exceptions import ErrorException if TYPE_CHECKING: - from .set import BrickSet + from .rebrickable_set import RebrickableSet logger = logging.getLogger(__name__) class BrickInstructions(object): allowed: bool - brickset: 'BrickSet | None' + rebrickable: 'RebrickableSet | None' extension: str filename: str mtime: datetime - number: 'str | None' + set: 'str | None' name: str size: int @@ -42,11 +42,11 @@ class BrickInstructions(object): # Store the name and extension, check if extension is allowed self.name, self.extension = os.path.splitext(self.filename) self.extension = self.extension.lower() - self.allowed = self.extension in current_app.config['INSTRUCTIONS_ALLOWED_EXTENSIONS'].value # noqa: E501 + self.allowed = self.extension in current_app.config['INSTRUCTIONS_ALLOWED_EXTENSIONS'] # noqa: E501 # Placeholder - self.brickset = None - self.number = None + self.rebrickable = None + self.set = None # Extract the set number if self.allowed: @@ -57,7 +57,14 @@ class BrickInstructions(object): splits = normalized.split('-', 2) if len(splits) >= 2: - self.number = '-'.join(splits[:2]) + try: + # Trying to make sense of each part as integers + int(splits[0]) + int(splits[1]) + + self.set = '-'.join(splits[:2]) + except Exception: + pass # Delete an instruction file def delete(self, /) -> None: @@ -70,17 +77,17 @@ class BrickInstructions(object): # Display the time in a human format def human_time(self) -> str: return self.mtime.astimezone(g.timezone).strftime( - current_app.config['FILE_DATETIME_FORMAT'].value + current_app.config['FILE_DATETIME_FORMAT'] ) # Compute the path of an instruction file - def path(self, /, filename=None) -> str: + def path(self, /, *, filename=None) -> str: if filename is None: filename = self.filename return os.path.join( current_app.static_folder, # type: ignore - current_app.config['INSTRUCTIONS_FOLDER'].value, + current_app.config['INSTRUCTIONS_FOLDER'], filename ) @@ -102,7 +109,7 @@ class BrickInstructions(object): # Upload a new instructions file def upload(self, file: FileStorage, /) -> None: - target = self.path(secure_filename(self.filename)) + target = self.path(filename=secure_filename(self.filename)) if os.path.isfile(target): raise ErrorException('Cannot upload {target} as it already exists'.format( # noqa: E501 @@ -180,7 +187,7 @@ class BrickInstructions(object): if not self.allowed: return '' - folder: str = current_app.config['INSTRUCTIONS_FOLDER'].value + folder: str = current_app.config['INSTRUCTIONS_FOLDER'] # Compute the path path = os.path.join(folder, self.filename) diff --git a/bricktracker/instructions_list.py b/bricktracker/instructions_list.py index 57329fe..6fc364d 100644 --- a/bricktracker/instructions_list.py +++ b/bricktracker/instructions_list.py @@ -1,11 +1,14 @@ import logging import os -from typing import Generator +from typing import Generator, TYPE_CHECKING from flask import current_app from .exceptions import NotFoundException from .instructions import BrickInstructions +from .rebrickable_set_list import RebrickableSetList +if TYPE_CHECKING: + from .rebrickable_set import RebrickableSet logger = logging.getLogger(__name__) @@ -18,7 +21,7 @@ class BrickInstructionsList(object): sets_total: int unknown_total: int - def __init__(self, /, force=False): + def __init__(self, /, *, force=False): # Load instructions only if there is none already loaded all = getattr(self, 'all', None) @@ -36,7 +39,7 @@ class BrickInstructionsList(object): # Make a folder relative to static folder: str = os.path.join( current_app.static_folder, # type: ignore - current_app.config['INSTRUCTIONS_FOLDER'].value, + current_app.config['INSTRUCTIONS_FOLDER'], ) for file in os.scandir(folder): @@ -46,47 +49,40 @@ class BrickInstructionsList(object): BrickInstructionsList.all[instruction.filename] = instruction # noqa: E501 if instruction.allowed: - if instruction.number: + if instruction.set: # Instantiate the list if not existing yet - if instruction.number not in BrickInstructionsList.sets: # noqa: E501 - BrickInstructionsList.sets[instruction.number] = [] # noqa: E501 + if instruction.set not in BrickInstructionsList.sets: # noqa: E501 + BrickInstructionsList.sets[instruction.set] = [] # noqa: E501 - BrickInstructionsList.sets[instruction.number].append(instruction) # noqa: E501 + BrickInstructionsList.sets[instruction.set].append(instruction) # noqa: E501 BrickInstructionsList.sets_total += 1 else: BrickInstructionsList.unknown_total += 1 else: BrickInstructionsList.rejected_total += 1 - # Associate bricksets - # Not ideal, to avoid a circular import - from .set import BrickSet - from .set_list import BrickSetList + # List of Rebrickable sets + rebrickable_sets: dict[str, RebrickableSet] = {} + for rebrickable_set in RebrickableSetList().all(): + rebrickable_sets[rebrickable_set.fields.set] = rebrickable_set # noqa: E501 - # Grab the generic list of sets - bricksets: dict[str, BrickSet] = {} - for brickset in BrickSetList().generic().records: - bricksets[brickset.fields.set_num] = brickset - - # Return the files + # Inject the brickset if it exists for instruction in self.all.values(): - # Inject the brickset if it exists if ( instruction.allowed and - instruction.number is not None and - instruction.brickset is None and - instruction.number in bricksets + instruction.set is not None and + instruction.rebrickable is None and + instruction.set in rebrickable_sets ): - instruction.brickset = bricksets[instruction.number] - + instruction.rebrickable = rebrickable_sets[instruction.set] # noqa: E501 # Ignore errors except Exception: pass # Grab instructions for a set - def get(self, number: str) -> list[BrickInstructions]: - if number in self.sets: - return self.sets[number] + def get(self, set: str) -> list[BrickInstructions]: + if set in self.sets: + return self.sets[set] else: return [] diff --git a/bricktracker/login.py b/bricktracker/login.py index 87e6683..2642464 100644 --- a/bricktracker/login.py +++ b/bricktracker/login.py @@ -12,7 +12,7 @@ class LoginManager(object): def __init__(self, app: Flask, /): # Setup basic authentication - app.secret_key = app.config['AUTHENTICATION_KEY'].value + app.secret_key = app.config['AUTHENTICATION_KEY'] manager = login_manager.LoginManager() manager.login_view = 'login.login' # type: ignore @@ -23,11 +23,11 @@ class LoginManager(object): def user_loader(*arg) -> LoginManager.User: return self.User( 'admin', - app.config['AUTHENTICATION_PASSWORD'].value + app.config['AUTHENTICATION_PASSWORD'] ) # If the password is unset, globally disable - app.config['LOGIN_DISABLED'] = app.config['AUTHENTICATION_PASSWORD'].value == '' # noqa: E501 + app.config['LOGIN_DISABLED'] = app.config['AUTHENTICATION_PASSWORD'] == '' # noqa: E501 # Tells whether the user is authenticated, meaning: # - Authentication disabled diff --git a/bricktracker/minifigure.py b/bricktracker/minifigure.py index 7a7adf6..0ad55b1 100644 --- a/bricktracker/minifigure.py +++ b/bricktracker/minifigure.py @@ -23,6 +23,7 @@ class BrickMinifigure(BrickRecord): def __init__( self, /, + *, brickset: 'BrickSet | None' = None, record: Row | dict[str, Any] | None = None, ): @@ -61,18 +62,13 @@ class BrickMinifigure(BrickRecord): # Save the parameters to the fields self.fields.fig_num = fig_num - record = self.select(override_query=self.generic_query) - - if record is None: + 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, ), ) - # Ingest the record - self.ingest(record) - return self # Select a specific minifigure (with a set and an number) @@ -81,19 +77,14 @@ class BrickMinifigure(BrickRecord): self.brickset = brickset self.fields.fig_num = fig_num - record = self.select() - - if record is None: + 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, - set=self.brickset.fields.set_num, + set=self.brickset.fields.set, ), ) - # Ingest the record - self.ingest(record) - return self # Return a dict with common SQL parameters for a minifigure @@ -103,10 +94,10 @@ class BrickMinifigure(BrickRecord): # Supplement from the brickset if self.brickset is not None: if 'u_id' not in parameters: - parameters['u_id'] = self.brickset.fields.u_id + parameters['u_id'] = self.brickset.fields.id if 'set_num' not in parameters: - parameters['set_num'] = self.brickset.fields.set_num + parameters['set_num'] = self.brickset.fields.set return parameters @@ -119,7 +110,7 @@ class BrickMinifigure(BrickRecord): # Compute the url for minifigure part image def url_for_image(self, /) -> str: - if not current_app.config['USE_REMOTE_IMAGES'].value: + if not current_app.config['USE_REMOTE_IMAGES']: if self.fields.set_img_url is None: file = RebrickableImage.nil_minifigure_name() else: @@ -128,15 +119,15 @@ class BrickMinifigure(BrickRecord): return RebrickableImage.static_url(file, 'MINIFIGURES_FOLDER') else: if self.fields.set_img_url is None: - return current_app.config['REBRICKABLE_IMAGE_NIL_MINIFIGURE'].value # noqa: E501 + 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'].value: + if current_app.config['REBRICKABLE_LINKS']: try: - return current_app.config['REBRICKABLE_LINK_MINIFIGURE_PATTERN'].value.format( # noqa: E501 + return current_app.config['REBRICKABLE_LINK_MINIFIGURE_PATTERN'].format( # noqa: E501 number=self.fields.fig_num.lower(), ) except Exception: @@ -149,6 +140,7 @@ class BrickMinifigure(BrickRecord): def from_rebrickable( data: dict[str, Any], /, + *, brickset: 'BrickSet | None' = None, **_, ) -> dict[str, Any]: @@ -160,7 +152,7 @@ class BrickMinifigure(BrickRecord): } if brickset is not None: - record['set_num'] = brickset.fields.set_num - record['u_id'] = brickset.fields.u_id + 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 dd00c2d..04ece73 100644 --- a/bricktracker/minifigure_list.py +++ b/bricktracker/minifigure_list.py @@ -27,7 +27,7 @@ class BrickMinifigureList(BrickRecordList[BrickMinifigure]): self.brickset = None # Store the order for this list - self.order = current_app.config['MINIFIGURES_DEFAULT_ORDER'].value + self.order = current_app.config['MINIFIGURES_DEFAULT_ORDER'] # Load all minifigures def all(self, /) -> Self: @@ -42,9 +42,9 @@ class BrickMinifigureList(BrickRecordList[BrickMinifigure]): return self # Last added minifigure - def last(self, /, limit: int = 6) -> Self: + def last(self, /, *, limit: int = 6) -> Self: # Randomize - if current_app.config['RANDOM'].value: + if current_app.config['RANDOM']: order = 'RANDOM()' else: order = 'minifigures.rowid DESC' @@ -78,8 +78,8 @@ class BrickMinifigureList(BrickRecordList[BrickMinifigure]): parameters: dict[str, Any] = super().sql_parameters() if self.brickset is not None: - parameters['u_id'] = self.brickset.fields.u_id - parameters['set_num'] = self.brickset.fields.set_num + parameters['u_id'] = self.brickset.fields.id + parameters['set_num'] = self.brickset.fields.set return parameters @@ -89,6 +89,7 @@ class BrickMinifigureList(BrickRecordList[BrickMinifigure]): part_num: str, color_id: int, /, + *, element_id: int | None = None, ) -> Self: # Save the parameters to the fields @@ -113,6 +114,7 @@ class BrickMinifigureList(BrickRecordList[BrickMinifigure]): part_num: str, color_id: int, /, + *, element_id: int | None = None, ) -> Self: # Save the parameters to the fields diff --git a/bricktracker/part.py b/bricktracker/part.py index e14b6c0..80a51bd 100644 --- a/bricktracker/part.py +++ b/bricktracker/part.py @@ -27,6 +27,7 @@ class BrickPart(BrickRecord): def __init__( self, /, + *, brickset: 'BrickSet | None' = None, minifigure: 'BrickMinifigure | None' = None, record: Row | dict[str, Any] | None = None, @@ -83,6 +84,7 @@ class BrickPart(BrickRecord): part_num: str, color_id: int, /, + *, element_id: int | None = None ) -> Self: # Save the parameters to the fields @@ -90,9 +92,7 @@ class BrickPart(BrickRecord): self.fields.color_id = color_id self.fields.element_id = element_id - record = self.select(override_query=self.generic_query) - - if record is None: + 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, @@ -101,9 +101,6 @@ class BrickPart(BrickRecord): ), ) - # Ingest the record - self.ingest(record) - return self # Select a specific part (with a set and an id, and option. a minifigure) @@ -112,6 +109,7 @@ class BrickPart(BrickRecord): brickset: 'BrickSet', id: str, /, + *, minifigure: 'BrickMinifigure | None' = None, ) -> Self: # Save the parameters to the fields @@ -119,19 +117,14 @@ class BrickPart(BrickRecord): self.minifigure = minifigure self.fields.id = id - record = self.select() - - if record is None: + if not self.select(): raise NotFoundException( 'Part with ID {id} from set {set} was not found in the database'.format( # noqa: E501 id=self.fields.id, - set=self.brickset.fields.set_num, + set=self.brickset.fields.set, ), ) - # Ingest the record - self.ingest(record) - return self # Return a dict with common SQL parameters for a part @@ -140,14 +133,14 @@ class BrickPart(BrickRecord): # Supplement from the brickset if 'u_id' not in parameters and self.brickset is not None: - parameters['u_id'] = self.brickset.fields.u_id + parameters['u_id'] = self.brickset.fields.id if 'set_num' not in parameters: 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_num + parameters['set_num'] = self.brickset.fields.set return parameters @@ -190,9 +183,9 @@ class BrickPart(BrickRecord): # Compute the url for the bricklink page def url_for_bricklink(self, /) -> str: - if current_app.config['BRICKLINK_LINKS'].value: + if current_app.config['BRICKLINK_LINKS']: try: - return current_app.config['BRICKLINK_LINK_PART_PATTERN'].value.format( # noqa: E501 + return current_app.config['BRICKLINK_LINK_PART_PATTERN'].format( # noqa: E501 number=self.fields.part_num, ) except Exception: @@ -202,7 +195,7 @@ class BrickPart(BrickRecord): # Compute the url for the part image def url_for_image(self, /) -> str: - if not current_app.config['USE_REMOTE_IMAGES'].value: + if not current_app.config['USE_REMOTE_IMAGES']: if self.fields.part_img_url is None: file = RebrickableImage.nil_name() else: @@ -211,7 +204,7 @@ class BrickPart(BrickRecord): return RebrickableImage.static_url(file, 'PARTS_FOLDER') else: if self.fields.part_img_url is None: - return current_app.config['REBRICKABLE_IMAGE_NIL'].value + return current_app.config['REBRICKABLE_IMAGE_NIL'] else: return self.fields.part_img_url @@ -234,9 +227,9 @@ class BrickPart(BrickRecord): # Compute the url for the rebrickable page def url_for_rebrickable(self, /) -> str: - if current_app.config['REBRICKABLE_LINKS'].value: + if current_app.config['REBRICKABLE_LINKS']: try: - return current_app.config['REBRICKABLE_LINK_PART_PATTERN'].value.format( # noqa: E501 + return current_app.config['REBRICKABLE_LINK_PART_PATTERN'].format( # noqa: E501 number=self.fields.part_num, color=self.fields.color_id, ) @@ -250,6 +243,7 @@ class BrickPart(BrickRecord): def from_rebrickable( data: dict[str, Any], /, + *, brickset: 'BrickSet | None' = None, minifigure: 'BrickMinifigure | None' = None, **_, @@ -269,7 +263,7 @@ class BrickPart(BrickRecord): } if brickset is not None: - record['u_id'] = brickset.fields.u_id + record['u_id'] = brickset.fields.id if minifigure is not None: record['set_num'] = data['fig_num'] diff --git a/bricktracker/part_list.py b/bricktracker/part_list.py index d6e6945..93897f8 100644 --- a/bricktracker/part_list.py +++ b/bricktracker/part_list.py @@ -30,7 +30,7 @@ class BrickPartList(BrickRecordList[BrickPart]): self.minifigure = None # Store the order for this list - self.order = current_app.config['PARTS_DEFAULT_ORDER'].value + self.order = current_app.config['PARTS_DEFAULT_ORDER'] # Load all parts def all(self, /) -> Self: @@ -49,6 +49,7 @@ class BrickPartList(BrickRecordList[BrickPart]): self, brickset: 'BrickSet', /, + *, minifigure: 'BrickMinifigure | None' = None, ) -> Self: # Save the brickset and minifigure @@ -63,10 +64,7 @@ class BrickPartList(BrickRecordList[BrickPart]): record=record, ) - if ( - current_app.config['SKIP_SPARE_PARTS'].value and - part.fields.is_spare - ): + if current_app.config['SKIP_SPARE_PARTS'] and part.fields.is_spare: continue self.records.append(part) @@ -92,10 +90,7 @@ class BrickPartList(BrickRecordList[BrickPart]): record=record, ) - if ( - current_app.config['SKIP_SPARE_PARTS'].value and - part.fields.is_spare - ): + if current_app.config['SKIP_SPARE_PARTS'] and part.fields.is_spare: continue self.records.append(part) @@ -120,13 +115,13 @@ class BrickPartList(BrickRecordList[BrickPart]): # Set id if self.brickset is not None: - parameters['u_id'] = self.brickset.fields.u_id + parameters['u_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_num + parameters['set_num'] = self.brickset.fields.set return parameters diff --git a/bricktracker/rebrickable.py b/bricktracker/rebrickable.py index 3f34fdd..aa69c55 100644 --- a/bricktracker/rebrickable.py +++ b/bricktracker/rebrickable.py @@ -9,11 +9,12 @@ from .exceptions import NotFoundException, ErrorException if TYPE_CHECKING: from .minifigure import BrickMinifigure from .part import BrickPart + from .rebrickable_set import RebrickableSet from .set import BrickSet from .socket import BrickSocket from .wish import BrickWish -T = TypeVar('T', 'BrickSet', 'BrickPart', 'BrickMinifigure', 'BrickWish') +T = TypeVar('T', 'RebrickableSet', 'BrickPart', 'BrickMinifigure', 'BrickWish') # An helper around the rebrick library, autoconverting @@ -23,10 +24,11 @@ class Rebrickable(Generic[T]): number: str model: Type[T] - socket: 'BrickSocket | None' brickset: 'BrickSet | None' - minifigure: 'BrickMinifigure | None' + instance: T | None kind: str + minifigure: 'BrickMinifigure | None' + socket: 'BrickSocket | None' def __init__( self, @@ -34,9 +36,11 @@ class Rebrickable(Generic[T]): number: str, model: Type[T], /, - socket: 'BrickSocket | None' = None, + *, brickset: 'BrickSet | None' = None, - minifigure: 'BrickMinifigure | None' = None + instance: T | None = None, + minifigure: 'BrickMinifigure | None' = None, + socket: 'BrickSocket | None' = None, ): if not hasattr(lego, method): raise ErrorException('{method} is not a valid method for the rebrick.lego module'.format( # noqa: E501 @@ -48,9 +52,10 @@ class Rebrickable(Generic[T]): self.number = number self.model = model - self.socket = socket self.brickset = brickset + self.instance = instance self.minifigure = minifigure + self.socket = socket if self.minifigure is not None: self.kind = 'Minifigure' @@ -61,13 +66,15 @@ class Rebrickable(Generic[T]): def get(self, /) -> T: model_parameters = self.model_parameters() - return self.model( - **model_parameters, - record=self.model.from_rebrickable( - self.load(), - brickset=self.brickset, - ), - ) + if self.instance is None: + self.instance = self.model(**model_parameters) + + self.instance.ingest(self.model.from_rebrickable( + self.load(), + brickset=self.brickset, + )) + + return self.instance # Get paginated elements from the Rebrickable API def list(self, /) -> list[T]: @@ -77,7 +84,7 @@ class Rebrickable(Generic[T]): # Bootstrap a first set of parameters parameters: dict[str, Any] | None = { - 'page_size': current_app.config['REBRICKABLE_PAGE_SIZE'].value, + 'page_size': current_app.config['REBRICKABLE_PAGE_SIZE'], } # Read all pages @@ -113,9 +120,9 @@ class Rebrickable(Generic[T]): return results # Load from the API - def load(self, /, parameters: dict[str, Any] = {}) -> dict[str, Any]: + def load(self, /, *, parameters: dict[str, Any] = {}) -> dict[str, Any]: # Inject the API key - parameters['api_key'] = current_app.config['REBRICKABLE_API_KEY'].value, # noqa: E501 + parameters['api_key'] = current_app.config['REBRICKABLE_API_KEY'] try: return json.loads( diff --git a/bricktracker/rebrickable_image.py b/bricktracker/rebrickable_image.py index 513e4b2..0a0d9f4 100644 --- a/bricktracker/rebrickable_image.py +++ b/bricktracker/rebrickable_image.py @@ -10,12 +10,12 @@ from .exceptions import DownloadException if TYPE_CHECKING: from .minifigure import BrickMinifigure from .part import BrickPart - from .set import BrickSet + from .rebrickable_set import RebrickableSet # A set, part or minifigure image from Rebrickable class RebrickableImage(object): - brickset: 'BrickSet' + set: 'RebrickableSet' minifigure: 'BrickMinifigure | None' part: 'BrickPart | None' @@ -23,13 +23,14 @@ class RebrickableImage(object): def __init__( self, - brickset: 'BrickSet', + set: 'RebrickableSet', /, + *, minifigure: 'BrickMinifigure | None' = None, part: 'BrickPart | None' = None, ): # Save all objects - self.brickset = brickset + self.set = set self.minifigure = minifigure self.part = part @@ -70,12 +71,12 @@ class RebrickableImage(object): # Return the folder depending on the objects provided def folder(self, /) -> str: if self.part is not None: - return current_app.config['PARTS_FOLDER'].value + return current_app.config['PARTS_FOLDER'] if self.minifigure is not None: - return current_app.config['MINIFIGURES_FOLDER'].value + return current_app.config['MINIFIGURES_FOLDER'] - return current_app.config['SETS_FOLDER'].value + return current_app.config['SETS_FOLDER'] # Return the id depending on the objects provided def id(self, /) -> str: @@ -91,7 +92,7 @@ class RebrickableImage(object): else: return self.minifigure.fields.fig_num - return self.brickset.fields.set_num + return self.set.fields.set # Return the path depending on the objects provided def path(self, /) -> str: @@ -105,24 +106,24 @@ class RebrickableImage(object): def url(self, /) -> str: if self.part is not None: if self.part.fields.part_img_url is None: - return current_app.config['REBRICKABLE_IMAGE_NIL'].value + return current_app.config['REBRICKABLE_IMAGE_NIL'] else: return self.part.fields.part_img_url if self.minifigure is not None: if self.minifigure.fields.set_img_url is None: - return current_app.config['REBRICKABLE_IMAGE_NIL_MINIFIGURE'].value # noqa: E501 + return current_app.config['REBRICKABLE_IMAGE_NIL_MINIFIGURE'] else: return self.minifigure.fields.set_img_url - return self.brickset.fields.set_img_url + return self.set.fields.image # Return the name of the nil image file @staticmethod def nil_name() -> str: filename, _ = os.path.splitext( os.path.basename( - urlparse(current_app.config['REBRICKABLE_IMAGE_NIL'].value).path # noqa: E501 + urlparse(current_app.config['REBRICKABLE_IMAGE_NIL']).path ) ) @@ -133,7 +134,7 @@ class RebrickableImage(object): def nil_minifigure_name() -> str: filename, _ = os.path.splitext( os.path.basename( - urlparse(current_app.config['REBRICKABLE_IMAGE_NIL_MINIFIGURE'].value).path # noqa: E501 + urlparse(current_app.config['REBRICKABLE_IMAGE_NIL_MINIFIGURE']).path # noqa: E501 ) ) @@ -142,7 +143,7 @@ class RebrickableImage(object): # Return the static URL for an image given a name and folder @staticmethod def static_url(name: str, folder_name: str) -> str: - folder: str = current_app.config[folder_name].value + folder: str = current_app.config[folder_name] # /!\ Everything is saved as .jpg, even if it came from a .png # not changing this behaviour. diff --git a/bricktracker/rebrickable_minifigures.py b/bricktracker/rebrickable_minifigures.py index b34284a..eb72e06 100644 --- a/bricktracker/rebrickable_minifigures.py +++ b/bricktracker/rebrickable_minifigures.py @@ -30,18 +30,18 @@ class RebrickableMinifigures(object): def download(self, /) -> None: self.socket.auto_progress( message='Set {number}: loading minifigures from Rebrickable'.format( # noqa: E501 - number=self.brickset.fields.set_num, + number=self.brickset.fields.set, ), increment_total=True, ) - logger.debug('rebrick.lego.get_set_minifigs("{set_num}")'.format( - set_num=self.brickset.fields.set_num, + logger.debug('rebrick.lego.get_set_minifigs("{set}")'.format( + set=self.brickset.fields.set, )) minifigures = Rebrickable[BrickMinifigure]( 'get_set_minifigs', - self.brickset.fields.set_num, + self.brickset.fields.set, BrickMinifigure, socket=self.socket, brickset=self.brickset, @@ -53,7 +53,7 @@ class RebrickableMinifigures(object): # 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_num, + number=self.brickset.fields.set, current=index+1, total=total, ) @@ -65,13 +65,13 @@ class RebrickableMinifigures(object): # Grab the image self.socket.progress( message='Set {number}: downloading minifigure {current}/{total} image'.format( # noqa: E501 - number=self.brickset.fields.set_num, + number=self.brickset.fields.set, current=index+1, total=total, ) ) - if not current_app.config['USE_REMOTE_IMAGES'].value: + if not current_app.config['USE_REMOTE_IMAGES']: RebrickableImage( self.brickset, minifigure=minifigure diff --git a/bricktracker/rebrickable_parts.py b/bricktracker/rebrickable_parts.py index 1049b92..69c42dc 100644 --- a/bricktracker/rebrickable_parts.py +++ b/bricktracker/rebrickable_parts.py @@ -29,6 +29,7 @@ class RebrickableParts(object): socket: 'BrickSocket', brickset: 'BrickSet', /, + *, minifigure: 'BrickMinifigure | None' = None, ): # Save the socket @@ -43,7 +44,7 @@ class RebrickableParts(object): self.kind = 'Minifigure' self.method = 'get_minifig_elements' else: - self.number = self.brickset.fields.set_num + self.number = self.brickset.fields.set self.kind = 'Set' self.method = 'get_set_elements' @@ -76,7 +77,7 @@ class RebrickableParts(object): for index, part in enumerate(inventory): # Skip spare parts if ( - current_app.config['SKIP_SPARE_PARTS'].value and + current_app.config['SKIP_SPARE_PARTS'] and part.fields.is_spare ): continue @@ -104,7 +105,7 @@ class RebrickableParts(object): ) ) - if not current_app.config['USE_REMOTE_IMAGES'].value: + if not current_app.config['USE_REMOTE_IMAGES']: RebrickableImage( self.brickset, minifigure=self.minifigure, diff --git a/bricktracker/rebrickable_set.py b/bricktracker/rebrickable_set.py index b6ccf16..37e26b3 100644 --- a/bricktracker/rebrickable_set.py +++ b/bricktracker/rebrickable_set.py @@ -1,148 +1,135 @@ import logging +from sqlite3 import Row import traceback from typing import Any, TYPE_CHECKING -from uuid import uuid4 from flask import current_app from .exceptions import ErrorException, NotFoundException +from .instructions import BrickInstructions from .rebrickable import Rebrickable from .rebrickable_image import RebrickableImage -from .rebrickable_minifigures import RebrickableMinifigures -from .rebrickable_parts import RebrickableParts -from .set import BrickSet -from .sql import BrickSQL -from .wish import BrickWish +from .record import BrickRecord +from .theme_list import BrickThemeList if TYPE_CHECKING: from .socket import BrickSocket + from .theme import BrickTheme logger = logging.getLogger(__name__) # A set from Rebrickable -class RebrickableSet(object): +class RebrickableSet(BrickRecord): socket: 'BrickSocket' + theme: 'BrickTheme' + instructions: list[BrickInstructions] + + # Flags + resolve_instructions: bool = True + + # Queries + select_query: str = 'rebrickable/set/select' + insert_query: str = 'rebrickable/set/insert' + + def __init__( + self, + /, + *, + socket: 'BrickSocket | None' = None, + record: Row | dict[str, Any] | None = None + ): + super().__init__() + + # Placeholders + self.instructions = [] - def __init__(self, socket: 'BrickSocket', /): # Save the socket - self.socket = 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(self, data: dict[str, Any], /) -> None: - # Reset the progress - self.socket.progress_count = 0 - self.socket.progress_total = 0 + def download_rebrickable(self, /) -> None: + # Insert the Rebrickable set to the database + rows, _ = self.insert( + commit=False, + no_defer=True, + override_query=RebrickableSet.insert_query + ) - # Load the set - brickset = self.load(data, from_download=True) + if rows > 0: + if not current_app.config['USE_REMOTE_IMAGES']: + RebrickableImage(self).download() - # None brickset means loading failed - if brickset is None: - return + # Ingest a set + def ingest(self, record: Row | dict[str, Any], /): + super().ingest(record) - try: - # Insert into the database - self.socket.auto_progress( - message='Set {number}: inserting into database'.format( - number=brickset.fields.set_num - ), - increment_total=True, - ) + # Resolve theme + if not hasattr(self.fields, 'theme_id'): + self.fields.theme_id = 0 - # Assign a unique ID to the set - brickset.fields.u_id = str(uuid4()) + self.theme = BrickThemeList().get(self.fields.theme_id) - # Insert into database - brickset.insert(commit=False) + # Resolve instructions + if self.resolve_instructions: + # Not idead, avoiding cyclic import + from .instructions_list import BrickInstructionsList - if not current_app.config['USE_REMOTE_IMAGES'].value: - RebrickableImage(brickset).download() - - # Load the inventory - RebrickableParts(self.socket, brickset).download() - - # Load the minifigures - RebrickableMinifigures(self.socket, brickset).download() - - # Commit the transaction to the database - self.socket.auto_progress( - message='Set {number}: writing to the database'.format( - number=brickset.fields.set_num - ), - increment_total=True, - ) - - BrickSQL().commit() - - # Info - logger.info('Set {number}: imported (id: {id})'.format( - number=brickset.fields.set_num, - id=brickset.fields.u_id, - )) - - # Complete - self.socket.complete( - message='Set {number}: imported (Go to the set)'.format( # noqa: E501 - number=brickset.fields.set_num, - url=brickset.url() - ), - download=True - ) - - except Exception as e: - self.socket.fail( - message='Error while importing set {number}: {error}'.format( - number=brickset.fields.set_num, - error=e, + if self.fields.set is not None: + self.instructions = BrickInstructionsList().get( + self.fields.set ) - ) - - logger.debug(traceback.format_exc()) # Load the set from Rebrickable def load( self, data: dict[str, Any], /, + *, from_download=False, - ) -> BrickSet | None: + ) -> bool: # Reset the progress self.socket.progress_count = 0 self.socket.progress_total = 2 try: self.socket.auto_progress(message='Parsing set number') - set_num = RebrickableSet.parse_number(str(data['set_num'])) + set = RebrickableSet.parse_number(str(data['set'])) self.socket.auto_progress( - message='Set {num}: loading from Rebrickable'.format( - num=set_num, + message='Set {set}: loading from Rebrickable'.format( + set=set, ), ) - logger.debug('rebrick.lego.get_set("{set_num}")'.format( - set_num=set_num, + logger.debug('rebrick.lego.get_set("{set}")'.format( + set=set, )) - brickset = Rebrickable[BrickSet]( + Rebrickable[RebrickableSet]( 'get_set', - set_num, - BrickSet, + set, + RebrickableSet, + instance=self, ).get() - short = brickset.short() - short['download'] = from_download - - self.socket.emit('SET_LOADED', short) + self.socket.emit('SET_LOADED', self.short( + from_download=from_download + )) if not from_download: self.socket.complete( - message='Set {num}: loaded from Rebrickable'.format( - num=brickset.fields.set_num + message='Set {set}: loaded from Rebrickable'.format( + set=self.fields.set ) ) - return brickset + return True + except Exception as e: self.socket.fail( message='Could not load the set from Rebrickable: {error}. Data: {data}'.format( # noqa: E501 @@ -154,12 +141,57 @@ class RebrickableSet(object): if not isinstance(e, (NotFoundException, ErrorException)): logger.debug(traceback.format_exc()) - return None + return False + + # Return a short form of the Rebrickable set + def short(self, /, *, from_download: bool = False) -> dict[str, Any]: + return { + 'download': from_download, + 'image': self.fields.image, + 'name': self.fields.name, + 'set': self.fields.set, + } + + # Compute the url for the set image + def url_for_image(self, /) -> str: + if not current_app.config['USE_REMOTE_IMAGES']: + return RebrickableImage.static_url( + self.fields.set, + 'SETS_FOLDER' + ) + else: + return self.fields.image + + # Compute the url for the rebrickable page + def url_for_rebrickable(self, /) -> str: + if current_app.config['REBRICKABLE_LINKS']: + return self.fields.url + + return '' + + # Normalize from Rebrickable + @staticmethod + def from_rebrickable(data: dict[str, Any], /, **_) -> dict[str, Any]: + # Extracting version and number + number, _, version = str(data['set_num']).partition('-') + + return { + 'set': str(data['set_num']), + 'number': int(number), + 'version': int(version), + 'name': str(data['name']), + 'year': int(data['year']), + 'theme_id': int(data['theme_id']), + 'number_of_parts': int(data['num_parts']), + 'image': str(data['set_img_url']), + 'url': str(data['set_url']), + 'last_modified': str(data['last_modified_dt']), + } # Make sense of the number from the data @staticmethod - def parse_number(set_num: str, /) -> str: - number, _, version = set_num.partition('-') + def parse_number(set: str, /) -> str: + number, _, version = set.partition('-') # Making sure both are integers if version == '': @@ -191,24 +223,3 @@ class RebrickableSet(object): )) return '{number}-{version}'.format(number=number, version=version) - - # Wish from Rebrickable - # Redefine this one outside of the socket logic - @staticmethod - def wish(set_num: str) -> None: - set_num = RebrickableSet.parse_number(set_num) - logger.debug('rebrick.lego.get_set("{set_num}")'.format( - set_num=set_num, - )) - - brickwish = Rebrickable[BrickWish]( - 'get_set', - set_num, - BrickWish, - ).get() - - # Insert into database - brickwish.insert() - - if not current_app.config['USE_REMOTE_IMAGES'].value: - RebrickableImage(brickwish).download() diff --git a/bricktracker/rebrickable_set_list.py b/bricktracker/rebrickable_set_list.py new file mode 100644 index 0000000..8fe7ee9 --- /dev/null +++ b/bricktracker/rebrickable_set_list.py @@ -0,0 +1,21 @@ +from typing import Self + +from .rebrickable_set import RebrickableSet +from .record_list import BrickRecordList + + +# All the rebrickable sets from the database +class RebrickableSetList(BrickRecordList[RebrickableSet]): + + # Queries + select_query: str = 'rebrickable/set/list' + + # All the sets + def all(self, /) -> Self: + # Load the sets from the database + for record in self.select(): + rebrickable_set = RebrickableSet(record=record) + + self.records.append(rebrickable_set) + + return self diff --git a/bricktracker/record.py b/bricktracker/record.py index a98db0d..08651d2 100644 --- a/bricktracker/record.py +++ b/bricktracker/record.py @@ -1,5 +1,5 @@ from sqlite3 import Row -from typing import Any, ItemsView +from typing import Any, ItemsView, Tuple from .fields import BrickRecordFields from .sql import BrickSQL @@ -24,33 +24,63 @@ class BrickRecord(object): # Insert into the database # If we do not commit immediately, we defer the execute() call - def insert(self, /, commit=True) -> None: + def insert( + self, + /, + *, + commit=True, + no_defer=False, + override_query: str | None = None + ) -> Tuple[int, str]: + if override_query: + query = override_query + else: + query = self.insert_query + database = BrickSQL() rows, q = database.execute( - self.insert_query, + query, parameters=self.sql_parameters(), - defer=not commit, + defer=not commit and not no_defer, ) if commit: database.commit() + return rows, q + # Shorthand to field items def items(self, /) -> ItemsView[str, Any]: return self.fields.__dict__.items() # Get from the database using the query - def select(self, /, override_query: str | None = None) -> Row | None: + def select( + self, + /, + *, + override_query: str | None = None, + **context: Any + ) -> bool: if override_query: query = override_query else: query = self.select_query - return BrickSQL().fetchone( + record = BrickSQL().fetchone( query, - parameters=self.sql_parameters() + parameters=self.sql_parameters(), + **context ) + # Ingest the record + if record is not None: + self.ingest(record) + + return True + + else: + return False + # Generic SQL parameters from fields def sql_parameters(self, /) -> dict[str, Any]: parameters: dict[str, Any] = {} diff --git a/bricktracker/record_list.py b/bricktracker/record_list.py index 093d73b..0798991 100644 --- a/bricktracker/record_list.py +++ b/bricktracker/record_list.py @@ -6,10 +6,20 @@ from .sql import BrickSQL if TYPE_CHECKING: from .minifigure import BrickMinifigure from .part import BrickPart + from .rebrickable_set import RebrickableSet from .set import BrickSet + from .set_checkbox import BrickSetCheckbox from .wish import BrickWish -T = TypeVar('T', 'BrickSet', 'BrickPart', 'BrickMinifigure', 'BrickWish') +T = TypeVar( + 'T', + 'BrickSet', + 'BrickSetCheckbox', + 'BrickPart', + 'BrickMinifigure', + 'BrickWish', + 'RebrickableSet' +) # SQLite records @@ -32,9 +42,11 @@ class BrickRecordList(Generic[T]): def select( self, /, + *, override_query: str | None = None, order: str | None = None, limit: int | None = None, + **context: Any, ) -> list[Row]: # Select the query if override_query: @@ -47,6 +59,7 @@ class BrickRecordList(Generic[T]): parameters=self.sql_parameters(), order=order, limit=limit, + **context ) # Generic SQL parameters from fields @@ -62,6 +75,6 @@ class BrickRecordList(Generic[T]): for record in self.records: yield record - # Make the sets measurable + # Make the list measurable def __len__(self, /) -> int: return len(self.records) diff --git a/bricktracker/reload.py b/bricktracker/reload.py new file mode 100644 index 0000000..62564df --- /dev/null +++ b/bricktracker/reload.py @@ -0,0 +1,23 @@ +from .instructions_list import BrickInstructionsList +from .retired_list import BrickRetiredList +from .set_checkbox_list import BrickSetCheckboxList +from .theme_list import BrickThemeList + + +# Reload everything related to a database after an operation +def reload() -> None: + # Failsafe + try: + # Reload the instructions + BrickInstructionsList(force=True) + + # Reload the checkboxes + BrickSetCheckboxList(force=True) + + # Reload retired sets + BrickRetiredList(force=True) + + # Reload themes + BrickThemeList(force=True) + except Exception: + pass diff --git a/bricktracker/retired_list.py b/bricktracker/retired_list.py index e76a699..231c472 100644 --- a/bricktracker/retired_list.py +++ b/bricktracker/retired_list.py @@ -22,7 +22,7 @@ class BrickRetiredList(object): size: int | None exception: Exception | None - def __init__(self, /, force: bool = False): + def __init__(self, /, *, force: bool = False): # Load sets only if there is none already loaded retired = getattr(self, 'retired', None) @@ -33,7 +33,7 @@ class BrickRetiredList(object): # Try to read the themes from a CSV file try: - with open(current_app.config['RETIRED_SETS_PATH'].value, newline='') as themes_file: # noqa: E501 + with open(current_app.config['RETIRED_SETS_PATH'], newline='') as themes_file: # noqa: E501 themes_reader = csv.reader(themes_file) # Ignore the header @@ -44,7 +44,7 @@ class BrickRetiredList(object): BrickRetiredList.retired[retired.number] = retired # File stats - stat = os.stat(current_app.config['RETIRED_SETS_PATH'].value) + stat = os.stat(current_app.config['RETIRED_SETS_PATH']) BrickRetiredList.size = stat.st_size BrickRetiredList.mtime = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc) # noqa: E501 @@ -79,7 +79,7 @@ class BrickRetiredList(object): def human_time(self) -> str: if self.mtime is not None: return self.mtime.astimezone(g.timezone).strftime( - current_app.config['FILE_DATETIME_FORMAT'].value + current_app.config['FILE_DATETIME_FORMAT'] ) else: return '' @@ -88,7 +88,7 @@ class BrickRetiredList(object): @staticmethod def update() -> None: response = requests.get( - current_app.config['RETIRED_SETS_FILE_URL'].value, + current_app.config['RETIRED_SETS_FILE_URL'], stream=True, ) @@ -99,7 +99,7 @@ class BrickRetiredList(object): content = gzip.GzipFile(fileobj=response.raw) - with open(current_app.config['RETIRED_SETS_PATH'].value, 'wb') as f: + with open(current_app.config['RETIRED_SETS_PATH'], 'wb') as f: copyfileobj(content, f) logger.info('Retired sets list updated') diff --git a/bricktracker/set.py b/bricktracker/set.py index 2f1778e..aa536b8 100644 --- a/bricktracker/set.py +++ b/bricktracker/set.py @@ -1,70 +1,105 @@ -from sqlite3 import Row +import logging +import traceback from typing import Any, Self +from uuid import uuid4 -from flask import current_app, url_for +from flask import url_for from .exceptions import DatabaseException, NotFoundException -from .instructions import BrickInstructions -from .instructions_list import BrickInstructionsList from .minifigure_list import BrickMinifigureList from .part_list import BrickPartList -from .rebrickable_image import RebrickableImage -from .record import BrickRecord +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 .sql import BrickSQL -from .theme_list import BrickThemeList + +logger = logging.getLogger(__name__) # Lego brick set -class BrickSet(BrickRecord): - instructions: list[BrickInstructions] - theme_name: str - +class BrickSet(RebrickableSet): # Queries - select_query: str = 'set/select' + select_query: str = 'set/select/full' + light_query: str = 'set/select/light' insert_query: str = 'set/insert' - def __init__( - self, - /, - record: Row | dict[str, Any] | None = None, - ): - super().__init__() - - # Placeholders - self.theme_name = '' - self.instructions = [] - - # Ingest the record if it has one - if record is not None: - self.ingest(record) - - # Resolve the theme - self.resolve_theme() - - # Check for the instructions - self.resolve_instructions() - # Delete a set def delete(self, /) -> None: - database = BrickSQL() - parameters = self.sql_parameters() + BrickSQL().executescript( + 'set/delete/set', + id=self.fields.id + ) - # Delete the set - database.execute('set/delete/set', parameters=parameters) + # Import a set into the database + def download(self, data: dict[str, Any], /) -> None: + # Load the set + if not self.load(data, from_download=True): + return - # Delete the minifigures - database.execute( - 'minifigure/delete/all_from_set', parameters=parameters) + try: + # Insert into the database + self.socket.auto_progress( + message='Set {number}: inserting into database'.format( + number=self.fields.set + ), + increment_total=True, + ) - # Delete the parts - database.execute( - 'part/delete/all_from_set', parameters=parameters) + # Generate an UUID for self + self.fields.id = str(uuid4()) - # Delete missing parts - database.execute('missing/delete/all_from_set', parameters=parameters) + # Insert into database + self.insert(commit=False) - # Commit to the database - database.commit() + # Execute the parent download method + self.download_rebrickable() + + # Load the inventory + RebrickableParts(self.socket, self).download() + + # Load the minifigures + RebrickableMinifigures(self.socket, self).download() + + # Commit the transaction to the database + self.socket.auto_progress( + message='Set {number}: writing to the database'.format( + number=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, + )) + + # Complete + self.socket.complete( + message='Set {number}: imported (Go to the set)'.format( # noqa: E501 + number=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, + error=e, + ) + ) + + logger.debug(traceback.format_exc()) + + # Insert a Rebrickable set + def insert_rebrickable(self, /) -> None: + self.insert() # Minifigures def minifigures(self, /) -> BrickMinifigureList: @@ -74,145 +109,81 @@ class BrickSet(BrickRecord): def parts(self, /) -> BrickPartList: return BrickPartList().load(self) - # Add instructions to the set - def resolve_instructions(self, /) -> None: - if self.fields.set_num is not None: - self.instructions = BrickInstructionsList().get( - self.fields.set_num - ) - - # Add a theme to the set - def resolve_theme(self, /) -> None: - try: - id = self.fields.theme_id - except Exception: - id = 0 - - theme = BrickThemeList().get(id) - self.theme_name = theme.name - - # Return a short form of the set - def short(self, /) -> dict[str, Any]: - return { - 'name': self.fields.name, - 'set_img_url': self.fields.set_img_url, - 'set_num': self.fields.set_num, - } - - # Select a specific part (with a set and an id) - def select_specific(self, u_id: str, /) -> Self: + # Select a light set (with an id) + def select_light(self, id: str, /) -> Self: # Save the parameters to the fields - self.fields.u_id = u_id + self.fields.id = id # Load from database - record = self.select() - - if record is None: + if not self.select(override_query=self.light_query): raise NotFoundException( 'Set with ID {id} was not found in the database'.format( - id=self.fields.u_id, + id=self.fields.id, ), ) - # Ingest the record - self.ingest(record) + return self - # Resolve the theme - self.resolve_theme() + # Select a specific set (with an id) + def select_specific(self, id: str, /) -> Self: + # Save the parameters to the fields + self.fields.id = id - # Check for the instructions - self.resolve_instructions() + # Load from database + if not self.select( + statuses=BrickSetCheckboxList().as_columns(solo=True) + ): + raise NotFoundException( + 'Set with ID {id} was not found in the database'.format( + id=self.fields.id, + ), + ) return self - # Update a checked state - def update_checked(self, name: str, status: bool, /) -> None: + # Update a status + def update_status( + self, + checkbox: BrickSetCheckbox, + status: bool, + / + ) -> None: parameters = self.sql_parameters() parameters['status'] = status - # Update the checked status + # Update the status rows, _ = BrickSQL().execute_and_commit( - 'set/update_checked', + 'set/update/status', parameters=parameters, - name=name, + name=checkbox.as_column(), ) if rows != 1: - raise DatabaseException('Could not update the status {status} for set {number}'.format( # noqa: E501 - status=name, - number=self.fields.set_num, + raise DatabaseException('Could not update the status "{status}" for set {number} ({id})'.format( # noqa: E501 + status=checkbox.fields.name, + number=self.fields.set, + id=self.fields.id, )) # Self url def url(self, /) -> str: - return url_for('set.details', id=self.fields.u_id) + return url_for('set.details', id=self.fields.id) # Deletion url def url_for_delete(self, /) -> str: - return url_for('set.delete', id=self.fields.u_id) + return url_for('set.delete', id=self.fields.id) # Actual deletion url def url_for_do_delete(self, /) -> str: - return url_for('set.do_delete', id=self.fields.u_id) - - # Compute the url for the set image - def url_for_image(self, /) -> str: - if not current_app.config['USE_REMOTE_IMAGES'].value: - return RebrickableImage.static_url( - self.fields.set_num, - 'SETS_FOLDER' - ) - else: - return self.fields.set_img_url + return url_for('set.do_delete', id=self.fields.id) # Compute the url for the set instructions def url_for_instructions(self, /) -> str: if len(self.instructions): return url_for( 'set.details', - id=self.fields.u_id, + id=self.fields.id, open_instructions=True ) else: return '' - - # Check minifigure collected url - def url_for_minifigures_collected(self, /) -> str: - return url_for('set.minifigures_collected', id=self.fields.u_id) - - # Compute the url for the rebrickable page - def url_for_rebrickable(self, /) -> str: - if current_app.config['REBRICKABLE_LINKS'].value: - try: - return current_app.config['REBRICKABLE_LINK_SET_PATTERN'].value.format( # noqa: E501 - number=self.fields.set_num.lower(), - ) - except Exception: - pass - - return '' - - # Check set checked url - def url_for_set_checked(self, /) -> str: - return url_for('set.set_checked', id=self.fields.u_id) - - # Check set collected url - def url_for_set_collected(self, /) -> str: - return url_for('set.set_collected', id=self.fields.u_id) - - # Normalize from Rebrickable - @staticmethod - def from_rebrickable(data: dict[str, Any], /, **_) -> dict[str, Any]: - return { - 'set_num': data['set_num'], - 'name': data['name'], - 'year': data['year'], - 'theme_id': data['theme_id'], - 'num_parts': data['num_parts'], - 'set_img_url': data['set_img_url'], - 'set_url': data['set_url'], - 'last_modified_dt': data['last_modified_dt'], - 'mini_col': False, - 'set_col': False, - 'set_check': False, - } diff --git a/bricktracker/set_checkbox.py b/bricktracker/set_checkbox.py new file mode 100644 index 0000000..ea6d6d2 --- /dev/null +++ b/bricktracker/set_checkbox.py @@ -0,0 +1,142 @@ +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 new file mode 100644 index 0000000..0f32240 --- /dev/null +++ b/bricktracker/set_checkbox_list.py @@ -0,0 +1,74 @@ +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 6bbd1bf..3b229e8 100644 --- a/bricktracker/set_list.py +++ b/bricktracker/set_list.py @@ -3,6 +3,7 @@ from typing import Self from flask import current_app from .record_list import BrickRecordList +from .set_checkbox_list import BrickSetCheckboxList from .set import BrickSet @@ -13,6 +14,7 @@ class BrickSetList(BrickRecordList[BrickSet]): # Queries generic_query: str = 'set/list/generic' + light_query: str = 'set/list/light' missing_minifigure_query: str = 'set/list/missing_minifigure' missing_part_query: str = 'set/list/missing_part' select_query: str = 'set/list/all' @@ -26,18 +28,21 @@ class BrickSetList(BrickRecordList[BrickSet]): self.themes = [] # Store the order for this list - self.order = current_app.config['SETS_DEFAULT_ORDER'].value + self.order = current_app.config['SETS_DEFAULT_ORDER'] # All the sets def all(self, /) -> Self: themes = set() # Load the sets from the database - for record in self.select(order=self.order): + 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) + themes.add(brickset.theme.name) # Convert the set into a list and sort it self.themes = list(themes) @@ -58,14 +63,18 @@ class BrickSetList(BrickRecordList[BrickSet]): return self # Last added sets - def last(self, /, limit: int = 6) -> Self: + def last(self, /, *, limit: int = 6) -> Self: # Randomize - if current_app.config['RANDOM'].value: + if current_app.config['RANDOM']: order = 'RANDOM()' else: - order = 'sets.rowid DESC' + order = '"bricktracker_sets"."rowid" DESC' - for record in self.select(order=order, limit=limit): + for record in self.select( + order=order, + limit=limit, + statuses=BrickSetCheckboxList().as_columns() + ): brickset = BrickSet(record=record) self.records.append(brickset) @@ -76,7 +85,7 @@ class BrickSetList(BrickRecordList[BrickSet]): def missing_minifigure( self, fig_num: str, - /, + / ) -> Self: # Save the parameters to the fields self.fields.fig_num = fig_num @@ -98,6 +107,7 @@ class BrickSetList(BrickRecordList[BrickSet]): part_num: str, color_id: int, /, + *, element_id: int | None = None, ) -> Self: # Save the parameters to the fields @@ -120,7 +130,7 @@ class BrickSetList(BrickRecordList[BrickSet]): def using_minifigure( self, fig_num: str, - /, + / ) -> Self: # Save the parameters to the fields self.fields.fig_num = fig_num @@ -142,6 +152,7 @@ class BrickSetList(BrickRecordList[BrickSet]): part_num: str, color_id: int, /, + *, element_id: int | None = None, ) -> Self: # Save the parameters to the fields diff --git a/bricktracker/socket.py b/bricktracker/socket.py index 44ddadf..4351592 100644 --- a/bricktracker/socket.py +++ b/bricktracker/socket.py @@ -6,7 +6,7 @@ from flask_socketio import SocketIO from .configuration_list import BrickConfigurationList from .login import LoginManager -from .rebrickable_set import RebrickableSet +from .set import BrickSet from .sql import close as sql_close logger = logging.getLogger(__name__) @@ -56,19 +56,19 @@ class BrickSocket(object): # Compute the namespace self.namespace = '/{namespace}'.format( - namespace=app.config['SOCKET_NAMESPACE'].value + namespace=app.config['SOCKET_NAMESPACE'] ) # Inject CORS if a domain is defined - if app.config['DOMAIN_NAME'].value != '': - kwargs['cors_allowed_origins'] = app.config['DOMAIN_NAME'].value + if app.config['DOMAIN_NAME'] != '': + kwargs['cors_allowed_origins'] = app.config['DOMAIN_NAME'] # Instantiate the socket self.socket = SocketIO( self.app, *args, **kwargs, - path=app.config['SOCKET_PATH'].value, + path=app.config['SOCKET_PATH'], async_mode='eventlet', ) @@ -98,7 +98,7 @@ class BrickSocket(object): self.fail(message=str(e)) return - brickset = RebrickableSet(self) + brickset = BrickSet(socket=self) # Start it in a thread if requested if self.threaded: @@ -124,7 +124,7 @@ class BrickSocket(object): self.fail(message=str(e)) return - brickset = RebrickableSet(self) + brickset = BrickSet(socket=self) # Start it in a thread if requested if self.threaded: @@ -140,6 +140,7 @@ class BrickSocket(object): def auto_progress( self, /, + *, message: str | None = None, increment_total=False, ) -> None: @@ -203,7 +204,7 @@ class BrickSocket(object): sql_close() # Update the progress - def progress(self, /, message: str | None = None) -> None: + def progress(self, /, *, message: str | None = None) -> None: # Save the las message if message is not None: self.progress_message = message @@ -218,14 +219,14 @@ class BrickSocket(object): self.emit('PROGRESS', data) # Update the progress total only - def update_total(self, total: int, /, add: bool = False) -> None: + def update_total(self, total: int, /, *, add: bool = False) -> None: if add: self.progress_total += total else: self.progress_total = total # Update the total - def total_progress(self, total: int, /, add: bool = False) -> None: + def total_progress(self, total: int, /, *, add: bool = False) -> None: self.update_total(total, add=add) self.progress() diff --git a/bricktracker/sql.py b/bricktracker/sql.py index 77aa7b5..07811d9 100644 --- a/bricktracker/sql.py +++ b/bricktracker/sql.py @@ -1,33 +1,46 @@ import logging import os import sqlite3 -from typing import Any, Tuple - -from .sql_stats import BrickSQLStats +from typing import Any, Final, Tuple from flask import current_app, g from jinja2 import Environment, FileSystemLoader from werkzeug.datastructures import FileStorage +from .exceptions import DatabaseException +from .sql_counter import BrickCounter +from .sql_migration_list import BrickSQLMigrationList +from .sql_stats import BrickSQLStats +from .version import __database_version__ + logger = logging.getLogger(__name__) +G_CONNECTION: Final[str] = 'database_connection' +G_ENVIRONMENT: Final[str] = 'database_environment' +G_DEFER: Final[str] = 'database_defer' +G_STATS: Final[str] = 'database_stats' + # SQLite3 client with our extra features class BrickSQL(object): connection: sqlite3.Connection cursor: sqlite3.Cursor stats: BrickSQLStats + version: int - def __init__(self, /): + def __init__(self, /, *, failsafe: bool = False): # Instantiate the database connection in the Flask # application context so that it can be used by all # requests without re-opening connections - database = getattr(g, 'database', None) + connection = getattr(g, G_CONNECTION, None) # Grab the existing connection if it exists - if database is not None: - self.connection = database - self.stats = getattr(g, 'database_stats', BrickSQLStats()) + if connection is not None: + self.connection = connection + self.stats = getattr(g, G_STATS, BrickSQLStats()) + + # Grab a cursor + self.cursor = self.connection.cursor() else: # Instantiate the stats self.stats = BrickSQLStats() @@ -37,26 +50,54 @@ class BrickSQL(object): logger.debug('SQLite3: connect') self.connection = sqlite3.connect( - current_app.config['DATABASE_PATH'].value + current_app.config['DATABASE_PATH'] ) # Setup the row factory to get pseudo-dicts rather than tuples self.connection.row_factory = sqlite3.Row + # Grab a cursor + self.cursor = self.connection.cursor() + + # Grab the version and check + try: + version = self.fetchone('schema/get_version') + + if version is None: + raise Exception('version is None') + + self.version = version[0] + except Exception as e: + self.version = 0 + + raise DatabaseException('Could not get the database version: {error}'.format( # noqa: E501 + error=str(e) + )) + + if self.upgrade_too_far(): + raise DatabaseException('Your database version ({version}) is too far ahead for this version of the application. Expected at most {required}'.format( # noqa: E501 + version=self.version, + required=__database_version__, + )) + # Debug: Attach the debugger # Uncomment manually because this is ultra verbose # self.connection.set_trace_callback(print) # Save the connection globally for later use - g.database = self.connection - g.database_stats = self.stats + setattr(g, G_CONNECTION, self.connection) + setattr(g, G_STATS, self.stats) - # Grab a cursor - self.cursor = self.connection.cursor() + if not failsafe: + if self.upgrade_needed(): + raise DatabaseException('Your database need to be upgraded from version {version} to version {required}'.format( # noqa: E501 + version=self.version, + required=__database_version__, + )) # Clear the defer stack def clear_defer(self, /) -> None: - g.database_defer = [] + setattr(g, G_DEFER, []) # Shorthand to commit def commit(self, /) -> None: @@ -72,6 +113,27 @@ class BrickSQL(object): logger.debug('SQLite3: commit') return self.connection.commit() + # Count the database records + def count_records(self) -> list[BrickCounter]: + counters: list[BrickCounter] = [] + + # Get all tables + for table in self.fetchall('schema/tables'): + counter = BrickCounter(table['name']) + + # Failsafe this one + try: + record = self.fetchone('schema/count', table=counter.table) + + if record is not None: + counter.count = record['count'] + except Exception: + pass + + counters.append(counter) + + return counters + # Defer a call to execute def defer(self, query: str, parameters: dict[str, Any], /): defer = self.get_defer() @@ -82,16 +144,17 @@ class BrickSQL(object): defer.append((query, parameters)) # Save the defer stack - g.database_defer = defer + setattr(g, G_DEFER, defer) # Shorthand to execute, returning number of affected rows def execute( self, query: str, /, + *, parameters: dict[str, Any] = {}, defer: bool = False, - **context, + **context: Any, ) -> Tuple[int, str]: # Stats: execute self.stats.execute += 1 @@ -114,7 +177,7 @@ class BrickSQL(object): return result.rowcount, query # Shorthand to executescript - def executescript(self, query: str, /, **context) -> None: + def executescript(self, query: str, /, **context: Any) -> None: # Load the query query = self.load_query(query, **context) @@ -129,8 +192,9 @@ class BrickSQL(object): self, query: str, /, + *, parameters: dict[str, Any] = {}, - **context, + **context: Any, ) -> Tuple[int, str]: rows, query = self.execute(query, parameters=parameters, **context) self.commit() @@ -142,8 +206,9 @@ class BrickSQL(object): self, query: str, /, + *, parameters: dict[str, Any] = {}, - **context, + **context: Any, ) -> list[sqlite3.Row]: _, query = self.execute(query, parameters=parameters, **context) @@ -163,8 +228,9 @@ class BrickSQL(object): self, query: str, /, + *, parameters: dict[str, Any] = {}, - **context, + **context: Any, ) -> sqlite3.Row | None: _, query = self.execute(query, parameters=parameters, **context) @@ -182,21 +248,18 @@ class BrickSQL(object): # Grab the defer stack def get_defer(self, /) -> list[Tuple[str, dict[str, Any]]]: - defer: list[Tuple[str, dict[str, Any]]] = getattr( - g, - 'database_defer', - [] - ) + defer: list[Tuple[str, dict[str, Any]]] = getattr(g, G_DEFER, []) return defer # Load a query by name - def load_query(self, name: str, /, **context) -> str: + def load_query(self, name: str, /, **context: Any) -> str: # Grab the existing environment if it exists - environment = getattr(g, 'database_loader', None) + environment = getattr(g, G_ENVIRONMENT, None) # Instantiate Jinja environment for SQL files if environment is None: + logger.debug('SQLite3: instantiating the Jinja loader') environment = Environment( loader=FileSystemLoader( os.path.join(os.path.dirname(__file__), 'sql/') @@ -204,10 +267,10 @@ class BrickSQL(object): ) # Save the environment globally for later use - g.database_environment = environment + setattr(g, G_ENVIRONMENT, environment) # Grab the template - logger.debug('SQLite: loading {name} (context: {context})'.format( + logger.debug('SQLite3: loading {name} (context: {context})'.format( name=name, context=context, )) @@ -221,7 +284,8 @@ class BrickSQL(object): def raw_execute( self, query: str, - parameters: dict[str, Any] + parameters: dict[str, Any], + / ) -> sqlite3.Cursor: logger.debug('SQLite3: execute: {query}'.format( query=BrickSQL.clean_query(query) @@ -229,6 +293,25 @@ class BrickSQL(object): return self.cursor.execute(query, parameters) + # Upgrade the database + def upgrade(self) -> None: + if self.upgrade_needed(): + for pending in BrickSQLMigrationList().pending(self.version): + logger.info('Applying migration {version}'.format( + version=pending.version) + ) + + self.executescript(pending.get_query()) + self.execute('schema/set_version', version=pending.version) + + # Tells whether the database needs upgrade + def upgrade_needed(self) -> bool: + return self.version < __database_version__ + + # Tells whether the database is too far + def upgrade_too_far(self) -> bool: + return self.version > __database_version__ + # Clean the query for debugging @staticmethod def clean_query(query: str, /) -> str: @@ -249,7 +332,7 @@ class BrickSQL(object): # Delete the database @staticmethod def delete() -> None: - os.remove(current_app.config['DATABASE_PATH'].value) + os.remove(current_app.config['DATABASE_PATH']) # Info logger.info('The database has been deleted') @@ -262,37 +345,10 @@ class BrickSQL(object): # Info logger.info('The database has been dropped') - # Count the database records - @staticmethod - def count_records() -> dict[str, int]: - database = BrickSQL() - - counters: dict[str, int] = {} - for table in ['sets', 'minifigures', 'inventory', 'missing']: - record = database.fetchone('schema/count', table=table) - - if record is not None: - counters[table] = record['count'] - - return counters - - # Initialize the database - @staticmethod - def initialize() -> None: - BrickSQL().executescript('migrations/init') - - # Info - logger.info('The database has been initialized') - - # Check if the database is initialized - @staticmethod - def is_init() -> bool: - return BrickSQL().fetchone('schema/is_init') is not None - # Replace the database with a new file @staticmethod def upload(file: FileStorage, /) -> None: - file.save(current_app.config['DATABASE_PATH'].value) + file.save(current_app.config['DATABASE_PATH']) # Info logger.info('The database has been imported using file {file}'.format( @@ -302,11 +358,11 @@ class BrickSQL(object): # Close all existing SQLite3 connections def close() -> None: - database: sqlite3.Connection | None = getattr(g, 'database', None) + connection: sqlite3.Connection | None = getattr(g, G_CONNECTION, None) - if database is not None: + if connection is not None: logger.debug('SQLite3: close') - database.close() + connection.close() # Remove the database from the context - delattr(g, 'database') + delattr(g, G_CONNECTION) diff --git a/bricktracker/sql/checkbox/add.sql b/bricktracker/sql/checkbox/add.sql new file mode 100644 index 0000000..5de9c17 --- /dev/null +++ b/bricktracker/sql/checkbox/add.sql @@ -0,0 +1,16 @@ +BEGIN TRANSACTION; + +ALTER TABLE "bricktracker_set_statuses" +ADD COLUMN "status_{{ id }}" BOOLEAN NOT NULL DEFAULT 0; + +INSERT INTO "bricktracker_set_checkboxes" ( + "id", + "name", + "displayed_on_grid" +) VALUES ( + '{{ id }}', + '{{ name }}', + {{ displayed_on_grid }} +); + +COMMIT; \ No newline at end of file diff --git a/bricktracker/sql/checkbox/base.sql b/bricktracker/sql/checkbox/base.sql new file mode 100644 index 0000000..9726a6c --- /dev/null +++ b/bricktracker/sql/checkbox/base.sql @@ -0,0 +1,7 @@ +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 new file mode 100644 index 0000000..6eae9d0 --- /dev/null +++ b/bricktracker/sql/checkbox/delete.sql @@ -0,0 +1,9 @@ +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 new file mode 100644 index 0000000..7420eb3 --- /dev/null +++ b/bricktracker/sql/checkbox/list.sql @@ -0,0 +1 @@ +{% extends 'checkbox/base.sql' %} diff --git a/bricktracker/sql/checkbox/select.sql b/bricktracker/sql/checkbox/select.sql new file mode 100644 index 0000000..76557a8 --- /dev/null +++ b/bricktracker/sql/checkbox/select.sql @@ -0,0 +1,5 @@ +{% 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 new file mode 100644 index 0000000..19fccc0 --- /dev/null +++ b/bricktracker/sql/checkbox/update/name.sql @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..3c04c22 --- /dev/null +++ b/bricktracker/sql/checkbox/update/status.sql @@ -0,0 +1,3 @@ +UPDATE "bricktracker_set_checkboxes" +SET "{{name}}" = :status +WHERE "bricktracker_set_checkboxes"."id" IS NOT DISTINCT FROM :id diff --git a/bricktracker/sql/migrations/0001.sql b/bricktracker/sql/migrations/0001.sql new file mode 100644 index 0000000..6b71f7e --- /dev/null +++ b/bricktracker/sql/migrations/0001.sql @@ -0,0 +1,66 @@ +-- description: Original database initialization +-- FROM sqlite3 app.db .schema > init.sql with extra IF NOT EXISTS, transaction and quotes +BEGIN TRANSACTION; + +CREATE TABLE IF NOT EXISTS "wishlist" ( + "set_num" TEXT, + "name" TEXT, + "year" INTEGER, + "theme_id" INTEGER, + "num_parts" INTEGER, + "set_img_url" TEXT, + "set_url" TEXT, + "last_modified_dt" TEXT +); + +CREATE TABLE IF NOT EXISTS "sets" ( + "set_num" TEXT, + "name" TEXT, + "year" INTEGER, + "theme_id" INTEGER, + "num_parts" INTEGER, + "set_img_url" TEXT, + "set_url" TEXT, + "last_modified_dt" TEXT, + "mini_col" BOOLEAN, + "set_check" BOOLEAN, + "set_col" BOOLEAN, + "u_id" TEXT +); + +CREATE TABLE IF NOT EXISTS "inventory" ( + "set_num" TEXT, + "id" INTEGER, + "part_num" TEXT, + "name" TEXT, + "part_img_url" TEXT, + "part_img_url_id" TEXT, + "color_id" INTEGER, + "color_name" TEXT, + "quantity" INTEGER, + "is_spare" BOOLEAN, + "element_id" INTEGER, + "u_id" TEXT +); + +CREATE TABLE IF NOT EXISTS "minifigures" ( + "fig_num" TEXT, + "set_num" TEXT, + "name" TEXT, + "quantity" INTEGER, + "set_img_url" TEXT, + "u_id" TEXT +); + +CREATE TABLE IF NOT EXISTS "missing" ( + "set_num" TEXT, + "id" INTEGER, + "part_num" TEXT, + "part_img_url_id" TEXT, + "color_id" INTEGER, + "quantity" INTEGER, + "element_id" INTEGER, + "u_id" TEXT +); + +COMMIT; \ No newline at end of file diff --git a/bricktracker/sql/migrations/0002.sql b/bricktracker/sql/migrations/0002.sql new file mode 100644 index 0000000..1987812 --- /dev/null +++ b/bricktracker/sql/migrations/0002.sql @@ -0,0 +1,13 @@ +-- description: WAL journal, 'None' fix for missing table + +-- Set the journal mode to WAL +PRAGMA journal_mode = WAL; + +BEGIN TRANSACTION; + +-- Fix a bug where 'None' was inserted in missing instead of NULL +UPDATE "missing" +SET "element_id" = NULL +WHERE "missing"."element_id" = 'None'; + +COMMIT; \ No newline at end of file diff --git a/bricktracker/sql/migrations/0003.sql b/bricktracker/sql/migrations/0003.sql new file mode 100644 index 0000000..8871a63 --- /dev/null +++ b/bricktracker/sql/migrations/0003.sql @@ -0,0 +1,48 @@ +-- description: Creation of the deduplicated table of Rebrickable sets + +BEGIN TRANSACTION; + +-- Create a Rebrickable set table: each unique set imported from Rebrickable +CREATE TABLE "rebrickable_sets" ( + "set" TEXT NOT NULL, + "number" INTEGER NOT NULL, + "version" INTEGER NOT NULL, + "name" TEXT NOT NULL, + "year" INTEGER NOT NULL, + "theme_id" INTEGER NOT NULL, + "number_of_parts" INTEGER NOT NULL, + "image" TEXT, + "url" TEXT, + "last_modified" TEXT, + PRIMARY KEY("set") +); + +-- Insert existing sets into the new table +INSERT INTO "rebrickable_sets" ( + "set", + "number", + "version", + "name", + "year", + "theme_id", + "number_of_parts", + "image", + "url", + "last_modified" +) +SELECT + "sets"."set_num", + CAST(SUBSTR("sets"."set_num", 1, INSTR("sets"."set_num", '-') - 1) AS INTEGER), + CAST(SUBSTR("sets"."set_num", INSTR("sets"."set_num", '-') + 1) AS INTEGER), + "sets"."name", + "sets"."year", + "sets"."theme_id", + "sets"."num_parts", + "sets"."set_img_url", + "sets"."set_url", + "sets"."last_modified_dt" +FROM "sets" +GROUP BY + "sets"."set_num"; + +COMMIT; \ No newline at end of file diff --git a/bricktracker/sql/migrations/0004.sql b/bricktracker/sql/migrations/0004.sql new file mode 100644 index 0000000..3828204 --- /dev/null +++ b/bricktracker/sql/migrations/0004.sql @@ -0,0 +1,25 @@ +-- description: Migrate the Bricktracker sets + +PRAGMA foreign_keys = ON; + +BEGIN TRANSACTION; + +-- Create a Bricktable 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, + PRIMARY KEY("id"), + FOREIGN KEY("rebrickable_set") REFERENCES "rebrickable_sets"("set") +); + +-- Insert existing sets into the new table +INSERT INTO "bricktracker_sets" ( + "id", + "rebrickable_set" +) +SELECT + "sets"."u_id", + "sets"."set_num" +FROM "sets"; + +COMMIT; \ No newline at end of file diff --git a/bricktracker/sql/migrations/0005.sql b/bricktracker/sql/migrations/0005.sql new file mode 100644 index 0000000..564f875 --- /dev/null +++ b/bricktracker/sql/migrations/0005.sql @@ -0,0 +1,72 @@ +-- description: Creation of the configurable set checkboxes + +PRAGMA foreign_keys = ON; + +BEGIN TRANSACTION; + +-- Create a table to define each set checkbox: with an ID, a name and if they should be displayed on the grid cards +CREATE TABLE "bricktracker_set_checkboxes" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "displayed_on_grid" BOOLEAN NOT NULL DEFAULT 0, + PRIMARY KEY("id") +); + +-- Seed our checkbox with the 3 original ones +INSERT INTO "bricktracker_set_checkboxes" ( + "id", + "name", + "displayed_on_grid" +) VALUES ( + "minifigures_collected", + "Minifigures are collected", + 1 +); + +INSERT INTO "bricktracker_set_checkboxes" ( + "id", + "name", + "displayed_on_grid" +) VALUES ( + "set_checked", + "Set is checked", + 1 +); + +INSERT INTO "bricktracker_set_checkboxes" ( + "id", + "name", + "displayed_on_grid" +) VALUES ( + "set_collected", + "Set is collected and boxed", + 1 +); + +-- Create a table for the status of each checkbox: with the 3 first status +CREATE TABLE "bricktracker_set_statuses" ( + "bricktracker_set_id" TEXT NOT NULL, + "status_minifigures_collected" BOOLEAN NOT NULL DEFAULT 0, + "status_set_checked" BOOLEAN NOT NULL DEFAULT 0, + "status_set_collected" BOOLEAN NOT NULL DEFAULT 0, + PRIMARY KEY("bricktracker_set_id"), + FOREIGN KEY("bricktracker_set_id") REFERENCES "bricktracker_sets"("id") +); + +INSERT INTO "bricktracker_set_statuses" ( + "bricktracker_set_id", + "status_minifigures_collected", + "status_set_checked", + "status_set_collected" +) +SELECT + "sets"."u_id", + "sets"."mini_col", + "sets"."set_check", + "sets"."set_col" +FROM "sets"; + +-- Rename the original table (don't delete it yet?) +ALTER TABLE "sets" RENAME TO "sets_old"; + +COMMIT; diff --git a/bricktracker/sql/migrations/0006.sql b/bricktracker/sql/migrations/0006.sql new file mode 100644 index 0000000..7d4b9a0 --- /dev/null +++ b/bricktracker/sql/migrations/0006.sql @@ -0,0 +1,42 @@ +-- description: Migrate the whislist to have a Rebrickable sets structure + +BEGIN TRANSACTION; + +-- Create a Rebrickable wish table: each unique (light) set imported from Rebrickable +CREATE TABLE "bricktracker_wishes" ( + "set" TEXT NOT NULL, + "name" TEXT NOT NULL, + "year" INTEGER NOT NULL, + "theme_id" INTEGER NOT NULL, + "number_of_parts" INTEGER NOT NULL, + "image" TEXT, + "url" TEXT, + PRIMARY KEY("set") +); + +-- Insert existing wishes into the new table +INSERT INTO "bricktracker_wishes" ( + "set", + "name", + "year", + "theme_id", + "number_of_parts", + "image", + "url" +) +SELECT + "wishlist"."set_num", + "wishlist"."name", + "wishlist"."year", + "wishlist"."theme_id", + "wishlist"."num_parts", + "wishlist"."set_img_url", + "wishlist"."set_url" +FROM "wishlist" +GROUP BY + "wishlist"."set_num"; + +-- Rename the original table (don't delete it yet?) +ALTER TABLE "wishlist" RENAME TO "wishlist_old"; + +COMMIT; \ No newline at end of file diff --git a/bricktracker/sql/migrations/init.sql b/bricktracker/sql/migrations/init.sql deleted file mode 100644 index 85defbb..0000000 --- a/bricktracker/sql/migrations/init.sql +++ /dev/null @@ -1,66 +0,0 @@ --- FROM sqlite3 app.db .schema > init.sql with extra IF NOT EXISTS and transaction -BEGIN transaction; - -CREATE TABLE IF NOT EXISTS wishlist ( - set_num TEXT, - name TEXT, - year INTEGER, - theme_id INTEGER, - num_parts INTEGER, - set_img_url TEXT, - set_url TEXT, - last_modified_dt TEXT - ); -CREATE TABLE IF NOT EXISTS sets ( - set_num TEXT, - name TEXT, - year INTEGER, - theme_id INTEGER, - num_parts INTEGER, - set_img_url TEXT, - set_url TEXT, - last_modified_dt TEXT, - mini_col BOOLEAN, - set_check BOOLEAN, - set_col BOOLEAN, - u_id TEXT - ); -CREATE TABLE IF NOT EXISTS inventory ( - set_num TEXT, - id INTEGER, - part_num TEXT, - name TEXT, - part_img_url TEXT, - part_img_url_id TEXT, - color_id INTEGER, - color_name TEXT, - quantity INTEGER, - is_spare BOOLEAN, - element_id INTEGER, - u_id TEXT - ); -CREATE TABLE IF NOT EXISTS minifigures ( - fig_num TEXT, - set_num TEXT, - name TEXT, - quantity INTEGER, - set_img_url TEXT, - u_id TEXT - ); -CREATE TABLE IF NOT EXISTS missing ( - set_num TEXT, - id INTEGER, - part_num TEXT, - part_img_url_id TEXT, - color_id INTEGER, - quantity INTEGER, - element_id INTEGER, - u_id TEXT - ); - --- Fix a bug where 'None' was inserted in missing instead of NULL -UPDATE missing -SET element_id = NULL -WHERE element_id = 'None'; - -COMMIT; \ No newline at end of file diff --git a/bricktracker/sql/minifigure/base/select.sql b/bricktracker/sql/minifigure/base/select.sql index 365019f..8182998 100644 --- a/bricktracker/sql/minifigure/base/select.sql +++ b/bricktracker/sql/minifigure/base/select.sql @@ -1,20 +1,20 @@ SELECT - minifigures.fig_num, - minifigures.set_num, - minifigures.name, - minifigures.quantity, - minifigures.set_img_url, - minifigures.u_id, + "minifigures"."fig_num", + "minifigures"."set_num", + "minifigures"."name", + "minifigures"."quantity", + "minifigures"."set_img_url", + "minifigures"."u_id", {% block total_missing %} - NULL AS total_missing, -- dummy for order: total_missing + NULL AS "total_missing", -- dummy for order: total_missing {% endblock %} {% block total_quantity %} - NULL AS total_quantity, -- dummy for order: total_quantity + NULL AS "total_quantity", -- dummy for order: total_quantity {% endblock %} {% block total_sets %} - NULL AS total_sets -- dummy for order: total_sets + NULL AS "total_sets" -- dummy for order: total_sets {% endblock %} -FROM minifigures +FROM "minifigures" {% block join %}{% endblock %} diff --git a/bricktracker/sql/minifigure/delete/all_from_set.sql b/bricktracker/sql/minifigure/delete/all_from_set.sql deleted file mode 100644 index b04b2b5..0000000 --- a/bricktracker/sql/minifigure/delete/all_from_set.sql +++ /dev/null @@ -1,2 +0,0 @@ -DELETE FROM minifigures -WHERE u_id IS NOT DISTINCT FROM :u_id \ No newline at end of file diff --git a/bricktracker/sql/minifigure/insert.sql b/bricktracker/sql/minifigure/insert.sql index b957645..d72a2a3 100644 --- a/bricktracker/sql/minifigure/insert.sql +++ b/bricktracker/sql/minifigure/insert.sql @@ -1,10 +1,10 @@ -INSERT INTO minifigures ( - fig_num, - set_num, - name, - quantity, - set_img_url, - u_id +INSERT INTO "minifigures" ( + "fig_num", + "set_num", + "name", + "quantity", + "set_img_url", + "u_id" ) VALUES ( :fig_num, :set_num, diff --git a/bricktracker/sql/minifigure/list/all.sql b/bricktracker/sql/minifigure/list/all.sql index 804387a..a00f474 100644 --- a/bricktracker/sql/minifigure/list/all.sql +++ b/bricktracker/sql/minifigure/list/all.sql @@ -1,34 +1,34 @@ {% extends 'minifigure/base/select.sql' %} {% block total_missing %} -SUM(IFNULL(missing_join.total, 0)) AS total_missing, +SUM(IFNULL("missing_join"."total", 0)) AS "total_missing", {% endblock %} {% block total_quantity %} -SUM(IFNULL(minifigures.quantity, 0)) AS total_quantity, +SUM(IFNULL("minifigures"."quantity", 0)) AS "total_quantity", {% endblock %} {% block total_sets %} -COUNT(minifigures.set_num) AS total_sets +COUNT("minifigures"."set_num") AS "total_sets" {% endblock %} {% block join %} -- LEFT JOIN + SELECT to avoid messing the total LEFT JOIN ( SELECT - set_num, - u_id, - SUM(quantity) AS total - FROM missing + "missing"."set_num", + "missing"."u_id", + SUM("missing"."quantity") AS total + FROM "missing" GROUP BY - set_num, - u_id + "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 +ON "minifigures"."u_id" IS NOT DISTINCT FROM "missing_join"."u_id" +AND "minifigures"."fig_num" IS NOT DISTINCT FROM "missing_join"."set_num" {% endblock %} {% block group %} GROUP BY - minifigures.fig_num + "minifigures"."fig_num" {% endblock %} diff --git a/bricktracker/sql/minifigure/list/from_set.sql b/bricktracker/sql/minifigure/list/from_set.sql index 60c79f3..ea2dcbe 100644 --- a/bricktracker/sql/minifigure/list/from_set.sql +++ b/bricktracker/sql/minifigure/list/from_set.sql @@ -1,6 +1,6 @@ {% extends 'minifigure/base/select.sql' %} {% block where %} -WHERE u_id IS NOT DISTINCT FROM :u_id -AND set_num IS NOT DISTINCT FROM :set_num +WHERE "minifigures"."u_id" IS NOT DISTINCT FROM :u_id +AND "minifigures"."set_num" IS NOT DISTINCT FROM :set_num {% endblock %} diff --git a/bricktracker/sql/minifigure/list/last.sql b/bricktracker/sql/minifigure/list/last.sql index 2660af9..faf3f40 100644 --- a/bricktracker/sql/minifigure/list/last.sql +++ b/bricktracker/sql/minifigure/list/last.sql @@ -1,17 +1,17 @@ {% extends 'minifigure/base/select.sql' %} {% block total_missing %} -SUM(IFNULL(missing.quantity, 0)) AS total_missing, +SUM(IFNULL("missing"."quantity", 0)) 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 "missing" +ON "minifigures"."fig_num" IS NOT DISTINCT FROM "missing"."set_num" +AND "minifigures"."u_id" IS NOT DISTINCT FROM "missing"."u_id" {% endblock %} {% block group %} GROUP BY - minifigures.fig_num, - minifigures.u_id + "minifigures"."fig_num", + "minifigures"."u_id" {% endblock %} diff --git a/bricktracker/sql/minifigure/list/missing_part.sql b/bricktracker/sql/minifigure/list/missing_part.sql index 99eee8f..e0bc54d 100644 --- a/bricktracker/sql/minifigure/list/missing_part.sql +++ b/bricktracker/sql/minifigure/list/missing_part.sql @@ -1,30 +1,30 @@ {% extends 'minifigure/base/select.sql' %} {% block total_missing %} -SUM(IFNULL(missing.quantity, 0)) AS total_missing, +SUM(IFNULL("missing"."quantity", 0)) 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 "missing" +ON "minifigures"."fig_num" IS NOT DISTINCT FROM "missing"."set_num" +AND "minifigures"."u_id" IS NOT DISTINCT FROM "missing"."u_id" {% endblock %} {% block where %} -WHERE minifigures.fig_num IN ( +WHERE "minifigures"."fig_num" IN ( SELECT - missing.set_num - FROM missing + "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 + 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 + GROUP BY "missing"."set_num" ) {% endblock %} {% block group %} GROUP BY - minifigures.fig_num + "minifigures"."fig_num" {% endblock %} diff --git a/bricktracker/sql/minifigure/list/using_part.sql b/bricktracker/sql/minifigure/list/using_part.sql index 9b6b82c..c40d379 100644 --- a/bricktracker/sql/minifigure/list/using_part.sql +++ b/bricktracker/sql/minifigure/list/using_part.sql @@ -1,24 +1,24 @@ {% extends 'minifigure/base/select.sql' %} {% block total_quantity %} -SUM(minifigures.quantity) AS total_quantity, +SUM("minifigures"."quantity") AS "total_quantity", {% endblock %} {% block where %} -WHERE minifigures.fig_num IN ( +WHERE "minifigures"."fig_num" IN ( SELECT - inventory.set_num - FROM inventory + "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 + 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 + GROUP BY "inventory"."set_num" ) {% endblock %} {% block group %} GROUP BY - minifigures.fig_num + "minifigures"."fig_num" {% endblock %} diff --git a/bricktracker/sql/minifigure/select/generic.sql b/bricktracker/sql/minifigure/select/generic.sql index 704ca5c..114810d 100644 --- a/bricktracker/sql/minifigure/select/generic.sql +++ b/bricktracker/sql/minifigure/select/generic.sql @@ -1,38 +1,38 @@ {% extends 'minifigure/base/select.sql' %} {% block total_missing %} -SUM(IFNULL(missing_join.total, 0)) AS total_missing, +SUM(IFNULL("missing_join"."total", 0)) AS "total_missing", {% endblock %} {% block total_quantity %} -SUM(IFNULL(minifigures.quantity, 0)) AS total_quantity, +SUM(IFNULL("minifigures"."quantity", 0)) AS "total_quantity", {% endblock %} {% block total_sets %} -COUNT(minifigures.set_num) AS total_sets +COUNT("minifigures"."set_num") AS "total_sets" {% endblock %} {% block join %} -- LEFT JOIN + SELECT to avoid messing the total LEFT JOIN ( SELECT - set_num, - u_id, - SUM(quantity) AS total - FROM missing + "missing"."set_num", + "missing"."u_id", + SUM("missing"."quantity") AS "total" + FROM "missing" GROUP BY - set_num, - 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 + "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" {% endblock %} {% block where %} -WHERE fig_num IS NOT DISTINCT FROM :fig_num +WHERE "minifigures"."fig_num" IS NOT DISTINCT FROM :fig_num {% endblock %} {% block group %} GROUP BY - minifigures.fig_num + "minifigures"."fig_num" {% endblock %} diff --git a/bricktracker/sql/minifigure/select/specific.sql b/bricktracker/sql/minifigure/select/specific.sql index 02100b8..34a8b3d 100644 --- a/bricktracker/sql/minifigure/select/specific.sql +++ b/bricktracker/sql/minifigure/select/specific.sql @@ -1,7 +1,7 @@ {% extends 'minifigure/base/select.sql' %} {% block where %} -WHERE fig_num IS NOT DISTINCT FROM :fig_num -AND u_id IS NOT DISTINCT FROM :u_id -AND set_num IS NOT DISTINCT FROM :set_num +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 {% endblock %} diff --git a/bricktracker/sql/missing/count_none.sql b/bricktracker/sql/missing/count_none.sql deleted file mode 100644 index 3f4073b..0000000 --- a/bricktracker/sql/missing/count_none.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT count(*) AS count -FROM missing -WHERE element_id = 'None' diff --git a/bricktracker/sql/missing/delete/all_from_set.sql b/bricktracker/sql/missing/delete/all_from_set.sql deleted file mode 100644 index 6ec5f55..0000000 --- a/bricktracker/sql/missing/delete/all_from_set.sql +++ /dev/null @@ -1,2 +0,0 @@ -DELETE FROM missing -WHERE u_id IS NOT DISTINCT FROM :u_id \ No newline at end of file diff --git a/bricktracker/sql/missing/delete/from_set.sql b/bricktracker/sql/missing/delete/from_set.sql index 66819d2..ceedc78 100644 --- a/bricktracker/sql/missing/delete/from_set.sql +++ b/bricktracker/sql/missing/delete/from_set.sql @@ -1,4 +1,4 @@ -DELETE FROM missing -WHERE set_num IS NOT DISTINCT FROM :set_num -AND id IS NOT DISTINCT FROM :id -AND u_id IS NOT DISTINCT FROM :u_id +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 index a883f50..c645028 100644 --- a/bricktracker/sql/missing/insert.sql +++ b/bricktracker/sql/missing/insert.sql @@ -1,12 +1,12 @@ -INSERT INTO missing ( - set_num, - id, - part_num, - part_img_url_id, - color_id, - quantity, - element_id, - u_id +INSERT INTO "missing" ( + "set_num", + "id", + "part_num", + "part_img_url_id", + "color_id", + "quantity", + "element_id", + "u_id" ) VALUES( :set_num, diff --git a/bricktracker/sql/missing/update/from_set.sql b/bricktracker/sql/missing/update/from_set.sql index 26ef04d..335dd06 100644 --- a/bricktracker/sql/missing/update/from_set.sql +++ b/bricktracker/sql/missing/update/from_set.sql @@ -1,5 +1,5 @@ -UPDATE missing -SET quantity = :quantity -WHERE set_num IS NOT DISTINCT FROM :set_num -AND id IS NOT DISTINCT FROM :id -AND u_id IS NOT DISTINCT FROM :u_id +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/select.sql b/bricktracker/sql/part/base/select.sql index 648cfad..3966a75 100644 --- a/bricktracker/sql/part/base/select.sql +++ b/bricktracker/sql/part/base/select.sql @@ -1,32 +1,32 @@ 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, + "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 + NULL AS "total_missing", -- dummy for order: total_missing {% endblock %} {% block total_quantity %} - NULL AS total_quantity, -- dummy for order: total_quantity + NULL AS "total_quantity", -- dummy for order: total_quantity {% endblock %} {% block total_spare %} - NULL AS total_spare, -- dummy for order: total_spare + NULL AS "total_spare", -- dummy for order: total_spare {% endblock %} {% block total_sets %} - NULL AS total_sets, -- dummy for order: total_sets + NULL AS "total_sets", -- dummy for order: total_sets {% endblock %} {% block total_minifigures %} - NULL AS total_minifigures -- dummy for order: total_minifigures + NULL AS "total_minifigures" -- dummy for order: total_minifigures {% endblock %} -FROM inventory +FROM "inventory" {% block join %}{% endblock %} diff --git a/bricktracker/sql/part/delete/all_from_set.sql b/bricktracker/sql/part/delete/all_from_set.sql deleted file mode 100644 index 99c576a..0000000 --- a/bricktracker/sql/part/delete/all_from_set.sql +++ /dev/null @@ -1,2 +0,0 @@ -DELETE FROM inventory -WHERE u_id IS NOT DISTINCT FROM :u_id \ No newline at end of file diff --git a/bricktracker/sql/part/insert.sql b/bricktracker/sql/part/insert.sql index 6e47df1..39b2d14 100644 --- a/bricktracker/sql/part/insert.sql +++ b/bricktracker/sql/part/insert.sql @@ -1,16 +1,16 @@ INSERT INTO inventory ( - set_num, - id, - part_num, - name, - part_img_url, - part_img_url_id, - color_id, - color_name, - quantity, - is_spare, - element_id, - u_id + "set_num", + "id", + "part_num", + "name", + "part_img_url", + "part_img_url_id", + "color_id", + "color_name", + "quantity", + "is_spare", + "element_id", + "u_id" ) VALUES ( :set_num, :id, diff --git a/bricktracker/sql/part/list/all.sql b/bricktracker/sql/part/list/all.sql index 5c48393..b1ff2ac 100644 --- a/bricktracker/sql/part/list/all.sql +++ b/bricktracker/sql/part/list/all.sql @@ -1,43 +1,43 @@ {% extends 'part/base/select.sql' %} {% block total_missing %} -SUM(IFNULL(missing.quantity, 0)) AS total_missing, +SUM(IFNULL("missing"."quantity", 0)) AS "total_missing", {% endblock %} {% block total_quantity %} -SUM(inventory.quantity * IFNULL(minifigures.quantity, 1)) AS total_quantity, +SUM("inventory"."quantity" * IFNULL("minifigures"."quantity", 1)) AS "total_quantity", {% endblock %} {% block total_sets %} -COUNT(DISTINCT sets.u_id) AS total_sets, +COUNT(DISTINCT "bricktracker_sets"."id") AS "total_sets", {% endblock %} {% block total_minifigures %} -SUM(IFNULL(minifigures.quantity, 0)) AS total_minifigures +SUM(IFNULL("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 "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 "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 sets -ON inventory.u_id IS NOT DISTINCT FROM sets.u_id +LEFT JOIN "bricktracker_sets" +ON "inventory"."u_id" IS NOT DISTINCT FROM "bricktracker_sets"."id" {% endblock %} {% block group %} GROUP BY - inventory.part_num, - inventory.name, - inventory.color_id, - inventory.is_spare, - inventory.element_id + "inventory"."part_num", + "inventory"."name", + "inventory"."color_id", + "inventory"."is_spare", + "inventory"."element_id" {% endblock %} diff --git a/bricktracker/sql/part/list/from_minifigure.sql b/bricktracker/sql/part/list/from_minifigure.sql index 9c2128b..cf4135f 100644 --- a/bricktracker/sql/part/list/from_minifigure.sql +++ b/bricktracker/sql/part/list/from_minifigure.sql @@ -2,27 +2,27 @@ {% extends 'part/base/select.sql' %} {% block total_missing %} -SUM(IFNULL(missing.quantity, 0)) AS total_missing, +SUM(IFNULL("missing"."quantity", 0)) 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 +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" {% endblock %} {% block where %} -WHERE inventory.set_num IS NOT DISTINCT FROM :set_num +WHERE "inventory"."set_num" IS NOT DISTINCT FROM :set_num {% endblock %} {% block group %} GROUP BY - inventory.part_num, - inventory.name, - inventory.color_id, - inventory.is_spare, - inventory.element_id + "inventory"."part_num", + "inventory"."name", + "inventory"."color_id", + "inventory"."is_spare", + "inventory"."element_id" {% endblock %} diff --git a/bricktracker/sql/part/list/from_set.sql b/bricktracker/sql/part/list/from_set.sql index 1e9d411..2646eeb 100644 --- a/bricktracker/sql/part/list/from_set.sql +++ b/bricktracker/sql/part/list/from_set.sql @@ -2,20 +2,20 @@ {% extends 'part/base/select.sql' %} {% block total_missing %} -IFNULL(missing.quantity, 0) AS 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 +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 +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 index e85bbd4..555916f 100644 --- a/bricktracker/sql/part/list/missing.sql +++ b/bricktracker/sql/part/list/missing.sql @@ -1,36 +1,36 @@ {% extends 'part/base/select.sql' %} {% block total_missing %} -SUM(IFNULL(missing.quantity, 0)) AS 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, +COUNT("inventory"."u_id") - COUNT("minifigures"."u_id") AS "total_sets", {% endblock %} {% block total_minifigures %} -SUM(IFNULL(minifigures.quantity, 0)) AS 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 +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 +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 + "inventory"."part_num", + "inventory"."name", + "inventory"."color_id", + "inventory"."is_spare", + "inventory"."element_id" {% endblock %} diff --git a/bricktracker/sql/part/select/generic.sql b/bricktracker/sql/part/select/generic.sql index fbc84e8..4a75b4c 100644 --- a/bricktracker/sql/part/select/generic.sql +++ b/bricktracker/sql/part/select/generic.sql @@ -1,40 +1,40 @@ {% extends 'part/base/select.sql' %} {% block total_missing %} -SUM(IFNULL(missing.quantity, 0)) AS total_missing, +SUM(IFNULL("missing"."quantity", 0)) AS "total_missing", {% endblock %} {% block total_quantity %} -SUM((NOT inventory.is_spare) * inventory.quantity * IFNULL(minifigures.quantity, 1)) AS total_quantity, +SUM((NOT "inventory"."is_spare") * "inventory"."quantity" * IFNULL("minifigures"."quantity", 1)) AS "total_quantity", {% endblock %} {% block total_spare %} -SUM(inventory.is_spare * inventory.quantity * IFNULL(minifigures.quantity, 1)) AS total_spare, +SUM("inventory"."is_spare" * "inventory"."quantity" * IFNULL("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 "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 "minifigures" +ON "inventory"."set_num" IS NOT DISTINCT FROM "minifigures"."fig_num" +AND "inventory"."u_id" IS NOT DISTINCT FROM "minifigures"."u_id" {% 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 "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 {% endblock %} {% block group %} GROUP BY - inventory.part_num, - inventory.color_id, - inventory.element_id + "inventory"."part_num", + "inventory"."color_id", + "inventory"."element_id" {% endblock %} diff --git a/bricktracker/sql/part/select/specific.sql b/bricktracker/sql/part/select/specific.sql index 1918b3f..ebdd5f5 100644 --- a/bricktracker/sql/part/select/specific.sql +++ b/bricktracker/sql/part/select/specific.sql @@ -1,24 +1,24 @@ {% 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 +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 %} {% 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 "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 {% endblock %} {% block group %} GROUP BY - inventory.set_num, - inventory.id, - inventory.part_num, - inventory.color_id, - inventory.element_id, - inventory.u_id + "inventory"."set_num", + "inventory"."id", + "inventory"."part_num", + "inventory"."color_id", + "inventory"."element_id", + "inventory"."u_id" {% endblock %} diff --git a/bricktracker/sql/rebrickable/set/insert.sql b/bricktracker/sql/rebrickable/set/insert.sql new file mode 100644 index 0000000..88b2b44 --- /dev/null +++ b/bricktracker/sql/rebrickable/set/insert.sql @@ -0,0 +1,23 @@ +INSERT OR IGNORE INTO "rebrickable_sets" ( + "set", + "number", + "version", + "name", + "year", + "theme_id", + "number_of_parts", + "image", + "url", + "last_modified" +) VALUES ( + :set, + :number, + :version, + :name, + :year, + :theme_id, + :number_of_parts, + :image, + :url, + :last_modified +) diff --git a/bricktracker/sql/rebrickable/set/list.sql b/bricktracker/sql/rebrickable/set/list.sql new file mode 100644 index 0000000..53f4886 --- /dev/null +++ b/bricktracker/sql/rebrickable/set/list.sql @@ -0,0 +1,11 @@ +SELECT + "rebrickable_sets"."set", + "rebrickable_sets"."number", + "rebrickable_sets"."version", + "rebrickable_sets"."name", + "rebrickable_sets"."year", + "rebrickable_sets"."theme_id", + "rebrickable_sets"."number_of_parts", + "rebrickable_sets"."image", + "rebrickable_sets"."url" +FROM "rebrickable_sets" diff --git a/bricktracker/sql/rebrickable/set/select.sql b/bricktracker/sql/rebrickable/set/select.sql new file mode 100644 index 0000000..f760bb6 --- /dev/null +++ b/bricktracker/sql/rebrickable/set/select.sql @@ -0,0 +1,13 @@ +SELECT + "rebrickable_sets"."set", + "rebrickable_sets"."number", + "rebrickable_sets"."version", + "rebrickable_sets"."name", + "rebrickable_sets"."year", + "rebrickable_sets"."theme_id", + "rebrickable_sets"."number_of_parts", + "rebrickable_sets"."image", + "rebrickable_sets"."url" +FROM "rebrickable_sets" + +WHERE "rebrickable_sets"."set" IS NOT DISTINCT FROM :set diff --git a/bricktracker/sql/schema/count.sql b/bricktracker/sql/schema/count.sql index 6f2d241..5df9cda 100644 --- a/bricktracker/sql/schema/count.sql +++ b/bricktracker/sql/schema/count.sql @@ -1,2 +1,2 @@ -SELECT COUNT(*) AS count -FROM {{ table }} \ No newline at end of file +SELECT COUNT(*) AS "count" +FROM "{{ table }}" \ No newline at end of file diff --git a/bricktracker/sql/schema/drop.sql b/bricktracker/sql/schema/drop.sql index 450bc3d..b961b28 100644 --- a/bricktracker/sql/schema/drop.sql +++ b/bricktracker/sql/schema/drop.sql @@ -1,9 +1,18 @@ BEGIN transaction; -DROP TABLE IF EXISTS wishlist; -DROP TABLE IF EXISTS sets; -DROP TABLE IF EXISTS inventory; -DROP TABLE IF EXISTS minifigures; -DROP TABLE IF EXISTS missing; +DROP TABLE IF EXISTS "bricktracker_sets"; +DROP TABLE IF EXISTS "bricktracker_set_checkboxes"; +DROP TABLE IF EXISTS "bricktracker_set_statuses"; +DROP TABLE IF EXISTS "bricktracker_wishes"; +DROP TABLE IF EXISTS "inventory"; +DROP TABLE IF EXISTS "minifigures"; +DROP TABLE IF EXISTS "missing"; +DROP TABLE IF EXISTS "rebrickable_sets"; +DROP TABLE IF EXISTS "sets"; +DROP TABLE IF EXISTS "sets_old"; +DROP TABLE IF EXISTS "wishlist"; +DROP TABLE IF EXISTS "wishlist_old"; -COMMIT; \ No newline at end of file +COMMIT; + +PRAGMA user_version = 0; \ No newline at end of file diff --git a/bricktracker/sql/schema/get_version.sql b/bricktracker/sql/schema/get_version.sql new file mode 100644 index 0000000..693601a --- /dev/null +++ b/bricktracker/sql/schema/get_version.sql @@ -0,0 +1 @@ +PRAGMA user_version diff --git a/bricktracker/sql/schema/is_init.sql b/bricktracker/sql/schema/is_init.sql deleted file mode 100644 index 17a3a81..0000000 --- a/bricktracker/sql/schema/is_init.sql +++ /dev/null @@ -1,4 +0,0 @@ -SELECT name -FROM sqlite_master -WHERE type="table" -AND name="sets" diff --git a/bricktracker/sql/schema/set_version.sql b/bricktracker/sql/schema/set_version.sql new file mode 100644 index 0000000..53d0eb2 --- /dev/null +++ b/bricktracker/sql/schema/set_version.sql @@ -0,0 +1 @@ +PRAGMA user_version = {{ version }} diff --git a/bricktracker/sql/schema/tables.sql b/bricktracker/sql/schema/tables.sql new file mode 100644 index 0000000..44ac37a --- /dev/null +++ b/bricktracker/sql/schema/tables.sql @@ -0,0 +1 @@ +SELECT "name" FROM "sqlite_master" WHERE type='table' ORDER BY "name" ASC \ No newline at end of file diff --git a/bricktracker/sql/set/base/base.sql b/bricktracker/sql/set/base/base.sql new file mode 100644 index 0000000..2f4d683 --- /dev/null +++ b/bricktracker/sql/set/base/base.sql @@ -0,0 +1,38 @@ +SELECT + {% block id %}{% endblock %} + "rebrickable_sets"."set", + "rebrickable_sets"."number", + "rebrickable_sets"."version", + "rebrickable_sets"."name", + "rebrickable_sets"."year", + "rebrickable_sets"."theme_id", + "rebrickable_sets"."number_of_parts", + "rebrickable_sets"."image", + "rebrickable_sets"."url", + {% block statuses %} + {% if statuses %}{{ statuses }},{% endif %} + {% endblock %} + {% 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 %} +FROM "bricktracker_sets" + +INNER JOIN "rebrickable_sets" +ON "bricktracker_sets"."rebrickable_set" IS NOT DISTINCT FROM "rebrickable_sets"."set" + +{% 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/set/base/full.sql b/bricktracker/sql/set/base/full.sql new file mode 100644 index 0000000..c169c7a --- /dev/null +++ b/bricktracker/sql/set/base/full.sql @@ -0,0 +1,42 @@ +{% extends 'set/base/base.sql' %} + +{% block id %} +"bricktracker_sets"."id", +{% endblock %} + +{% block total_missing %} +IFNULL("missing_join"."total", 0) AS "total_missing", +{% endblock %} + +{% block total_quantity %} +IFNULL("minifigures_join"."total", 0) AS "total_minifigures" +{% endblock %} + +{% block join %} +{% if statuses %} +LEFT JOIN "bricktracker_set_statuses" +ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "bricktracker_set_statuses"."bricktracker_set_id" +{% endif %} + +-- LEFT JOIN + SELECT to avoid messing the total +LEFT JOIN ( + SELECT + "missing"."u_id", + SUM("missing"."quantity") AS "total" + FROM "missing" + {% block where_missing %}{% endblock %} + GROUP BY "u_id" +) "missing_join" +ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "missing_join"."u_id" + +-- LEFT JOIN + SELECT to avoid messing the total +LEFT JOIN ( + SELECT + "minifigures"."u_id", + SUM("minifigures"."quantity") AS "total" + FROM "minifigures" + {% block where_minifigures %}{% endblock %} + GROUP BY "u_id" +) "minifigures_join" +ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "minifigures_join"."u_id" +{% endblock %} \ No newline at end of file diff --git a/bricktracker/sql/set/base/light.sql b/bricktracker/sql/set/base/light.sql new file mode 100644 index 0000000..b599a87 --- /dev/null +++ b/bricktracker/sql/set/base/light.sql @@ -0,0 +1,18 @@ +SELECT + "bricktracker_sets"."id", + "bricktracker_sets"."rebrickable_set" AS "set" +FROM "bricktracker_sets" + +{% 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/set/base/select.sql b/bricktracker/sql/set/base/select.sql deleted file mode 100644 index 4162375..0000000 --- a/bricktracker/sql/set/base/select.sql +++ /dev/null @@ -1,52 +0,0 @@ -SELECT - sets.set_num, - sets.name, - sets.year, - sets.theme_id, - sets.num_parts, - sets.set_img_url, - sets.set_url, - sets.last_modified_dt, - sets.mini_col, - sets.set_check, - sets.set_col, - sets.u_id, - {% block number %} - CAST(SUBSTR(sets.set_num, 1, INSTR(sets.set_num, '-') - 1) AS INTEGER) AS set_number, - CAST(SUBSTR(sets.set_num, 1, INSTR(sets.set_num, '-') + 1) AS INTEGER) AS set_version, - {% endblock %} - IFNULL(missing_join.total, 0) AS total_missing, - IFNULL(minifigures_join.total, 0) AS total_minifigures -FROM sets - --- LEFT JOIN + SELECT to avoid messing the total -LEFT JOIN ( - SELECT - u_id, - SUM(quantity) AS total - FROM missing - {% block where_missing %}{% endblock %} - GROUP BY u_id -) missing_join -ON sets.u_id IS NOT DISTINCT FROM missing_join.u_id - --- LEFT JOIN + SELECT to avoid messing the total -LEFT JOIN ( - SELECT - u_id, - SUM(quantity) AS total - FROM minifigures - {% block where_minifigures %}{% endblock %} - GROUP BY u_id -) minifigures_join -ON sets.u_id IS NOT DISTINCT FROM minifigures_join.u_id - -{% block where %}{% endblock %} - -{% if order %} -ORDER BY {{ order }} -{% endif %} - -{% if limit %} -LIMIT {{ limit }} -{% endif %} diff --git a/bricktracker/sql/set/delete/set.sql b/bricktracker/sql/set/delete/set.sql index c4f3ebf..dd2c856 100644 --- a/bricktracker/sql/set/delete/set.sql +++ b/bricktracker/sql/set/delete/set.sql @@ -1,2 +1,21 @@ -DELETE FROM sets -WHERE u_id IS NOT DISTINCT FROM :u_id \ No newline at end of file +-- A bit unsafe as it does not use a prepared statement but it +-- should not be possible to inject anything through the {{ id }} context + +BEGIN TRANSACTION; + +DELETE FROM "bricktracker_sets" +WHERE "bricktracker_sets"."id" IS NOT DISTINCT FROM '{{ id }}'; + +DELETE FROM "bricktracker_set_statuses" +WHERE "bricktracker_set_statuses"."bricktracker_set_id" IS NOT DISTINCT FROM '{{ id }}'; + +DELETE FROM "minifigures" +WHERE "minifigures"."u_id" IS NOT DISTINCT FROM '{{ id }}'; + +DELETE FROM "missing" +WHERE "missing"."u_id" IS NOT DISTINCT FROM '{{ id }}'; + +DELETE FROM "inventory" +WHERE "inventory"."u_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 5858039..2462ac5 100644 --- a/bricktracker/sql/set/insert.sql +++ b/bricktracker/sql/set/insert.sql @@ -1,27 +1,7 @@ -INSERT INTO sets ( - set_num, - name, - year, - theme_id, - num_parts, - set_img_url, - set_url, - last_modified_dt, - mini_col, - set_check, - set_col, - u_id +INSERT OR IGNORE INTO "bricktracker_sets" ( + "id", + "rebrickable_set" ) VALUES ( - :set_num, - :name, - :year, - :theme_id, - :num_parts, - :set_img_url, - :set_url, - :last_modified_dt, - :mini_col, - :set_check, - :set_col, - :u_id + :id, + :set ) diff --git a/bricktracker/sql/set/list/all.sql b/bricktracker/sql/set/list/all.sql index 66e3549..28629cc 100644 --- a/bricktracker/sql/set/list/all.sql +++ b/bricktracker/sql/set/list/all.sql @@ -1 +1 @@ -{% extends 'set/base/select.sql' %} +{% extends 'set/base/full.sql' %} diff --git a/bricktracker/sql/set/list/generic.sql b/bricktracker/sql/set/list/generic.sql index f4cfc12..0177c2b 100644 --- a/bricktracker/sql/set/list/generic.sql +++ b/bricktracker/sql/set/list/generic.sql @@ -1,12 +1,6 @@ -SELECT - sets.set_num, - sets.name, - sets.year, - sets.theme_id, - sets.num_parts, - sets.set_img_url, - sets.set_url -FROM sets +{% extends 'set/base/base.sql' %} +{% block group %} GROUP BY - sets.set_num + "bricktracker_sets"."rebrickable_set" +{% endblock %} diff --git a/bricktracker/sql/set/list/missing_minifigure.sql b/bricktracker/sql/set/list/missing_minifigure.sql index 6a5fd28..5f27088 100644 --- a/bricktracker/sql/set/list/missing_minifigure.sql +++ b/bricktracker/sql/set/list/missing_minifigure.sql @@ -1,13 +1,13 @@ -{% extends 'set/base/select.sql' %} +{% extends 'set/base/full.sql' %} {% block where %} -WHERE sets.u_id IN ( +WHERE "bricktracker_sets"."id" IN ( SELECT - missing.u_id - FROM missing + "missing"."u_id" + FROM "missing" - WHERE missing.set_num IS NOT DISTINCT FROM :fig_num + WHERE "missing"."set_num" IS NOT DISTINCT FROM :fig_num - GROUP BY missing.u_id + GROUP BY "missing"."u_id" ) {% endblock %} diff --git a/bricktracker/sql/set/list/missing_part.sql b/bricktracker/sql/set/list/missing_part.sql index 1d14daf..781754c 100644 --- a/bricktracker/sql/set/list/missing_part.sql +++ b/bricktracker/sql/set/list/missing_part.sql @@ -1,15 +1,15 @@ -{% extends 'set/base/select.sql' %} +{% extends 'set/base/full.sql' %} {% block where %} -WHERE sets.u_id IN ( +WHERE "bricktracker_sets"."id" IN ( SELECT - missing.u_id - FROM missing + "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 + 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 + GROUP BY "missing"."u_id" ) {% endblock %} diff --git a/bricktracker/sql/set/list/using_minifigure.sql b/bricktracker/sql/set/list/using_minifigure.sql index de98221..f08a5d7 100644 --- a/bricktracker/sql/set/list/using_minifigure.sql +++ b/bricktracker/sql/set/list/using_minifigure.sql @@ -1,13 +1,13 @@ -{% extends 'set/base/select.sql' %} +{% extends 'set/base/full.sql' %} {% block where %} -WHERE sets.u_id IN ( +WHERE "bricktracker_sets"."id" IN ( SELECT - inventory.u_id - FROM inventory + "inventory"."u_id" + FROM "inventory" - WHERE inventory.set_num IS NOT DISTINCT FROM :fig_num + WHERE "inventory"."set_num" IS NOT DISTINCT FROM :fig_num - GROUP BY inventory.u_id + GROUP BY "inventory"."u_id" ) {% endblock %} diff --git a/bricktracker/sql/set/list/using_part.sql b/bricktracker/sql/set/list/using_part.sql index afa788d..8877cff 100644 --- a/bricktracker/sql/set/list/using_part.sql +++ b/bricktracker/sql/set/list/using_part.sql @@ -1,15 +1,15 @@ -{% extends 'set/base/select.sql' %} +{% extends 'set/base/full.sql' %} {% block where %} -WHERE sets.u_id IN ( +WHERE "bricktracker_sets"."id" IN ( SELECT - inventory.u_id - FROM inventory + "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 + 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 + GROUP BY "inventory"."u_id" ) {% endblock %} diff --git a/bricktracker/sql/set/select.sql b/bricktracker/sql/set/select.sql deleted file mode 100644 index d9c10aa..0000000 --- a/bricktracker/sql/set/select.sql +++ /dev/null @@ -1,13 +0,0 @@ -{% extends 'set/base/select.sql' %} - -{% block where_missing %} -WHERE u_id IS NOT DISTINCT FROM :u_id -{% endblock %} - -{% block where_minifigures %} -WHERE u_id IS NOT DISTINCT FROM :u_id -{% endblock %} - -{% block where %} -WHERE sets.u_id IS NOT DISTINCT FROM :u_id -{% endblock %} diff --git a/bricktracker/sql/set/select/full.sql b/bricktracker/sql/set/select/full.sql new file mode 100644 index 0000000..4b19136 --- /dev/null +++ b/bricktracker/sql/set/select/full.sql @@ -0,0 +1,13 @@ +{% extends 'set/base/full.sql' %} + +{% block where_missing %} +WHERE "missing"."u_id" IS NOT DISTINCT FROM :id +{% endblock %} + +{% block where_minifigures %} +WHERE "minifigures"."u_id" IS NOT DISTINCT FROM :id +{% endblock %} + +{% block where %} +WHERE "bricktracker_sets"."id" IS NOT DISTINCT FROM :id +{% endblock %} diff --git a/bricktracker/sql/set/select/light.sql b/bricktracker/sql/set/select/light.sql new file mode 100644 index 0000000..61dce04 --- /dev/null +++ b/bricktracker/sql/set/select/light.sql @@ -0,0 +1,5 @@ +{% extends 'set/base/light.sql' %} + +{% block where %} +WHERE "bricktracker_sets"."id" IS NOT DISTINCT FROM :id +{% endblock %} diff --git a/bricktracker/sql/set/update/status.sql b/bricktracker/sql/set/update/status.sql new file mode 100644 index 0000000..d72616e --- /dev/null +++ b/bricktracker/sql/set/update/status.sql @@ -0,0 +1,10 @@ +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/set/update_checked.sql b/bricktracker/sql/set/update_checked.sql deleted file mode 100644 index 77a5cca..0000000 --- a/bricktracker/sql/set/update_checked.sql +++ /dev/null @@ -1,3 +0,0 @@ -UPDATE sets -SET {{name}} = :status -WHERE u_id IS NOT DISTINCT FROM :u_id diff --git a/bricktracker/sql/wish/base/base.sql b/bricktracker/sql/wish/base/base.sql new file mode 100644 index 0000000..b06c66f --- /dev/null +++ b/bricktracker/sql/wish/base/base.sql @@ -0,0 +1,19 @@ +SELECT + "bricktracker_wishes"."set", + "bricktracker_wishes"."name", + "bricktracker_wishes"."year", + "bricktracker_wishes"."theme_id", + "bricktracker_wishes"."number_of_parts", + "bricktracker_wishes"."image", + "bricktracker_wishes"."url" +FROM "bricktracker_wishes" + +{% block where %}{% endblock %} + +{% if order %} +ORDER BY {{ order }} +{% endif %} + +{% if limit %} +LIMIT {{ limit }} +{% endif %} diff --git a/bricktracker/sql/wish/base/select.sql b/bricktracker/sql/wish/base/select.sql deleted file mode 100644 index 0d516c2..0000000 --- a/bricktracker/sql/wish/base/select.sql +++ /dev/null @@ -1,20 +0,0 @@ -SELECT - wishlist.set_num, - wishlist.name, - wishlist.year, - wishlist.theme_id, - wishlist.num_parts, - wishlist.set_img_url, - wishlist.set_url, - wishlist.last_modified_dt -FROM wishlist - -{% block where %}{% endblock %} - -{% if order %} -ORDER BY {{ order }} -{% endif %} - -{% if limit %} -LIMIT {{ limit }} -{% endif %} diff --git a/bricktracker/sql/wish/delete/wish.sql b/bricktracker/sql/wish/delete/wish.sql index 387ffd7..e60b2e4 100644 --- a/bricktracker/sql/wish/delete/wish.sql +++ b/bricktracker/sql/wish/delete/wish.sql @@ -1,2 +1,2 @@ -DELETE FROM wishlist -WHERE set_num IS NOT DISTINCT FROM :set_num \ No newline at end of file +DELETE FROM "bricktracker_wishes" +WHERE "bricktracker_wishes"."set" IS NOT DISTINCT FROM :set \ No newline at end of file diff --git a/bricktracker/sql/wish/insert.sql b/bricktracker/sql/wish/insert.sql index 9ace503..3a4fdec 100644 --- a/bricktracker/sql/wish/insert.sql +++ b/bricktracker/sql/wish/insert.sql @@ -1,19 +1,17 @@ -INSERT INTO wishlist ( - set_num, - name, - year, - theme_id, - num_parts, - set_img_url, - set_url, - last_modified_dt +INSERT OR IGNORE INTO "bricktracker_wishes" ( + "set", + "name", + "year", + "theme_id", + "number_of_parts", + "image", + "url" ) VALUES ( - :set_num, + :set, :name, :year, :theme_id, - :num_parts, - :set_img_url, - :set_url, - :last_modified_dt + :number_of_parts, + :image, + :url ) diff --git a/bricktracker/sql/wish/list/all.sql b/bricktracker/sql/wish/list/all.sql index e1e10c5..0d56355 100644 --- a/bricktracker/sql/wish/list/all.sql +++ b/bricktracker/sql/wish/list/all.sql @@ -1 +1 @@ -{% extends 'wish/base/select.sql' %} +{% extends 'wish/base/base.sql' %} diff --git a/bricktracker/sql/wish/select.sql b/bricktracker/sql/wish/select.sql index 2559843..2c399c8 100644 --- a/bricktracker/sql/wish/select.sql +++ b/bricktracker/sql/wish/select.sql @@ -1,5 +1,5 @@ -{% extends 'wish/base/select.sql' %} +{% extends 'wish/base/base.sql' %} {% block where %} -WHERE wishlist.set_num IS NOT DISTINCT FROM :set_num +WHERE "bricktracker_wishes"."set" IS NOT DISTINCT FROM :set {% endblock %} diff --git a/bricktracker/sql_counter.py b/bricktracker/sql_counter.py new file mode 100644 index 0000000..d104269 --- /dev/null +++ b/bricktracker/sql_counter.py @@ -0,0 +1,46 @@ +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_sets': ('Bricktracker sets', 'hashtag'), + 'bricktracker_wishes': ('Bricktracker wishes', 'gift-line'), + 'inventory': ('Parts', 'shapes-line'), + 'minifigures': ('Minifigures', 'group-line'), + 'missing': ('Missing', 'error-warning-line'), + 'rebrickable_sets': ('Rebrickable sets', 'hashtag'), + 'sets': ('Sets', 'hashtag'), + 'sets_old': ('Sets (legacy)', 'hashtag'), + 'wishlist': ('Wishlist', 'gift-line'), + 'wishlist_old': ('Wishlist (legacy)', 'gift-line'), +} + + +class BrickCounter(object): + name: str + table: str + icon: str + count: int + + def __init__( + self, + table: str, + /, + *, + name: str | None = None, + icon: str = 'question-line' + ): + self.table = table + + # Check if there is an alias + if table in ALIASES: + self.name = ALIASES[table][0] + self.icon = ALIASES[table][1] + else: + if name is None: + self.name = table + else: + self.name = name + + self.icon = icon diff --git a/bricktracker/sql_migration.py b/bricktracker/sql_migration.py new file mode 100644 index 0000000..b8ee73f --- /dev/null +++ b/bricktracker/sql_migration.py @@ -0,0 +1,52 @@ +import os + +from .version import __database_version__ + + +class BrickSQLMigration(object): + description: str | None + name: str + file: str + version: int + + # Description marker + description_marker: str = '-- description:' + + def __init__(self, file: str): + self.file = file + self.name, _ = os.path.splitext(os.path.basename(self.file)) + self.version = int(self.name) + + self.description = None + + # Read the description from the migration file if it exists + def get_description(self) -> str: + if self.description is None: + self.description = '' + + # First line or ignored + with open(self.file, 'r') as file: + line = file.readline() + + # Extract a description (only the first one) + if line.startswith(self.description_marker): + self.description = line.strip()[ + len(self.description_marker): + ] + + return self.description + + # Tells whether the migration is need + def is_needed(self, current: int, /): + return self.version > current and self.version <= __database_version__ + + # Query name for the SQL loader + def get_query(self) -> str: + relative, _ = os.path.splitext( + os.path.relpath(self.file, os.path.join( + os.path.dirname(__file__), + 'sql/' + )) + ) + + return relative diff --git a/bricktracker/sql_migration_list.py b/bricktracker/sql_migration_list.py new file mode 100644 index 0000000..10303c4 --- /dev/null +++ b/bricktracker/sql_migration_list.py @@ -0,0 +1,53 @@ +from glob import glob +import logging +import os + +from .sql_migration import BrickSQLMigration + +logger = logging.getLogger(__name__) + + +class BrickSQLMigrationList(object): + migrations: list[BrickSQLMigration] + + def __init__(self): + # Load the migrations only there is none already loaded + migrations = getattr(self, 'migrations', None) + + if migrations is None: + logger.info('Loading SQL migrations list') + + BrickSQLMigrationList.migrations = [] + + path: str = os.path.join( + os.path.dirname(__file__), + 'sql/migrations/*.sql' + ) + + files = glob(path) + + for file in files: + try: + BrickSQLMigrationList.migrations.append( + BrickSQLMigration(file) + ) + # Ignore file if error + except Exception: + pass + + # Get the sorted list of pending migrations + def pending( + self, + current: int, + / + ) -> list[BrickSQLMigration]: + pending: list[BrickSQLMigration] = [] + + for migration in self.migrations: + if migration.is_needed(current): + pending.append(migration) + + # Sort the list + pending.sort(key=lambda e: e.version) + + return pending diff --git a/bricktracker/theme_list.py b/bricktracker/theme_list.py index d8a0b47..22cac8e 100644 --- a/bricktracker/theme_list.py +++ b/bricktracker/theme_list.py @@ -22,7 +22,7 @@ class BrickThemeList(object): size: int | None exception: Exception | None - def __init__(self, /, force: bool = False): + def __init__(self, /, *, force: bool = False): # Load themes only if there is none already loaded themes = getattr(self, 'themes', None) @@ -33,7 +33,7 @@ class BrickThemeList(object): # Try to read the themes from a CSV file try: - with open(current_app.config['THEMES_PATH'].value, newline='') as themes_file: # noqa: E501 + with open(current_app.config['THEMES_PATH'], newline='') as themes_file: # noqa: E501 themes_reader = csv.reader(themes_file) # Ignore the header @@ -44,7 +44,7 @@ class BrickThemeList(object): BrickThemeList.themes[theme.id] = theme # File stats - stat = os.stat(current_app.config['THEMES_PATH'].value) + stat = os.stat(current_app.config['THEMES_PATH']) BrickThemeList.size = stat.st_size BrickThemeList.mtime = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc) # noqa: E501 @@ -78,7 +78,7 @@ class BrickThemeList(object): def human_time(self) -> str: if self.mtime is not None: return self.mtime.astimezone(g.timezone).strftime( - current_app.config['FILE_DATETIME_FORMAT'].value + current_app.config['FILE_DATETIME_FORMAT'] ) else: return '' @@ -87,7 +87,7 @@ class BrickThemeList(object): @staticmethod def update() -> None: response = requests.get( - current_app.config['THEMES_FILE_URL'].value, + current_app.config['THEMES_FILE_URL'], stream=True, ) @@ -98,7 +98,7 @@ class BrickThemeList(object): content = gzip.GzipFile(fileobj=response.raw) - with open(current_app.config['THEMES_PATH'].value, 'wb') as f: + with open(current_app.config['THEMES_PATH'], 'wb') as f: copyfileobj(content, f) logger.info('Theme list updated') diff --git a/bricktracker/version.py b/bricktracker/version.py index 1f356cc..73c525d 100644 --- a/bricktracker/version.py +++ b/bricktracker/version.py @@ -1 +1,4 @@ -__version__ = '1.0.0' +from typing import Final + +__version__: Final[str] = '1.1.0' +__database_version__: Final[int] = 6 diff --git a/bricktracker/views/add.py b/bricktracker/views/add.py index 45b08a3..218a0bf 100644 --- a/bricktracker/views/add.py +++ b/bricktracker/views/add.py @@ -17,8 +17,8 @@ def add() -> str: return render_template( 'add.html', - path=current_app.config['SOCKET_PATH'].value, - namespace=current_app.config['SOCKET_NAMESPACE'].value, + path=current_app.config['SOCKET_PATH'], + namespace=current_app.config['SOCKET_NAMESPACE'], messages=MESSAGES ) @@ -32,7 +32,7 @@ def bulk() -> str: return render_template( 'bulk.html', - path=current_app.config['SOCKET_PATH'].value, - namespace=current_app.config['SOCKET_NAMESPACE'].value, + path=current_app.config['SOCKET_PATH'], + namespace=current_app.config['SOCKET_NAMESPACE'], messages=MESSAGES ) diff --git a/bricktracker/views/admin.py b/bricktracker/views/admin.py deleted file mode 100644 index 77b8c63..0000000 --- a/bricktracker/views/admin.py +++ /dev/null @@ -1,317 +0,0 @@ -from datetime import datetime -import logging -import os - -from flask import ( - Blueprint, - current_app, - g, - redirect, - request, - render_template, - send_file, - url_for, -) -from flask_login import login_required -from werkzeug.wrappers.response import Response - -from ..configuration_list import BrickConfigurationList -from .exceptions import exception_handler -from ..instructions_list import BrickInstructionsList -from ..minifigure import BrickMinifigure -from ..part import BrickPart -from ..rebrickable_image import RebrickableImage -from ..retired_list import BrickRetiredList -from ..set import BrickSet -from ..sql import BrickSQL -from ..theme_list import BrickThemeList -from .upload import upload_helper - -logger = logging.getLogger(__name__) - -admin_page = Blueprint('admin', __name__, url_prefix='/admin') - - -# Admin -@admin_page.route('/', methods=['GET']) -@login_required -@exception_handler(__file__) -def admin() -> str: - counters: dict[str, int] = {} - count_none: int = 0 - exception: Exception | None = None - is_init: bool = False - nil_minifigure_name: str = '' - nil_minifigure_url: str = '' - nil_part_name: str = '' - nil_part_url: str = '' - - # This view needs to be protected against SQL errors - try: - is_init = BrickSQL.is_init() - - if is_init: - counters = BrickSQL.count_records() - - record = BrickSQL().fetchone('missing/count_none') - if record is not None: - count_none = record['count'] - - nil_minifigure_name = RebrickableImage.nil_minifigure_name() - nil_minifigure_url = RebrickableImage.static_url( - nil_minifigure_name, - 'MINIFIGURES_FOLDER' - ) - - nil_part_name = RebrickableImage.nil_name() - nil_part_url = RebrickableImage.static_url( - nil_part_name, - 'PARTS_FOLDER' - ) - - except Exception as e: - exception = e - - # Warning - logger.warning('An exception occured while loading the admin page: {exception}'.format( # noqa: E501 - exception=str(e), - )) - - 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_retired = request.args.get('open_retired', None) - open_theme = request.args.get('open_theme', None) - - open_database = ( - open_image is None and - open_instructions is None and - open_logout is None and - open_retired is None and - open_theme is None - ) - - return render_template( - 'admin.html', - configuration=BrickConfigurationList.list(), - counters=counters, - count_none=count_none, - error=request.args.get('error'), - exception=exception, - instructions=BrickInstructionsList(), - is_init=is_init, - 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_database=open_database, - open_image=open_image, - open_instructions=open_instructions, - open_logout=open_logout, - open_retired=open_retired, - open_theme=open_theme, - retired=BrickRetiredList(), - theme=BrickThemeList(), - ) - - -# Initialize the database -@admin_page.route('/init-database', methods=['POST']) -@login_required -@exception_handler(__file__, post_redirect='admin.admin') -def init_database() -> Response: - BrickSQL.initialize() - - # Reload the instructions - BrickInstructionsList(force=True) - - return redirect(url_for('admin.admin')) - - -# Delete the database -@admin_page.route('/delete-database', methods=['GET']) -@login_required -@exception_handler(__file__) -def delete_database() -> str: - return render_template( - 'admin.html', - delete_database=True, - error=request.args.get('error') - ) - - -# Actually delete the database -@admin_page.route('/delete-database', methods=['POST']) -@login_required -@exception_handler(__file__, post_redirect='admin.delete_database') -def do_delete_database() -> Response: - BrickSQL.delete() - - # Reload the instructions - BrickInstructionsList(force=True) - - return redirect(url_for('admin.admin')) - - -# Download the database -@admin_page.route('/download-database', methods=['GET']) -@login_required -@exception_handler(__file__) -def download_database() -> Response: - # Create a file name with a timestamp embedded - name, extension = os.path.splitext( - os.path.basename(current_app.config['DATABASE_PATH'].value) - ) - - # Info - logger.info('The database has been downloaded') - - return send_file( - current_app.config['DATABASE_PATH'].value, - as_attachment=True, - download_name='{name}-{timestamp}{extension}'.format( - name=name, - timestamp=datetime.now().astimezone(g.timezone).strftime( - current_app.config['DATABASE_TIMESTAMP_FORMAT'].value - ), - extension=extension - ) - ) - - -# Drop the database -@admin_page.route('/drop-database', methods=['GET']) -@login_required -@exception_handler(__file__) -def drop_database() -> str: - return render_template( - 'admin.html', - drop_database=True, - error=request.args.get('error') - ) - - -# Actually drop the database -@admin_page.route('/drop-database', methods=['POST']) -@login_required -@exception_handler(__file__, post_redirect='admin.drop_database') -def do_drop_database() -> Response: - BrickSQL.drop() - - # Reload the instructions - BrickInstructionsList(force=True) - - return redirect(url_for('admin.admin')) - - -# Import a database -@admin_page.route('/import-database', methods=['GET']) -@login_required -@exception_handler(__file__) -def import_database() -> str: - return render_template( - 'admin.html', - import_database=True, - error=request.args.get('error') - ) - - -# Actually import a database -@admin_page.route('/import-database', methods=['POST']) -@login_required -@exception_handler(__file__, post_redirect='admin.import_database') -def do_import_database() -> Response: - file = upload_helper( - 'database', - 'admin.import_database', - extensions=['.db'], - ) - - if isinstance(file, Response): - return file - - BrickSQL.upload(file) - - # Reload the instructions - BrickInstructionsList(force=True) - - return redirect(url_for('admin.admin')) - - -# Refresh the instructions cache -@admin_page.route('/refresh-instructions', methods=['GET']) -@login_required -@exception_handler(__file__) -def refresh_instructions() -> Response: - BrickInstructionsList(force=True) - - return redirect(url_for('admin.admin', open_instructions=True)) - - -# Refresh the retired sets cache -@admin_page.route('/refresh-retired', methods=['GET']) -@login_required -@exception_handler(__file__) -def refresh_retired() -> Response: - BrickRetiredList(force=True) - - return redirect(url_for('admin.admin', open_retired=True)) - - -# Refresh the themes cache -@admin_page.route('/refresh-themes', methods=['GET']) -@login_required -@exception_handler(__file__) -def refresh_themes() -> Response: - BrickThemeList(force=True) - - return redirect(url_for('admin.admin', open_theme=True)) - - -# Update the default images -@admin_page.route('/update-image', methods=['GET']) -@login_required -@exception_handler(__file__) -def update_image() -> Response: - # Abusing the object to create a 'nil' minifigure - RebrickableImage( - BrickSet(), - minifigure=BrickMinifigure(record={ - 'set_img_url': None, - }) - ).download() - - # Abusing the object to create a 'nil' part - RebrickableImage( - BrickSet(), - part=BrickPart(record={ - 'part_img_url': None, - 'part_img_url_id': None - }) - ).download() - - return redirect(url_for('admin.admin', open_image=True)) - - -# Update the themes file -@admin_page.route('/update-retired', methods=['GET']) -@login_required -@exception_handler(__file__) -def update_retired() -> Response: - BrickRetiredList().update() - - BrickRetiredList(force=True) - - return redirect(url_for('admin.admin', open_retired=True)) - - -# Update the themes file -@admin_page.route('/update-themes', methods=['GET']) -@login_required -@exception_handler(__file__) -def update_themes() -> Response: - BrickThemeList().update() - - BrickThemeList(force=True) - - return redirect(url_for('admin.admin', open_theme=True)) diff --git a/bricktracker/views/admin/__init__.py b/bricktracker/views/admin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bricktracker/views/admin/admin.py b/bricktracker/views/admin/admin.py new file mode 100644 index 0000000..847e42a --- /dev/null +++ b/bricktracker/views/admin/admin.py @@ -0,0 +1,104 @@ +import logging + +from flask import Blueprint, request, render_template +from flask_login import login_required + +from ...configuration_list import BrickConfigurationList +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 ...sql_counter import BrickCounter +from ...sql import BrickSQL +from ...theme_list import BrickThemeList + +logger = logging.getLogger(__name__) + +admin_page = Blueprint('admin', __name__, url_prefix='/admin') + + +# Admin +@admin_page.route('/', methods=['GET']) +@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 + nil_minifigure_name: str = '' + nil_minifigure_url: str = '' + nil_part_name: str = '' + nil_part_url: str = '' + + # This view needs to be protected against SQL errors + try: + database = BrickSQL(failsafe=True) + database_upgrade_needed = database.upgrade_needed() + database_version = database.version + database_counters = BrickSQL().count_records() + + brickset_checkboxes = BrickSetCheckboxList().list(all=True) + except Exception as e: + database_exception = e + + # Warning + logger.warning('A database exception occured while loading the admin page: {exception}'.format( # noqa: E501 + exception=str(e), + )) + + nil_minifigure_name = RebrickableImage.nil_minifigure_name() + nil_minifigure_url = RebrickableImage.static_url( + nil_minifigure_name, + 'MINIFIGURES_FOLDER' + ) + + nil_part_name = RebrickableImage.nil_name() + nil_part_url = RebrickableImage.static_url( + nil_part_name, + '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_retired = request.args.get('open_retired', None) + open_theme = request.args.get('open_theme', None) + + open_database = ( + open_checkbox is None and + open_image is None and + open_instructions is None and + open_logout is None and + open_retired is None and + open_theme is None + ) + + return render_template( + 'admin.html', + configuration=BrickConfigurationList.list(), + brickset_checkboxes=brickset_checkboxes, + database_counters=database_counters, + database_error=request.args.get('error'), + database_exception=database_exception, + database_upgrade_needed=database_upgrade_needed, + database_version=database_version, + instructions=BrickInstructionsList(), + 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_retired=open_retired, + open_theme=open_theme, + retired=BrickRetiredList(), + theme=BrickThemeList(), + ) diff --git a/bricktracker/views/admin/checkbox.py b/bricktracker/views/admin/checkbox.py new file mode 100644 index 0000000..1dd58bb --- /dev/null +++ b/bricktracker/views/admin/checkbox.py @@ -0,0 +1,98 @@ +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 new file mode 100644 index 0000000..bd0f213 --- /dev/null +++ b/bricktracker/views/admin/database.py @@ -0,0 +1,170 @@ +from datetime import datetime +import logging +import os + +from flask import ( + Blueprint, + current_app, + g, + redirect, + request, + render_template, + send_file, + url_for, +) +from flask_login import login_required +from werkzeug.wrappers.response import Response + +from ..exceptions import exception_handler +from ...reload import reload +from ...sql_migration_list import BrickSQLMigrationList +from ...sql import BrickSQL +from ..upload import upload_helper + +logger = logging.getLogger(__name__) + +admin_database_page = Blueprint( + 'admin_database', + __name__, + url_prefix='/admin/database' +) + + +# Delete the database +@admin_database_page.route('/delete', methods=['GET']) +@login_required +@exception_handler(__file__) +def delete() -> str: + return render_template( + 'admin.html', + delete_database=True, + error=request.args.get('error') + ) + + +# Actually delete the database +@admin_database_page.route('/delete', methods=['POST']) +@login_required +@exception_handler(__file__, post_redirect='admin_database.delete') +def do_delete() -> Response: + BrickSQL.delete() + + reload() + + return redirect(url_for('admin.admin')) + + +# Download the database +@admin_database_page.route('/download', methods=['GET']) +@login_required +@exception_handler(__file__) +def download() -> Response: + # Create a file name with a timestamp embedded + name, extension = os.path.splitext( + os.path.basename(current_app.config['DATABASE_PATH']) + ) + + # Info + logger.info('The database has been downloaded') + + return send_file( + current_app.config['DATABASE_PATH'], + as_attachment=True, + download_name='{name}-v{version}-{timestamp}{extension}'.format( + name=name, + version=BrickSQL(failsafe=True).version, + timestamp=datetime.now().astimezone(g.timezone).strftime( + current_app.config['DATABASE_TIMESTAMP_FORMAT'] + ), + extension=extension + ) + ) + + +# Drop the database +@admin_database_page.route('/drop', methods=['GET']) +@login_required +@exception_handler(__file__) +def drop() -> str: + return render_template( + 'admin.html', + drop_database=True, + error=request.args.get('error') + ) + + +# Actually drop the database +@admin_database_page.route('/drop', methods=['POST']) +@login_required +@exception_handler(__file__, post_redirect='admin_database.drop') +def do_drop() -> Response: + BrickSQL.drop() + + reload() + + return redirect(url_for('admin.admin')) + + +# Actually upgrade the database +@admin_database_page.route('/upgrade', methods=['POST']) +@login_required +@exception_handler(__file__, post_redirect='admin_database.upgrade') +def do_upgrade() -> Response: + BrickSQL(failsafe=True).upgrade() + + reload() + + return redirect(url_for('admin.admin')) + + +# Import a database +@admin_database_page.route('/import', methods=['GET']) +@login_required +@exception_handler(__file__) +def upload() -> str: + return render_template( + 'admin.html', + import_database=True, + error=request.args.get('error') + ) + + +# Actually import a database +@admin_database_page.route('/import', methods=['POST']) +@login_required +@exception_handler(__file__, post_redirect='admin_database.upload') +def do_upload() -> Response: + file = upload_helper( + 'database', + 'admin_database.upload', + extensions=['.db'], + ) + + if isinstance(file, Response): + return file + + BrickSQL.upload(file) + + reload() + + return redirect(url_for('admin.admin')) + + +# Upgrade the database +@admin_database_page.route('/upgrade', methods=['GET']) +@login_required +@exception_handler(__file__, post_redirect='admin.admin') +def upgrade() -> str | Response: + database = BrickSQL(failsafe=True) + + if not database.upgrade_needed(): + return redirect(url_for('admin.admin')) + + return render_template( + 'admin.html', + upgrade_database=True, + migrations=BrickSQLMigrationList().pending( + database.version + ), + error=request.args.get('error') + ) diff --git a/bricktracker/views/admin/image.py b/bricktracker/views/admin/image.py new file mode 100644 index 0000000..30dce28 --- /dev/null +++ b/bricktracker/views/admin/image.py @@ -0,0 +1,44 @@ +import logging + +from flask import Blueprint, redirect, url_for +from flask_login import login_required +from werkzeug.wrappers.response import Response + +from ..exceptions import exception_handler +from ...minifigure import BrickMinifigure +from ...part import BrickPart +from ...rebrickable_image import RebrickableImage +from ...set import BrickSet + +logger = logging.getLogger(__name__) + +admin_image_page = Blueprint( + 'admin_image', + __name__, + url_prefix='/admin/image' +) + + +# Update the default images +@admin_image_page.route('/update', methods=['GET']) +@login_required +@exception_handler(__file__) +def update() -> Response: + # Abusing the object to create a 'nil' minifigure + RebrickableImage( + BrickSet(), + minifigure=BrickMinifigure(record={ + 'set_img_url': None, + }) + ).download() + + # Abusing the object to create a 'nil' part + RebrickableImage( + BrickSet(), + part=BrickPart(record={ + 'part_img_url': None, + 'part_img_url_id': None + }) + ).download() + + return redirect(url_for('admin.admin', open_image=True)) diff --git a/bricktracker/views/admin/instructions.py b/bricktracker/views/admin/instructions.py new file mode 100644 index 0000000..354782d --- /dev/null +++ b/bricktracker/views/admin/instructions.py @@ -0,0 +1,26 @@ +import logging + +from flask import Blueprint, redirect, url_for +from flask_login import login_required +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__, + url_prefix='/admin/instructions' +) + + +# Refresh the instructions cache +@admin_instructions_page.route('/refresh', methods=['GET']) +@login_required +@exception_handler(__file__) +def refresh() -> Response: + BrickInstructionsList(force=True) + + return redirect(url_for('admin.admin', open_instructions=True)) diff --git a/bricktracker/views/admin/retired.py b/bricktracker/views/admin/retired.py new file mode 100644 index 0000000..c3aa2f2 --- /dev/null +++ b/bricktracker/views/admin/retired.py @@ -0,0 +1,38 @@ +import logging + +from flask import Blueprint, redirect, url_for +from flask_login import login_required +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__, + url_prefix='/admin/retired' +) + + +# Refresh the retired sets cache +@admin_retired_page.route('/refresh', methods=['GET']) +@login_required +@exception_handler(__file__) +def refresh() -> Response: + BrickRetiredList(force=True) + + return redirect(url_for('admin.admin', open_retired=True)) + + +# Update the retired sets +@admin_retired_page.route('/update', methods=['GET']) +@login_required +@exception_handler(__file__) +def update() -> Response: + BrickRetiredList().update() + + BrickRetiredList(force=True) + + return redirect(url_for('admin.admin', open_retired=True)) diff --git a/bricktracker/views/admin/theme.py b/bricktracker/views/admin/theme.py new file mode 100644 index 0000000..d5f15bb --- /dev/null +++ b/bricktracker/views/admin/theme.py @@ -0,0 +1,38 @@ +import logging + +from flask import Blueprint, redirect, url_for +from flask_login import login_required +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__, + url_prefix='/admin/theme' +) + + +# Refresh the themes cache +@admin_theme_page.route('/refresh', methods=['GET']) +@login_required +@exception_handler(__file__) +def refresh() -> Response: + BrickThemeList(force=True) + + return redirect(url_for('admin.admin', open_theme=True)) + + +# Update the themes file +@admin_theme_page.route('/update', methods=['GET']) +@login_required +@exception_handler(__file__) +def update() -> Response: + BrickThemeList().update() + + BrickThemeList(force=True) + + return redirect(url_for('admin.admin', open_theme=True)) diff --git a/bricktracker/views/error.py b/bricktracker/views/error.py index c9f47e6..c034ea8 100644 --- a/bricktracker/views/error.py +++ b/bricktracker/views/error.py @@ -30,6 +30,7 @@ def error( error: Exception | None, file: str, /, + *, json: bool = False, post_redirect: str | None = None, **kwargs, @@ -121,6 +122,7 @@ def error( def error_404( error: Exception, /, + *, json: bool = False, post_redirect: str | None = None, **kwargs, diff --git a/bricktracker/views/exceptions.py b/bricktracker/views/exceptions.py index 06120d4..b78c390 100644 --- a/bricktracker/views/exceptions.py +++ b/bricktracker/views/exceptions.py @@ -25,15 +25,17 @@ ViewCallable = Callable[P, ViewReturn] def exception_handler( file: str, /, + *, json: bool = False, - post_redirect: str | None = None + post_redirect: str | None = None, + **superkwargs, ) -> Callable[[ViewCallable], ViewCallable]: def outer(function: ViewCallable, /) -> ViewCallable: @wraps(function) def wrapper(*args, **kwargs) -> ViewReturn: try: return function(*args, **kwargs) - # Catch SQLite errors as database errors + # Handle errors except Exception as e: return error( e, @@ -41,6 +43,7 @@ def exception_handler( json=json, post_redirect=post_redirect, **kwargs, + **superkwargs, ) return wrapper return outer diff --git a/bricktracker/views/index.py b/bricktracker/views/index.py index 511e858..c1f0811 100644 --- a/bricktracker/views/index.py +++ b/bricktracker/views/index.py @@ -2,6 +2,7 @@ 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 index_page = Blueprint('index', __name__) @@ -15,4 +16,5 @@ def index() -> str: 'index.html', brickset_collection=BrickSetList().last(), minifigure_collection=BrickMinifigureList().last(), + brickset_checkboxes=BrickSetCheckboxList().list(), ) diff --git a/bricktracker/views/instructions.py b/bricktracker/views/instructions.py index de2c3b9..159f8db 100644 --- a/bricktracker/views/instructions.py +++ b/bricktracker/views/instructions.py @@ -115,7 +115,7 @@ def do_upload() -> Response: file = upload_helper( 'file', 'instructions.upload', - extensions=current_app.config['INSTRUCTIONS_ALLOWED_EXTENSIONS'].value, + extensions=current_app.config['INSTRUCTIONS_ALLOWED_EXTENSIONS'], ) if isinstance(file, Response): diff --git a/bricktracker/views/set.py b/bricktracker/views/set.py index 2b589b0..b36f7e1 100644 --- a/bricktracker/views/set.py +++ b/bricktracker/views/set.py @@ -15,6 +15,7 @@ from .exceptions import exception_handler from ..minifigure import BrickMinifigure from ..part import BrickPart from ..set import BrickSet +from ..set_checkbox_list import BrickSetCheckboxList from ..set_list import BrickSetList logger = logging.getLogger(__name__) @@ -26,47 +27,34 @@ set_page = Blueprint('set', __name__, url_prefix='/sets') @set_page.route('/', methods=['GET']) @exception_handler(__file__) def list() -> str: - return render_template('sets.html', collection=BrickSetList().all()) + return render_template( + 'sets.html', + collection=BrickSetList().all(), + brickset_checkboxes=BrickSetCheckboxList().list(), + ) -# Change the set checked status of one set -@set_page.route('//checked', methods=['POST']) +# Change the status of a checkbox +@set_page.route('//status/', methods=['POST']) @login_required @exception_handler(__file__, json=True) -def set_checked(*, id: str) -> Response: - state: bool = request.json.get('state', False) # type: ignore +def update_status(*, id: str, checkbox_id: str) -> Response: + value: bool = request.json.get('value', False) # type: ignore - brickset = BrickSet().select_specific(id) - brickset.update_checked('set_check', state) + brickset = BrickSet().select_light(id) + checkbox = BrickSetCheckboxList().get(checkbox_id) + + brickset.update_status(checkbox, value) # Info - logger.info('Set {number} ({id}): changed set checked status to {state}'.format( # noqa: E501 - number=brickset.fields.set_num, - id=brickset.fields.u_id, - state=state, + 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, )) - return jsonify({'state': state}) - - -# Change the set collected status of one set -@set_page.route('//collected', methods=['POST']) -@login_required -@exception_handler(__file__, json=True) -def set_collected(*, id: str) -> Response: - state: bool = request.json.get('state', False) # type: ignore - - brickset = BrickSet().select_specific(id) - brickset.update_checked('set_col', state) - - # Info - logger.info('Set {number} ({id}): changed set collected status to {state}'.format( # noqa: E501 - number=brickset.fields.set_num, - id=brickset.fields.u_id, - state=state, - )) - - return jsonify({'state': state}) + return jsonify({'value': value}) # Ask for deletion of a set @@ -85,13 +73,13 @@ def delete(*, id: str) -> str: @set_page.route('//delete', methods=['POST']) @exception_handler(__file__, post_redirect='set.delete') def do_delete(*, id: str) -> Response: - brickset = BrickSet().select_specific(id) + brickset = BrickSet().select_light(id) brickset.delete() # Info logger.info('Set {number} ({id}): deleted'.format( - number=brickset.fields.set_num, - id=brickset.fields.u_id, + number=brickset.fields.set, + id=brickset.fields.id, )) return redirect(url_for('set.deleted', id=id)) @@ -115,29 +103,10 @@ 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), ) -# Change the minifigures collected status of one set -@set_page.route('/sets//minifigures/collected', methods=['POST']) -@login_required -@exception_handler(__file__, json=True) -def minifigures_collected(*, id: str) -> Response: - state: bool = request.json.get('state', False) # type: ignore - - brickset = BrickSet().select_specific(id) - brickset.update_checked('mini_col', state) - - # Info - logger.info('Set {number} ({id}): changed minifigures collected status to {state}'.format( # noqa: E501 - number=brickset.fields.set_num, - id=brickset.fields.u_id, - state=state, - )) - - return jsonify({'state': state}) - - # Update the missing pieces of a minifig part @set_page.route('//minifigures//parts//missing', methods=['POST']) # noqa: E501 @login_required @@ -162,8 +131,8 @@ def missing_minifigure_part( # Info logger.info('Set {number} ({id}): updated minifigure ({minifigure}) part ({part}) missing count to {missing}'.format( # noqa: E501 - number=brickset.fields.set_num, - id=brickset.fields.u_id, + number=brickset.fields.set, + id=brickset.fields.id, minifigure=minifigure.fields.fig_num, part=part.fields.id, missing=missing, @@ -186,8 +155,8 @@ def missing_part(*, id: str, part_id: str) -> Response: # Info logger.info('Set {number} ({id}): updated part ({part}) missing count to {missing}'.format( # noqa: E501 - number=brickset.fields.set_num, - id=brickset.fields.u_id, + number=brickset.fields.set, + id=brickset.fields.id, part=part.fields.id, missing=missing, )) diff --git a/bricktracker/views/upload.py b/bricktracker/views/upload.py index ff9b3ff..9f6ff67 100644 --- a/bricktracker/views/upload.py +++ b/bricktracker/views/upload.py @@ -12,6 +12,7 @@ def upload_helper( name: str, endpoint: str, /, + *, extensions: list[str] = [], ) -> FileStorage | Response: # Bogus submit diff --git a/bricktracker/wish.py b/bricktracker/wish.py index 3851562..1e301fa 100644 --- a/bricktracker/wish.py +++ b/bricktracker/wish.py @@ -1,37 +1,21 @@ -from sqlite3 import Row -from typing import Any, Self +from typing import Self from flask import url_for from .exceptions import NotFoundException -from .set import BrickSet +from .rebrickable_set import RebrickableSet from .sql import BrickSQL # Lego brick wished set -class BrickWish(BrickSet): +class BrickWish(RebrickableSet): + # Flags + resolve_instructions: bool = False + # Queries select_query: str = 'wish/select' insert_query: str = 'wish/insert' - def __init__( - self, - /, - record: Row | dict[str, Any] | None = None, - ): - # Don't init BrickSet, init the parent of BrickSet directly - super(BrickSet, self).__init__() - - # Placeholders - self.theme_name = '' - - # Ingest the record if it has one - if record is not None: - self.ingest(record) - - # Resolve the theme - self.resolve_theme() - # Delete a wished set def delete(self, /) -> None: BrickSQL().execute_and_commit( @@ -40,42 +24,20 @@ class BrickWish(BrickSet): ) # Select a specific part (with a set and an id) - def select_specific(self, set_num: str, /) -> Self: + def select_specific(self, set: str, /) -> Self: # Save the parameters to the fields - self.fields.set_num = set_num + self.fields.set = set # Load from database - record = self.select() - - if record is None: + if not self.select(): raise NotFoundException( 'Wish with number {number} was not found in the database'.format( # noqa: E501 - number=self.fields.set_num, + number=self.fields.set, ), ) - # Ingest the record - self.ingest(record) - - # Resolve the theme - self.resolve_theme() - return self # Deletion url def url_for_delete(self, /) -> str: - return url_for('wish.delete', number=self.fields.set_num) - - # Normalize from Rebrickable - @staticmethod - def from_rebrickable(data: dict[str, Any], /, **_) -> dict[str, Any]: - return { - 'set_num': data['set_num'], - 'name': data['name'], - 'year': data['year'], - 'theme_id': data['theme_id'], - 'num_parts': data['num_parts'], - 'set_img_url': data['set_img_url'], - 'set_url': data['set_url'], - 'last_modified_dt': data['last_modified_dt'], - } + return url_for('wish.delete', number=self.fields.set) diff --git a/bricktracker/wish_list.py b/bricktracker/wish_list.py index 266ee43..dfba800 100644 --- a/bricktracker/wish_list.py +++ b/bricktracker/wish_list.py @@ -1,13 +1,17 @@ +import logging from typing import Self from flask import current_app -from bricktracker.exceptions import NotFoundException - +from .exceptions import NotFoundException +from .rebrickable import Rebrickable +from .rebrickable_image import RebrickableImage from .rebrickable_set import RebrickableSet from .record_list import BrickRecordList from .wish import BrickWish +logger = logging.getLogger(__name__) + # All the wished sets from the database class BrickWishList(BrickRecordList[BrickWish]): @@ -18,7 +22,7 @@ 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'].value + order=current_app.config['WISHES_DEFAULT_ORDER'] ): brickwish = BrickWish(record=record) @@ -28,10 +32,23 @@ class BrickWishList(BrickRecordList[BrickWish]): # Add a set to the wishlist @staticmethod - def add(set_num: str, /) -> None: - # Check if it already exists + def add(set: str, /) -> None: try: - set_num = RebrickableSet.parse_number(set_num) - BrickWish().select_specific(set_num) + set = RebrickableSet.parse_number(set) + BrickWish().select_specific(set) except NotFoundException: - RebrickableSet.wish(set_num) + logger.debug('rebrick.lego.get_set("{set}")'.format( + set=set, + )) + + brickwish = Rebrickable[BrickWish]( + 'get_set', + set, + BrickWish, + ).get() + + # Insert into database + brickwish.insert() + + if not current_app.config['USE_REMOTE_IMAGES']: + RebrickableImage(brickwish).download() diff --git a/compose.legacy.yml b/compose.legacy.yml index adcb044..27ea59e 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.0.0 + image: gitea.baerentsen.space/frederikbaerentsen/bricktracker:1.1.0 ports: - "3333:3333" volumes: diff --git a/compose.yaml b/compose.yaml index 180a189..59dac38 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.0.0 + image: gitea.baerentsen.space/frederikbaerentsen/bricktracker:1.1.0 ports: - "3333:3333" volumes: diff --git a/docs/DOCS.md b/docs/DOCS.md new file mode 100644 index 0000000..450ca29 --- /dev/null +++ b/docs/DOCS.md @@ -0,0 +1,32 @@ +# Bricktracker documentation + +This page helps you navigate the documentation of BrickTracker. + +## Overview + +- [Overview](overview.md) (the screenshots of the application are here!) + +## Installation + +- [Setup](setup.md) + +## Usage + +- [First steps](first-steps.md) +- [Managing your sets](set.md) +- [Managing your set checkboxes](checkbox.md) + +## Specific procedures + +- [Setup lightweight authentication](authentication.md) +- Database + - [Move an existing ./app.db database](move-existing-database.md) + - [Upgrade your database](upgrade-database.md) + +## Troubleshooting + +- [Common errors](common-errors.md) + +## Development + +- [Development](development.md) diff --git a/docs/authentication.md b/docs/authentication.md index a49ad0a..083b503 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -1,9 +1,9 @@ # Authentication -> **Note**
+> **Note** > The following page is based on version `1.0.0` of BrickTracker. -> **Warning**
+> **Warning** > This is a lightweight access control feature and does not provide any strong layer of security to the application. By default, every feature of the application is available. @@ -15,7 +15,7 @@ To set up the authentication, you need to set the two following environment vari - `BK_AUTHENTICATION_KEY`: a secret for the server to encrypt the session cookie. See [.env.sample](../.env.sample) for how to generate the value - `BK_AUTHENTICATION_PASSWORD`: the actual password -> **Warning**
+> **Warning** > The password is stored in **plaintext**. Be mindful. Once the authentication is set up, you should see a ![read-only](images/authentication-01.png) pill on the right side of the menu bar. diff --git a/docs/checkbox.md b/docs/checkbox.md new file mode 100644 index 0000000..3071e67 --- /dev/null +++ b/docs/checkbox.md @@ -0,0 +1,58 @@ +# 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/common-errors.md b/docs/common-errors.md index 91ebb49..62a5543 100644 --- a/docs/common-errors.md +++ b/docs/common-errors.md @@ -1,54 +1,18 @@ # Common errors/problems -> **Note**
-> The following page is based on version `1.0.0` of BrickTracker. +> **Note** +> The following page is based on version `1.1.0` of BrickTracker. ## I need a password to access some pages -You have setup lightweight authentication. Your password is in your environement `BK_AUTHENTICATION_PASSWORD` variable. +You have setup [lightweight authentication](authentication.md). Your password is in your environement `BK_AUTHENTICATION_PASSWORD` variable. ## I cannot access the Add page (Configuration missing!) ![](images/common-errors-01.png) You need to pass the `BK_REBRICKABLE_API_KEY` environment to your application, depending on how you run the application. -For instance: - -- Docker: `docker run -e BK_REBRICKABLE_API_KEY=xxxx` -- Docker compose (directly in `compose.yaml`): - -``` -services: - bricktracker: - environment: - - BK_AUTHENTICATION_KEY=xxxx -``` - -- Docker compose (with an environement file, for instance `.env`) - -``` --- .env -BK_AUTHENTICATION_KEY=xxxx - --- compose.yaml -services: - bricktracker: - env_file: ".env" -``` - -> **Warning**
-> Do not use quotes (", ') around your environment variables. -> Docker will interpret them has being part of the **value** of the environment variable. -> For instance... -> -> ``` -> services: -> bricktracker: -> environment: -> - BK_AUTHENTICATION_KEY="xxxx" -> ``` -> -> ...will make Docker believe that your API key is `"xxxx"`. +See [setup](setup.md) for more information. ## The socket is disconnected @@ -69,16 +33,22 @@ Make sure the value you have set is matching the URL of your application. If it is not the case, adjust the value and restart the application. -## No such file or directory: '' when adding a set +## No such file or directory: '<path>' when adding a set ![](images/common-errors-03.png) The application doestake care of creating folders for static images and expects them to be writable. Make sure that the folder exists, and if it exists that it is writable by the application. -## I'm seeing Unknown () instead of the set theme +## I'm seeing Unknown (<number>) instead of the set theme ![](images/common-errors-04.png) Either the theme is too recent for your version of the themes file, or your theme file has not be initialized. Head to the **Admin** page, **Themes** section and update the file. + +## Your database need to be upgraded from version <current> to version <required>. + +![](images/common-errors-05.png) + +A database schema modification was necessary to implement new features and the database need to be upgraded from the **Admin** page. See [upgrade your database](upgrade-database.md) for more information. diff --git a/docs/development.md b/docs/development.md index 752b4ba..6799be0 100644 --- a/docs/development.md +++ b/docs/development.md @@ -1,17 +1,29 @@ # Development -> **Note**
+> **Note** > The following page is based on version `1.0.0` of BrickTracker. The application is written in Python version 3. -It uses: +It uses the following Python/pip packages: - `flask` (Web framework) - `flask_socketio` (Socket.IO: Websocket library for real-time client-server communication) - `flask-login` (Lightweight Flask authentication library) +- `humanize` (Unit and datetime conversion) - `sqlite3` (Light database management system) - `rebrick` API (Library to interact with the Rebrickable.com API) +It also uses the following libraries and frameworks: + +- Boostrap (https://getbootstrap.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) + +The BrickTracker brick logo is part of the Small n' Flat Icons set designed by [Arnaud Chesne](https://iconduck.com/designers/arnaud-chesne). + + ## Running a local debug instance You can use the [compose.local.yaml](../compose.local.yaml) file to build and run an instance with debug enabled and on a different port (`3334`). diff --git a/docs/first-steps.md b/docs/first-steps.md index b1e625e..162854b 100644 --- a/docs/first-steps.md +++ b/docs/first-steps.md @@ -1,6 +1,6 @@ # First steps -> **Note**
+> **Note** > The following page is based on version `1.0.0` of BrickTracker. ## Database initialization @@ -35,10 +35,10 @@ If everything went well you should see no more error message and some counters. ## Add a set -> **Important**
+> **Important** > Make sure you have set up your Rebrickable API key (`BK_REBRICKABLE_KEY`) for this to work (see [common errors](common-errors.md)). -> **Important**
+> **Important** > If you are using the CORS allowed origin restriction (`BK_DOMAIN_NAME`), make sure it is matching your application URL (see [common errors](common-errors.md)). Use the menu bar to navigate to the **Add** page, make sure the socket is in a **connected** state. @@ -55,7 +55,7 @@ It will load information about the set you are about to add, but not add it yet. Use the **Confirm add** button to add the set, or the **Dismiss** button if it is not the one you wanted. -> **Note**
+> **Note** > If you do not want to go through the confirmation process, check the **Add without confirmation** checkbox and the > set will be added when you press the **Add** button. diff --git a/docs/images/checkbox-01.png b/docs/images/checkbox-01.png new file mode 100644 index 0000000..cd64258 Binary files /dev/null and b/docs/images/checkbox-01.png differ diff --git a/docs/images/checkbox-02.png b/docs/images/checkbox-02.png new file mode 100644 index 0000000..bae0ee6 Binary files /dev/null and b/docs/images/checkbox-02.png differ diff --git a/docs/images/checkbox-03.png b/docs/images/checkbox-03.png new file mode 100644 index 0000000..b941206 Binary files /dev/null and b/docs/images/checkbox-03.png differ diff --git a/docs/images/checkbox-04.png b/docs/images/checkbox-04.png new file mode 100644 index 0000000..4c62c05 Binary files /dev/null and b/docs/images/checkbox-04.png differ diff --git a/docs/images/checkbox-05.png b/docs/images/checkbox-05.png new file mode 100644 index 0000000..1de55ea Binary files /dev/null and b/docs/images/checkbox-05.png differ diff --git a/docs/images/checkbox-06.png b/docs/images/checkbox-06.png new file mode 100644 index 0000000..14db2bf Binary files /dev/null and b/docs/images/checkbox-06.png differ diff --git a/docs/images/checkbox-07.png b/docs/images/checkbox-07.png new file mode 100644 index 0000000..061128d Binary files /dev/null and b/docs/images/checkbox-07.png differ diff --git a/docs/images/common-errors-05.png b/docs/images/common-errors-05.png new file mode 100644 index 0000000..0828ebd Binary files /dev/null and b/docs/images/common-errors-05.png differ diff --git a/docs/images/upgrade-database-01.png b/docs/images/upgrade-database-01.png new file mode 100644 index 0000000..8d41f6b Binary files /dev/null and b/docs/images/upgrade-database-01.png differ diff --git a/docs/images/upgrade-database-02.png b/docs/images/upgrade-database-02.png new file mode 100644 index 0000000..7528c72 Binary files /dev/null and b/docs/images/upgrade-database-02.png differ diff --git a/docs/images/upgrade-database-03.png b/docs/images/upgrade-database-03.png new file mode 100644 index 0000000..3676e84 Binary files /dev/null and b/docs/images/upgrade-database-03.png differ diff --git a/docs/images/upgrade-database-04.png b/docs/images/upgrade-database-04.png new file mode 100644 index 0000000..6e90f64 Binary files /dev/null and b/docs/images/upgrade-database-04.png differ diff --git a/docs/move-existing-database.md b/docs/move-existing-database.md index a72c3e1..594dd75 100644 --- a/docs/move-existing-database.md +++ b/docs/move-existing-database.md @@ -2,7 +2,7 @@ If you are coming from the original version of BrickTracker and you wish to move your database from a bind mount to a volume, you can follow this procedure. -> **Note**
+> **Note** > If you already have a copy of your `app.db` file easily available, you can skip directly to "Move the database" section ## Update to version 1.0.0 @@ -57,7 +57,7 @@ This will download a copy of the database file to your computer. Now that you have a copy of your database, you can tell the application its new path. Edit the `BK_DATABASE_PATH` environment variable and redeploy your application. -> **Note**
+> **Note** > The name of the database file does not have to be `app.db` For instance: diff --git a/docs/bricktracker.md b/docs/overview.md similarity index 97% rename from docs/bricktracker.md rename to docs/overview.md index 9792f98..a5789be 100644 --- a/docs/bricktracker.md +++ b/docs/overview.md @@ -1,6 +1,6 @@ # BrickTracker -> **Note**
+> **Note** > The following page is based on version `1.0.0` of BrickTracker. ## Frontpage @@ -14,7 +14,7 @@ You can click the card name or image to access a set or minifigure detail. ## Sets -> **Info**
+> **Info** > This does not do any pagination and loads **everything**. It can be slow depending on how many sets you have. ![](images/bricktracker-02.png) @@ -66,7 +66,7 @@ If an error occur, it will put back in the input field the list of number that w ## Parts -> **Info**
+> **Info** > This does not do any pagination and loads **everything**. It can be slow depending on how many sets you have. ![](images/bricktracker-06.png) @@ -79,7 +79,7 @@ Clicking on a part name will load its details. ## Missing (parts) -> **Info**
+> **Info** > This does not do any pagination and loads **everything**. It can be slow depending on how missing parts you have. ![](images/bricktracker-07.png) @@ -92,7 +92,7 @@ Clicking on a part name will load its details. ## Minifigures -> **Info**
+> **Info** > This does not do any pagination and loads **everything**. It can be slow depending on how many minifigures you have. ![](images/bricktracker-08.png) @@ -105,7 +105,7 @@ Clicking on a minifigure name will load its details. ## Instructions -> **Info**
+> **Info** > This does not do any pagination and loads **everything**. It can be slow depending on how many instructions you have. ![](images/bricktracker-08.png) @@ -118,7 +118,7 @@ Clicking on a set image will open it fullscreen. ## Wishlist -> **Info**
+> **Info** > This does not do any pagination and loads **everything**. It can be slow depending on how many wished sets you have. ![](images/bricktracker-12.png) diff --git a/docs/set.md b/docs/set.md index eba2183..aa2702f 100644 --- a/docs/set.md +++ b/docs/set.md @@ -1,6 +1,6 @@ # Managing your sets -> **Note**
+> **Note** > The following page is based on version `1.0.0` of BrickTracker. ## Set image diff --git a/docs/setup.md b/docs/setup.md index a058233..502502f 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -1,6 +1,6 @@ # Setup -> **Note**
+> **Note** > The following page is based on version `1.0.0` of BrickTracker. ## Prerequisites @@ -34,7 +34,7 @@ services: env_file: ".env" ``` -> **Warning**
+> **Warning** > Do not use quotes (", ') around your environment variables. > Docker will interpret them has being part of the **value** of the environment variable. > For instance... diff --git a/docs/upgrade-database.md b/docs/upgrade-database.md new file mode 100644 index 0000000..3e90bdb --- /dev/null +++ b/docs/upgrade-database.md @@ -0,0 +1,34 @@ +# Upgrade your database + +Sometimes, it is necessary to perform structural changes to the database to implement new features. +A built-in tool is in charge of this task, however it is not automatic (because is rare case it can lead to unexpected results.) + +To perform a database upgrade, you need to navigate to the **Admin page**. + +![](images/upgrade-database-01.png) + +## Backup your data + +> **Warning** +> Even if the migration steps are crafted so that your data is preserved, there can be unexpected results. +> **ALWAYS BACKUP YOUR DATA BEFORE PROCEEDING WITH A MIGRATION**. + +To back up your data, use the **Download the database file** button. It will download a copy of the database file to your disk. +The file will be named: `-v-year-month-day-hours-minutes-secondes.db`. + +### Restore a backup + +In case of a problem, you can always use the **Import a database file** button in the **Database danger zone**. + +![](images/upgrade-database-02.png) + +## Perform the upgrade + +The upgrade procedure is automated, you simply need to start it. Press the **Upgrade the database** button to see the planned list of changes. + +![](images/upgrade-database-03.png) + +Confirm by pressing the **Upgrade the database** button once more. +Once the upgrade is done you wil be back to the **Admin page**. You can see that your database file is at the required version. + +![](images/upgrade-database-04.png) diff --git a/entrypoint.sh b/entrypoint.sh index 7add85e..148c366 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -13,4 +13,4 @@ then fi # Execute the WSGI server -gunicorn --bind "${BK_SERVER}:${BK_PORT}" "app:app" --worker-class "eventlet" "$@" +gunicorn --bind "${BK_SERVER}:${BK_PORT}" "app:create_app()" --worker-class "eventlet" "$@" diff --git a/static/scripts/changer.js b/static/scripts/changer.js new file mode 100644 index 0000000..224e24b --- /dev/null +++ b/static/scripts/changer.js @@ -0,0 +1,125 @@ +// Generic state changer with visual feedback +class BrickChanger { + constructor(prefix, id, url, parent = undefined) { + this.prefix = prefix + this.html_element = document.getElementById(`${prefix}-${id}`); + this.html_status = document.getElementById(`status-${prefix}-${id}`); + this.html_type = this.html_element.getAttribute("type"); + this.url = url; + + if (parent) { + this.html_parent = document.getElementById(`${parent}-${id}`); + this.parent_dataset = `data-${prefix}` + } + + // Register an event depending on the type + if (this.html_type == "checkbox") { + var listener = "change"; + } else { + var listener = "click"; + } + + this.html_element.addEventListener(listener, ((changer) => (e) => { + changer.change(); + })(this)); + } + + // Clean the status + status_clean() { + if (this.html_status) { + const to_remove = Array.from( + this.html_status.classList.values() + ).filter( + (name) => name.startsWith('ri-') || name.startsWith('text-') || name.startsWith('bg-') + ); + + if (to_remove.length) { + this.html_status.classList.remove(...to_remove); + } + } + } + + // Set the status to Error + status_error() { + if (this.html_status) { + this.status_clean(); + this.html_status.classList.add("ri-alert-line", "text-danger"); + } + } + + // Set the status to OK + status_ok() { + if (this.html_status) { + this.status_clean(); + this.html_status.classList.add("ri-checkbox-circle-line", "text-success"); + } + } + + // Set the status to Unknown + status_unknown() { + if (this.html_status) { + this.status_clean(); + this.html_status.classList.add("ri-question-line", "text-warning"); + } + } + + async change() { + try { + 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; + } + + const response = await fetch(this.url, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: this.prefix, + value: value, + }) + }); + + if (!response.ok) { + throw new Error(`Response status: ${response.status}`); + } + + const json = await response.json(); + + if ("error" in json) { + throw new Error(`Error received: ${json.error}`) + } + + this.status_ok(); + + // Update the parent + if (this.html_parent) { + if (this.html_type == "checkbox") { + value = Number(value) + } + + // Not going through dataset to avoid converting + this.html_parent.setAttribute(this.parent_dataset, value); + } + } catch (error) { + console.log(error.message); + + this.status_error(); + } + } +} + +// Helper to setup the changer +const setup_changers = () => document.querySelectorAll("*[data-changer-id]").forEach( + el => new BrickChanger( + el.dataset.changerPrefix, + el.dataset.changerId, + el.dataset.changerUrl, + el.dataset.changerParent + ) +); \ No newline at end of file diff --git a/static/scripts/grid.js b/static/scripts/grid.js index 6246817..42b8ac3 100644 --- a/static/scripts/grid.js +++ b/static/scripts/grid.js @@ -50,15 +50,15 @@ class BrickGridSortButton { // Grid class class BrickGrid { - constructor(id) { - this.id = id; + constructor(grid) { + this.id = grid.id; // Grid elements (built based on the initial id) - this.html_grid = document.getElementById(id); - this.html_sort = document.getElementById(`${id}-sort`); - this.html_search = document.getElementById(`${id}-search`); - this.html_filter = document.getElementById(`${id}-filter`); - this.html_theme = document.getElementById(`${id}-theme`); + 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 = {}; @@ -251,3 +251,8 @@ class BrickGrid { } } } + +// Helper to setup the grids +const setup_grids = () => document.querySelectorAll('*[data-grid="true"]').forEach( + el => new BrickGrid(el) +); diff --git a/static/scripts/set.js b/static/scripts/set.js index 88966d2..25ba15d 100644 --- a/static/scripts/set.js +++ b/static/scripts/set.js @@ -7,60 +7,6 @@ const clean_status = (status) => { } } -// Change the status of a set checkbox -const change_set_checkbox_status = async (el, kind, id, url) => { - const status = document.getElementById(`status-${kind}-${id}`); - - try { - // Set the status to unknown - if (status) { - clean_status(status) - status.classList.add("ri-question-line", "text-warning"); - } - - const response = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - state: el.checked - }) - }); - - if (!response.ok) { - throw new Error(`Response status: ${response.status}`); - } - - const json = await response.json(); - - if ("error" in json) { - throw new Error(`Error received: ${json.error}`) - } - - // Set the status to OK - if (status) { - clean_status(status) - status.classList.add("ri-checkbox-circle-line", "text-success"); - } - - // Update the card - const card = document.getElementById(`set-${id}`); - if (card) { - // Not going through dataset to avoid converting - card.setAttribute(`data-${kind}`, Number(el.checked)); - } - } catch (error) { - console.log(error.message); - - // Set the status to not OK - if (status) { - clean_status(status) - status.classList.add("ri-alert-line", "text-danger"); - } - } -} - // Change the amount of missing parts const change_part_missing_amount = async (el, set_id, part_id, url) => { const status = document.getElementById(`status-part-${set_id}-${part_id}`); diff --git a/static/scripts/socket.js b/static/scripts/socket.js index 581b09d..5a24d06 100644 --- a/static/scripts/socket.js +++ b/static/scripts/socket.js @@ -30,7 +30,7 @@ class BrickSocket { // Card elements this.html_card = document.getElementById(`${id}-card`); - this.html_card_number = document.getElementById(`${id}-card-number`); + this.html_card_set = document.getElementById(`${id}-card-set`); this.html_card_name = document.getElementById(`${id}-card-name`); this.html_card_image_container = document.getElementById(`${id}-card-image-container`); this.html_card_image = document.getElementById(`${id}-card-image`); @@ -190,9 +190,9 @@ class BrickSocket { } if (this.bulk && this.html_input) { - if (this.set_list_last_number !== undefined) { - this.set_list.unshift(this.set_list_last_number); - this.set_list_last_number = undefined; + if (this.set_list_last_set !== undefined) { + this.set_list.unshift(this.set_list_last_set); + this.set_list_last_set = undefined; } this.html_input.value = this.set_list.join(', '); @@ -200,7 +200,7 @@ class BrickSocket { } // Import a set - import_set(no_confirm, number, from_complete=false) { + import_set(no_confirm, set, from_complete=false) { if (this.html_input) { if (!this.bulk || !from_complete) { // Reset the progress @@ -213,10 +213,10 @@ class BrickSocket { // Grab from the list if bulk if (this.bulk) { - number = this.set_list.shift() + set = this.set_list.shift() // Abort if nothing left to process - if (number === undefined) { + if (set === undefined) { // Clear the input this.html_input.value = ""; @@ -227,14 +227,14 @@ class BrickSocket { return; } - // Save the pulled number - this.set_list_last_number = number; + // Save the pulled set + this.set_list_last_set = set; } this.spinner(true); this.socket.emit(this.messages.IMPORT_SET, { - set_num: (number !== undefined) ? number : this.html_input.value, + set: (set !== undefined) ? set : this.html_input.value, }); } else { this.fail("Could not find the input field for the set number"); @@ -249,7 +249,7 @@ class BrickSocket { this.spinner(true); this.socket.emit(this.messages.LOAD_SET, { - set_num: this.html_input.value + set: this.html_input.value }); } else { this.fail("Could not find the input field for the set number"); @@ -319,8 +319,8 @@ class BrickSocket { if (this.html_card) { this.html_card.classList.remove("d-none"); - if (this.html_card_number) { - this.html_card_number.textContent = data["set_num"]; + if (this.html_card_set) { + this.html_card_set.textContent = data["set"]; } if (this.html_card_name) { @@ -328,12 +328,12 @@ class BrickSocket { } if (this.html_card_image_container) { - this.html_card_image_container.setAttribute("style", `background-image: url(${data["set_img_url"]})`); + this.html_card_image_container.setAttribute("style", `background-image: url(${data["image"]})`); } if (this.html_card_image) { - this.html_card_image.setAttribute("src", data["set_img_url"]); - this.html_card_image.setAttribute("alt", data["set_num"]); + this.html_card_image.setAttribute("src", data["image"]); + this.html_card_image.setAttribute("alt", data["set"]); } if (this.html_card_footer) { @@ -347,12 +347,12 @@ class BrickSocket { this.html_card_confirm.removeEventListener("click", this.confirm_listener); } - this.confirm_listener = ((bricksocket, number) => (e) => { + this.confirm_listener = ((bricksocket, set) => (e) => { if (!bricksocket.disabled) { bricksocket.toggle(false); - bricksocket.import_set(false, number); + bricksocket.import_set(false, set); } - })(this, data["set_num"]); + })(this, data["set"]); this.html_card_confirm.addEventListener("click", this.confirm_listener); } diff --git a/static/scripts/table.js b/static/scripts/table.js new file mode 100644 index 0000000..669afc5 --- /dev/null +++ b/static/scripts/table.js @@ -0,0 +1,87 @@ +class BrickTable { + constructor(table, per_page) { + const columns = [] + const no_sort = []; + const number = []; + + // Read the table header for parameters + table.querySelectorAll('th').forEach((th, index) => { + if (th.dataset.tableNoSort) { + no_sort.push(index); + } + + if (th.dataset.tableNumber) { + number.push(index); + } + }); + + if (no_sort.length) { + columns.push({ select: no_sort, sortable: false, searchable: false }); + } + + if (number.length) { + columns.push({ select: number, type: "number", searchable: false }); + } + + this.table = new simpleDatatables.DataTable(`#${table.id}`, { + columns: columns, + pagerDelta: 1, + perPage: per_page, + perPageSelect: [10, 25, 50, 100, 500, 1000], + searchable: true, + searchMethod: (table => (terms, cell, row, column, source) => table.search(terms, cell, row, column, source))(this), + searchQuerySeparator: "", + tableRender: () => { + baguetteBox.run("[data-lightbox]"); + }, + pagerRender: () => { + baguetteBox.run("[data-lightbox]"); + } + }); + } + + // Custom search method + // Very simplistic but will exclude pill links + search(terms, cell, row, column, source) { + // Create a searchable string from the data stack ignoring data-search="exclude" + const search = this.buildSearch(cell.data).filter(data => data != "").join(" "); + + // Search it + for (const term of terms) { + if (search.includes(term)) { + return true; + } + } + + return false; + } + + // Build the search string + buildSearch(dataList) { + let search = []; + + for (const data of dataList) { + // Exclude + if (data.attributes && data.attributes['data-search'] && data.attributes['data-search'] == 'exclude') { + continue; + } + + // Childnodes + if (data.childNodes) { + search = search.concat(this.buildSearch(data.childNodes)); + } + + // Data + if(data.data) { + search.push(data.data.trim().toLowerCase()); + } + } + + return search; + } +} + +// Helper to setup the tables +const setup_tables = (per_page) => document.querySelectorAll('table[data-table="true"]').forEach( + el => new BrickTable(el, per_page) +); diff --git a/templates/add.html b/templates/add.html index a4a4917..140eec6 100644 --- a/templates/add.html +++ b/templates/add.html @@ -4,7 +4,7 @@ {% block main %}
- {% if not config['HIDE_ADD_BULK_SET'].value %} + {% if not config['HIDE_ADD_BULK_SET'] %}
- {% if delete_database %} + {% if delete_checkbox %} + {% include 'admin/checkbox/delete.html' %} + {% elif delete_database %} {% include 'admin/database/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' %} {% else %} {% include 'admin/logout.html' %} {% include 'admin/instructions.html' %} - {% if not config['USE_REMOTE_IMAGES'].value %} + {% if not config['USE_REMOTE_IMAGES'] %} {% include 'admin/image.html' %} {% endif %} {% include 'admin/theme.html' %} {% include 'admin/retired.html' %} + {% include 'admin/checkbox.html' %} {% include 'admin/database.html' %} {% include 'admin/configuration.html' %} {% endif %} @@ -36,4 +41,4 @@
-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/templates/admin/checkbox.html b/templates/admin/checkbox.html new file mode 100644 index 0000000..b71cc2a --- /dev/null +++ b/templates/admin/checkbox.html @@ -0,0 +1,62 @@ +{% 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 new file mode 100644 index 0000000..49d507a --- /dev/null +++ b/templates/admin/checkbox/delete.html @@ -0,0 +1,25 @@ +{% 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/configuration.html b/templates/admin/configuration.html index 44cabdf..9ef4496 100644 --- a/templates/admin/configuration.html +++ b/templates/admin/configuration.html @@ -18,12 +18,10 @@ {{ entry.value }} {% endif %} {% endif %} - {% if not entry.not_from_env %} - Env: {{ entry.env_name }} - {% if entry.extra_name %}Env: {{ entry.extra_name }}{% endif %} - {% if entry.is_changed() %} - Changed - {% endif %} + Env: {{ entry.env_name }} + {% if entry.extra_name %}Env: {{ entry.extra_name }}{% endif %} + {% if entry.is_changed() %} + Changed {% endif %} {% endfor %} diff --git a/templates/admin/database.html b/templates/admin/database.html index bbe02d9..36d2b0d 100644 --- a/templates/admin/database.html +++ b/templates/admin/database.html @@ -2,48 +2,34 @@ {{ accordion.header('Database', 'database', 'admin', expanded=open_database, icon='database-2-line') }}
Status
-{% if exception %}{% endif %} -{% if error %}{% endif %} -{% if not is_init %} +{% if database_exception %}{% endif %} +{% if database_error %}{% endif %} +{% if database_upgrade_needed %} -{% else %} - {% if count_none %} - +{% endif %} +

The database file is: {{ config['DATABASE_PATH'] }} at version {{ database_version }}

+

+ Download the database file +

+{% if database_counters %}
Records
-
    -
  • - Sets {{ counters['sets'] }} -
  • -
  • - Minifigures {{ counters['minifigures'] }} -
  • -
  • - Parts {{ counters['inventory'] }} -
  • -
  • - Missing {{ counters['missing'] }} -
  • +
      + {% for counter in database_counters %} +
    • + {{ counter.name }} {{ counter.count }} +
    • + {% if not (loop.index % 5) %} +
    +
      + {% endif %} + {% endfor %}
{% endif %} @@ -51,10 +37,7 @@ {{ accordion.header('Database danger zone', 'database-danger', 'admin', danger=true, class='text-end') }} {% if error %}{% endif %} - Import a database file -
- - - Drop the database - Delete the database file + Import a database file + Drop the database + Delete the database file {{ accordion.footer() }} diff --git a/templates/admin/database/delete.html b/templates/admin/database/delete.html index 675e39d..9a2d286 100644 --- a/templates/admin/database/delete.html +++ b/templates/admin/database/delete.html @@ -1,7 +1,7 @@ {% import 'macro/accordion.html' as accordion %} {{ accordion.header('Database danger zone', 'database-danger', 'admin', expanded=true, danger=true, class='text-end') }} -
+ {% if error %}{% endif %} Back to the admin diff --git a/templates/admin/database/drop.html b/templates/admin/database/drop.html index 059ea62..e0f5b2e 100644 --- a/templates/admin/database/drop.html +++ b/templates/admin/database/drop.html @@ -1,7 +1,7 @@ {% import 'macro/accordion.html' as accordion %} {{ accordion.header('Database danger zone', 'database-danger', 'admin', expanded=true, danger=true, class='text-end') }} - + {% if error %}{% endif %} Back to the admin diff --git a/templates/admin/database/import.html b/templates/admin/database/import.html index cd0fa2a..f7a7763 100644 --- a/templates/admin/database/import.html +++ b/templates/admin/database/import.html @@ -1,7 +1,7 @@ {% import 'macro/accordion.html' as accordion %} {{ accordion.header('Database danger zone', 'database-danger', 'admin', expanded=true, danger=true) }} - + {% if error %}{% endif %}
diff --git a/templates/admin/database/upgrade.html b/templates/admin/database/upgrade.html new file mode 100644 index 0000000..2739c79 --- /dev/null +++ b/templates/admin/database/upgrade.html @@ -0,0 +1,29 @@ +{% import 'macro/accordion.html' as accordion %} + +{{ accordion.header('Database', 'database', 'admin', expanded=true, icon='database-2-line') }} + + {% if error %}{% endif %} + +
Upgrades
+
    + {% for migration in migrations %} +
  • + {{ migration.version }} + {% if migration.get_description() %} + {{ migration.get_description() }} + {% else %} + No description + {% endif %} +
  • + {% endfor %} +
+
+ Back to the admin + Download the database file + +
+ +{{ accordion.footer() }} diff --git a/templates/admin/image.html b/templates/admin/image.html index 8b5d535..e4f040a 100644 --- a/templates/admin/image.html +++ b/templates/admin/image.html @@ -9,6 +9,6 @@ {{ nil_part_name }}

- Update the images + Update the images

{{ accordion.footer() }} diff --git a/templates/admin/instructions.html b/templates/admin/instructions.html index 17c1e4f..99fbe5e 100644 --- a/templates/admin/instructions.html +++ b/templates/admin/instructions.html @@ -3,8 +3,8 @@ {{ accordion.header('Instructions', 'instructions', 'admin', expanded=open_instructions, icon='file-line') }}
Folder

- The instructions files folder is: {{ config['INSTRUCTIONS_FOLDER'].value }}.
- Allowed file formats for instructions are the following: {{ ', '.join(config['INSTRUCTIONS_ALLOWED_EXTENSIONS'].value) }}. + The instructions files folder is: {{ config['INSTRUCTIONS_FOLDER'] }}.
+ Allowed file formats for instructions are the following: {{ ', '.join(config['INSTRUCTIONS_ALLOWED_EXTENSIONS']) }}.

Counters

@@ -27,6 +27,6 @@

Refresh

- Refresh the instructions cache + Refresh the instructions cache

{{ accordion.footer() }} diff --git a/templates/admin/retired.html b/templates/admin/retired.html index 121e47f..cced0d1 100644 --- a/templates/admin/retired.html +++ b/templates/admin/retired.html @@ -4,7 +4,7 @@
File
{% if retired.exception %}{% endif %}

- The retired sets file is: {{ config['RETIRED_SETS_PATH'].value }}. + The retired sets file is: {{ config['RETIRED_SETS_PATH'] }}. {% if retired.size %} {{ retired.human_size() }}{% endif %} {% if retired.mtime %} {{ retired.human_time() }}{% endif %}

@@ -20,7 +20,7 @@

Refresh

- Refresh the retired sets cache - Update the retired sets file + Refresh the retired sets cache + Update the retired sets file

{{ accordion.footer() }} diff --git a/templates/admin/theme.html b/templates/admin/theme.html index c935e6f..0105fdc 100644 --- a/templates/admin/theme.html +++ b/templates/admin/theme.html @@ -4,7 +4,7 @@
File
{% if theme.exception %}{% endif %}

- The themes file is: {{ config['THEMES_PATH'].value }}. + The themes file is: {{ config['THEMES_PATH'] }}. {% if theme.size %} {{ theme.human_size() }}{% endif %} {% if theme.mtime %} {{ theme.human_time() }}{% endif %}

@@ -20,7 +20,7 @@

Refresh

- Refresh the themes cache - Update the themes file + Refresh the themes cache + Update the themes file

{{ accordion.footer() }} diff --git a/templates/base.html b/templates/base.html index 2985f46..1132f2b 100644 --- a/templates/base.html +++ b/templates/base.html @@ -25,7 +25,7 @@ - {% endblock %} - -{% block scripts %} - -{% endblock %} \ No newline at end of file diff --git a/templates/index.html b/templates/index.html index 2fdf640..3e16427 100644 --- a/templates/index.html +++ b/templates/index.html @@ -3,8 +3,8 @@ {% block main %}

- {% if config['RANDOM'].value %}Random selection of{% else %}Latest added{% endif %} sets - {% if not config['HIDE_ALL_SETS'].value %} + {% if config['RANDOM'] %}Random selection of{% else %}Latest added{% endif %} sets + {% if not config['HIDE_ALL_SETS'] %} All sets {% endif %}

@@ -23,8 +23,8 @@ {% endif %} {% if minifigure_collection | length %}

- {% if config['RANDOM'].value %}Random selection of{% else %}Latest added{% endif %} minifigures - {% if not config['HIDE_ALL_MINIFIGURES'].value %} + {% if config['RANDOM'] %}Random selection of{% else %}Latest added{% endif %} minifigures + {% if not config['HIDE_ALL_MINIFIGURES'] %} All minifigures {% endif %}

diff --git a/templates/instructions.html b/templates/instructions.html index 28734f3..78d4ce8 100644 --- a/templates/instructions.html +++ b/templates/instructions.html @@ -25,4 +25,4 @@ {% endwith %}
{% endif %} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/templates/instructions/delete.html b/templates/instructions/delete.html index cc1ba69..247e703 100644 --- a/templates/instructions/delete.html +++ b/templates/instructions/delete.html @@ -8,9 +8,9 @@
{{ accordion.header('Instructions danger zone', 'instructions-delete', 'instructions', expanded=true, danger=true) }} - {% if item.brickset %} + {% if item.rebrickable %}
- {% with item=item.brickset %} + {% with item=item.rebrickable %} {% include 'set/mini.html' %} {% endwith %}
@@ -28,8 +28,3 @@
- diff --git a/templates/instructions/rename.html b/templates/instructions/rename.html index cfc5626..a3269c1 100644 --- a/templates/instructions/rename.html +++ b/templates/instructions/rename.html @@ -8,9 +8,9 @@
{{ accordion.header('Management', 'instructions-rename', 'instructions', expanded=true) }} - {% if item.brickset %} + {% if item.rebrickable %}
- {% with item=item.brickset %} + {% with item=item.rebrickable %} {% include 'set/mini.html' %} {% endwith %}
@@ -34,8 +34,3 @@
- diff --git a/templates/instructions/table.html b/templates/instructions/table.html index 8f7c505..8ad9f3c 100644 --- a/templates/instructions/table.html +++ b/templates/instructions/table.html @@ -1,14 +1,14 @@ {% import 'macro/table.html' as table %}
-
+
- + {% if g.login.is_authenticated() %} - + {% endif %} @@ -23,15 +23,15 @@ {%- if item.allowed -%} {% endif %} - {{ item.human_size() }} - {{ item.human_time() }} + {{ item.human_size() }} + {{ item.human_time() }} - {% if item.brickset %} - {{ table.image(item.brickset.url_for_image(), caption=item.brickset.fields.name, alt=item.brickset.fields.set_num) }} + {% if item.rebrickable %} + {{ table.image(item.rebrickable.url_for_image(), caption=item.rebrickable.fields.name, alt=item.rebrickable.fields.set) }} {% else %} {% endif %} @@ -46,6 +46,3 @@
Filename Set Image Image Actions Actions
- {% if item.number %} {{ item.number }}{% endif %} - {% if item.brickset %}{{ item.brickset.fields.name }}{% endif %} + {% if item.set %} {{ item.set }}{% endif %} + {% if item.rebrickable %}{{ item.rebrickable.fields.name }}{% endif %}
-{% if all %} - {{ table.dynamic('instructions', no_sort='2,3')}} -{% endif %} diff --git a/templates/instructions/upload.html b/templates/instructions/upload.html index db877d8..f82fb2a 100644 --- a/templates/instructions/upload.html +++ b/templates/instructions/upload.html @@ -26,8 +26,8 @@
- - {{ ', '.join(config['INSTRUCTIONS_ALLOWED_EXTENSIONS'].value) }} + + {{ ', '.join(config['INSTRUCTIONS_ALLOWED_EXTENSIONS']) }}
diff --git a/templates/macro/accordion.html b/templates/macro/accordion.html index c622c86..fbfd01b 100644 --- a/templates/macro/accordion.html +++ b/templates/macro/accordion.html @@ -16,7 +16,7 @@ {% endif %} -
+
{% endmacro %} diff --git a/templates/macro/form.html b/templates/macro/form.html index 2e2f8bb..f9c5a5d 100644 --- a/templates/macro/form.html +++ b/templates/macro/form.html @@ -1,10 +1,14 @@ -{% macro checkbox(kind, id, text, url, checked, delete=false) %} +{% macro checkbox(prefix, id, text, url, checked, delete=false) %} {% if g.login.is_authenticated() %} - -
- {% endblock %} diff --git a/templates/minifigure/table.html b/templates/minifigure/table.html index 287d7c9..94ccef7 100644 --- a/templates/minifigure/table.html +++ b/templates/minifigure/table.html @@ -1,7 +1,7 @@ {% import 'macro/table.html' as table %}
- +
{{ table.header(quantity=true, missing_parts=true, sets=true) }} {% for item in table_collection %} @@ -21,6 +21,3 @@
-{% if all %} - {{ table.dynamic('minifigures', no_sort='0', number='2, 3, 4')}} -{% endif %} diff --git a/templates/minifigures.html b/templates/minifigures.html index 5b42357..ce42f18 100644 --- a/templates/minifigures.html +++ b/templates/minifigures.html @@ -8,4 +8,4 @@ {% include 'minifigure/table.html' %} {% endwith %}
-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/templates/missing.html b/templates/missing.html index 659205b..d7c82a5 100644 --- a/templates/missing.html +++ b/templates/missing.html @@ -8,4 +8,4 @@ {% include 'part/table.html' %} {% endwith %} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/templates/part.html b/templates/part.html index 969b676..40899d9 100644 --- a/templates/part.html +++ b/templates/part.html @@ -12,9 +12,4 @@ - {% endblock %} diff --git a/templates/part/table.html b/templates/part/table.html index c47ca71..1fca264 100644 --- a/templates/part/table.html +++ b/templates/part/table.html @@ -1,7 +1,7 @@ {% import 'macro/table.html' as table %}
- +
{{ table.header(color=true, quantity=not no_quantity, missing=not no_missing, sets=all, minifigures=all) }} {% for item in table_collection %} @@ -51,6 +51,3 @@
-{% if all %} - {{ table.dynamic('parts', no_sort='0', number='3, 4, 5, 6')}} -{% endif %} \ No newline at end of file diff --git a/templates/parts.html b/templates/parts.html index 9ed411b..1c3e417 100644 --- a/templates/parts.html +++ b/templates/parts.html @@ -8,4 +8,4 @@ {% include 'part/table.html' %} {% endwith %} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/templates/set.html b/templates/set.html index a9c5cf7..69ba6dd 100644 --- a/templates/set.html +++ b/templates/set.html @@ -1,6 +1,6 @@ {% extends 'base.html' %} -{% block title %} - Set {{ item.fields.name}} ({{ item.fields.set_num }}){% endblock %} +{% block title %} - Set {{ item.fields.name}} ({{ item.fields.set }}){% endblock %} {% block main %}
@@ -12,13 +12,4 @@
- {% endblock %} - -{% block scripts %} - -{% endblock %} \ No newline at end of file diff --git a/templates/set/card.html b/templates/set/card.html index 31900d1..536c969 100644 --- a/templates/set/card.html +++ b/templates/set/card.html @@ -3,18 +3,20 @@ {% import 'macro/card.html' as card %} {% import 'macro/form.html' as form %} -
- {{ card.header(item, item.fields.name, solo=solo, number=item.fields.set_num) }} - {{ card.image(item, solo=solo, last=last, caption=item.fields.name, alt=item.fields.set_num) }} -
- {{ badge.theme(item.theme_name, solo=solo, last=last) }} + {{ card.header(item, item.fields.name, solo=solo, number=item.fields.number) }} + {{ card.image(item, solo=solo, last=last, caption=item.fields.name, alt=item.fields.number) }} +
+ {{ badge.theme(item.theme.name, solo=solo, last=last) }} {{ badge.year(item.fields.year, solo=solo, last=last) }} - {{ badge.parts(item.fields.num_parts, solo=solo, last=last) }} + {{ badge.parts(item.fields.number_of_parts, solo=solo, last=last) }} {{ badge.total_minifigures(item.fields.total_minifigures, solo=solo, last=last) }} {{ badge.total_missing(item.fields.total_missing, solo=solo, last=last) }} {% if not last %} @@ -24,21 +26,17 @@ {{ badge.rebrickable(item, solo=solo, last=last) }} {% endif %}
- {% if not tiny %} -