Instructions downloader #54

Merged
FrederikBaerentsen merged 19 commits from instructions into master 2025-01-26 19:17:42 +01:00
198 changed files with 3436 additions and 1888 deletions
Showing only changes of commit 6abf4a314f - Show all commits

View File

@ -19,6 +19,8 @@ LICENSE
# Database # Database
*.db *.db
*.db-shm
*.db-wal
# Python # Python
**/__pycache__ **/__pycache__

View File

@ -4,13 +4,13 @@
# see the schema and the column names. Some fields are compound and not visible # see the schema and the column names. Some fields are compound and not visible
# directly from the schema (joins). You can check the query in the */list.sql files # directly from the schema (joins). You can check the query in the */list.sql files
# in the source to see all column names. # in the source to see all column names.
# The usual syntax for those variables is <table>.<column> [ASC|DESC]. # The usual syntax for those variables is "<table>"."<column>" [ASC|DESC].
# For composite fields (CASE, SUM, COUNT) the syntax is <field>, there is no <table> name. # For composite fields (CASE, SUM, COUNT) the syntax is <field>, there is no <table> name.
# For instance: # For instance:
# - table.name (by table.name, default order) # - "table"."name" (by "table"."name", default order)
# - table.name ASC (by table.name, ascending) # - "table"."name" ASC (by "table"."name", ascending)
# - table.name DESC (by table.name, descending) # - "table"."name" DESC (by "table"."name", descending)
# - field (by field, default order) # - "field" (by "field", default order)
# - ... # - ...
# You can combine the ordering options. # You can combine the ordering options.
# You can use the special column name 'rowid' to order by insertion order. # 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. # Optional: Change the default order of minifigures. By default ordered by insertion order.
# Useful column names for this option are: # Useful column names for this option are:
# - minifigures.fig_num: minifigure ID (fig-xxxxx) # - "minifigures"."fig_num": minifigure ID (fig-xxxxx)
# - minifigures.name: minifigure name # - "minifigures"."name": minifigure name
# Default: minifigures.name ASC # Default: "minifigures"."name" ASC
# BK_MINIFIGURES_DEFAULT_ORDER=minifigures.name ASC # BK_MINIFIGURES_DEFAULT_ORDER="minifigures"."name" ASC
# Optional: Folder where to store the minifigures images, relative to the '/app/static/' folder # Optional: Folder where to store the minifigures images, relative to the '/app/static/' folder
# Default: minifigs # Default: minifigs
@ -134,12 +134,13 @@
# Optional: Change the default order of parts. By default ordered by insertion order. # Optional: Change the default order of parts. By default ordered by insertion order.
# Useful column names for this option are: # Useful column names for this option are:
# - inventory.part_num: part number # - "inventory"."part_num": part number
# - inventory.name: part name # - "inventory"."name": part name
# - inventory.color_name: par color name # - "inventory"."color_name": part color name
# - total_missing: number of missing parts # - "inventory"."is_spare": par is a spare part
# Default: inventory.name ASC, inventory.color_name ASC, is_spare ASC # - "total_missing": number of missing parts
# BK_PARTS_DEFAULT_ORDER=total_missing DESC, inventory.name ASC # 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 # Optional: Folder where to store the parts images, relative to the '/app/static/' folder
# Default: parts # Default: parts
@ -177,10 +178,6 @@
# Default: https://rebrickable.com/parts/{number}/_/{color} # Default: https://rebrickable.com/parts/{number}/_/{color}
# BK_REBRICKABLE_LINK_PART_PATTERN= # 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 # Optional: Display Rebrickable links wherever applicable
# Default: false # Default: false
# Legacy name: LINKS # Legacy name: LINKS
@ -201,16 +198,16 @@
# Optional: Change the default order of sets. By default ordered by insertion order. # Optional: Change the default order of sets. By default ordered by insertion order.
# Useful column names for this option are: # Useful column names for this option are:
# - sets.set_num: set number as a string # - "rebrickable_sets"."set": set number as a string
# - sets.name: set name # - "rebrickable_sets"."number": the number part of set as an integer
# - sets.year: set release year # - "rebrickable_sets"."version": the version part of set as an integer
# - sets.num_parts: set number of parts # - "rebrickable_sets"."name": set name
# - set_number: the number part of set_num as an integer # - "rebrickable_sets"."year": set release year
# - set_version: the version part of set_num as an integer # - "rebrickable_sets"."number_of_parts": set number of parts
# - total_missing: number of missing parts # - "total_missing": number of missing parts
# - total_minifigures: number of minifigures # - "total_minifigures": number of minifigures
# Default: set_number DESC, set_version ASC # Default: "rebrickable_sets"."number" DESC, "rebrickable_sets"."version" ASC
# BK_SETS_DEFAULT_ORDER=sets.year ASC # BK_SETS_DEFAULT_ORDER="rebrickable_sets"."year" ASC
# Optional: Folder where to store the sets images, relative to the '/app/static/' folder # Optional: Folder where to store the sets images, relative to the '/app/static/' folder
# Default: sets # Default: sets
@ -249,9 +246,9 @@
# Optional: Change the default order of sets. By default ordered by insertion order. # Optional: Change the default order of sets. By default ordered by insertion order.
# Useful column names for this option are: # Useful column names for this option are:
# - wishlist.set_num: set number as a string # - "bricktracker_wishes"."set": set number as a string
# - wishlist.name: set name # - "bricktracker_wishes"."name": set name
# - wishlist.year: set release year # - "bricktracker_wishes"."year": set release year
# - wishlist.num_parts: set number of parts # - "bricktracker_wishes"."number_of_parts": set number of parts
# Default: wishlist.rowid DESC # Default: "bricktracker_wishes"."rowid" DESC
# BK_WISHES_DEFAULT_ORDER=set_number DESC, set_version ASC # BK_WISHES_DEFAULT_ORDER="bricktracker_wishes"."set" DESC

3
.gitignore vendored
View File

@ -1,6 +1,8 @@
# Application # Application
.env .env
*.db *.db
*.db-shm
*.db-wal
# Python specifics # Python specifics
__pycache__/ __pycache__/
@ -9,6 +11,7 @@ __pycache__/
# Static folders # Static folders
static/instructions/ static/instructions/
static/minifigs/ static/minifigs/
static/minifigures/
static/parts/ static/parts/
static/sets/ static/sets/

196
CHANGELOG.md Normal file
View File

@ -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 `<br>` 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)

View File

@ -27,6 +27,6 @@ See [first steps](docs/first-steps.md).
## Documentation ## Documentation
Most of the pages should be self explanatory to use. 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.

44
app.py
View File

@ -11,28 +11,44 @@ from bricktracker.socket import BrickSocket # noqa: E402
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Create the Flask app
app = Flask(__name__)
# Setup the app # Create the app
setup_app(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 # Setup the app
s = BrickSocket( setup_app(app)
# Create the socket
s = BrickSocket(
app, app,
threaded=not app.config['NO_THREADED_SOCKET'].value, threaded=not app.config['NO_THREADED_SOCKET'],
) )
if main:
return s
else:
return app
if __name__ == '__main__': 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 # Run the application
logger.info('Starting BrickTracker on {host}:{port}'.format( logger.info('Starting BrickTracker on {host}:{port}'.format(
host=app.config['HOST'].value, host=s.app.config['HOST'],
port=app.config['PORT'].value, port=s.app.config['PORT'],
)) ))
s.socket.run( s.socket.run(
app, s.app,
host=app.config['HOST'].value, host=s.app.config['HOST'],
debug=app.config['DEBUG'].value, debug=s.app.config['DEBUG'],
port=app.config['PORT'].value, port=s.app.config['PORT'],
) )

View File

@ -12,7 +12,13 @@ from bricktracker.navbar import Navbar
from bricktracker.sql import close from bricktracker.sql import close
from bricktracker.version import __version__ from bricktracker.version import __version__
from bricktracker.views.add import add_page 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.error import error_404
from bricktracker.views.index import index_page from bricktracker.views.index import index_page
from bricktracker.views.instructions import instructions_page from bricktracker.views.instructions import instructions_page
@ -28,7 +34,7 @@ def setup_app(app: Flask) -> None:
BrickConfigurationList(app) BrickConfigurationList(app)
# Set the logging level # Set the logging level
if app.config['DEBUG'].value: if app.config['DEBUG']:
logging.basicConfig( logging.basicConfig(
stream=sys.stdout, stream=sys.stdout,
level=logging.DEBUG, level=logging.DEBUG,
@ -60,9 +66,8 @@ def setup_app(app: Flask) -> None:
# Register errors # Register errors
app.register_error_handler(404, error_404) app.register_error_handler(404, error_404)
# Register routes # Register app routes
app.register_blueprint(add_page) app.register_blueprint(add_page)
app.register_blueprint(admin_page)
app.register_blueprint(index_page) app.register_blueprint(index_page)
app.register_blueprint(instructions_page) app.register_blueprint(instructions_page)
app.register_blueprint(login_page) app.register_blueprint(login_page)
@ -71,6 +76,15 @@ def setup_app(app: Flask) -> None:
app.register_blueprint(set_page) app.register_blueprint(set_page)
app.register_blueprint(wish_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 # An helper to make global variables available to the
# request # request
@app.before_request @app.before_request
@ -90,12 +104,12 @@ def setup_app(app: Flask) -> None:
g.request_time = request_time g.request_time = request_time
# Register the timezone # Register the timezone
g.timezone = ZoneInfo(current_app.config['TIMEZONE'].value) g.timezone = ZoneInfo(current_app.config['TIMEZONE'])
# Version # Version
g.version = __version__ g.version = __version__
# Make sure all connections are closed at the end # Make sure all connections are closed at the end
@app.teardown_appcontext @app.teardown_request
def close_connections(exception, /) -> None: def teardown_request(_: BaseException | None) -> None:
close() close()

View File

@ -31,10 +31,10 @@ CONFIG: Final[list[dict[str, Any]]] = [
{'n': 'HIDE_ALL_SETS', 'c': bool}, {'n': 'HIDE_ALL_SETS', 'c': bool},
{'n': 'HIDE_MISSING_PARTS', 'c': bool}, {'n': 'HIDE_MISSING_PARTS', 'c': bool},
{'n': 'HIDE_WISHES', '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': 'MINIFIGURES_FOLDER', 'd': 'minifigs', 's': True},
{'n': 'NO_THREADED_SOCKET', 'c': bool}, {'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': 'PARTS_FOLDER', 'd': 'parts', 's': True},
{'n': 'PORT', 'd': 3333, 'c': int}, {'n': 'PORT', 'd': 3333, 'c': int},
{'n': 'RANDOM', 'e': 'RANDOM', 'c': bool}, {'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_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_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_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_LINKS', 'e': 'LINKS', 'c': bool},
{'n': 'REBRICKABLE_PAGE_SIZE', 'd': 100, 'c': int}, {'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_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': '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': 'SETS_FOLDER', 'd': 'sets', 's': True},
{'n': 'SKIP_SPARE_PARTS', 'c': bool}, {'n': 'SKIP_SPARE_PARTS', 'c': bool},
{'n': 'SOCKET_NAMESPACE', 'd': 'bricksocket'}, {'n': 'SOCKET_NAMESPACE', 'd': 'bricksocket'},
@ -57,5 +56,5 @@ CONFIG: Final[list[dict[str, Any]]] = [
{'n': 'THEMES_PATH', 'd': './themes.csv'}, {'n': 'THEMES_PATH', 'd': './themes.csv'},
{'n': 'TIMEZONE', 'd': 'Etc/UTC'}, {'n': 'TIMEZONE', 'd': 'Etc/UTC'},
{'n': 'USE_REMOTE_IMAGES', 'c': bool}, {'n': 'USE_REMOTE_IMAGES', 'c': bool},
{'n': 'WISHES_DEFAULT_ORDER', 'd': 'wishlist.rowid DESC'}, {'n': 'WISHES_DEFAULT_ORDER', 'd': '"bricktracker_wishes"."rowid" DESC'},
] ]

View File

@ -16,6 +16,7 @@ class BrickConfiguration(object):
def __init__( def __init__(
self, self,
/, /,
*,
n: str, n: str,
e: str | None = None, e: str | None = None,
d: Any = None, d: Any = None,
@ -69,6 +70,7 @@ class BrickConfiguration(object):
# Remove static prefix # Remove static prefix
value = value.removeprefix('static/') value = value.removeprefix('static/')
# Type casting
if self.cast is not None: if self.cast is not None:
self.value = self.cast(value) self.value = self.cast(value)
else: else:

View File

@ -1,46 +1,60 @@
import logging
from typing import Generator from typing import Generator
from flask import current_app, Flask from flask import Flask
from .config import CONFIG from .config import CONFIG
from .configuration import BrickConfiguration from .configuration import BrickConfiguration
from .exceptions import ConfigurationMissingException from .exceptions import ConfigurationMissingException
logger = logging.getLogger(__name__)
# Application configuration # Application configuration
class BrickConfigurationList(object): class BrickConfigurationList(object):
app: Flask app: Flask
configurations: dict[str, BrickConfiguration]
# Load configuration # Load configuration
def __init__(self, app: Flask, /): def __init__(self, app: Flask, /):
self.app = app self.app = app
# 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 # Process all configuration items
for config in CONFIG: for config in CONFIG:
item = BrickConfiguration(**config) item = BrickConfiguration(**config)
self.app.config[item.name] = item
# 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 # Check whether a str configuration is set
@staticmethod @staticmethod
def error_unless_is_set(name: str): 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( raise ConfigurationMissingException(
'{name} must be defined (using the {environ} environment variable)'.format( # noqa: E501 '{name} must be defined (using the {environ} environment variable)'.format( # noqa: E501
name=config.name, name=name,
environ=config.env_name environ=configuration.env_name
), ),
) )
# Get all the configuration items from the app config # Get all the configuration items from the app config
@staticmethod @staticmethod
def list() -> Generator[BrickConfiguration, None, None]: def list() -> Generator[BrickConfiguration, None, None]:
keys = list(current_app.config.keys()) keys = sorted(BrickConfigurationList.configurations.keys())
keys.sort()
for name in keys: for name in keys:
config = current_app.config[name] yield BrickConfigurationList.configurations[name]
if isinstance(config, BrickConfiguration):
yield config

View File

@ -4,6 +4,9 @@ from typing import Any
# SQLite record fields # SQLite record fields
class BrickRecordFields(object): class BrickRecordFields(object):
def __getattr__(self, name: str, /) -> Any: def __getattr__(self, name: str, /) -> Any:
if name not in self.__dict__:
raise AttributeError(name)
return self.__dict__[name] return self.__dict__[name]
def __setattr__(self, name: str, value: Any, /) -> None: def __setattr__(self, name: str, value: Any, /) -> None:

View File

@ -13,18 +13,18 @@ from bs4 import BeautifulSoup
from .exceptions import ErrorException from .exceptions import ErrorException
if TYPE_CHECKING: if TYPE_CHECKING:
from .set import BrickSet from .rebrickable_set import RebrickableSet
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class BrickInstructions(object): class BrickInstructions(object):
allowed: bool allowed: bool
brickset: 'BrickSet | None' rebrickable: 'RebrickableSet | None'
extension: str extension: str
filename: str filename: str
mtime: datetime mtime: datetime
number: 'str | None' set: 'str | None'
name: str name: str
size: int size: int
@ -42,11 +42,11 @@ class BrickInstructions(object):
# Store the name and extension, check if extension is allowed # Store the name and extension, check if extension is allowed
self.name, self.extension = os.path.splitext(self.filename) self.name, self.extension = os.path.splitext(self.filename)
self.extension = self.extension.lower() 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 # Placeholder
self.brickset = None self.rebrickable = None
self.number = None self.set = None
# Extract the set number # Extract the set number
if self.allowed: if self.allowed:
@ -57,7 +57,14 @@ class BrickInstructions(object):
splits = normalized.split('-', 2) splits = normalized.split('-', 2)
if len(splits) >= 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 # Delete an instruction file
def delete(self, /) -> None: def delete(self, /) -> None:
@ -70,17 +77,17 @@ class BrickInstructions(object):
# Display the time in a human format # Display the time in a human format
def human_time(self) -> str: def human_time(self) -> str:
return self.mtime.astimezone(g.timezone).strftime( 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 # Compute the path of an instruction file
def path(self, /, filename=None) -> str: def path(self, /, *, filename=None) -> str:
if filename is None: if filename is None:
filename = self.filename filename = self.filename
return os.path.join( return os.path.join(
current_app.static_folder, # type: ignore current_app.static_folder, # type: ignore
current_app.config['INSTRUCTIONS_FOLDER'].value, current_app.config['INSTRUCTIONS_FOLDER'],
filename filename
) )
@ -102,7 +109,7 @@ class BrickInstructions(object):
# Upload a new instructions file # Upload a new instructions file
def upload(self, file: FileStorage, /) -> None: 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): if os.path.isfile(target):
raise ErrorException('Cannot upload {target} as it already exists'.format( # noqa: E501 raise ErrorException('Cannot upload {target} as it already exists'.format( # noqa: E501
@ -180,7 +187,7 @@ class BrickInstructions(object):
if not self.allowed: if not self.allowed:
return '' return ''
folder: str = current_app.config['INSTRUCTIONS_FOLDER'].value folder: str = current_app.config['INSTRUCTIONS_FOLDER']
# Compute the path # Compute the path
path = os.path.join(folder, self.filename) path = os.path.join(folder, self.filename)

View File

@ -1,11 +1,14 @@
import logging import logging
import os import os
from typing import Generator from typing import Generator, TYPE_CHECKING
from flask import current_app from flask import current_app
from .exceptions import NotFoundException from .exceptions import NotFoundException
from .instructions import BrickInstructions from .instructions import BrickInstructions
from .rebrickable_set_list import RebrickableSetList
if TYPE_CHECKING:
from .rebrickable_set import RebrickableSet
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -18,7 +21,7 @@ class BrickInstructionsList(object):
sets_total: int sets_total: int
unknown_total: int unknown_total: int
def __init__(self, /, force=False): def __init__(self, /, *, force=False):
# Load instructions only if there is none already loaded # Load instructions only if there is none already loaded
all = getattr(self, 'all', None) all = getattr(self, 'all', None)
@ -36,7 +39,7 @@ class BrickInstructionsList(object):
# Make a folder relative to static # Make a folder relative to static
folder: str = os.path.join( folder: str = os.path.join(
current_app.static_folder, # type: ignore current_app.static_folder, # type: ignore
current_app.config['INSTRUCTIONS_FOLDER'].value, current_app.config['INSTRUCTIONS_FOLDER'],
) )
for file in os.scandir(folder): for file in os.scandir(folder):
@ -46,47 +49,40 @@ class BrickInstructionsList(object):
BrickInstructionsList.all[instruction.filename] = instruction # noqa: E501 BrickInstructionsList.all[instruction.filename] = instruction # noqa: E501
if instruction.allowed: if instruction.allowed:
if instruction.number: if instruction.set:
# Instantiate the list if not existing yet # Instantiate the list if not existing yet
if instruction.number not in BrickInstructionsList.sets: # noqa: E501 if instruction.set not in BrickInstructionsList.sets: # noqa: E501
BrickInstructionsList.sets[instruction.number] = [] # 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 BrickInstructionsList.sets_total += 1
else: else:
BrickInstructionsList.unknown_total += 1 BrickInstructionsList.unknown_total += 1
else: else:
BrickInstructionsList.rejected_total += 1 BrickInstructionsList.rejected_total += 1
# Associate bricksets # List of Rebrickable sets
# Not ideal, to avoid a circular import rebrickable_sets: dict[str, RebrickableSet] = {}
from .set import BrickSet for rebrickable_set in RebrickableSetList().all():
from .set_list import BrickSetList 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
for instruction in self.all.values():
# Inject the brickset if it exists # Inject the brickset if it exists
for instruction in self.all.values():
if ( if (
instruction.allowed and instruction.allowed and
instruction.number is not None and instruction.set is not None and
instruction.brickset is None and instruction.rebrickable is None and
instruction.number in bricksets instruction.set in rebrickable_sets
): ):
instruction.brickset = bricksets[instruction.number] instruction.rebrickable = rebrickable_sets[instruction.set] # noqa: E501
# Ignore errors # Ignore errors
except Exception: except Exception:
pass pass
# Grab instructions for a set # Grab instructions for a set
def get(self, number: str) -> list[BrickInstructions]: def get(self, set: str) -> list[BrickInstructions]:
if number in self.sets: if set in self.sets:
return self.sets[number] return self.sets[set]
else: else:
return [] return []

View File

@ -12,7 +12,7 @@ class LoginManager(object):
def __init__(self, app: Flask, /): def __init__(self, app: Flask, /):
# Setup basic authentication # Setup basic authentication
app.secret_key = app.config['AUTHENTICATION_KEY'].value app.secret_key = app.config['AUTHENTICATION_KEY']
manager = login_manager.LoginManager() manager = login_manager.LoginManager()
manager.login_view = 'login.login' # type: ignore manager.login_view = 'login.login' # type: ignore
@ -23,11 +23,11 @@ class LoginManager(object):
def user_loader(*arg) -> LoginManager.User: def user_loader(*arg) -> LoginManager.User:
return self.User( return self.User(
'admin', 'admin',
app.config['AUTHENTICATION_PASSWORD'].value app.config['AUTHENTICATION_PASSWORD']
) )
# If the password is unset, globally disable # 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: # Tells whether the user is authenticated, meaning:
# - Authentication disabled # - Authentication disabled

View File

@ -23,6 +23,7 @@ class BrickMinifigure(BrickRecord):
def __init__( def __init__(
self, self,
/, /,
*,
brickset: 'BrickSet | None' = None, brickset: 'BrickSet | None' = None,
record: Row | dict[str, Any] | None = None, record: Row | dict[str, Any] | None = None,
): ):
@ -61,18 +62,13 @@ class BrickMinifigure(BrickRecord):
# Save the parameters to the fields # Save the parameters to the fields
self.fields.fig_num = fig_num self.fields.fig_num = fig_num
record = self.select(override_query=self.generic_query) if not self.select(override_query=self.generic_query):
if record is None:
raise NotFoundException( raise NotFoundException(
'Minifigure with number {number} was not found in the database'.format( # noqa: E501 'Minifigure with number {number} was not found in the database'.format( # noqa: E501
number=self.fields.fig_num, number=self.fields.fig_num,
), ),
) )
# Ingest the record
self.ingest(record)
return self return self
# Select a specific minifigure (with a set and an number) # Select a specific minifigure (with a set and an number)
@ -81,19 +77,14 @@ class BrickMinifigure(BrickRecord):
self.brickset = brickset self.brickset = brickset
self.fields.fig_num = fig_num self.fields.fig_num = fig_num
record = self.select() if not self.select():
if record is None:
raise NotFoundException( raise NotFoundException(
'Minifigure with number {number} from set {set} was not found in the database'.format( # noqa: E501 'Minifigure with number {number} from set {set} was not found in the database'.format( # noqa: E501
number=self.fields.fig_num, 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 self
# Return a dict with common SQL parameters for a minifigure # Return a dict with common SQL parameters for a minifigure
@ -103,10 +94,10 @@ class BrickMinifigure(BrickRecord):
# Supplement from the brickset # Supplement from the brickset
if self.brickset is not None: if self.brickset is not None:
if 'u_id' not in parameters: 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: if 'set_num' not in parameters:
parameters['set_num'] = self.brickset.fields.set_num parameters['set_num'] = self.brickset.fields.set
return parameters return parameters
@ -119,7 +110,7 @@ class BrickMinifigure(BrickRecord):
# Compute the url for minifigure part image # Compute the url for minifigure part image
def url_for_image(self, /) -> str: 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: if self.fields.set_img_url is None:
file = RebrickableImage.nil_minifigure_name() file = RebrickableImage.nil_minifigure_name()
else: else:
@ -128,15 +119,15 @@ class BrickMinifigure(BrickRecord):
return RebrickableImage.static_url(file, 'MINIFIGURES_FOLDER') return RebrickableImage.static_url(file, 'MINIFIGURES_FOLDER')
else: else:
if self.fields.set_img_url is None: 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: else:
return self.fields.set_img_url return self.fields.set_img_url
# Compute the url for the rebrickable page # Compute the url for the rebrickable page
def url_for_rebrickable(self, /) -> str: def url_for_rebrickable(self, /) -> str:
if current_app.config['REBRICKABLE_LINKS'].value: if current_app.config['REBRICKABLE_LINKS']:
try: 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(), number=self.fields.fig_num.lower(),
) )
except Exception: except Exception:
@ -149,6 +140,7 @@ class BrickMinifigure(BrickRecord):
def from_rebrickable( def from_rebrickable(
data: dict[str, Any], data: dict[str, Any],
/, /,
*,
brickset: 'BrickSet | None' = None, brickset: 'BrickSet | None' = None,
**_, **_,
) -> dict[str, Any]: ) -> dict[str, Any]:
@ -160,7 +152,7 @@ class BrickMinifigure(BrickRecord):
} }
if brickset is not None: if brickset is not None:
record['set_num'] = brickset.fields.set_num record['set_num'] = brickset.fields.set
record['u_id'] = brickset.fields.u_id record['u_id'] = brickset.fields.id
return record return record

View File

@ -27,7 +27,7 @@ class BrickMinifigureList(BrickRecordList[BrickMinifigure]):
self.brickset = None self.brickset = None
# Store the order for this list # 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 # Load all minifigures
def all(self, /) -> Self: def all(self, /) -> Self:
@ -42,9 +42,9 @@ class BrickMinifigureList(BrickRecordList[BrickMinifigure]):
return self return self
# Last added minifigure # Last added minifigure
def last(self, /, limit: int = 6) -> Self: def last(self, /, *, limit: int = 6) -> Self:
# Randomize # Randomize
if current_app.config['RANDOM'].value: if current_app.config['RANDOM']:
order = 'RANDOM()' order = 'RANDOM()'
else: else:
order = 'minifigures.rowid DESC' order = 'minifigures.rowid DESC'
@ -78,8 +78,8 @@ class BrickMinifigureList(BrickRecordList[BrickMinifigure]):
parameters: dict[str, Any] = super().sql_parameters() parameters: dict[str, Any] = super().sql_parameters()
if self.brickset is not None: if self.brickset is not None:
parameters['u_id'] = self.brickset.fields.u_id parameters['u_id'] = self.brickset.fields.id
parameters['set_num'] = self.brickset.fields.set_num parameters['set_num'] = self.brickset.fields.set
return parameters return parameters
@ -89,6 +89,7 @@ class BrickMinifigureList(BrickRecordList[BrickMinifigure]):
part_num: str, part_num: str,
color_id: int, color_id: int,
/, /,
*,
element_id: int | None = None, element_id: int | None = None,
) -> Self: ) -> Self:
# Save the parameters to the fields # Save the parameters to the fields
@ -113,6 +114,7 @@ class BrickMinifigureList(BrickRecordList[BrickMinifigure]):
part_num: str, part_num: str,
color_id: int, color_id: int,
/, /,
*,
element_id: int | None = None, element_id: int | None = None,
) -> Self: ) -> Self:
# Save the parameters to the fields # Save the parameters to the fields

View File

@ -27,6 +27,7 @@ class BrickPart(BrickRecord):
def __init__( def __init__(
self, self,
/, /,
*,
brickset: 'BrickSet | None' = None, brickset: 'BrickSet | None' = None,
minifigure: 'BrickMinifigure | None' = None, minifigure: 'BrickMinifigure | None' = None,
record: Row | dict[str, Any] | None = None, record: Row | dict[str, Any] | None = None,
@ -83,6 +84,7 @@ class BrickPart(BrickRecord):
part_num: str, part_num: str,
color_id: int, color_id: int,
/, /,
*,
element_id: int | None = None element_id: int | None = None
) -> Self: ) -> Self:
# Save the parameters to the fields # Save the parameters to the fields
@ -90,9 +92,7 @@ class BrickPart(BrickRecord):
self.fields.color_id = color_id self.fields.color_id = color_id
self.fields.element_id = element_id self.fields.element_id = element_id
record = self.select(override_query=self.generic_query) if not self.select(override_query=self.generic_query):
if record is None:
raise NotFoundException( raise NotFoundException(
'Part with number {number}, color ID {color} and element ID {element} was not found in the database'.format( # noqa: E501 '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, number=self.fields.part_num,
@ -101,9 +101,6 @@ class BrickPart(BrickRecord):
), ),
) )
# Ingest the record
self.ingest(record)
return self return self
# Select a specific part (with a set and an id, and option. a minifigure) # Select a specific part (with a set and an id, and option. a minifigure)
@ -112,6 +109,7 @@ class BrickPart(BrickRecord):
brickset: 'BrickSet', brickset: 'BrickSet',
id: str, id: str,
/, /,
*,
minifigure: 'BrickMinifigure | None' = None, minifigure: 'BrickMinifigure | None' = None,
) -> Self: ) -> Self:
# Save the parameters to the fields # Save the parameters to the fields
@ -119,19 +117,14 @@ class BrickPart(BrickRecord):
self.minifigure = minifigure self.minifigure = minifigure
self.fields.id = id self.fields.id = id
record = self.select() if not self.select():
if record is None:
raise NotFoundException( raise NotFoundException(
'Part with ID {id} from set {set} was not found in the database'.format( # noqa: E501 'Part with ID {id} from set {set} was not found in the database'.format( # noqa: E501
id=self.fields.id, id=self.fields.id,
set=self.brickset.fields.set_num, set=self.brickset.fields.set,
), ),
) )
# Ingest the record
self.ingest(record)
return self return self
# Return a dict with common SQL parameters for a part # Return a dict with common SQL parameters for a part
@ -140,14 +133,14 @@ class BrickPart(BrickRecord):
# Supplement from the brickset # Supplement from the brickset
if 'u_id' not in parameters and self.brickset is not None: 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 'set_num' not in parameters:
if self.minifigure is not None: if self.minifigure is not None:
parameters['set_num'] = self.minifigure.fields.fig_num parameters['set_num'] = self.minifigure.fields.fig_num
elif self.brickset is not None: elif self.brickset is not None:
parameters['set_num'] = self.brickset.fields.set_num parameters['set_num'] = self.brickset.fields.set
return parameters return parameters
@ -190,9 +183,9 @@ class BrickPart(BrickRecord):
# Compute the url for the bricklink page # Compute the url for the bricklink page
def url_for_bricklink(self, /) -> str: def url_for_bricklink(self, /) -> str:
if current_app.config['BRICKLINK_LINKS'].value: if current_app.config['BRICKLINK_LINKS']:
try: 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, number=self.fields.part_num,
) )
except Exception: except Exception:
@ -202,7 +195,7 @@ class BrickPart(BrickRecord):
# Compute the url for the part image # Compute the url for the part image
def url_for_image(self, /) -> str: 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: if self.fields.part_img_url is None:
file = RebrickableImage.nil_name() file = RebrickableImage.nil_name()
else: else:
@ -211,7 +204,7 @@ class BrickPart(BrickRecord):
return RebrickableImage.static_url(file, 'PARTS_FOLDER') return RebrickableImage.static_url(file, 'PARTS_FOLDER')
else: else:
if self.fields.part_img_url is None: if self.fields.part_img_url is None:
return current_app.config['REBRICKABLE_IMAGE_NIL'].value return current_app.config['REBRICKABLE_IMAGE_NIL']
else: else:
return self.fields.part_img_url return self.fields.part_img_url
@ -234,9 +227,9 @@ class BrickPart(BrickRecord):
# Compute the url for the rebrickable page # Compute the url for the rebrickable page
def url_for_rebrickable(self, /) -> str: def url_for_rebrickable(self, /) -> str:
if current_app.config['REBRICKABLE_LINKS'].value: if current_app.config['REBRICKABLE_LINKS']:
try: 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, number=self.fields.part_num,
color=self.fields.color_id, color=self.fields.color_id,
) )
@ -250,6 +243,7 @@ class BrickPart(BrickRecord):
def from_rebrickable( def from_rebrickable(
data: dict[str, Any], data: dict[str, Any],
/, /,
*,
brickset: 'BrickSet | None' = None, brickset: 'BrickSet | None' = None,
minifigure: 'BrickMinifigure | None' = None, minifigure: 'BrickMinifigure | None' = None,
**_, **_,
@ -269,7 +263,7 @@ class BrickPart(BrickRecord):
} }
if brickset is not None: if brickset is not None:
record['u_id'] = brickset.fields.u_id record['u_id'] = brickset.fields.id
if minifigure is not None: if minifigure is not None:
record['set_num'] = data['fig_num'] record['set_num'] = data['fig_num']

View File

@ -30,7 +30,7 @@ class BrickPartList(BrickRecordList[BrickPart]):
self.minifigure = None self.minifigure = None
# Store the order for this list # 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 # Load all parts
def all(self, /) -> Self: def all(self, /) -> Self:
@ -49,6 +49,7 @@ class BrickPartList(BrickRecordList[BrickPart]):
self, self,
brickset: 'BrickSet', brickset: 'BrickSet',
/, /,
*,
minifigure: 'BrickMinifigure | None' = None, minifigure: 'BrickMinifigure | None' = None,
) -> Self: ) -> Self:
# Save the brickset and minifigure # Save the brickset and minifigure
@ -63,10 +64,7 @@ class BrickPartList(BrickRecordList[BrickPart]):
record=record, record=record,
) )
if ( if current_app.config['SKIP_SPARE_PARTS'] and part.fields.is_spare:
current_app.config['SKIP_SPARE_PARTS'].value and
part.fields.is_spare
):
continue continue
self.records.append(part) self.records.append(part)
@ -92,10 +90,7 @@ class BrickPartList(BrickRecordList[BrickPart]):
record=record, record=record,
) )
if ( if current_app.config['SKIP_SPARE_PARTS'] and part.fields.is_spare:
current_app.config['SKIP_SPARE_PARTS'].value and
part.fields.is_spare
):
continue continue
self.records.append(part) self.records.append(part)
@ -120,13 +115,13 @@ class BrickPartList(BrickRecordList[BrickPart]):
# Set id # Set id
if self.brickset is not None: 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, # Use the minifigure number if present,
# otherwise use the set number # otherwise use the set number
if self.minifigure is not None: if self.minifigure is not None:
parameters['set_num'] = self.minifigure.fields.fig_num parameters['set_num'] = self.minifigure.fields.fig_num
elif self.brickset is not None: elif self.brickset is not None:
parameters['set_num'] = self.brickset.fields.set_num parameters['set_num'] = self.brickset.fields.set
return parameters return parameters

View File

@ -9,11 +9,12 @@ from .exceptions import NotFoundException, ErrorException
if TYPE_CHECKING: if TYPE_CHECKING:
from .minifigure import BrickMinifigure from .minifigure import BrickMinifigure
from .part import BrickPart from .part import BrickPart
from .rebrickable_set import RebrickableSet
from .set import BrickSet from .set import BrickSet
from .socket import BrickSocket from .socket import BrickSocket
from .wish import BrickWish 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 # An helper around the rebrick library, autoconverting
@ -23,10 +24,11 @@ class Rebrickable(Generic[T]):
number: str number: str
model: Type[T] model: Type[T]
socket: 'BrickSocket | None'
brickset: 'BrickSet | None' brickset: 'BrickSet | None'
minifigure: 'BrickMinifigure | None' instance: T | None
kind: str kind: str
minifigure: 'BrickMinifigure | None'
socket: 'BrickSocket | None'
def __init__( def __init__(
self, self,
@ -34,9 +36,11 @@ class Rebrickable(Generic[T]):
number: str, number: str,
model: Type[T], model: Type[T],
/, /,
socket: 'BrickSocket | None' = None, *,
brickset: 'BrickSet | 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): if not hasattr(lego, method):
raise ErrorException('{method} is not a valid method for the rebrick.lego module'.format( # noqa: E501 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.number = number
self.model = model self.model = model
self.socket = socket
self.brickset = brickset self.brickset = brickset
self.instance = instance
self.minifigure = minifigure self.minifigure = minifigure
self.socket = socket
if self.minifigure is not None: if self.minifigure is not None:
self.kind = 'Minifigure' self.kind = 'Minifigure'
@ -61,13 +66,15 @@ class Rebrickable(Generic[T]):
def get(self, /) -> T: def get(self, /) -> T:
model_parameters = self.model_parameters() model_parameters = self.model_parameters()
return self.model( if self.instance is None:
**model_parameters, self.instance = self.model(**model_parameters)
record=self.model.from_rebrickable(
self.instance.ingest(self.model.from_rebrickable(
self.load(), self.load(),
brickset=self.brickset, brickset=self.brickset,
), ))
)
return self.instance
# Get paginated elements from the Rebrickable API # Get paginated elements from the Rebrickable API
def list(self, /) -> list[T]: def list(self, /) -> list[T]:
@ -77,7 +84,7 @@ class Rebrickable(Generic[T]):
# Bootstrap a first set of parameters # Bootstrap a first set of parameters
parameters: dict[str, Any] | None = { 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 # Read all pages
@ -113,9 +120,9 @@ class Rebrickable(Generic[T]):
return results return results
# Load from the API # 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 # 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: try:
return json.loads( return json.loads(

View File

@ -10,12 +10,12 @@ from .exceptions import DownloadException
if TYPE_CHECKING: if TYPE_CHECKING:
from .minifigure import BrickMinifigure from .minifigure import BrickMinifigure
from .part import BrickPart from .part import BrickPart
from .set import BrickSet from .rebrickable_set import RebrickableSet
# A set, part or minifigure image from Rebrickable # A set, part or minifigure image from Rebrickable
class RebrickableImage(object): class RebrickableImage(object):
brickset: 'BrickSet' set: 'RebrickableSet'
minifigure: 'BrickMinifigure | None' minifigure: 'BrickMinifigure | None'
part: 'BrickPart | None' part: 'BrickPart | None'
@ -23,13 +23,14 @@ class RebrickableImage(object):
def __init__( def __init__(
self, self,
brickset: 'BrickSet', set: 'RebrickableSet',
/, /,
*,
minifigure: 'BrickMinifigure | None' = None, minifigure: 'BrickMinifigure | None' = None,
part: 'BrickPart | None' = None, part: 'BrickPart | None' = None,
): ):
# Save all objects # Save all objects
self.brickset = brickset self.set = set
self.minifigure = minifigure self.minifigure = minifigure
self.part = part self.part = part
@ -70,12 +71,12 @@ class RebrickableImage(object):
# Return the folder depending on the objects provided # Return the folder depending on the objects provided
def folder(self, /) -> str: def folder(self, /) -> str:
if self.part is not None: 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: 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 # Return the id depending on the objects provided
def id(self, /) -> str: def id(self, /) -> str:
@ -91,7 +92,7 @@ class RebrickableImage(object):
else: else:
return self.minifigure.fields.fig_num 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 # Return the path depending on the objects provided
def path(self, /) -> str: def path(self, /) -> str:
@ -105,24 +106,24 @@ class RebrickableImage(object):
def url(self, /) -> str: def url(self, /) -> str:
if self.part is not None: if self.part is not None:
if self.part.fields.part_img_url is 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: else:
return self.part.fields.part_img_url return self.part.fields.part_img_url
if self.minifigure is not None: if self.minifigure is not None:
if self.minifigure.fields.set_img_url is 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: else:
return self.minifigure.fields.set_img_url 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 # Return the name of the nil image file
@staticmethod @staticmethod
def nil_name() -> str: def nil_name() -> str:
filename, _ = os.path.splitext( filename, _ = os.path.splitext(
os.path.basename( 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: def nil_minifigure_name() -> str:
filename, _ = os.path.splitext( filename, _ = os.path.splitext(
os.path.basename( 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 # Return the static URL for an image given a name and folder
@staticmethod @staticmethod
def static_url(name: str, folder_name: str) -> str: 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 # /!\ Everything is saved as .jpg, even if it came from a .png
# not changing this behaviour. # not changing this behaviour.

View File

@ -30,18 +30,18 @@ class RebrickableMinifigures(object):
def download(self, /) -> None: def download(self, /) -> None:
self.socket.auto_progress( self.socket.auto_progress(
message='Set {number}: loading minifigures from Rebrickable'.format( # noqa: E501 message='Set {number}: loading minifigures from Rebrickable'.format( # noqa: E501
number=self.brickset.fields.set_num, number=self.brickset.fields.set,
), ),
increment_total=True, increment_total=True,
) )
logger.debug('rebrick.lego.get_set_minifigs("{set_num}")'.format( logger.debug('rebrick.lego.get_set_minifigs("{set}")'.format(
set_num=self.brickset.fields.set_num, set=self.brickset.fields.set,
)) ))
minifigures = Rebrickable[BrickMinifigure]( minifigures = Rebrickable[BrickMinifigure](
'get_set_minifigs', 'get_set_minifigs',
self.brickset.fields.set_num, self.brickset.fields.set,
BrickMinifigure, BrickMinifigure,
socket=self.socket, socket=self.socket,
brickset=self.brickset, brickset=self.brickset,
@ -53,7 +53,7 @@ class RebrickableMinifigures(object):
# Insert into the database # Insert into the database
self.socket.auto_progress( self.socket.auto_progress(
message='Set {number}: inserting minifigure {current}/{total} into database'.format( # noqa: E501 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, current=index+1,
total=total, total=total,
) )
@ -65,13 +65,13 @@ class RebrickableMinifigures(object):
# Grab the image # Grab the image
self.socket.progress( self.socket.progress(
message='Set {number}: downloading minifigure {current}/{total} image'.format( # noqa: E501 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, current=index+1,
total=total, total=total,
) )
) )
if not current_app.config['USE_REMOTE_IMAGES'].value: if not current_app.config['USE_REMOTE_IMAGES']:
RebrickableImage( RebrickableImage(
self.brickset, self.brickset,
minifigure=minifigure minifigure=minifigure

View File

@ -29,6 +29,7 @@ class RebrickableParts(object):
socket: 'BrickSocket', socket: 'BrickSocket',
brickset: 'BrickSet', brickset: 'BrickSet',
/, /,
*,
minifigure: 'BrickMinifigure | None' = None, minifigure: 'BrickMinifigure | None' = None,
): ):
# Save the socket # Save the socket
@ -43,7 +44,7 @@ class RebrickableParts(object):
self.kind = 'Minifigure' self.kind = 'Minifigure'
self.method = 'get_minifig_elements' self.method = 'get_minifig_elements'
else: else:
self.number = self.brickset.fields.set_num self.number = self.brickset.fields.set
self.kind = 'Set' self.kind = 'Set'
self.method = 'get_set_elements' self.method = 'get_set_elements'
@ -76,7 +77,7 @@ class RebrickableParts(object):
for index, part in enumerate(inventory): for index, part in enumerate(inventory):
# Skip spare parts # Skip spare parts
if ( if (
current_app.config['SKIP_SPARE_PARTS'].value and current_app.config['SKIP_SPARE_PARTS'] and
part.fields.is_spare part.fields.is_spare
): ):
continue 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( RebrickableImage(
self.brickset, self.brickset,
minifigure=self.minifigure, minifigure=self.minifigure,

View File

@ -1,148 +1,135 @@
import logging import logging
from sqlite3 import Row
import traceback import traceback
from typing import Any, TYPE_CHECKING from typing import Any, TYPE_CHECKING
from uuid import uuid4
from flask import current_app from flask import current_app
from .exceptions import ErrorException, NotFoundException from .exceptions import ErrorException, NotFoundException
from .instructions import BrickInstructions
from .rebrickable import Rebrickable from .rebrickable import Rebrickable
from .rebrickable_image import RebrickableImage from .rebrickable_image import RebrickableImage
from .rebrickable_minifigures import RebrickableMinifigures from .record import BrickRecord
from .rebrickable_parts import RebrickableParts from .theme_list import BrickThemeList
from .set import BrickSet
from .sql import BrickSQL
from .wish import BrickWish
if TYPE_CHECKING: if TYPE_CHECKING:
from .socket import BrickSocket from .socket import BrickSocket
from .theme import BrickTheme
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# A set from Rebrickable # A set from Rebrickable
class RebrickableSet(object): class RebrickableSet(BrickRecord):
socket: 'BrickSocket' 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 # Save the socket
if socket is not None:
self.socket = socket self.socket = socket
# Ingest the record if it has one
if record is not None:
self.ingest(record)
# Import the set from Rebrickable # Import the set from Rebrickable
def download(self, data: dict[str, Any], /) -> None: def download_rebrickable(self, /) -> None:
# Reset the progress # Insert the Rebrickable set to the database
self.socket.progress_count = 0 rows, _ = self.insert(
self.socket.progress_total = 0 commit=False,
no_defer=True,
# Load the set override_query=RebrickableSet.insert_query
brickset = self.load(data, from_download=True)
# None brickset means loading failed
if brickset is None:
return
try:
# Insert into the database
self.socket.auto_progress(
message='Set {number}: inserting into database'.format(
number=brickset.fields.set_num
),
increment_total=True,
) )
# Assign a unique ID to the set if rows > 0:
brickset.fields.u_id = str(uuid4()) if not current_app.config['USE_REMOTE_IMAGES']:
RebrickableImage(self).download()
# Insert into database # Ingest a set
brickset.insert(commit=False) def ingest(self, record: Row | dict[str, Any], /):
super().ingest(record)
if not current_app.config['USE_REMOTE_IMAGES'].value: # Resolve theme
RebrickableImage(brickset).download() if not hasattr(self.fields, 'theme_id'):
self.fields.theme_id = 0
# Load the inventory self.theme = BrickThemeList().get(self.fields.theme_id)
RebrickableParts(self.socket, brickset).download()
# Load the minifigures # Resolve instructions
RebrickableMinifigures(self.socket, brickset).download() if self.resolve_instructions:
# Not idead, avoiding cyclic import
from .instructions_list import BrickInstructionsList
# Commit the transaction to the database if self.fields.set is not None:
self.socket.auto_progress( self.instructions = BrickInstructionsList().get(
message='Set {number}: writing to the database'.format( self.fields.set
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 (<a href="{url}">Go to the set</a>)'.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,
)
)
logger.debug(traceback.format_exc())
# Load the set from Rebrickable # Load the set from Rebrickable
def load( def load(
self, self,
data: dict[str, Any], data: dict[str, Any],
/, /,
*,
from_download=False, from_download=False,
) -> BrickSet | None: ) -> bool:
# Reset the progress # Reset the progress
self.socket.progress_count = 0 self.socket.progress_count = 0
self.socket.progress_total = 2 self.socket.progress_total = 2
try: try:
self.socket.auto_progress(message='Parsing set number') 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( self.socket.auto_progress(
message='Set {num}: loading from Rebrickable'.format( message='Set {set}: loading from Rebrickable'.format(
num=set_num, set=set,
), ),
) )
logger.debug('rebrick.lego.get_set("{set_num}")'.format( logger.debug('rebrick.lego.get_set("{set}")'.format(
set_num=set_num, set=set,
)) ))
brickset = Rebrickable[BrickSet]( Rebrickable[RebrickableSet](
'get_set', 'get_set',
set_num, set,
BrickSet, RebrickableSet,
instance=self,
).get() ).get()
short = brickset.short() self.socket.emit('SET_LOADED', self.short(
short['download'] = from_download from_download=from_download
))
self.socket.emit('SET_LOADED', short)
if not from_download: if not from_download:
self.socket.complete( self.socket.complete(
message='Set {num}: loaded from Rebrickable'.format( message='Set {set}: loaded from Rebrickable'.format(
num=brickset.fields.set_num set=self.fields.set
) )
) )
return brickset return True
except Exception as e: except Exception as e:
self.socket.fail( self.socket.fail(
message='Could not load the set from Rebrickable: {error}. Data: {data}'.format( # noqa: E501 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)): if not isinstance(e, (NotFoundException, ErrorException)):
logger.debug(traceback.format_exc()) 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 # Make sense of the number from the data
@staticmethod @staticmethod
def parse_number(set_num: str, /) -> str: def parse_number(set: str, /) -> str:
number, _, version = set_num.partition('-') number, _, version = set.partition('-')
# Making sure both are integers # Making sure both are integers
if version == '': if version == '':
@ -191,24 +223,3 @@ class RebrickableSet(object):
)) ))
return '{number}-{version}'.format(number=number, version=version) 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()

View File

@ -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

View File

@ -1,5 +1,5 @@
from sqlite3 import Row from sqlite3 import Row
from typing import Any, ItemsView from typing import Any, ItemsView, Tuple
from .fields import BrickRecordFields from .fields import BrickRecordFields
from .sql import BrickSQL from .sql import BrickSQL
@ -24,33 +24,63 @@ class BrickRecord(object):
# Insert into the database # Insert into the database
# If we do not commit immediately, we defer the execute() call # 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() database = BrickSQL()
rows, q = database.execute( rows, q = database.execute(
self.insert_query, query,
parameters=self.sql_parameters(), parameters=self.sql_parameters(),
defer=not commit, defer=not commit and not no_defer,
) )
if commit: if commit:
database.commit() database.commit()
return rows, q
# Shorthand to field items # Shorthand to field items
def items(self, /) -> ItemsView[str, Any]: def items(self, /) -> ItemsView[str, Any]:
return self.fields.__dict__.items() return self.fields.__dict__.items()
# Get from the database using the query # 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: if override_query:
query = override_query query = override_query
else: else:
query = self.select_query query = self.select_query
return BrickSQL().fetchone( record = BrickSQL().fetchone(
query, 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 # Generic SQL parameters from fields
def sql_parameters(self, /) -> dict[str, Any]: def sql_parameters(self, /) -> dict[str, Any]:
parameters: dict[str, Any] = {} parameters: dict[str, Any] = {}

View File

@ -6,10 +6,20 @@ from .sql import BrickSQL
if TYPE_CHECKING: if TYPE_CHECKING:
from .minifigure import BrickMinifigure from .minifigure import BrickMinifigure
from .part import BrickPart from .part import BrickPart
from .rebrickable_set import RebrickableSet
from .set import BrickSet from .set import BrickSet
from .set_checkbox import BrickSetCheckbox
from .wish import BrickWish from .wish import BrickWish
T = TypeVar('T', 'BrickSet', 'BrickPart', 'BrickMinifigure', 'BrickWish') T = TypeVar(
'T',
'BrickSet',
'BrickSetCheckbox',
'BrickPart',
'BrickMinifigure',
'BrickWish',
'RebrickableSet'
)
# SQLite records # SQLite records
@ -32,9 +42,11 @@ class BrickRecordList(Generic[T]):
def select( def select(
self, self,
/, /,
*,
override_query: str | None = None, override_query: str | None = None,
order: str | None = None, order: str | None = None,
limit: int | None = None, limit: int | None = None,
**context: Any,
) -> list[Row]: ) -> list[Row]:
# Select the query # Select the query
if override_query: if override_query:
@ -47,6 +59,7 @@ class BrickRecordList(Generic[T]):
parameters=self.sql_parameters(), parameters=self.sql_parameters(),
order=order, order=order,
limit=limit, limit=limit,
**context
) )
# Generic SQL parameters from fields # Generic SQL parameters from fields
@ -62,6 +75,6 @@ class BrickRecordList(Generic[T]):
for record in self.records: for record in self.records:
yield record yield record
# Make the sets measurable # Make the list measurable
def __len__(self, /) -> int: def __len__(self, /) -> int:
return len(self.records) return len(self.records)

23
bricktracker/reload.py Normal file
View File

@ -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

View File

@ -22,7 +22,7 @@ class BrickRetiredList(object):
size: int | None size: int | None
exception: Exception | 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 # Load sets only if there is none already loaded
retired = getattr(self, 'retired', None) retired = getattr(self, 'retired', None)
@ -33,7 +33,7 @@ class BrickRetiredList(object):
# Try to read the themes from a CSV file # Try to read the themes from a CSV file
try: 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) themes_reader = csv.reader(themes_file)
# Ignore the header # Ignore the header
@ -44,7 +44,7 @@ class BrickRetiredList(object):
BrickRetiredList.retired[retired.number] = retired BrickRetiredList.retired[retired.number] = retired
# File stats # 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.size = stat.st_size
BrickRetiredList.mtime = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc) # noqa: E501 BrickRetiredList.mtime = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc) # noqa: E501
@ -79,7 +79,7 @@ class BrickRetiredList(object):
def human_time(self) -> str: def human_time(self) -> str:
if self.mtime is not None: if self.mtime is not None:
return self.mtime.astimezone(g.timezone).strftime( return self.mtime.astimezone(g.timezone).strftime(
current_app.config['FILE_DATETIME_FORMAT'].value current_app.config['FILE_DATETIME_FORMAT']
) )
else: else:
return '' return ''
@ -88,7 +88,7 @@ class BrickRetiredList(object):
@staticmethod @staticmethod
def update() -> None: def update() -> None:
response = requests.get( response = requests.get(
current_app.config['RETIRED_SETS_FILE_URL'].value, current_app.config['RETIRED_SETS_FILE_URL'],
stream=True, stream=True,
) )
@ -99,7 +99,7 @@ class BrickRetiredList(object):
content = gzip.GzipFile(fileobj=response.raw) 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) copyfileobj(content, f)
logger.info('Retired sets list updated') logger.info('Retired sets list updated')

View File

@ -1,70 +1,105 @@
from sqlite3 import Row import logging
import traceback
from typing import Any, Self 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 .exceptions import DatabaseException, NotFoundException
from .instructions import BrickInstructions
from .instructions_list import BrickInstructionsList
from .minifigure_list import BrickMinifigureList from .minifigure_list import BrickMinifigureList
from .part_list import BrickPartList from .part_list import BrickPartList
from .rebrickable_image import RebrickableImage from .rebrickable_minifigures import RebrickableMinifigures
from .record import BrickRecord 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 .sql import BrickSQL
from .theme_list import BrickThemeList
logger = logging.getLogger(__name__)
# Lego brick set # Lego brick set
class BrickSet(BrickRecord): class BrickSet(RebrickableSet):
instructions: list[BrickInstructions]
theme_name: str
# Queries # Queries
select_query: str = 'set/select' select_query: str = 'set/select/full'
light_query: str = 'set/select/light'
insert_query: str = 'set/insert' 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 # Delete a set
def delete(self, /) -> None: def delete(self, /) -> None:
database = BrickSQL() BrickSQL().executescript(
parameters = self.sql_parameters() 'set/delete/set',
id=self.fields.id
)
# Delete the set # Import a set into the database
database.execute('set/delete/set', parameters=parameters) def download(self, data: dict[str, Any], /) -> None:
# Load the set
if not self.load(data, from_download=True):
return
# Delete the minifigures try:
database.execute( # Insert into the database
'minifigure/delete/all_from_set', parameters=parameters) self.socket.auto_progress(
message='Set {number}: inserting into database'.format(
number=self.fields.set
),
increment_total=True,
)
# Delete the parts # Generate an UUID for self
database.execute( self.fields.id = str(uuid4())
'part/delete/all_from_set', parameters=parameters)
# Delete missing parts # Insert into database
database.execute('missing/delete/all_from_set', parameters=parameters) self.insert(commit=False)
# Commit to the database # Execute the parent download method
database.commit() 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 (<a href="{url}">Go to the set</a>)'.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 # Minifigures
def minifigures(self, /) -> BrickMinifigureList: def minifigures(self, /) -> BrickMinifigureList:
@ -74,145 +109,81 @@ class BrickSet(BrickRecord):
def parts(self, /) -> BrickPartList: def parts(self, /) -> BrickPartList:
return BrickPartList().load(self) return BrickPartList().load(self)
# Add instructions to the set # Select a light set (with an id)
def resolve_instructions(self, /) -> None: def select_light(self, id: str, /) -> Self:
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:
# Save the parameters to the fields # Save the parameters to the fields
self.fields.u_id = u_id self.fields.id = id
# Load from database # Load from database
record = self.select() if not self.select(override_query=self.light_query):
if record is None:
raise NotFoundException( raise NotFoundException(
'Set with ID {id} was not found in the database'.format( 'Set with ID {id} was not found in the database'.format(
id=self.fields.u_id, id=self.fields.id,
), ),
) )
# Ingest the record return self
self.ingest(record)
# Resolve the theme # Select a specific set (with an id)
self.resolve_theme() def select_specific(self, id: str, /) -> Self:
# Save the parameters to the fields
self.fields.id = id
# Check for the instructions # Load from database
self.resolve_instructions() 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 return self
# Update a checked state # Update a status
def update_checked(self, name: str, status: bool, /) -> None: def update_status(
self,
checkbox: BrickSetCheckbox,
status: bool,
/
) -> None:
parameters = self.sql_parameters() parameters = self.sql_parameters()
parameters['status'] = status parameters['status'] = status
# Update the checked status # Update the status
rows, _ = BrickSQL().execute_and_commit( rows, _ = BrickSQL().execute_and_commit(
'set/update_checked', 'set/update/status',
parameters=parameters, parameters=parameters,
name=name, name=checkbox.as_column(),
) )
if rows != 1: if rows != 1:
raise DatabaseException('Could not update the status {status} for set {number}'.format( # noqa: E501 raise DatabaseException('Could not update the status "{status}" for set {number} ({id})'.format( # noqa: E501
status=name, status=checkbox.fields.name,
number=self.fields.set_num, number=self.fields.set,
id=self.fields.id,
)) ))
# Self url # Self url
def url(self, /) -> str: 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 # Deletion url
def url_for_delete(self, /) -> str: 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 # Actual deletion url
def url_for_do_delete(self, /) -> str: def url_for_do_delete(self, /) -> str:
return url_for('set.do_delete', id=self.fields.u_id) return url_for('set.do_delete', id=self.fields.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
# Compute the url for the set instructions # Compute the url for the set instructions
def url_for_instructions(self, /) -> str: def url_for_instructions(self, /) -> str:
if len(self.instructions): if len(self.instructions):
return url_for( return url_for(
'set.details', 'set.details',
id=self.fields.u_id, id=self.fields.id,
open_instructions=True open_instructions=True
) )
else: else:
return '' 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,
}

View File

@ -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,
))

View File

@ -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
]

View File

@ -3,6 +3,7 @@ from typing import Self
from flask import current_app from flask import current_app
from .record_list import BrickRecordList from .record_list import BrickRecordList
from .set_checkbox_list import BrickSetCheckboxList
from .set import BrickSet from .set import BrickSet
@ -13,6 +14,7 @@ class BrickSetList(BrickRecordList[BrickSet]):
# Queries # Queries
generic_query: str = 'set/list/generic' generic_query: str = 'set/list/generic'
light_query: str = 'set/list/light'
missing_minifigure_query: str = 'set/list/missing_minifigure' missing_minifigure_query: str = 'set/list/missing_minifigure'
missing_part_query: str = 'set/list/missing_part' missing_part_query: str = 'set/list/missing_part'
select_query: str = 'set/list/all' select_query: str = 'set/list/all'
@ -26,18 +28,21 @@ class BrickSetList(BrickRecordList[BrickSet]):
self.themes = [] self.themes = []
# Store the order for this list # 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 # All the sets
def all(self, /) -> Self: def all(self, /) -> Self:
themes = set() themes = set()
# Load the sets from the database # 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) brickset = BrickSet(record=record)
self.records.append(brickset) self.records.append(brickset)
themes.add(brickset.theme_name) themes.add(brickset.theme.name)
# Convert the set into a list and sort it # Convert the set into a list and sort it
self.themes = list(themes) self.themes = list(themes)
@ -58,14 +63,18 @@ class BrickSetList(BrickRecordList[BrickSet]):
return self return self
# Last added sets # Last added sets
def last(self, /, limit: int = 6) -> Self: def last(self, /, *, limit: int = 6) -> Self:
# Randomize # Randomize
if current_app.config['RANDOM'].value: if current_app.config['RANDOM']:
order = 'RANDOM()' order = 'RANDOM()'
else: 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) brickset = BrickSet(record=record)
self.records.append(brickset) self.records.append(brickset)
@ -76,7 +85,7 @@ class BrickSetList(BrickRecordList[BrickSet]):
def missing_minifigure( def missing_minifigure(
self, self,
fig_num: str, fig_num: str,
/, /
) -> Self: ) -> Self:
# Save the parameters to the fields # Save the parameters to the fields
self.fields.fig_num = fig_num self.fields.fig_num = fig_num
@ -98,6 +107,7 @@ class BrickSetList(BrickRecordList[BrickSet]):
part_num: str, part_num: str,
color_id: int, color_id: int,
/, /,
*,
element_id: int | None = None, element_id: int | None = None,
) -> Self: ) -> Self:
# Save the parameters to the fields # Save the parameters to the fields
@ -120,7 +130,7 @@ class BrickSetList(BrickRecordList[BrickSet]):
def using_minifigure( def using_minifigure(
self, self,
fig_num: str, fig_num: str,
/, /
) -> Self: ) -> Self:
# Save the parameters to the fields # Save the parameters to the fields
self.fields.fig_num = fig_num self.fields.fig_num = fig_num
@ -142,6 +152,7 @@ class BrickSetList(BrickRecordList[BrickSet]):
part_num: str, part_num: str,
color_id: int, color_id: int,
/, /,
*,
element_id: int | None = None, element_id: int | None = None,
) -> Self: ) -> Self:
# Save the parameters to the fields # Save the parameters to the fields

View File

@ -6,7 +6,7 @@ from flask_socketio import SocketIO
from .configuration_list import BrickConfigurationList from .configuration_list import BrickConfigurationList
from .login import LoginManager from .login import LoginManager
from .rebrickable_set import RebrickableSet from .set import BrickSet
from .sql import close as sql_close from .sql import close as sql_close
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -56,19 +56,19 @@ class BrickSocket(object):
# Compute the namespace # Compute the namespace
self.namespace = '/{namespace}'.format( self.namespace = '/{namespace}'.format(
namespace=app.config['SOCKET_NAMESPACE'].value namespace=app.config['SOCKET_NAMESPACE']
) )
# Inject CORS if a domain is defined # Inject CORS if a domain is defined
if app.config['DOMAIN_NAME'].value != '': if app.config['DOMAIN_NAME'] != '':
kwargs['cors_allowed_origins'] = app.config['DOMAIN_NAME'].value kwargs['cors_allowed_origins'] = app.config['DOMAIN_NAME']
# Instantiate the socket # Instantiate the socket
self.socket = SocketIO( self.socket = SocketIO(
self.app, self.app,
*args, *args,
**kwargs, **kwargs,
path=app.config['SOCKET_PATH'].value, path=app.config['SOCKET_PATH'],
async_mode='eventlet', async_mode='eventlet',
) )
@ -98,7 +98,7 @@ class BrickSocket(object):
self.fail(message=str(e)) self.fail(message=str(e))
return return
brickset = RebrickableSet(self) brickset = BrickSet(socket=self)
# Start it in a thread if requested # Start it in a thread if requested
if self.threaded: if self.threaded:
@ -124,7 +124,7 @@ class BrickSocket(object):
self.fail(message=str(e)) self.fail(message=str(e))
return return
brickset = RebrickableSet(self) brickset = BrickSet(socket=self)
# Start it in a thread if requested # Start it in a thread if requested
if self.threaded: if self.threaded:
@ -140,6 +140,7 @@ class BrickSocket(object):
def auto_progress( def auto_progress(
self, self,
/, /,
*,
message: str | None = None, message: str | None = None,
increment_total=False, increment_total=False,
) -> None: ) -> None:
@ -203,7 +204,7 @@ class BrickSocket(object):
sql_close() sql_close()
# Update the progress # Update the progress
def progress(self, /, message: str | None = None) -> None: def progress(self, /, *, message: str | None = None) -> None:
# Save the las message # Save the las message
if message is not None: if message is not None:
self.progress_message = message self.progress_message = message
@ -218,14 +219,14 @@ class BrickSocket(object):
self.emit('PROGRESS', data) self.emit('PROGRESS', data)
# Update the progress total only # 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: if add:
self.progress_total += total self.progress_total += total
else: else:
self.progress_total = total self.progress_total = total
# Update the 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.update_total(total, add=add)
self.progress() self.progress()

View File

@ -1,33 +1,46 @@
import logging import logging
import os import os
import sqlite3 import sqlite3
from typing import Any, Tuple from typing import Any, Final, Tuple
from .sql_stats import BrickSQLStats
from flask import current_app, g from flask import current_app, g
from jinja2 import Environment, FileSystemLoader from jinja2 import Environment, FileSystemLoader
from werkzeug.datastructures import FileStorage 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__) 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 # SQLite3 client with our extra features
class BrickSQL(object): class BrickSQL(object):
connection: sqlite3.Connection connection: sqlite3.Connection
cursor: sqlite3.Cursor cursor: sqlite3.Cursor
stats: BrickSQLStats stats: BrickSQLStats
version: int
def __init__(self, /): def __init__(self, /, *, failsafe: bool = False):
# Instantiate the database connection in the Flask # Instantiate the database connection in the Flask
# application context so that it can be used by all # application context so that it can be used by all
# requests without re-opening connections # requests without re-opening connections
database = getattr(g, 'database', None) connection = getattr(g, G_CONNECTION, None)
# Grab the existing connection if it exists # Grab the existing connection if it exists
if database is not None: if connection is not None:
self.connection = database self.connection = connection
self.stats = getattr(g, 'database_stats', BrickSQLStats()) self.stats = getattr(g, G_STATS, BrickSQLStats())
# Grab a cursor
self.cursor = self.connection.cursor()
else: else:
# Instantiate the stats # Instantiate the stats
self.stats = BrickSQLStats() self.stats = BrickSQLStats()
@ -37,26 +50,54 @@ class BrickSQL(object):
logger.debug('SQLite3: connect') logger.debug('SQLite3: connect')
self.connection = 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 # Setup the row factory to get pseudo-dicts rather than tuples
self.connection.row_factory = sqlite3.Row 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 # Debug: Attach the debugger
# Uncomment manually because this is ultra verbose # Uncomment manually because this is ultra verbose
# self.connection.set_trace_callback(print) # self.connection.set_trace_callback(print)
# Save the connection globally for later use # Save the connection globally for later use
g.database = self.connection setattr(g, G_CONNECTION, self.connection)
g.database_stats = self.stats setattr(g, G_STATS, self.stats)
# Grab a cursor if not failsafe:
self.cursor = self.connection.cursor() 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 # Clear the defer stack
def clear_defer(self, /) -> None: def clear_defer(self, /) -> None:
g.database_defer = [] setattr(g, G_DEFER, [])
# Shorthand to commit # Shorthand to commit
def commit(self, /) -> None: def commit(self, /) -> None:
@ -72,6 +113,27 @@ class BrickSQL(object):
logger.debug('SQLite3: commit') logger.debug('SQLite3: commit')
return self.connection.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 # Defer a call to execute
def defer(self, query: str, parameters: dict[str, Any], /): def defer(self, query: str, parameters: dict[str, Any], /):
defer = self.get_defer() defer = self.get_defer()
@ -82,16 +144,17 @@ class BrickSQL(object):
defer.append((query, parameters)) defer.append((query, parameters))
# Save the defer stack # Save the defer stack
g.database_defer = defer setattr(g, G_DEFER, defer)
# Shorthand to execute, returning number of affected rows # Shorthand to execute, returning number of affected rows
def execute( def execute(
self, self,
query: str, query: str,
/, /,
*,
parameters: dict[str, Any] = {}, parameters: dict[str, Any] = {},
defer: bool = False, defer: bool = False,
**context, **context: Any,
) -> Tuple[int, str]: ) -> Tuple[int, str]:
# Stats: execute # Stats: execute
self.stats.execute += 1 self.stats.execute += 1
@ -114,7 +177,7 @@ class BrickSQL(object):
return result.rowcount, query return result.rowcount, query
# Shorthand to executescript # Shorthand to executescript
def executescript(self, query: str, /, **context) -> None: def executescript(self, query: str, /, **context: Any) -> None:
# Load the query # Load the query
query = self.load_query(query, **context) query = self.load_query(query, **context)
@ -129,8 +192,9 @@ class BrickSQL(object):
self, self,
query: str, query: str,
/, /,
*,
parameters: dict[str, Any] = {}, parameters: dict[str, Any] = {},
**context, **context: Any,
) -> Tuple[int, str]: ) -> Tuple[int, str]:
rows, query = self.execute(query, parameters=parameters, **context) rows, query = self.execute(query, parameters=parameters, **context)
self.commit() self.commit()
@ -142,8 +206,9 @@ class BrickSQL(object):
self, self,
query: str, query: str,
/, /,
*,
parameters: dict[str, Any] = {}, parameters: dict[str, Any] = {},
**context, **context: Any,
) -> list[sqlite3.Row]: ) -> list[sqlite3.Row]:
_, query = self.execute(query, parameters=parameters, **context) _, query = self.execute(query, parameters=parameters, **context)
@ -163,8 +228,9 @@ class BrickSQL(object):
self, self,
query: str, query: str,
/, /,
*,
parameters: dict[str, Any] = {}, parameters: dict[str, Any] = {},
**context, **context: Any,
) -> sqlite3.Row | None: ) -> sqlite3.Row | None:
_, query = self.execute(query, parameters=parameters, **context) _, query = self.execute(query, parameters=parameters, **context)
@ -182,21 +248,18 @@ class BrickSQL(object):
# Grab the defer stack # Grab the defer stack
def get_defer(self, /) -> list[Tuple[str, dict[str, Any]]]: def get_defer(self, /) -> list[Tuple[str, dict[str, Any]]]:
defer: list[Tuple[str, dict[str, Any]]] = getattr( defer: list[Tuple[str, dict[str, Any]]] = getattr(g, G_DEFER, [])
g,
'database_defer',
[]
)
return defer return defer
# Load a query by name # 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 # 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 # Instantiate Jinja environment for SQL files
if environment is None: if environment is None:
logger.debug('SQLite3: instantiating the Jinja loader')
environment = Environment( environment = Environment(
loader=FileSystemLoader( loader=FileSystemLoader(
os.path.join(os.path.dirname(__file__), 'sql/') os.path.join(os.path.dirname(__file__), 'sql/')
@ -204,10 +267,10 @@ class BrickSQL(object):
) )
# Save the environment globally for later use # Save the environment globally for later use
g.database_environment = environment setattr(g, G_ENVIRONMENT, environment)
# Grab the template # Grab the template
logger.debug('SQLite: loading {name} (context: {context})'.format( logger.debug('SQLite3: loading {name} (context: {context})'.format(
name=name, name=name,
context=context, context=context,
)) ))
@ -221,7 +284,8 @@ class BrickSQL(object):
def raw_execute( def raw_execute(
self, self,
query: str, query: str,
parameters: dict[str, Any] parameters: dict[str, Any],
/
) -> sqlite3.Cursor: ) -> sqlite3.Cursor:
logger.debug('SQLite3: execute: {query}'.format( logger.debug('SQLite3: execute: {query}'.format(
query=BrickSQL.clean_query(query) query=BrickSQL.clean_query(query)
@ -229,6 +293,25 @@ class BrickSQL(object):
return self.cursor.execute(query, parameters) 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 # Clean the query for debugging
@staticmethod @staticmethod
def clean_query(query: str, /) -> str: def clean_query(query: str, /) -> str:
@ -249,7 +332,7 @@ class BrickSQL(object):
# Delete the database # Delete the database
@staticmethod @staticmethod
def delete() -> None: def delete() -> None:
os.remove(current_app.config['DATABASE_PATH'].value) os.remove(current_app.config['DATABASE_PATH'])
# Info # Info
logger.info('The database has been deleted') logger.info('The database has been deleted')
@ -262,37 +345,10 @@ class BrickSQL(object):
# Info # Info
logger.info('The database has been dropped') 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 # Replace the database with a new file
@staticmethod @staticmethod
def upload(file: FileStorage, /) -> None: def upload(file: FileStorage, /) -> None:
file.save(current_app.config['DATABASE_PATH'].value) file.save(current_app.config['DATABASE_PATH'])
# Info # Info
logger.info('The database has been imported using file {file}'.format( logger.info('The database has been imported using file {file}'.format(
@ -302,11 +358,11 @@ class BrickSQL(object):
# Close all existing SQLite3 connections # Close all existing SQLite3 connections
def close() -> None: 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') logger.debug('SQLite3: close')
database.close() connection.close()
# Remove the database from the context # Remove the database from the context
delattr(g, 'database') delattr(g, G_CONNECTION)

View File

@ -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;

View File

@ -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 %}

View File

@ -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;

View File

@ -0,0 +1 @@
{% extends 'checkbox/base.sql' %}

View File

@ -0,0 +1,5 @@
{% extends 'checkbox/base.sql' %}
{% block where %}
WHERE "bricktracker_set_checkboxes"."id" IS NOT DISTINCT FROM :id
{% endblock %}

View File

@ -0,0 +1,3 @@
UPDATE "bricktracker_set_checkboxes"
SET "name" = :safe_name
WHERE "bricktracker_set_checkboxes"."id" IS NOT DISTINCT FROM :id

View File

@ -0,0 +1,3 @@
UPDATE "bricktracker_set_checkboxes"
SET "{{name}}" = :status
WHERE "bricktracker_set_checkboxes"."id" IS NOT DISTINCT FROM :id

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -1,20 +1,20 @@
SELECT SELECT
minifigures.fig_num, "minifigures"."fig_num",
minifigures.set_num, "minifigures"."set_num",
minifigures.name, "minifigures"."name",
minifigures.quantity, "minifigures"."quantity",
minifigures.set_img_url, "minifigures"."set_img_url",
minifigures.u_id, "minifigures"."u_id",
{% block total_missing %} {% block total_missing %}
NULL AS total_missing, -- dummy for order: total_missing NULL AS "total_missing", -- dummy for order: total_missing
{% endblock %} {% endblock %}
{% block total_quantity %} {% block total_quantity %}
NULL AS total_quantity, -- dummy for order: total_quantity NULL AS "total_quantity", -- dummy for order: total_quantity
{% endblock %} {% endblock %}
{% block total_sets %} {% block total_sets %}
NULL AS total_sets -- dummy for order: total_sets NULL AS "total_sets" -- dummy for order: total_sets
{% endblock %} {% endblock %}
FROM minifigures FROM "minifigures"
{% block join %}{% endblock %} {% block join %}{% endblock %}

View File

@ -1,2 +0,0 @@
DELETE FROM minifigures
WHERE u_id IS NOT DISTINCT FROM :u_id

View File

@ -1,10 +1,10 @@
INSERT INTO minifigures ( INSERT INTO "minifigures" (
fig_num, "fig_num",
set_num, "set_num",
name, "name",
quantity, "quantity",
set_img_url, "set_img_url",
u_id "u_id"
) VALUES ( ) VALUES (
:fig_num, :fig_num,
:set_num, :set_num,

View File

@ -1,34 +1,34 @@
{% extends 'minifigure/base/select.sql' %} {% extends 'minifigure/base/select.sql' %}
{% block total_missing %} {% block total_missing %}
SUM(IFNULL(missing_join.total, 0)) AS total_missing, SUM(IFNULL("missing_join"."total", 0)) AS "total_missing",
{% endblock %} {% endblock %}
{% block total_quantity %} {% block total_quantity %}
SUM(IFNULL(minifigures.quantity, 0)) AS total_quantity, SUM(IFNULL("minifigures"."quantity", 0)) AS "total_quantity",
{% endblock %} {% endblock %}
{% block total_sets %} {% block total_sets %}
COUNT(minifigures.set_num) AS total_sets COUNT("minifigures"."set_num") AS "total_sets"
{% endblock %} {% endblock %}
{% block join %} {% block join %}
-- LEFT JOIN + SELECT to avoid messing the total -- LEFT JOIN + SELECT to avoid messing the total
LEFT JOIN ( LEFT JOIN (
SELECT SELECT
set_num, "missing"."set_num",
u_id, "missing"."u_id",
SUM(quantity) AS total SUM("missing"."quantity") AS total
FROM missing FROM "missing"
GROUP BY GROUP BY
set_num, "missing"."set_num",
u_id "missing"."u_id"
) missing_join ) missing_join
ON minifigures.u_id IS NOT DISTINCT FROM missing_join.u_id ON "minifigures"."u_id" IS NOT DISTINCT FROM "missing_join"."u_id"
AND minifigures.fig_num IS NOT DISTINCT FROM missing_join.set_num AND "minifigures"."fig_num" IS NOT DISTINCT FROM "missing_join"."set_num"
{% endblock %} {% endblock %}
{% block group %} {% block group %}
GROUP BY GROUP BY
minifigures.fig_num "minifigures"."fig_num"
{% endblock %} {% endblock %}

View File

@ -1,6 +1,6 @@
{% extends 'minifigure/base/select.sql' %} {% extends 'minifigure/base/select.sql' %}
{% block where %} {% block where %}
WHERE u_id IS NOT DISTINCT FROM :u_id WHERE "minifigures"."u_id" IS NOT DISTINCT FROM :u_id
AND set_num IS NOT DISTINCT FROM :set_num AND "minifigures"."set_num" IS NOT DISTINCT FROM :set_num
{% endblock %} {% endblock %}

View File

@ -1,17 +1,17 @@
{% extends 'minifigure/base/select.sql' %} {% extends 'minifigure/base/select.sql' %}
{% block total_missing %} {% block total_missing %}
SUM(IFNULL(missing.quantity, 0)) AS total_missing, SUM(IFNULL("missing"."quantity", 0)) AS "total_missing",
{% endblock %} {% endblock %}
{% block join %} {% block join %}
LEFT JOIN missing LEFT JOIN "missing"
ON minifigures.fig_num IS NOT DISTINCT FROM missing.set_num ON "minifigures"."fig_num" IS NOT DISTINCT FROM "missing"."set_num"
AND minifigures.u_id IS NOT DISTINCT FROM missing.u_id AND "minifigures"."u_id" IS NOT DISTINCT FROM "missing"."u_id"
{% endblock %} {% endblock %}
{% block group %} {% block group %}
GROUP BY GROUP BY
minifigures.fig_num, "minifigures"."fig_num",
minifigures.u_id "minifigures"."u_id"
{% endblock %} {% endblock %}

View File

@ -1,30 +1,30 @@
{% extends 'minifigure/base/select.sql' %} {% extends 'minifigure/base/select.sql' %}
{% block total_missing %} {% block total_missing %}
SUM(IFNULL(missing.quantity, 0)) AS total_missing, SUM(IFNULL("missing"."quantity", 0)) AS "total_missing",
{% endblock %} {% endblock %}
{% block join %} {% block join %}
LEFT JOIN missing LEFT JOIN "missing"
ON minifigures.fig_num IS NOT DISTINCT FROM missing.set_num ON "minifigures"."fig_num" IS NOT DISTINCT FROM "missing"."set_num"
AND minifigures.u_id IS NOT DISTINCT FROM missing.u_id AND "minifigures"."u_id" IS NOT DISTINCT FROM "missing"."u_id"
{% endblock %} {% endblock %}
{% block where %} {% block where %}
WHERE minifigures.fig_num IN ( WHERE "minifigures"."fig_num" IN (
SELECT SELECT
missing.set_num "missing"."set_num"
FROM missing FROM "missing"
WHERE missing.color_id IS NOT DISTINCT FROM :color_id WHERE "missing"."color_id" IS NOT DISTINCT FROM :color_id
AND missing.element_id IS NOT DISTINCT FROM :element_id AND "missing"."element_id" IS NOT DISTINCT FROM :element_id
AND missing.part_num IS NOT DISTINCT FROM :part_num AND "missing"."part_num" IS NOT DISTINCT FROM :part_num
GROUP BY missing.set_num GROUP BY "missing"."set_num"
) )
{% endblock %} {% endblock %}
{% block group %} {% block group %}
GROUP BY GROUP BY
minifigures.fig_num "minifigures"."fig_num"
{% endblock %} {% endblock %}

View File

@ -1,24 +1,24 @@
{% extends 'minifigure/base/select.sql' %} {% extends 'minifigure/base/select.sql' %}
{% block total_quantity %} {% block total_quantity %}
SUM(minifigures.quantity) AS total_quantity, SUM("minifigures"."quantity") AS "total_quantity",
{% endblock %} {% endblock %}
{% block where %} {% block where %}
WHERE minifigures.fig_num IN ( WHERE "minifigures"."fig_num" IN (
SELECT SELECT
inventory.set_num "inventory"."set_num"
FROM inventory FROM "inventory"
WHERE inventory.color_id IS NOT DISTINCT FROM :color_id WHERE "inventory"."color_id" IS NOT DISTINCT FROM :color_id
AND inventory.element_id IS NOT DISTINCT FROM :element_id AND "inventory"."element_id" IS NOT DISTINCT FROM :element_id
AND inventory.part_num IS NOT DISTINCT FROM :part_num AND "inventory"."part_num" IS NOT DISTINCT FROM :part_num
GROUP BY inventory.set_num GROUP BY "inventory"."set_num"
) )
{% endblock %} {% endblock %}
{% block group %} {% block group %}
GROUP BY GROUP BY
minifigures.fig_num "minifigures"."fig_num"
{% endblock %} {% endblock %}

View File

@ -1,38 +1,38 @@
{% extends 'minifigure/base/select.sql' %} {% extends 'minifigure/base/select.sql' %}
{% block total_missing %} {% block total_missing %}
SUM(IFNULL(missing_join.total, 0)) AS total_missing, SUM(IFNULL("missing_join"."total", 0)) AS "total_missing",
{% endblock %} {% endblock %}
{% block total_quantity %} {% block total_quantity %}
SUM(IFNULL(minifigures.quantity, 0)) AS total_quantity, SUM(IFNULL("minifigures"."quantity", 0)) AS "total_quantity",
{% endblock %} {% endblock %}
{% block total_sets %} {% block total_sets %}
COUNT(minifigures.set_num) AS total_sets COUNT("minifigures"."set_num") AS "total_sets"
{% endblock %} {% endblock %}
{% block join %} {% block join %}
-- LEFT JOIN + SELECT to avoid messing the total -- LEFT JOIN + SELECT to avoid messing the total
LEFT JOIN ( LEFT JOIN (
SELECT SELECT
set_num, "missing"."set_num",
u_id, "missing"."u_id",
SUM(quantity) AS total SUM("missing"."quantity") AS "total"
FROM missing FROM "missing"
GROUP BY GROUP BY
set_num, "missing"."set_num",
u_id "missing"."u_id"
) missing_join ) "missing_join"
ON minifigures.u_id IS NOT DISTINCT FROM missing_join.u_id ON "minifigures"."u_id" IS NOT DISTINCT FROM "missing_join"."u_id"
AND minifigures.fig_num IS NOT DISTINCT FROM missing_join.set_num AND "minifigures"."fig_num" IS NOT DISTINCT FROM "missing_join"."set_num"
{% endblock %} {% endblock %}
{% block where %} {% block where %}
WHERE fig_num IS NOT DISTINCT FROM :fig_num WHERE "minifigures"."fig_num" IS NOT DISTINCT FROM :fig_num
{% endblock %} {% endblock %}
{% block group %} {% block group %}
GROUP BY GROUP BY
minifigures.fig_num "minifigures"."fig_num"
{% endblock %} {% endblock %}

View File

@ -1,7 +1,7 @@
{% extends 'minifigure/base/select.sql' %} {% extends 'minifigure/base/select.sql' %}
{% block where %} {% block where %}
WHERE fig_num IS NOT DISTINCT FROM :fig_num WHERE "minifigures"."fig_num" IS NOT DISTINCT FROM :fig_num
AND u_id IS NOT DISTINCT FROM :u_id AND "minifigures"."u_id" IS NOT DISTINCT FROM :u_id
AND set_num IS NOT DISTINCT FROM :set_num AND "minifigures"."set_num" IS NOT DISTINCT FROM :set_num
{% endblock %} {% endblock %}

View File

@ -1,3 +0,0 @@
SELECT count(*) AS count
FROM missing
WHERE element_id = 'None'

View File

@ -1,2 +0,0 @@
DELETE FROM missing
WHERE u_id IS NOT DISTINCT FROM :u_id

View File

@ -1,4 +1,4 @@
DELETE FROM missing DELETE FROM "missing"
WHERE set_num IS NOT DISTINCT FROM :set_num WHERE "missing"."set_num" IS NOT DISTINCT FROM :set_num
AND id IS NOT DISTINCT FROM :id AND "missing"."id" IS NOT DISTINCT FROM :id
AND u_id IS NOT DISTINCT FROM :u_id AND "missing"."u_id" IS NOT DISTINCT FROM :u_id

View File

@ -1,12 +1,12 @@
INSERT INTO missing ( INSERT INTO "missing" (
set_num, "set_num",
id, "id",
part_num, "part_num",
part_img_url_id, "part_img_url_id",
color_id, "color_id",
quantity, "quantity",
element_id, "element_id",
u_id "u_id"
) )
VALUES( VALUES(
:set_num, :set_num,

View File

@ -1,5 +1,5 @@
UPDATE missing UPDATE "missing"
SET quantity = :quantity SET "quantity" = :quantity
WHERE set_num IS NOT DISTINCT FROM :set_num WHERE "missing"."set_num" IS NOT DISTINCT FROM :set_num
AND id IS NOT DISTINCT FROM :id AND "missing"."id" IS NOT DISTINCT FROM :id
AND u_id IS NOT DISTINCT FROM :u_id AND "missing"."u_id" IS NOT DISTINCT FROM :u_id

View File

@ -1,32 +1,32 @@
SELECT SELECT
inventory.set_num, "inventory"."set_num",
inventory.id, "inventory"."id",
inventory.part_num, "inventory"."part_num",
inventory.name, "inventory"."name",
inventory.part_img_url, "inventory"."part_img_url",
inventory.part_img_url_id, "inventory"."part_img_url_id",
inventory.color_id, "inventory"."color_id",
inventory.color_name, "inventory"."color_name",
inventory.quantity, "inventory"."quantity",
inventory.is_spare, "inventory"."is_spare",
inventory.element_id, "inventory"."element_id",
inventory.u_id, "inventory"."u_id",
{% block total_missing %} {% block total_missing %}
NULL AS total_missing, -- dummy for order: total_missing NULL AS "total_missing", -- dummy for order: total_missing
{% endblock %} {% endblock %}
{% block total_quantity %} {% block total_quantity %}
NULL AS total_quantity, -- dummy for order: total_quantity NULL AS "total_quantity", -- dummy for order: total_quantity
{% endblock %} {% endblock %}
{% block total_spare %} {% block total_spare %}
NULL AS total_spare, -- dummy for order: total_spare NULL AS "total_spare", -- dummy for order: total_spare
{% endblock %} {% endblock %}
{% block total_sets %} {% block total_sets %}
NULL AS total_sets, -- dummy for order: total_sets NULL AS "total_sets", -- dummy for order: total_sets
{% endblock %} {% endblock %}
{% block total_minifigures %} {% block total_minifigures %}
NULL AS total_minifigures -- dummy for order: total_minifigures NULL AS "total_minifigures" -- dummy for order: total_minifigures
{% endblock %} {% endblock %}
FROM inventory FROM "inventory"
{% block join %}{% endblock %} {% block join %}{% endblock %}

View File

@ -1,2 +0,0 @@
DELETE FROM inventory
WHERE u_id IS NOT DISTINCT FROM :u_id

View File

@ -1,16 +1,16 @@
INSERT INTO inventory ( INSERT INTO inventory (
set_num, "set_num",
id, "id",
part_num, "part_num",
name, "name",
part_img_url, "part_img_url",
part_img_url_id, "part_img_url_id",
color_id, "color_id",
color_name, "color_name",
quantity, "quantity",
is_spare, "is_spare",
element_id, "element_id",
u_id "u_id"
) VALUES ( ) VALUES (
:set_num, :set_num,
:id, :id,

View File

@ -1,43 +1,43 @@
{% extends 'part/base/select.sql' %} {% extends 'part/base/select.sql' %}
{% block total_missing %} {% block total_missing %}
SUM(IFNULL(missing.quantity, 0)) AS total_missing, SUM(IFNULL("missing"."quantity", 0)) AS "total_missing",
{% endblock %} {% endblock %}
{% block total_quantity %} {% block total_quantity %}
SUM(inventory.quantity * IFNULL(minifigures.quantity, 1)) AS total_quantity, SUM("inventory"."quantity" * IFNULL("minifigures"."quantity", 1)) AS "total_quantity",
{% endblock %} {% endblock %}
{% block total_sets %} {% block total_sets %}
COUNT(DISTINCT sets.u_id) AS total_sets, COUNT(DISTINCT "bricktracker_sets"."id") AS "total_sets",
{% endblock %} {% endblock %}
{% block total_minifigures %} {% block total_minifigures %}
SUM(IFNULL(minifigures.quantity, 0)) AS total_minifigures SUM(IFNULL("minifigures"."quantity", 0)) AS "total_minifigures"
{% endblock %} {% endblock %}
{% block join %} {% block join %}
LEFT JOIN missing LEFT JOIN "missing"
ON inventory.set_num IS NOT DISTINCT FROM missing.set_num ON "inventory"."set_num" IS NOT DISTINCT FROM "missing"."set_num"
AND inventory.id IS NOT DISTINCT FROM missing.id AND "inventory"."id" IS NOT DISTINCT FROM "missing"."id"
AND inventory.part_num IS NOT DISTINCT FROM missing.part_num AND "inventory"."part_num" IS NOT DISTINCT FROM "missing"."part_num"
AND inventory.color_id IS NOT DISTINCT FROM missing.color_id AND "inventory"."color_id" IS NOT DISTINCT FROM "missing"."color_id"
AND inventory.element_id IS NOT DISTINCT FROM missing.element_id AND "inventory"."element_id" IS NOT DISTINCT FROM "missing"."element_id"
AND inventory.u_id IS NOT DISTINCT FROM missing.u_id AND "inventory"."u_id" IS NOT DISTINCT FROM "missing"."u_id"
LEFT JOIN minifigures LEFT JOIN "minifigures"
ON inventory.set_num IS NOT DISTINCT FROM minifigures.fig_num ON "inventory"."set_num" IS NOT DISTINCT FROM "minifigures"."fig_num"
AND inventory.u_id IS NOT DISTINCT FROM minifigures.u_id AND "inventory"."u_id" IS NOT DISTINCT FROM "minifigures"."u_id"
LEFT JOIN sets LEFT JOIN "bricktracker_sets"
ON inventory.u_id IS NOT DISTINCT FROM sets.u_id ON "inventory"."u_id" IS NOT DISTINCT FROM "bricktracker_sets"."id"
{% endblock %} {% endblock %}
{% block group %} {% block group %}
GROUP BY GROUP BY
inventory.part_num, "inventory"."part_num",
inventory.name, "inventory"."name",
inventory.color_id, "inventory"."color_id",
inventory.is_spare, "inventory"."is_spare",
inventory.element_id "inventory"."element_id"
{% endblock %} {% endblock %}

View File

@ -2,27 +2,27 @@
{% extends 'part/base/select.sql' %} {% extends 'part/base/select.sql' %}
{% block total_missing %} {% block total_missing %}
SUM(IFNULL(missing.quantity, 0)) AS total_missing, SUM(IFNULL("missing"."quantity", 0)) AS "total_missing",
{% endblock %} {% endblock %}
{% block join %} {% block join %}
LEFT JOIN missing LEFT JOIN "missing"
ON missing.set_num IS NOT DISTINCT FROM inventory.set_num ON "missing"."set_num" IS NOT DISTINCT FROM "inventory"."set_num"
AND missing.id IS NOT DISTINCT FROM inventory.id AND "missing"."id" IS NOT DISTINCT FROM "inventory"."id"
AND missing.part_num IS NOT DISTINCT FROM inventory.part_num AND "missing"."part_num" IS NOT DISTINCT FROM "inventory"."part_num"
AND missing.color_id IS NOT DISTINCT FROM inventory.color_id AND "missing"."color_id" IS NOT DISTINCT FROM "inventory"."color_id"
AND missing.element_id IS NOT DISTINCT FROM inventory.element_id AND "missing"."element_id" IS NOT DISTINCT FROM "inventory"."element_id"
{% endblock %} {% endblock %}
{% block where %} {% block where %}
WHERE inventory.set_num IS NOT DISTINCT FROM :set_num WHERE "inventory"."set_num" IS NOT DISTINCT FROM :set_num
{% endblock %} {% endblock %}
{% block group %} {% block group %}
GROUP BY GROUP BY
inventory.part_num, "inventory"."part_num",
inventory.name, "inventory"."name",
inventory.color_id, "inventory"."color_id",
inventory.is_spare, "inventory"."is_spare",
inventory.element_id "inventory"."element_id"
{% endblock %} {% endblock %}

View File

@ -2,20 +2,20 @@
{% extends 'part/base/select.sql' %} {% extends 'part/base/select.sql' %}
{% block total_missing %} {% block total_missing %}
IFNULL(missing.quantity, 0) AS total_missing, IFNULL("missing"."quantity", 0) AS "total_missing",
{% endblock %} {% endblock %}
{% block join %} {% block join %}
LEFT JOIN missing LEFT JOIN "missing"
ON inventory.set_num IS NOT DISTINCT FROM missing.set_num ON "inventory"."set_num" IS NOT DISTINCT FROM "missing"."set_num"
AND inventory.id IS NOT DISTINCT FROM missing.id AND "inventory"."id" IS NOT DISTINCT FROM "missing"."id"
AND inventory.part_num IS NOT DISTINCT FROM missing.part_num AND "inventory"."part_num" IS NOT DISTINCT FROM "missing"."part_num"
AND inventory.color_id IS NOT DISTINCT FROM missing.color_id AND "inventory"."color_id" IS NOT DISTINCT FROM "missing"."color_id"
AND inventory.element_id IS NOT DISTINCT FROM missing.element_id AND "inventory"."element_id" IS NOT DISTINCT FROM "missing"."element_id"
AND inventory.u_id IS NOT DISTINCT FROM missing.u_id AND "inventory"."u_id" IS NOT DISTINCT FROM "missing"."u_id"
{% endblock %} {% endblock %}
{% block where %} {% block where %}
WHERE inventory.u_id IS NOT DISTINCT FROM :u_id WHERE "inventory"."u_id" IS NOT DISTINCT FROM :u_id
AND inventory.set_num IS NOT DISTINCT FROM :set_num AND "inventory"."set_num" IS NOT DISTINCT FROM :set_num
{% endblock %} {% endblock %}

View File

@ -1,36 +1,36 @@
{% extends 'part/base/select.sql' %} {% extends 'part/base/select.sql' %}
{% block total_missing %} {% block total_missing %}
SUM(IFNULL(missing.quantity, 0)) AS total_missing, SUM(IFNULL("missing"."quantity", 0)) AS "total_missing",
{% endblock %} {% endblock %}
{% block total_sets %} {% 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 %} {% endblock %}
{% block total_minifigures %} {% block total_minifigures %}
SUM(IFNULL(minifigures.quantity, 0)) AS total_minifigures SUM(IFNULL("minifigures"."quantity", 0)) AS "total_minifigures"
{% endblock %} {% endblock %}
{% block join %} {% block join %}
INNER JOIN missing INNER JOIN "missing"
ON missing.set_num IS NOT DISTINCT FROM inventory.set_num ON "missing"."set_num" IS NOT DISTINCT FROM "inventory"."set_num"
AND missing.id IS NOT DISTINCT FROM inventory.id AND "missing"."id" IS NOT DISTINCT FROM "inventory"."id"
AND missing.part_num IS NOT DISTINCT FROM inventory.part_num AND "missing"."part_num" IS NOT DISTINCT FROM "inventory"."part_num"
AND missing.color_id IS NOT DISTINCT FROM inventory.color_id AND "missing"."color_id" IS NOT DISTINCT FROM "inventory"."color_id"
AND missing.element_id IS NOT DISTINCT FROM inventory.element_id AND "missing"."element_id" IS NOT DISTINCT FROM "inventory"."element_id"
AND missing.u_id IS NOT DISTINCT FROM inventory.u_id AND "missing"."u_id" IS NOT DISTINCT FROM "inventory"."u_id"
LEFT JOIN minifigures LEFT JOIN "minifigures"
ON missing.set_num IS NOT DISTINCT FROM minifigures.fig_num ON "missing"."set_num" IS NOT DISTINCT FROM "minifigures"."fig_num"
AND missing.u_id IS NOT DISTINCT FROM minifigures.u_id AND "missing"."u_id" IS NOT DISTINCT FROM "minifigures"."u_id"
{% endblock %} {% endblock %}
{% block group %} {% block group %}
GROUP BY GROUP BY
inventory.part_num, "inventory"."part_num",
inventory.name, "inventory"."name",
inventory.color_id, "inventory"."color_id",
inventory.is_spare, "inventory"."is_spare",
inventory.element_id "inventory"."element_id"
{% endblock %} {% endblock %}

View File

@ -1,40 +1,40 @@
{% extends 'part/base/select.sql' %} {% extends 'part/base/select.sql' %}
{% block total_missing %} {% block total_missing %}
SUM(IFNULL(missing.quantity, 0)) AS total_missing, SUM(IFNULL("missing"."quantity", 0)) AS "total_missing",
{% endblock %} {% endblock %}
{% block total_quantity %} {% 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 %} {% endblock %}
{% block total_spare %} {% 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 %} {% endblock %}
{% block join %} {% block join %}
LEFT JOIN missing LEFT JOIN "missing"
ON inventory.set_num IS NOT DISTINCT FROM missing.set_num ON "inventory"."set_num" IS NOT DISTINCT FROM "missing"."set_num"
AND inventory.id IS NOT DISTINCT FROM missing.id AND "inventory"."id" IS NOT DISTINCT FROM "missing"."id"
AND inventory.part_num IS NOT DISTINCT FROM missing.part_num AND "inventory"."part_num" IS NOT DISTINCT FROM "missing"."part_num"
AND inventory.color_id IS NOT DISTINCT FROM missing.color_id AND "inventory"."color_id" IS NOT DISTINCT FROM "missing"."color_id"
AND inventory.element_id IS NOT DISTINCT FROM missing.element_id AND "inventory"."element_id" IS NOT DISTINCT FROM "missing"."element_id"
AND inventory.u_id IS NOT DISTINCT FROM missing.u_id AND "inventory"."u_id" IS NOT DISTINCT FROM "missing"."u_id"
LEFT JOIN minifigures LEFT JOIN "minifigures"
ON inventory.set_num IS NOT DISTINCT FROM minifigures.fig_num ON "inventory"."set_num" IS NOT DISTINCT FROM "minifigures"."fig_num"
AND inventory.u_id IS NOT DISTINCT FROM minifigures.u_id AND "inventory"."u_id" IS NOT DISTINCT FROM "minifigures"."u_id"
{% endblock %} {% endblock %}
{% block where %} {% block where %}
WHERE inventory.part_num IS NOT DISTINCT FROM :part_num WHERE "inventory"."part_num" IS NOT DISTINCT FROM :part_num
AND inventory.color_id IS NOT DISTINCT FROM :color_id AND "inventory"."color_id" IS NOT DISTINCT FROM :color_id
AND inventory.element_id IS NOT DISTINCT FROM :element_id AND "inventory"."element_id" IS NOT DISTINCT FROM :element_id
{% endblock %} {% endblock %}
{% block group %} {% block group %}
GROUP BY GROUP BY
inventory.part_num, "inventory"."part_num",
inventory.color_id, "inventory"."color_id",
inventory.element_id "inventory"."element_id"
{% endblock %} {% endblock %}

View File

@ -1,24 +1,24 @@
{% extends 'part/base/select.sql' %} {% extends 'part/base/select.sql' %}
{% block join %} {% block join %}
LEFT JOIN missing LEFT JOIN "missing"
ON inventory.set_num IS NOT DISTINCT FROM missing.set_num ON "inventory"."set_num" IS NOT DISTINCT FROM "missing"."set_num"
AND inventory.id IS NOT DISTINCT FROM missing.id AND "inventory"."id" IS NOT DISTINCT FROM "missing"."id"
AND inventory.u_id IS NOT DISTINCT FROM missing.u_id AND "inventory"."u_id" IS NOT DISTINCT FROM "missing"."u_id"
{% endblock %} {% endblock %}
{% block where %} {% block where %}
WHERE inventory.u_id IS NOT DISTINCT FROM :u_id WHERE "inventory"."u_id" IS NOT DISTINCT FROM :u_id
AND inventory.set_num IS NOT DISTINCT FROM :set_num AND "inventory"."set_num" IS NOT DISTINCT FROM :set_num
AND inventory.id IS NOT DISTINCT FROM :id AND "inventory"."id" IS NOT DISTINCT FROM :id
{% endblock %} {% endblock %}
{% block group %} {% block group %}
GROUP BY GROUP BY
inventory.set_num, "inventory"."set_num",
inventory.id, "inventory"."id",
inventory.part_num, "inventory"."part_num",
inventory.color_id, "inventory"."color_id",
inventory.element_id, "inventory"."element_id",
inventory.u_id "inventory"."u_id"
{% endblock %} {% endblock %}

View File

@ -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
)

View File

@ -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"

View File

@ -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

View File

@ -1,2 +1,2 @@
SELECT COUNT(*) AS count SELECT COUNT(*) AS "count"
FROM {{ table }} FROM "{{ table }}"

View File

@ -1,9 +1,18 @@
BEGIN transaction; BEGIN transaction;
DROP TABLE IF EXISTS wishlist; DROP TABLE IF EXISTS "bricktracker_sets";
DROP TABLE IF EXISTS sets; DROP TABLE IF EXISTS "bricktracker_set_checkboxes";
DROP TABLE IF EXISTS inventory; DROP TABLE IF EXISTS "bricktracker_set_statuses";
DROP TABLE IF EXISTS minifigures; DROP TABLE IF EXISTS "bricktracker_wishes";
DROP TABLE IF EXISTS missing; 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; COMMIT;
PRAGMA user_version = 0;

View File

@ -0,0 +1 @@
PRAGMA user_version

View File

@ -1,4 +0,0 @@
SELECT name
FROM sqlite_master
WHERE type="table"
AND name="sets"

View File

@ -0,0 +1 @@
PRAGMA user_version = {{ version }}

View File

@ -0,0 +1 @@
SELECT "name" FROM "sqlite_master" WHERE type='table' ORDER BY "name" ASC

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -1,2 +1,21 @@
DELETE FROM sets -- A bit unsafe as it does not use a prepared statement but it
WHERE u_id IS NOT DISTINCT FROM :u_id -- 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;

View File

@ -1,27 +1,7 @@
INSERT INTO sets ( INSERT OR IGNORE INTO "bricktracker_sets" (
set_num, "id",
name, "rebrickable_set"
year,
theme_id,
num_parts,
set_img_url,
set_url,
last_modified_dt,
mini_col,
set_check,
set_col,
u_id
) VALUES ( ) VALUES (
:set_num, :id,
:name, :set
:year,
:theme_id,
:num_parts,
:set_img_url,
:set_url,
:last_modified_dt,
:mini_col,
:set_check,
:set_col,
:u_id
) )

View File

@ -1 +1 @@
{% extends 'set/base/select.sql' %} {% extends 'set/base/full.sql' %}

View File

@ -1,12 +1,6 @@
SELECT {% extends 'set/base/base.sql' %}
sets.set_num,
sets.name,
sets.year,
sets.theme_id,
sets.num_parts,
sets.set_img_url,
sets.set_url
FROM sets
{% block group %}
GROUP BY GROUP BY
sets.set_num "bricktracker_sets"."rebrickable_set"
{% endblock %}

View File

@ -1,13 +1,13 @@
{% extends 'set/base/select.sql' %} {% extends 'set/base/full.sql' %}
{% block where %} {% block where %}
WHERE sets.u_id IN ( WHERE "bricktracker_sets"."id" IN (
SELECT SELECT
missing.u_id "missing"."u_id"
FROM missing 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 %} {% endblock %}

View File

@ -1,15 +1,15 @@
{% extends 'set/base/select.sql' %} {% extends 'set/base/full.sql' %}
{% block where %} {% block where %}
WHERE sets.u_id IN ( WHERE "bricktracker_sets"."id" IN (
SELECT SELECT
missing.u_id "missing"."u_id"
FROM missing FROM "missing"
WHERE missing.color_id IS NOT DISTINCT FROM :color_id WHERE "missing"."color_id" IS NOT DISTINCT FROM :color_id
AND missing.element_id IS NOT DISTINCT FROM :element_id AND "missing"."element_id" IS NOT DISTINCT FROM :element_id
AND missing.part_num IS NOT DISTINCT FROM :part_num AND "missing"."part_num" IS NOT DISTINCT FROM :part_num
GROUP BY missing.u_id GROUP BY "missing"."u_id"
) )
{% endblock %} {% endblock %}

View File

@ -1,13 +1,13 @@
{% extends 'set/base/select.sql' %} {% extends 'set/base/full.sql' %}
{% block where %} {% block where %}
WHERE sets.u_id IN ( WHERE "bricktracker_sets"."id" IN (
SELECT SELECT
inventory.u_id "inventory"."u_id"
FROM inventory 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 %} {% endblock %}

View File

@ -1,15 +1,15 @@
{% extends 'set/base/select.sql' %} {% extends 'set/base/full.sql' %}
{% block where %} {% block where %}
WHERE sets.u_id IN ( WHERE "bricktracker_sets"."id" IN (
SELECT SELECT
inventory.u_id "inventory"."u_id"
FROM inventory FROM "inventory"
WHERE inventory.color_id IS NOT DISTINCT FROM :color_id WHERE "inventory"."color_id" IS NOT DISTINCT FROM :color_id
AND inventory.element_id IS NOT DISTINCT FROM :element_id AND "inventory"."element_id" IS NOT DISTINCT FROM :element_id
AND inventory.part_num IS NOT DISTINCT FROM :part_num AND "inventory"."part_num" IS NOT DISTINCT FROM :part_num
GROUP BY inventory.u_id GROUP BY "inventory"."u_id"
) )
{% endblock %} {% endblock %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -0,0 +1,5 @@
{% extends 'set/base/light.sql' %}
{% block where %}
WHERE "bricktracker_sets"."id" IS NOT DISTINCT FROM :id
{% endblock %}

View File

@ -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

View File

@ -1,3 +0,0 @@
UPDATE sets
SET {{name}} = :status
WHERE u_id IS NOT DISTINCT FROM :u_id

View File

@ -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 %}

View File

@ -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 %}

Some files were not shown because too many files have changed in this diff Show More