From 6477be00b643c0231921b2ccf3966d9f9b47171b Mon Sep 17 00:00:00 2001 From: Gregoo Date: Fri, 17 Jan 2025 11:03:00 +0100 Subject: [PATCH] Massive rewrite --- .dockerignore | 33 + .env.sample | 259 +++- .gitignore | 29 +- Dockerfile | 10 +- __init__.py | 0 app.py | 1207 +---------------- archive/lego.py | 193 --- archive/set_template.json | 12 - bricktracker/__init__.py | 1 + bricktracker/app.py | 101 ++ bricktracker/config.py | 61 + bricktracker/configuration.py | 87 ++ bricktracker/configuration_list.py | 43 + bricktracker/exceptions.py | 23 + bricktracker/fields.py | 10 + bricktracker/instructions.py | 137 ++ bricktracker/instructions_list.py | 110 ++ bricktracker/login.py | 65 + bricktracker/minifigure.py | 183 +++ bricktracker/minifigure_list.py | 124 ++ bricktracker/navbar.py | 51 + bricktracker/part.py | 304 +++++ bricktracker/part_list.py | 130 ++ bricktracker/rebrickable.py | 156 +++ bricktracker/rebrickable_image.py | 140 ++ bricktracker/rebrickable_minifigures.py | 85 ++ bricktracker/rebrickable_parts.py | 112 ++ bricktracker/rebrickable_set.py | 214 +++ bricktracker/record.py | 60 + bricktracker/record_list.py | 67 + bricktracker/retired.py | 28 + bricktracker/retired_list.py | 105 ++ bricktracker/set.py | 233 ++++ bricktracker/set_list.py | 146 ++ bricktracker/socket.py | 231 ++++ bricktracker/sql.py | 316 +++++ bricktracker/sql/migrations/init.sql | 61 + bricktracker/sql/minifigure/base/select.sql | 31 + .../sql/minifigure/delete/all_from_set.sql | 2 + bricktracker/sql/minifigure/insert.sql | 15 + bricktracker/sql/minifigure/list/all.sql | 34 + bricktracker/sql/minifigure/list/from_set.sql | 6 + bricktracker/sql/minifigure/list/last.sql | 17 + .../sql/minifigure/list/missing_part.sql | 15 + .../sql/minifigure/list/using_part.sql | 15 + .../sql/minifigure/select/generic.sql | 38 + .../sql/minifigure/select/specific.sql | 7 + .../sql/missing/delete/all_from_set.sql | 2 + bricktracker/sql/missing/delete/from_set.sql | 4 + bricktracker/sql/missing/insert.sql | 20 + bricktracker/sql/missing/update/from_set.sql | 5 + bricktracker/sql/part/base/select.sql | 43 + bricktracker/sql/part/delete/all_from_set.sql | 2 + bricktracker/sql/part/insert.sql | 27 + bricktracker/sql/part/list/all.sql | 43 + .../sql/part/list/from_minifigure.sql | 28 + bricktracker/sql/part/list/from_set.sql | 21 + bricktracker/sql/part/list/missing.sql | 36 + bricktracker/sql/part/select/generic.sql | 36 + bricktracker/sql/part/select/specific.sql | 24 + bricktracker/sql/schema/count.sql | 2 + bricktracker/sql/schema/drop.sql | 9 + bricktracker/sql/schema/is_init.sql | 4 + bricktracker/sql/set/base/select.sql | 52 + bricktracker/sql/set/delete/set.sql | 2 + bricktracker/sql/set/insert.sql | 27 + bricktracker/sql/set/list/all.sql | 1 + bricktracker/sql/set/list/generic.sql | 12 + .../sql/set/list/missing_minifigure.sql | 13 + bricktracker/sql/set/list/missing_part.sql | 15 + .../sql/set/list/using_minifigure.sql | 13 + bricktracker/sql/set/list/using_part.sql | 15 + bricktracker/sql/set/select.sql | 13 + bricktracker/sql/set/update_checked.sql | 3 + bricktracker/sql/wish/base/select.sql | 20 + bricktracker/sql/wish/delete/wish.sql | 2 + bricktracker/sql/wish/insert.sql | 19 + bricktracker/sql/wish/list/all.sql | 1 + bricktracker/sql/wish/select.sql | 5 + bricktracker/sql_stats.py | 36 + bricktracker/theme.py | 14 + bricktracker/theme_list.py | 104 ++ bricktracker/version.py | 1 + bricktracker/views/__init__.py | 0 bricktracker/views/add.py | 38 + bricktracker/views/admin.py | 258 ++++ bricktracker/views/error.py | 144 ++ bricktracker/views/exceptions.py | 46 + bricktracker/views/index.py | 18 + bricktracker/views/instructions.py | 128 ++ bricktracker/views/login.py | 86 ++ bricktracker/views/minifigure.py | 30 + bricktracker/views/part.py | 60 + bricktracker/views/set.py | 195 +++ bricktracker/views/upload.py | 38 + bricktracker/views/wish.py | 47 + bricktracker/wish.py | 81 ++ bricktracker/wish_list.py | 37 + database.py | 89 -- db.py | 116 -- dl.sh | 4 - downloadRB.py | 87 -- entrypoint.sh | 16 + lego.sh | 16 - requirements.txt | 9 +- static/brick.png | Bin 0 -> 10192 bytes static/gitea.svg | 1 + static/save.svg | 1 - static/scripts/grid.js | 253 ++++ static/scripts/set.js | 116 ++ static/scripts/socket.js | 454 +++++++ static/style.css | 69 - static/styles.css | 61 + templates/404.html | 20 + templates/add.html | 76 ++ templates/admin.html | 36 + templates/admin/configuration.html | 31 + templates/admin/database.html | 48 + templates/admin/database/delete.html | 10 + templates/admin/database/drop.html | 10 + templates/admin/database/import.html | 16 + templates/admin/instructions.html | 32 + templates/admin/logout.html | 9 + templates/admin/retired.html | 26 + templates/admin/theme.html | 26 + templates/base.html | 447 ++---- templates/bootstrap_table.html | 336 ----- templates/bulk.html | 68 + templates/config.html | 90 -- templates/create.html | 169 --- templates/dashboard.html | 60 - templates/delete.html | 24 + templates/error.html | 18 + templates/exception.html | 19 + templates/frontpage.html | 593 -------- templates/index.html | 969 +------------ templates/instructions.html | 25 + templates/instructions/delete.html | 35 + templates/instructions/rename.html | 41 + templates/instructions/table.html | 51 + templates/instructions/upload.html | 42 + templates/login.html | 37 + templates/macro/accordion.html | 69 + templates/macro/badge.html | 80 ++ templates/macro/card.html | 24 + templates/macro/form.html | 13 + templates/macro/table.html | 76 ++ templates/minifigs.html | 215 --- templates/minifigure.html | 20 + templates/minifigure/card.html | 28 + templates/minifigure/table.html | 26 + templates/minifigures.html | 11 + templates/missing.html | 281 +--- templates/part.html | 20 + templates/part/card.html | 24 + templates/part/table.html | 52 + templates/parts.html | 315 +---- templates/set.html | 24 + templates/set/card.html | 76 ++ templates/set/empty.html | 8 + templates/set/mini.html | 12 + templates/set/socket.html | 12 + templates/sets.html | 83 ++ templates/success.html | 16 + templates/table.html | 293 ---- templates/wish/table.html | 44 + templates/wishes.html | 31 + templates/wishlist.html | 357 ----- test-server.sh | 17 + 169 files changed, 8295 insertions(+), 5775 deletions(-) create mode 100644 .dockerignore create mode 100644 __init__.py delete mode 100644 archive/lego.py delete mode 100644 archive/set_template.json create mode 100644 bricktracker/__init__.py create mode 100644 bricktracker/app.py create mode 100644 bricktracker/config.py create mode 100644 bricktracker/configuration.py create mode 100644 bricktracker/configuration_list.py create mode 100644 bricktracker/exceptions.py create mode 100644 bricktracker/fields.py create mode 100644 bricktracker/instructions.py create mode 100644 bricktracker/instructions_list.py create mode 100644 bricktracker/login.py create mode 100644 bricktracker/minifigure.py create mode 100644 bricktracker/minifigure_list.py create mode 100644 bricktracker/navbar.py create mode 100644 bricktracker/part.py create mode 100644 bricktracker/part_list.py create mode 100644 bricktracker/rebrickable.py create mode 100644 bricktracker/rebrickable_image.py create mode 100644 bricktracker/rebrickable_minifigures.py create mode 100644 bricktracker/rebrickable_parts.py create mode 100644 bricktracker/rebrickable_set.py create mode 100644 bricktracker/record.py create mode 100644 bricktracker/record_list.py create mode 100644 bricktracker/retired.py create mode 100644 bricktracker/retired_list.py create mode 100644 bricktracker/set.py create mode 100644 bricktracker/set_list.py create mode 100644 bricktracker/socket.py create mode 100644 bricktracker/sql.py create mode 100644 bricktracker/sql/migrations/init.sql create mode 100644 bricktracker/sql/minifigure/base/select.sql create mode 100644 bricktracker/sql/minifigure/delete/all_from_set.sql create mode 100644 bricktracker/sql/minifigure/insert.sql create mode 100644 bricktracker/sql/minifigure/list/all.sql create mode 100644 bricktracker/sql/minifigure/list/from_set.sql create mode 100644 bricktracker/sql/minifigure/list/last.sql create mode 100644 bricktracker/sql/minifigure/list/missing_part.sql create mode 100644 bricktracker/sql/minifigure/list/using_part.sql create mode 100644 bricktracker/sql/minifigure/select/generic.sql create mode 100644 bricktracker/sql/minifigure/select/specific.sql create mode 100644 bricktracker/sql/missing/delete/all_from_set.sql create mode 100644 bricktracker/sql/missing/delete/from_set.sql create mode 100644 bricktracker/sql/missing/insert.sql create mode 100644 bricktracker/sql/missing/update/from_set.sql create mode 100644 bricktracker/sql/part/base/select.sql create mode 100644 bricktracker/sql/part/delete/all_from_set.sql create mode 100644 bricktracker/sql/part/insert.sql create mode 100644 bricktracker/sql/part/list/all.sql create mode 100644 bricktracker/sql/part/list/from_minifigure.sql create mode 100644 bricktracker/sql/part/list/from_set.sql create mode 100644 bricktracker/sql/part/list/missing.sql create mode 100644 bricktracker/sql/part/select/generic.sql create mode 100644 bricktracker/sql/part/select/specific.sql create mode 100644 bricktracker/sql/schema/count.sql create mode 100644 bricktracker/sql/schema/drop.sql create mode 100644 bricktracker/sql/schema/is_init.sql create mode 100644 bricktracker/sql/set/base/select.sql create mode 100644 bricktracker/sql/set/delete/set.sql create mode 100644 bricktracker/sql/set/insert.sql create mode 100644 bricktracker/sql/set/list/all.sql create mode 100644 bricktracker/sql/set/list/generic.sql create mode 100644 bricktracker/sql/set/list/missing_minifigure.sql create mode 100644 bricktracker/sql/set/list/missing_part.sql create mode 100644 bricktracker/sql/set/list/using_minifigure.sql create mode 100644 bricktracker/sql/set/list/using_part.sql create mode 100644 bricktracker/sql/set/select.sql create mode 100644 bricktracker/sql/set/update_checked.sql create mode 100644 bricktracker/sql/wish/base/select.sql create mode 100644 bricktracker/sql/wish/delete/wish.sql create mode 100644 bricktracker/sql/wish/insert.sql create mode 100644 bricktracker/sql/wish/list/all.sql create mode 100644 bricktracker/sql/wish/select.sql create mode 100644 bricktracker/sql_stats.py create mode 100644 bricktracker/theme.py create mode 100644 bricktracker/theme_list.py create mode 100644 bricktracker/version.py create mode 100644 bricktracker/views/__init__.py create mode 100644 bricktracker/views/add.py create mode 100644 bricktracker/views/admin.py create mode 100644 bricktracker/views/error.py create mode 100644 bricktracker/views/exceptions.py create mode 100644 bricktracker/views/index.py create mode 100644 bricktracker/views/instructions.py create mode 100644 bricktracker/views/login.py create mode 100644 bricktracker/views/minifigure.py create mode 100644 bricktracker/views/part.py create mode 100644 bricktracker/views/set.py create mode 100644 bricktracker/views/upload.py create mode 100644 bricktracker/views/wish.py create mode 100644 bricktracker/wish.py create mode 100644 bricktracker/wish_list.py delete mode 100644 database.py delete mode 100644 db.py delete mode 100755 dl.sh delete mode 100644 downloadRB.py create mode 100644 entrypoint.sh delete mode 100644 lego.sh create mode 100644 static/brick.png create mode 100644 static/gitea.svg delete mode 100644 static/save.svg create mode 100644 static/scripts/grid.js create mode 100644 static/scripts/set.js create mode 100644 static/scripts/socket.js delete mode 100644 static/style.css create mode 100644 static/styles.css create mode 100644 templates/404.html create mode 100644 templates/add.html create mode 100644 templates/admin.html create mode 100644 templates/admin/configuration.html create mode 100644 templates/admin/database.html create mode 100644 templates/admin/database/delete.html create mode 100644 templates/admin/database/drop.html create mode 100644 templates/admin/database/import.html create mode 100644 templates/admin/instructions.html create mode 100644 templates/admin/logout.html create mode 100644 templates/admin/retired.html create mode 100644 templates/admin/theme.html delete mode 100644 templates/bootstrap_table.html create mode 100644 templates/bulk.html delete mode 100644 templates/config.html delete mode 100644 templates/create.html delete mode 100644 templates/dashboard.html create mode 100644 templates/delete.html create mode 100644 templates/error.html create mode 100644 templates/exception.html delete mode 100644 templates/frontpage.html create mode 100644 templates/instructions.html create mode 100644 templates/instructions/delete.html create mode 100644 templates/instructions/rename.html create mode 100644 templates/instructions/table.html create mode 100644 templates/instructions/upload.html create mode 100644 templates/login.html create mode 100644 templates/macro/accordion.html create mode 100644 templates/macro/badge.html create mode 100644 templates/macro/card.html create mode 100644 templates/macro/form.html create mode 100644 templates/macro/table.html delete mode 100644 templates/minifigs.html create mode 100644 templates/minifigure.html create mode 100644 templates/minifigure/card.html create mode 100644 templates/minifigure/table.html create mode 100644 templates/minifigures.html create mode 100644 templates/part.html create mode 100644 templates/part/card.html create mode 100644 templates/part/table.html create mode 100644 templates/set.html create mode 100644 templates/set/card.html create mode 100644 templates/set/empty.html create mode 100644 templates/set/mini.html create mode 100644 templates/set/socket.html create mode 100644 templates/sets.html create mode 100644 templates/success.html delete mode 100644 templates/table.html create mode 100644 templates/wish/table.html create mode 100644 templates/wishes.html delete mode 100644 templates/wishlist.html create mode 100755 test-server.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..aaf3312 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,33 @@ +# Static files +static/instructions +static/minifigs +static/parts +static/sets + +# Docker +Dockerfile +compose.yaml + +# Documentation +LICENSE +*.md +*.sample + +# Temporary +*.csv + +# Database +*.db + +# Python +**/__pycache__ +*.pyc + +# Git +.git + +# IDE +.vscode + +# Dev +test-server.sh diff --git a/.env.sample b/.env.sample index b29529a..19e60e7 100644 --- a/.env.sample +++ b/.env.sample @@ -1,2 +1,257 @@ -REBRICKABLE_API_KEY=xxxx -DOMAIN_NAME=https://lego.example.com +# Note on *_DEFAULT_ORDER +# If set, it will append a direct ORDER BY to the SQL query +# while listing objects. You can look at the structure of the SQLite database to +# see the schema and the column names. Some fields are compound and not visible +# directly from the schema (joins). You can check the query in the */list.sql files +# in the source to see all column names. +# The usual syntax for those variables is . [ASC|DESC]. +# For composite fields (CASE, SUM, COUNT) the syntax is , there is no
name. +# For instance: +# - table.name (by table.name, default order) +# - table.name ASC (by table.name, ascending) +# - table.name DESC (by table.name, descending) +# - field (by field, default order) +# - ... +# You can combine the ordering options. +# You can use the special column name 'rowid' to order by insertion order. + +# Optional: A unique password to protect sensitive areas of the app +# Useful if you want to share the page with other in read-only +# Security: Currently not fully protecting the socket action, would be better +# to have server-side sessions, with flask-session for instance +# BK_AUTHENTICATION_PASSWORD="my-secret-password" + +# Optional/Mandatory: A unique key used to sign the secrets when using authentication +# Do not share it with anyone, and you MUST make it random. +# You can use the following command in your terminal to generate such random secret: +# python3 -c 'import secrets; print(secrets.token_hex())' +# BK_AUTHENTICATION_KEY="change-this-to-something-random" + +# Optional: Pattern of the link to Bricklink for a part. Will be passed to Python .format() +# Default: "https://www.bricklink.com/v2/catalog/catalogitem.page?P={number}" +# BK_BRICKLINK_LINK_PART_PATTERN="" + +# Optional: Display Bricklink links wherever applicable +# Default: false +# BK_BRICKLINK_LINKS=true + +# Optional: Path to the database. +# Useful if you need it mounted in a Docker volume. Keep in mind that it will not +# do any check on the existence of the path, or if it is dangerous. +# Default: "./app.db" +# BK_DATABASE_PATH="/var/lib/bricktracker/app.db" + +# Optional: Format of the timestamp added to the database file when downloading it +# Check https://docs.python.org/3/library/time.html#time.strftime for format details +# Default: "%Y-%m-%d-%H-%M-%S" +# BK_DATABASE_TIMESTAMP_FORMAT="%Y%m%d-%H%M%S" + +# Optional: Enable debugging. +# Default: false +# BK_DEBUG=true + +# Optional: Default number of items per page displayed for big tables +# You can put whatever value but the exist steps are: 10, 25, 50, 100, 500, 1000 +# Default: 25 +# BK_DEFAULT_TABLE_PER_PAGE=50 + +# Optional: if set up, will add a CORS allow origin restriction to the socket. +# Default: "" +# Legacy name: DOMAIN_NAME +# BK_DOMAIN_NAME="http://localhost:3333" + +# Optional: IP address the server will listen on. +# Default: "0.0.0.0" +# BK_HOST="0.0.0.0" + +# Optional: By default, accordion items are linked together and only one can be in +# a collapsed state. This makes all the items indepedent. +# Default: false +# BK_INDEPENDENT_ACCORDIONS=true + +# Optional: A comma separated list of extensions allowed for uploading and displaying +# instruction files. You need to keep the dot (.) in the extension. +# Security: not really +# Default: .pdf +# BK_INSTRUCTIONS_ALLOWED_EXTENSIONS=".pdf, .docx, .png" + +# Optional: Folder where to store the instructions, relative to the 'static/' folder +# Default: "instructions" +# BK_INSTRUCTIONS_FOLDER="/var/lib/bricktracker/instructions/" + +# Optional: Hide the 'Add' entry from the menu. Does not disable the route. +# Default: false +# BK_HIDE_ADD_SET=true + +# Optional: Hide the 'Bulk add' entry from the add page. Does not disable the route. +# Default: false +# BK_HIDE_ADD_BULK_SET=true + +# Optional: Hide the 'Admin' entry from the menu. Does not disable the route. +# Default: false +# BK_HIDE_ADMIN=true + +# Optional: Hide the 'Instructions' entry from the menu. Does not disable the route. +# Default: false +# BK_HIDE_ALL_INSTRUCTIONS=true + +# Optional: Hide the 'Minifigures' entry from the menu. Does not disable the route. +# Default: false +# BK_HIDE_ALL_MINIFIGURES=true + +# Optional: Hide the 'Parts' entry from the menu. Does not disable the route. +# Default: false +# BK_HIDE_ALL_PARTS=true + +# Optional: Hide the 'Sets' entry from the menu. Does not disable the route. +# Default: false +# BK_HIDE_ALL_SETS=true + +# Optional: Hide the 'Missing' entry from the menu. Does not disable the route. +# Default: false +# BK_HIDE_MISSING_PARTS=true + +# Optional: Hide the 'Wishlist' entry from the menu. Does not disable the route. +# Default: false +# BK_HIDE_WISHES=true + +# Optional: Change the default order of minifigures. By default ordered by insertion order. +# Useful column names for this option are: +# - minifigures.fig_num: minifigure ID (fig-xxxxx) +# - minifigures.name: minifigure name +# Default: "minifigures.name ASC" +# BK_MINIFIGURES_DEFAULT_ORDER="minifigures.name ASC" + +# Optional: Folder where to store the minifigures images, relative to the 'static/' folder +# Default: "minifigs/" +# BK_MINIFIGURES_FOLDER="minifigures/" + +# Optional: Disable threading on the task executed by the socket. +# You should not need to change this parameter unless you are debugging something with the +# socket itself. +# Default: false +# BK_NO_THREADED_SOCKET=true + +# Optional: Change the default order of parts. By default ordered by insertion order. +# Useful column names for this option are: +# - inventory.part_num: part number +# - inventory.name: part name +# - inventory.color_name: par color name +# - total_missing: number of missing parts +# Default: "inventory.name ASC, inventory.color_name ASC, is_spare ASC" +# BK_PARTS_DEFAULT_ORDER="total_missing DESC, inventory.name ASC" + +# Optional: Folder where to store the parts images, relative to the 'static/' folder +# Default: "parts/" +# BK_PARTS_FOLDER="parts/" + +# Optional: Port the server will listen on. +# Default: 3333 +# BK_PORT=3333 + +# Optional: Shuffle the lists on the front page. +# Default: false +# Legacy name: RANDOM +# BK_RANDOM=true + +# Optional/Mandatory: The API key used to retrieve sets from the Rebrickable API. +# It is not necessary to set it to display the site, but it will limit its capabilities +# as you will not be able to add new sets +# Default: "" +# Legacy name: REBRICKABLE_API_KEY +# BK_REBRICKABLE_API_KEY="xxxx" + +# Optional: URL of the image representing a missing image in Rebrickable +# Default: "https://rebrickable.com/static/img/nil.png" +# BK_REBRICKABLE_IMAGE_NIL="" + +# Optional: URL of the image representing a missing minifigure image in Rebrickable +# Default: "https://rebrickable.com/static/img/nil_mf.jpg" +# BK_REBRICKABLE_IMAGE_NIL_MINIFIGURE="" + +# Optional: Pattern of the link to Rebrickable for a minifigure. Will be passed to Python .format() +# Default: "https://rebrickable.com/minifigs/{number}" +# BK_REBRICKABLE_LINK_MINIFIGURE_PATTERN="" + +# Optional: Pattern of the link to Rebrickable for a part. Will be passed to Python .format() +# Default: "https://rebrickable.com/parts/{number}/_/{color}" +# BK_REBRICKABLE_LINK_PART_PATTERN="" + +# Optional: Pattern of the link to Rebrickable for a set. Will be passed to Python .format() +# Default: "https://rebrickable.com/sets/{number}" +# BK_REBRICKABLE_LINK_SET_PATTERN="" + +# Optional: Display Rebrickable links wherever applicable +# Default: false +# Legacy name: LINKS +# BK_REBRICKABLE_LINKS=true + +# Optional: The amount of items to retrieve per Rebrickable API call when. +# Default: 100 +# BK_REBRICKABLE_PAGE_SIZE=200 + +# Optional: URL to the unofficial retired sets list on Google Sheets +# Default: "https://docs.google.com/spreadsheets/d/1rlYfEXtNKxUOZt2Mfv0H17DvK7bj6Pe0CuYwq6ay8WA/gviz/tq?tqx=out:csv&sheet=Sorted%20by%20Retirement%20Date" +# BK_RETIRED_SETS_FILE_URL="" + +# Optional: Path to the unofficial retired sets lists +# You can name it whatever you want, but content has to be a CSV +# Default: "./retired_sets.csv" +# BK_RETIRED_SETS_PATH="/var/lib/bricktracker/retired_sets.csv" + +# Optional: Change the default order of sets. By default ordered by insertion order. +# Useful column names for this option are: +# - sets.set_num: set number as a string +# - sets.name: set name +# - sets.year: set release year +# - sets.num_parts: set number of parts +# - set_number: the number part of set_num as an integer +# - set_version: the version part of set_num as an integer +# - total_missing: number of missing parts +# - total_minifigures: number of minifigures +# Default: "set_number DESC, set_version ASC" +# BK_SETS_DEFAULT_ORDER="sets.year ASC" + +# Optional: Folder where to store the sets images, relative to the 'static/' folder +# Default: "sets/" +# BK_SETS_FOLDER="sets/" + +# Optional: Skip saving or displaying spare parts +# Default: false +# BK_SKIP_SPARE_PARTS=true + +# Optional: Namespace of the Socket.IO socket +# Default: "bricksocket" +# BK_SOCKET_NAMESPACE="customsocket" + +# Optional: Namespace of the Socket.IO path +# Default: "/bricksocket/" +# BK_SOCKET_PATH="custompath" + +# Optional: URL to the themes.csv.gz on Rebrickable +# Default: "https://cdn.rebrickable.com/media/downloads/themes.csv.gz" +# BK_THEMES_FILE_URL="" + +# Optional: Path to the themes file +# You can name it whatever you want, but content has to be a CSV +# Default: "./themes.csv" +# BK_THEMES_PATH="/var/lib/bricktracker/themes.csv" + +# Optional: Timezone to use to display datetimes +# Check your system for available timezone/TZ values +# Default: "Etc/UTC" +# BK_TIMEZONE="Europe/Copenhagen" + +# Optional: Use remote image rather than the locally stored ones +# Also prevents downloading any image when adding sets +# Default: false +# BK_USE_REMOTE_IMAGES=true + +# Optional: Change the default order of sets. By default ordered by insertion order. +# Useful column names for this option are: +# - wishlist.set_num: set number as a string +# - wishlist.name: set name +# - wishlist.year: set release year +# - wishlist.num_parts: set number of parts +# Default: "wishlist.rowid DESC" +# BK_WISHES_DEFAULT_ORDER="set_number DESC, set_version ASC" diff --git a/.gitignore b/.gitignore index f56e093..ba326ed 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,22 @@ -api +# Application .env -*.csv *.db -*.png -*.pdf -*.jpg -*.log -/info -/__pycache__ -*.bk + +# Python specifics +__pycache__/ +*.pyc + +# Static folders +static/instructions/ +static/minifigs/ +static/parts/ +static/sets/ + +# IDE +.vscode/ + +# Temporary +*.csv + +# Apple idiocy +.DS_Store diff --git a/Dockerfile b/Dockerfile index ed6f7b6..3aab4f5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,12 @@ FROM python:slim + WORKDIR /app + +# Python library requirements COPY requirements.txt . RUN pip install -r requirements.txt + +# Bricktracker COPY . . -RUN bash lego.sh -#CMD ["python", "app.py"] -CMD ["gunicorn","--bind","0.0.0.0:3333","app:app","--worker-class","eventlet"] + +ENTRYPOINT ["entrypoint.sh"] diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app.py b/app.py index e535cee..40f3000 100644 --- a/app.py +++ b/app.py @@ -1,1185 +1,38 @@ -from flask import Flask, request, redirect, jsonify, render_template, Response,url_for, send_from_directory -import os -import json -from flask_socketio import SocketIO -from threading import Thread -from pprint import pprint as pp -from pathlib import Path -import time,random,string,sqlite3,csv -import numpy as np -import re #regex -import rebrick #rebrickable api -import requests # request img from web -import shutil # save img locally +# This need to be first import eventlet -from collections import defaultdict -import plotly.express as px -import pandas as pd +eventlet.monkey_patch() -from downloadRB import download_and_unzip,get_nil_images,get_retired_sets -from db import initialize_database,get_rows,delete_tables -from werkzeug.middleware.proxy_fix import ProxyFix -from werkzeug.utils import secure_filename +import logging # noqa: E402 +from flask import Flask # noqa: E402 -app = Flask(__name__) -app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_port=1, x_prefix=1) -socketio = SocketIO(app,cors_allowed_origins=os.getenv("DOMAIN_NAME")) -count = 0 +from bricktracker.app import setup_app # noqa: E402 +from bricktracker.socket import BrickSocket # noqa: E402 -if os.getenv("RANDOM") == 'True': - RANDOM = True -else: - RANDOM = False - -if os.getenv("LINKS"): - LINKS = os.getenv("LINKS") -else: - LINKS = False - -DIRECTORY = os.path.join(os.getcwd(), 'static', 'instructions') - -UPLOAD_FOLDER = DIRECTORY -ALLOWED_EXTENSIONS = {'pdf'} -app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER - -@app.route('/favicon.ico') - -# SocketIO event handler for client connection -@socketio.on('connect', namespace='/progress') -def test_connect(): - print('Client connected') - return ('', 301) - -# SocketIO event handler for client disconnection -@socketio.on('disconnect', namespace='/progress') -def test_disconnect(): - print('Client disconnected') - -# SocketIO event handler for starting the task -@socketio.on('start_task', namespace='/progress') -def start_task(data): - input_value = data.get('inputField') - print(input_value) - - - - input_value = input_value.replace(" ","") - if '-' not in input_value: - input_value = input_value + '-1' - - - - total_set_file = np.genfromtxt("sets.csv",delimiter=",",dtype="str",usecols=(0)) - print(total_set_file) - - if input_value not in total_set_file: - print('ERROR: ' + input_value) - # Reload create.html with error message - socketio.emit('task_failed', namespace='/progress') - #return render_template('create.html',error=input_value) - - - # Start the task in a separate thread to avoid blocking the serve - else: - print('starting servers') - thread = Thread(target=new_set, args=(input_value,)) - thread.start() - - #return redirect('/') - -def hyphen_split(a): - if a.count("-") == 1: - return a.split("-")[0] - return "-".join(a.split("-", 2)[:2]) - -def allowed_file(filename): - return '.' in filename and \ - filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS - -@app.route('/upload',methods=['GET','POST']) -def uploadInst(): - if request.method == 'POST': - # check if the post request has the file part - if 'file' not in request.files: - flash('No file part') - return redirect(request.url) - file = request.files['file'] - # If the user does not select a file, the browser submits an - # empty file without a filename. - if file.filename == '': - flash('No selected file') - return redirect(request.url) - if file and allowed_file(file.filename): - filename = secure_filename(file.filename) - file.save(os.path.join(app.config['UPLOAD_FOLDER'], filename)) - return redirect('/') - return ''' - - Upload instructions -

Upload instructions

-

Files must be named like:

- <set number>-<version>-<part>.pdf - - - - - - ''' - -@app.route('/delete/',methods=['POST', 'GET']) -def delete(tmp): - - conn = sqlite3.connect('app.db') - cursor = conn.cursor() - - if request.method == 'POST': - print("POST") - if request.method == "GET": - print("GET") - print(tmp) - tables = ['inventory', 'sets', 'minifigures', 'missing'] - for t in tables: - cursor.execute('DELETE FROM ' + t + ' where u_id="' +tmp+ '";') - conn.commit() - cursor.close() - conn.close() - return redirect('/') - -def progress(count,total_parts,state): - print (state) - socketio.emit('update_progress', {'progress': int(count/total_parts*100), 'desc': state}, namespace='/progress') - -def new_set(set_num): - global count - ###### total count #### - # 1 for set - # 1 for set image - - total_parts = 20 - - - - # add_duplicate = request.form.get('addDuplicate', False) == 'true' - # Do something with the input value and the checkbox value - # print("Input value:", set_num) - # print("Add duplicate:", add_duplicate) - # You can perform any further processing or redirect to another page - - # >>>>>>>> - progress(count, total_parts,'Opening database') - - conn = sqlite3.connect('app.db') - cursor = conn.cursor() - - # >>>>>>>> - progress(count, total_parts,'Adding set: ' + set_num) - - #with open('api','r') as f: - # api_key = f.read().replace('\n','') - # TODO add 401 error on wrong key - rb = rebrick.init(os.getenv("REBRICKABLE_API_KEY")) - - # >>>>>>>> - progress(count, total_parts,'Generating Unique ID') - unique_set_id = generate_unique_set_unique() - - # Get Set info and add to SQL - response = '' - - # >>>>>>>> - progress(count, total_parts,'Get set info') - response = json.loads(rebrick.lego.get_set(set_num).read()) - - # except Exception as e: - # #print(e.code) - # if e.code == 404: - # return render_template('create.html',error=set_num) - - count+=1 - - # >>>>>>>> - progress(count, total_parts,'Adding set to database') - - cursor.execute('''INSERT INTO sets ( - set_num, - name, - year, - theme_id, - num_parts, - set_img_url, - set_url, - last_modified_dt, - mini_col, - set_check, - set_col, - u_id - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ''', (response['set_num'], response['name'], response['year'], response['theme_id'], response['num_parts'],response['set_img_url'],response['set_url'],response['last_modified_dt'],False,False,False,unique_set_id)) - - conn.commit() - - - - # Get set image. Saved under ./static/sets/xxx-x.jpg - set_img_url = response["set_img_url"] - - #print('Saving set image:',end='') - - # >>>>>>>> - progress(count, total_parts,'Get set image') - - res = requests.get(set_img_url, stream = True) - count+=1 - if res.status_code == 200: - # >>>>>>>> - progress(count, total_parts,'Saving set image') - with open("./static/sets/"+set_num+".jpg",'wb') as f: - shutil.copyfileobj(res.raw, f) - #print(' OK') - else: - #print('Image Couldn\'t be retrieved for set ' + set_num) - logging.error('set_img_url: ' + set_num) - #print(' ERROR') - - - # Get inventory and add to SQL - # >>>>>>>> - progress(count, total_parts,'Get set inventory') - response = json.loads(rebrick.lego.get_set_elements(set_num,page_size=500).read()) - count+=1 - total_parts += len(response['results']) - - for i in response['results']: - if i['is_spare']: - continue - # Get part image. Saved under ./static/parts/xxxx.jpg - part_img_url = i['part']['part_img_url'] - part_img_url_id = 'nil' - - try: - pattern = r'/([^/]+)\.(?:png|jpg)$' - match = re.search(pattern, part_img_url) - - if match: - part_img_url_id = match.group(1) - #print("Part number:", part_img_url_id) - else: - #print("Part number not found in the URL.") - print(">>> " + part_img_url) - except Exception as e: - #print("Part number not found in the URL.") - #print(">>> " + str(part_img_url)) - print(str(e)) - - # >>>>>>>> - progress(count, total_parts,'Adding ' + i['part']['name'] + ' to database') - cursor.execute('''INSERT INTO inventory ( - set_num, - id, - part_num, - name, - part_img_url, - part_img_url_id, - color_id, - color_name, - quantity, - is_spare, - element_id, - u_id - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)''', (set_num, i['id'], i['part']['part_num'],i['part']['name'],i['part']['part_img_url'],part_img_url_id,i['color']['id'],i['color']['name'],i['quantity'],i['is_spare'],i['element_id'],unique_set_id)) - - - if not Path("./static/parts/"+part_img_url_id+".jpg").is_file(): - #print('Saving part image:',end='') - if part_img_url is not None: - # >>>>>>>> - progress(count, total_parts,'Get part image') - res = requests.get(part_img_url, stream = True) - count+=1 - if res.status_code == 200: - # >>>>>>>> - progress(count, total_parts,'Saving part image') - with open("./static/parts/"+part_img_url_id+".jpg",'wb') as f: - shutil.copyfileobj(res.raw, f) - #print(' OK') - else: - #print('Image Couldn\'t be retrieved for set ' + part_img_url_id) - logging.error('part_img_url: ' + part_img_url_id) - #print(' ERROR') - else: - #print('Part url is None') - print(i) - - - conn.commit() - - # Get minifigs - #print('Savings minifigs') - tmp_set_num = set_num - # >>>>>>>> - progress(count, total_parts,'Get set minifigs') - response = json.loads(rebrick.lego.get_set_minifigs(set_num).read()) - count+=1 - - #print(response) - for i in response['results']: - - # Get set image. Saved under ./static/minifigs/xxx-x.jpg - set_img_url = i["set_img_url"] - set_num = i['set_num'] - - #print('Saving set image:',end='') - if not Path("./static/minifigs/"+set_num+".jpg").is_file(): - if set_img_url is not None: - # >>>>>>>> - progress(count, total_parts,'Get minifig image') - res = requests.get(set_img_url, stream = True) - count+=1 - if res.status_code == 200: - # >>>>>>>> - progress(count, total_parts,'Saving minifig image') - with open("./static/minifigs/"+set_num+".jpg",'wb') as f: - shutil.copyfileobj(res.raw, f) - #print(' OK') - else: - #print('Image Couldn\'t be retrieved for set ' + set_num) - logging.error('set_img_url: ' + set_num) - #print(' ERROR') - else: - print(i) - # >>>>>>>> - progress(count, total_parts,'Adding minifig to database') - cursor.execute('''INSERT INTO minifigures ( - fig_num, - set_num, - name, - quantity, - set_img_url, - u_id - ) VALUES (?, ?, ?, ?, ?, ?) ''', (i['set_num'],tmp_set_num, i['set_name'], i['quantity'],i['set_img_url'],unique_set_id)) - - conn.commit() - - # Get minifigs inventory - # >>>>>>>> - progress(count, total_parts,'Get minifig inventory') - response_minifigs = json.loads(rebrick.lego.get_minifig_elements(i['set_num']).read()) - count+=1 - for i in response_minifigs['results']: - - # Get part image. Saved under ./static/parts/xxxx.jpg - part_img_url = i['part']['part_img_url'] - part_img_url_id = 'nil' - try: - pattern = r'/([^/]+)\.(?:png|jpg)$' - match = re.search(pattern, part_img_url) - - if match: - part_img_url_id = match.group(1) - #print("Part number:", part_img_url_id) - if not Path("./static/parts/"+part_img_url_id+".jpg").is_file(): - #print('Saving part image:',end='') - - # >>>>>>>> - progress(count, total_parts,'Get minifig image') - res = requests.get(part_img_url, stream = True) - count+=1 - if res.status_code == 200: - # >>>>>>>> - progress(count, total_parts,'Saving minifig image') - with open("./static/parts/"+part_img_url_id+".jpg",'wb') as f: - shutil.copyfileobj(res.raw, f) - #print(' OK') - else: - #print('Image Couldn\'t be retrieved for set ' + part_img_url_id) - logging.error('part_img_url: ' + part_img_url_id) - #print(' ERROR') - else: - print(part_img_url_id + '.jpg exists!') - except Exception as e: - #print("Part number not found in the URL.") - #print(">>> " + str(part_img_url)) - print(str(e)) - # >>>>>>>> - progress(count, total_parts,'Adding minifig inventory to database') - cursor.execute('''INSERT INTO inventory ( - set_num, - id, - part_num, - name, - part_img_url, - part_img_url_id, - color_id, - color_name, - quantity, - is_spare, - element_id, - u_id - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)''', (i['set_num'], i['id'], i['part']['part_num'],i['part']['name'],i['part']['part_img_url'],part_img_url_id,i['color']['id'],i['color']['name'],i['quantity'],i['is_spare'],i['element_id'],unique_set_id)) - - - - conn.commit() - conn.close() - # >>>>>>>> - progress(count, total_parts,'Closing database') - #print('End Count: ' + str(count)) - #print('End Total: ' + str(total_parts)) - count = total_parts - - # >>>>>>>> - progress(count, total_parts,'Cleaning up') - - count = 0 - socketio.emit('task_completed', namespace='/progress') - -def get_file_creation_dates(file_list): - creation_dates = {} - for file_name in file_list: - file_path = f"{file_name}" - if os.path.exists(file_path): - creation_time = os.path.getctime(file_path) - creation_dates[file_name] = time.ctime(creation_time) - else: - creation_dates[file_name] = "File not found" - return creation_dates - -@app.route('/config',methods=['POST','GET']) -def config(): - - file_list = ['themes.csv', 'colors.csv', 'sets.csv','static/nil.png','static/nil_mf.jpg','retired_sets.csv'] - creation_dates = get_file_creation_dates(file_list) - - row_counts = [0] - db_exists = Path("app.db") - if db_exists.is_file(): - db_is_there = True - row_counts = get_rows() - else: - db_is_there = False - - if request.method == 'POST': - - if request.form.get('CreateDB') == 'Create Database': - initialize_database() - row_counts = get_rows() - return redirect(url_for('config')) - elif request.form.get('Update local data') == 'Update local data': - urls = ["themes","sets","colors"] - for i in urls: - download_and_unzip("https://cdn.rebrickable.com/media/downloads/"+i+".csv.gz") - get_nil_images() - get_retired_sets() - return redirect(url_for('config')) - - elif request.form.get('deletedb') == 'Delete Database': - delete_tables() - initialize_database() - - else: - # pass # unknown - return render_template("config.html") - elif request.method == 'GET': - # return render_template("index.html") - print("No Post Back Call") - return render_template("config.html",db_is_there=db_is_there,creation_dates = creation_dates,row_counts=row_counts) - -@app.route('/missing',methods=['POST','GET']) -def missing(): - conn = sqlite3.connect('app.db') - cursor = conn.cursor() - #cursor.execute("SELECT part_num, color_id, element_id, part_img_url_id, SUM(quantity) AS total_quantity, GROUP_CONCAT(set_num, ', ') AS set_number FROM missing GROUP BY part_num, color_id, element_id;") - - cursor.execute("SELECT part_num, color_id, element_id, part_img_url_id, SUM(quantity) AS total_quantity, GROUP_CONCAT(set_num || ',' || u_id, ',') AS set_number FROM missing GROUP BY part_num, color_id, element_id, part_img_url_id ORDER BY part_num;") - - results = cursor.fetchall() - missing_list = [list(i) for i in results] - cursor.close() - conn.close() - - color_file = np.loadtxt("colors.csv",delimiter=",",dtype="str") - - color_dict = {str(code): name for code, name, _, _ in color_file} - - for item in missing_list: - color_code = str(item[1]) - if color_code in color_dict: - item[1] = color_dict[color_code] - - return render_template('missing.html',missing_list=missing_list) - -@app.route('/parts',methods=['POST','GET']) -def parts(): - conn = sqlite3.connect('app.db') - cursor = conn.cursor() - cursor.execute('SELECT id, part_num, color_id, color_name, element_id, part_img_url_id, SUM(quantity) AS total_quantity, name FROM inventory GROUP BY part_num, part_img_url_id, color_id, color_name, element_id, name;') - - results = cursor.fetchall() - missing_list = [list(i) for i in results] - cursor.close() - conn.close() - - #color_file = np.loadtxt("colors.csv",delimiter=",",dtype="str") - - #color_dict = {str(code): name for code, name, _, _ in color_file} - - #for item in missing_list: - # color_code = str(item[2]) - # if color_code in color_dict: - # item[2] = color_dict[color_code] - - return render_template('parts.html',missing_list=missing_list) - -@app.route('/minifigs',methods=['POST','GET']) -def minifigs(): - conn = sqlite3.connect('app.db') - cursor = conn.cursor() - cursor.execute('SELECT fig_num, name, SUM(quantity) AS total_quantity FROM minifigures GROUP BY fig_num, name;') - - results = cursor.fetchall() - missing_list = [list(i) for i in results] - cursor.close() - conn.close() - - - return render_template('minifigs.html',missing_list=missing_list) - -@app.route('/wishlist',methods=['POST','GET']) -def wishlist(): - input_value = 'None' - - if request.method == 'POST': - if 'create_submit' in request.form: - input_value = request.form.get('inputField') - print(input_value) - - - input_value = input_value.replace(" ","") - if '-' not in input_value: - input_value = input_value + '-1' - - total_set_file = np.genfromtxt("sets.csv",delimiter=",",dtype="str",usecols=(0)) - if input_value not in total_set_file: - print('ERROR: ' + input_value) - #return render_template('wishlist.html',error=input_value) - - else: - set_num = input_value - - input_value = 'None' - conn = sqlite3.connect('app.db') - cursor = conn.cursor() - rb = rebrick.init(os.getenv("REBRICKABLE_API_KEY")) - response = json.loads(rebrick.lego.get_set(set_num).read()) - cursor.execute('''INSERT INTO wishlist ( - set_num, - name, - year, - theme_id, - num_parts, - set_img_url, - set_url, - last_modified_dt - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ''', (response['set_num'], response['name'], response['year'], response['theme_id'], response['num_parts'],response['set_img_url'],response['set_url'],response['last_modified_dt'])) - set_img_url = response["set_img_url"] - res = requests.get(set_img_url, stream = True) - if res.status_code == 200: - with open("./static/sets/"+set_num+".jpg",'wb') as f: - shutil.copyfileobj(res.raw, f) - else: - logging.error('set_img_url: ' + set_num) - - conn.commit() - conn.close() - elif 'add_to_list' in request.form: - set_num = request.form.get('set_num') - conn = sqlite3.connect('app.db') - cursor = conn.cursor() - - cursor.execute('DELETE FROM wishlist where set_num="' +set_num+ '";') - conn.commit() - cursor.close() - conn.close() - - conn = sqlite3.connect('app.db') - cursor = conn.cursor() - cursor.execute('SELECT * from wishlist;') - - results = cursor.fetchall() - wishlist = [list(i) for i in results] - retired_sets_dict = {} - - try: - with open('retired_sets.csv', mode='r', encoding='utf-8') as csvfile: - reader = csv.reader(csvfile) - header = next(reader) - for row in reader: - key = row[2] - retired_sets_dict[key] = row - for w in wishlist: - set_num = w[0].split('-')[0] - w.append(retired_sets_dict.get(set_num,[""]*7)[6]) - except: - print('No retired list') - - if wishlist == None or wishlist == '': - wishlist = '' - conn.commit() - conn.close() - return render_template('wishlist.html',error=input_value,wishlist=wishlist) - -@app.route('/create',methods=['POST','GET']) -def create(): - - global count - - - - print('Count: ' + str(count)) - - return render_template('create.html') - -def generate_unique_set_unique(): - timestamp = int(time.time() * 1000) # Current timestamp in milliseconds - random_chars = ''.join(random.choices(string.ascii_uppercase + string.digits, k=8)) # 8-digit alphanumeric - return f'{timestamp}{random_chars}' - -@app.route('/',methods=['GET','POST']) -def index(): - set_list = [] - try: - theme_file = np.loadtxt("themes.csv",delimiter=",",dtype="str") - except: #First time running, no csvs. - initialize_database() - urls = ["themes","sets","colors"] - for i in urls: - download_and_unzip("https://cdn.rebrickable.com/media/downloads/"+i+".csv.gz") - get_nil_images() - return redirect('/create') - - if request.method == 'GET': - - conn = sqlite3.connect('app.db') - cursor = conn.cursor() - cursor.execute('SELECT * from sets;') - - results = cursor.fetchall() - set_list = [list(i) for i in results] - if RANDOM: - random.shuffle(set_list) - cursor.execute('SELECT DISTINCT u_id from missing;') - results = cursor.fetchall() - missing_list = [list(i)[0] for i in results] - - #print(set_list) - for i in set_list: - try: - i[3] = theme_file[theme_file[:, 0] == str(i[3])][0][1] - except Exception as e: - print(e) - - cursor.execute('select distinct set_num from minifigures;') - results = cursor.fetchall() - minifigs = [list(i)[0] for i in results] - - cursor.close() - conn.close() - - - files = [f for f in os.listdir(DIRECTORY) if f.endswith('.pdf')] - #files = [re.match(r'^([\w]+-[\w]+)', f).group() for f in os.listdir(DIRECTORY) if f.endswith('.pdf')] - files.sort() - - return render_template('index.html',set_list=set_list,themes_list=theme_file,missing_list=missing_list,files=files,minifigs=minifigs,links=LINKS) - - if request.method == 'POST': - set_num = request.form.get('set_num') - u_id = request.form.get('u_id') - minif = request.form.get('minif') - scheck = request.form.get('scheck') - scol = request.form.get('scol') - - conn = sqlite3.connect('app.db') - cursor = conn.cursor() - - if minif != None: - if minif == 'true': - val = 1 - else: - val = 0 - cursor.execute('''UPDATE sets - SET mini_col = ? - WHERE set_num = ? AND - u_id = ?''', - (val, set_num, u_id)) - conn.commit() - - if scheck != None: - if scheck == 'true': - val = 1 - else: - val = 0 - cursor.execute('''UPDATE sets - SET set_check = ? - WHERE set_num = ? AND - u_id = ?''', - (val, set_num, u_id)) - conn.commit() - if scol != None: - if scol == 'true': - val = 1 - else: - val = 0 - cursor.execute('''UPDATE sets - SET set_col = ? - WHERE set_num = ? AND - u_id = ?''', - (val, set_num, u_id)) - conn.commit() - - cursor.close() - conn.close() - - - - return ('', 204) - -# Route to serve individual files -@app.route('/files/', methods=['GET']) -def serve_file(filename): - try: - return send_from_directory(DIRECTORY, filename) - except Exception as e: - return jsonify({'error': str(e)}), 404 - -@app.route('//', methods=['GET', 'POST']) -def inventory(tmp,u_id): - - if request.method == 'GET': - - conn = sqlite3.connect('app.db') - cursor = conn.cursor() - - # Get set info - cursor.execute("SELECT * from sets where set_num = '" + tmp + "' and u_id = '" + u_id + "';") - results = cursor.fetchall() - set_list = [list(i) for i in results] - - # Get inventory - cursor.execute("SELECT * from inventory where set_num = '" + tmp + "' and u_id = '" + u_id + "';") - results = cursor.fetchall() - inventory_list = [list(i) for i in results] - - # Get missing parts - cursor.execute("SELECT * from missing where u_id = '" + u_id + "';") - results = cursor.fetchall() - missing_list = [list(i) for i in results] - print(missing_list) - - # Get minifigures - cursor.execute("SELECT * from minifigures where set_num = '" + tmp + "' and u_id = '" + u_id + "';") - results = cursor.fetchall() - minifig_list = [list(i) for i in results] - - minifig_inventory_list = [] - - for i in minifig_list: - cursor.execute("SELECT * from inventory where set_num = '" + i[0] + "' and u_id = '" + u_id + "';") - results = cursor.fetchall() - tmp_inv = [list(i) for i in results] - minifig_inventory_list.append(tmp_inv) - - cursor.close() - conn.close() - - return render_template('table.html', u_id=u_id,tmp=tmp,title=set_list[0][1],set_list=set_list,inventory_list=inventory_list,missing_list=missing_list,minifig_list=minifig_list,minifig_inventory_list=minifig_inventory_list) - - - if request.method == 'POST': - set_num = request.form.get('set_num') - id = request.form.get('id') - part_num = request.form.get('part_num') - part_img_url_id = request.form.get('part_img_url_id') - color_id = request.form.get('color_id') - element_id = request.form.get('element_id') - u_id = request.form.get('u_id') - missing = request.form.get('missing') - - conn = sqlite3.connect('app.db') - cursor = conn.cursor() - - # If quantity is not empty - if missing != '' and missing != '0': - #Check if there's an existing entry - #print('in first') - #print(missing) - #cursor.execute('''SELECT quantity FROM missing - # WHERE set_num = ? AND - # id = ? AND - # part_num = ? AND - # part_img_url_id = ? AND - # color_id = ? AND - # element_id = ? AND - # u_id = ?''', - # (set_num, id, part_num, part_img_url_id, color_id, element_id, u_id)) - # - #existing_quantity = cursor.fetchone() - #print("existing" + str(existing_quantity)) - #conn.commit() - - - #If there's an existing entry or if entry isn't the same as the new value - # First, check if a row with the same values for the other columns exists - cursor.execute(''' - SELECT quantity FROM missing WHERE - set_num = ? AND - id = ? AND - part_num = ? AND - part_img_url_id = ? AND - color_id = ? AND - element_id = ? AND - u_id = ? - ''', (set_num, id, part_num, part_img_url_id, color_id, element_id, u_id)) - - # Fetch the result - row = cursor.fetchone() - - if row: - # If a row exists and the missing value is different, update the row - if row[0] != missing: - cursor.execute(''' - UPDATE missing SET - quantity = ? - WHERE set_num = ? AND - id = ? AND - part_num = ? AND - part_img_url_id = ? AND - color_id = ? AND - element_id = ? AND - u_id = ? - ''', (missing, set_num, id, part_num, part_img_url_id, color_id, element_id, u_id)) - else: - # If no row exists, insert a new row - cursor.execute(''' - INSERT INTO missing ( - set_num, - id, - part_num, - part_img_url_id, - color_id, - quantity, - element_id, - u_id - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) - ''', (set_num, id, part_num, part_img_url_id, color_id, missing, element_id, u_id)) - conn.commit() - -# if existing_quantity is None: -# print('in second') -# print(existing_quantity) -# cursor.execute('''INSERT INTO missing ( -# set_num, -# id, -# part_num, -# part_img_url_id, -# color_id, -# quantity, -# element_id, -# u_id -# ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)''', -# (set_num, id, part_num, part_img_url_id, color_id, missing, element_id, u_id)) -# -# conn.commit() -# -# else: -# try: -# if int(existing_quantity[0]) != int(missing): -# print('in third') -# print(existing_quantity) -# cursor.execute('''update missing set ( -# set_num, -# id, -# part_num, -# part_img_url_id, -# color_id, -# quantity, -# element_id, -# u_id -# ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)''', -# (set_num, id, part_num, part_img_url_id, color_id, missing, element_id, u_id)) -# -# conn.commit() -# except: -# pass - - # If quantity is empty, delete the entry. - else: - cursor.execute('''DELETE FROM missing - WHERE set_num = ? AND - id = ? AND - part_num = ? AND - part_img_url_id = ? AND - color_id = ? AND - element_id = ? AND - u_id = ?''', - (set_num, id, part_num, part_img_url_id, color_id, element_id, u_id)) - - conn.commit() - - cursor.close() - conn.close() - return ('', 204) - -@app.route('/old', methods=['GET', 'POST']) -def frontpage(): - pathlist = Path('./info/').rglob('*.json') - set_list = [] - json_file = {} - theme_file = np.loadtxt("themes.csv", delimiter=",",dtype="str") - if request.method == 'GET': - for path in pathlist: - set_num = re.findall(r"\b\d+(?:-\d+)?\b",str(path))[0] - with open('./static/sets/'+set_num+'/info.json') as info: - info_file = json.loads(info.read()) - try: - info_file['theme_id'] = theme_file[theme_file[:, 0] == str(info_file['theme_id'])][0][1] - except Exception as e: - print(e) - - with open('./info/'+set_num+'.json') as info: - json_file[set_num] = json.loads(info.read()) - - set_list.append(info_file) - - return render_template('frontpage.html',set_list=set_list,themes_list=theme_file,json_file=json_file) - - if request.method == 'POST': - set_num = request.form.get('set_num') - index = request.form.get('index') - minif = request.form.get('minif') - scheck = request.form.get('scheck') - scol = request.form.get('scol') - - with open('./info/'+set_num+'.json') as info: - json_file = json.loads(info.read()) - if minif != None: - json_file['unit'][int(index)]['Minifigs Collected'] = minif - if scheck != None: - json_file['unit'][int(index)]['Set Checked'] = scheck - if scol != None: - json_file['unit'][int(index)]['Set Collected'] = scol - - with open('./info/'+set_num+'.json', 'w') as dump_file: - json.dump(json_file,dump_file) - return ('', 204) - -@app.route('/old/', methods=['GET', 'POST']) -def sets(tmp): - - with open('./static/sets/'+tmp+'/info.json') as info: - info_file = json.loads(info.read()) - with open('./static/sets/'+tmp+'/minifigs.json') as info: - minifigs_file = json.loads(info.read()) - with open('./static/sets/'+tmp+'/inventory.json') as inventory: - inventory_file = json.loads(inventory.read()) - with open('./info/'+tmp+'.json') as info: - json_file = json.loads(info.read()) - - if request.method == 'POST': - part_num = request.form.get('brickpartpart_num') - color = request.form.get('brickcolorname') - index = request.form.get('index') - number = request.form.get('numberInput') - is_spare = request.form.get('is_spare') - - # print(part_num) - # print(color) - # print(index) - # print(number) - # print(is_spare) - - if number is not None: - - print(part_num) - print(color) - print(number) - print(is_spare) - - with open('./info/'+tmp+'.json') as info: - json_file = json.loads(info.read()) - print(json_file['count']) - - data = '{"brick" : {"ID":"' + part_num + '","is_spare": "' + is_spare + '","color_name": "' + color + '","amount":"' + number + '"}}' - - if len(json_file['unit'][int(index)]['bricks']['missing']) == 0: - json_file['unit'][int(index)]['bricks']['missing'].append(json.loads(data)) - print(json_file) - elif number == '': - for idx,i in enumerate(json_file['unit'][int(index)]['bricks']['missing']): - if i['brick']['ID'] == part_num and i['brick']['is_spare'] == is_spare and i['brick']['color_name'] == color: - json_file['unit'][int(index)]['bricks']['missing'].pop(idx) - else: - found = False - for idx,i in enumerate(json_file['unit'][int(index)]['bricks']['missing']): - if not found and i['brick']['ID'] == part_num and i['brick']['is_spare'] == is_spare and i['brick']['color_name'] == color: - json_file['unit'][int(index)]['bricks']['missing'][idx]['brick']['amount'] = number - found = True - if not found: - json_file['unit'][int(index)]['bricks']['missing'].append(json.loads(data)) - - - with open('./info/'+tmp+'.json', 'w') as dump_file: - json.dump(json_file,dump_file) - #return Response(status=200) - return ('', 204) - else: - return render_template('bootstrap_table.html', tmp=tmp,title=info_file['name'], - info_file=info_file,inventory_file=inventory_file,json_file=json_file,minifigs_file=minifigs_file) - - - -@app.route('//saveNumber', methods=['POST']) -def save_number(tmp): - part_num = request.form.get('brickpartpart_num') - color = request.form.get('brickcolorname') - index = request.form.get('index') - number = request.form.get('numberInput') - is_spare = request.form.get('is_spare') - - if number is not None: - - print(part_num) - print(color) - print(number) - print(is_spare) - - with open('./info/'+tmp+'.json') as info: - json_file = json.loads(info.read()) - - data = '{"brick" : {"ID":"' + part_num + '","is_spare": "' + is_spare + '","color_name": "' + color + '","amount":"' + number + '"}}' - - if len(json_file['unit'][int(index)]['bricks']['missing']) == 0: - json_file['unit'][int(index)]['bricks']['missing'].append(json.loads(data)) - print(json_file) - elif number == '': - for idx,i in enumerate(json_file['unit'][int(index)]['bricks']['missing']): - if i['brick']['ID'] == part_num and i['brick']['is_spare'] == is_spare and i['brick']['color_name'] == color: - json_file['unit'][int(index)]['bricks']['missing'].pop(idx) - else: - found = False - for idx,i in enumerate(json_file['unit'][int(index)]['bricks']['missing']): - if not found and i['brick']['ID'] == part_num and i['brick']['is_spare'] == is_spare and i['brick']['color_name'] == color: - json_file['unit'][int(index)]['bricks']['missing'][idx]['brick']['amount'] = number - found = True - if not found: - json_file['unit'][int(index)]['bricks']['missing'].append(json.loads(data)) - - - with open('./info/'+tmp+'.json', 'w') as dump_file: - json.dump(json_file,dump_file) - - return Response(status=204) - -@app.route('/dashboard') -def dashboard(): - - # Connect to the SQLite database - conn = sqlite3.connect('app.db') - cursor = conn.cursor() - - # Execute the query - cursor.execute("SELECT year, set_num, theme_id FROM sets") - rows = cursor.fetchall() - - # Initialize defaultdict to count occurrences - theme_counts = defaultdict(int) - year_counts = defaultdict(int) - - # Count unique occurrences (removing duplicates) - seen = set() # To track unique combinations - for year, set_num, theme_id in rows: - # Create a unique identifier for each entry - entry_id = f"{year}-{set_num}-{theme_id}" - if entry_id not in seen: - theme_counts[theme_id] += 1 - year_counts[year] += 1 - seen.add(entry_id) - - # Convert to regular dictionaries and sort - sets_by_theme = dict(sorted( - {k: v for k, v in theme_counts.items() if v > 1}.items() - )) - - sets_by_year = dict(sorted( - {k: v for k, v in year_counts.items() if v > 0}.items() - )) - - - # Graphs using Plotly - fig_sets_by_theme = px.bar( - x=list(sets_by_theme.keys()), - y=list(sets_by_theme.values()), - labels={'x': 'Theme ID', 'y': 'Number of Sets'}, - title='Number of Sets by Theme' - ) - fig_sets_by_year = px.line( - x=list(sets_by_year.keys()), - y=list(sets_by_year.values()), - labels={'x': 'Year', 'y': 'Number of Sets'}, - title='Number of Sets Released Per Year' - ) - - - most_frequent_parts = { - "Brick 1 x 1": 866, - "Plate 1 x 1": 782, - "Plate 1 x 2": 633, - "Plate Round 1 x 1 with Solid Stud": 409, - "Tile 1 x 2 with Groove": 382, - } - minifigs_by_set = {"10217-1": 12, "7595-1": 8, "10297-1": 7, "21338-1": 4, "4865-1": 4} - missing_parts_by_set = {"10297-1": 4, "10280-1": 1, "21301-1": 1, "21338-1": 1, "7595-1": 1} - - - fig_parts = px.bar( - x=list(most_frequent_parts.keys()), - y=list(most_frequent_parts.values()), - labels={'x': 'Part Name', 'y': 'Quantity'}, - title='Most Frequent Parts' - ) - fig_minifigs = px.bar( - x=list(minifigs_by_set.keys()), - y=list(minifigs_by_set.values()), - labels={'x': 'Set Number', 'y': 'Number of Minifigures'}, - title='Minifigures by Set' - ) - fig_missing_parts = px.bar( - x=list(missing_parts_by_set.keys()), - y=list(missing_parts_by_set.values()), - labels={'x': 'Set Number', 'y': 'Missing Parts Count'}, - title='Missing Parts by Set' - ) - - # Convert graphs to HTML - graphs = { - "sets_by_theme": fig_sets_by_theme.to_html(full_html=False), - "sets_by_year": fig_sets_by_year.to_html(full_html=False), - "parts": fig_parts.to_html(full_html=False), - "minifigs": fig_minifigs.to_html(full_html=False), - "missing_parts": fig_missing_parts.to_html(full_html=False), - } - - return render_template("dashboard.html", graphs=graphs) +logger = logging.getLogger(__name__) if __name__ == '__main__': - socketio.run(app.run(host='0.0.0.0', debug=True, port=3333)) + # Create the Flask app + app = Flask(__name__) + + # Setup the app + setup_app(app) + + # Create the socket + s = BrickSocket( + app, + threaded=not app.config['NO_THREADED_SOCKET'].value, + ) + + # Run the application + logger.info('Starting BrickTracker on {host}:{port}'.format( + host=app.config['HOST'].value, + port=app.config['PORT'].value, + )) + s.socket.run( + app, + host=app.config['HOST'].value, + debug=app.config['DEBUG'].value, + port=app.config['PORT'].value, + ) diff --git a/archive/lego.py b/archive/lego.py deleted file mode 100644 index 0516f2e..0000000 --- a/archive/lego.py +++ /dev/null @@ -1,193 +0,0 @@ -import sys #using argv -import logging #logging errors - -from pathlib import Path # creating folders -import rebrick #rebrickable api - -# json things -import json -from pprint import pprint as pp -import requests # request img from web -import shutil # save img locally - -log_name='lego.log' - -logging.basicConfig(filename=log_name, level=logging.DEBUG) -logging.FileHandler(log_name,mode='w') - -if '-' not in sys.argv[1]: - set_num = sys.argv[1] + '-1' -else: - set_num=sys.argv[1] - -#online_set_num=set_num+"-1" - -print ("Adding set: " + set_num) - -set_path="./static/sets/" + set_num + "/" -Path('./static/parts').mkdir(parents=True, exist_ok=True) -Path('./static/figs').mkdir(parents=True, exist_ok=True) -Path('./info').mkdir(parents=True, exist_ok=True) - -with open('api','r') as f: - api_key = f.read().replace('\n','') - -rb = rebrick.init(api_key) - -# if Path(set_path).is_dir(): -# print('Set exists, exitting') -# logging.error('Set exists!') -# #exit() - -Path(set_path).mkdir(parents=True, exist_ok=True) - -# Get set info -response = json.loads(rebrick.lego.get_set(set_num).read()) - -if Path("./info/"+set_num + ".json").is_file(): - - ans = input('Set exists, would you like to add another copy (Y/N)?\n') - if ans.lower() == 'yes' or ans.lower() == 'y': - with open("./info/" + set_num + ".json",'r') as f: - data = json.load(f) - data['count'] = data['count'] + 1 - - tmp = {"location": "","minifigs": "","bricks": {"missing": []}} - - data['unit'].append(tmp) - pp(data) - with open("./info/" + set_num + ".json",'w') as f: - json.dump(data,f,indent = 4) - -with open(set_path+'info.json', 'w', encoding='utf-8') as f: - json.dump(response, f, ensure_ascii=False, indent=4) - -# save set image to folder -set_img_url = response["set_img_url"] - -print('Saving set image:',end='') - -res = requests.get(set_img_url, stream = True) - -if res.status_code == 200: - with open(set_path+"cover.jpg",'wb') as f: - shutil.copyfileobj(res.raw, f) - print(' OK') -else: - print('Image Couldn\'t be retrieved for set ' + set_num) - logging.error('set_img_url: ' + set_num) - print(' ERROR') - -# set inventory -print('Saving set inventory') -response = json.loads(rebrick.lego.get_set_elements(set_num,page_size=20000).read()) -with open(set_path+'inventory.json', 'w', encoding='utf-8') as f: - json.dump(response, f, ensure_ascii=False, indent=4) - -# get part images if not exists -print('Saving part images') -for i in response["results"]: - try: - if i['element_id'] == None: - if not Path("./static/parts/p_"+i["part"]["part_id"]+".jpg").is_file(): - res = requests.get(i["part"]["part_img_url"], stream = True) - if res.status_code == 200: - with open("./static/parts/p_"+i["part"]["part_id"]+".jpg",'wb') as f: - shutil.copyfileobj(res.raw, f) - else: - if not Path("./static/parts/"+i["element_id"]+".jpg").is_file(): - res = requests.get(i["part"]["part_img_url"], stream = True) - - if res.status_code == 200: - with open("./static/parts/"+i["element_id"]+".jpg",'wb') as f: - shutil.copyfileobj(res.raw, f) - if not Path("./static/parts/"+i["element_id"]+".jpg").is_file(): - res = requests.get(i["part"]["part_img_url"], stream = True) - - if res.status_code == 200: - with open("./static/parts/"+i["element_id"]+".jpg",'wb') as f: - shutil.copyfileobj(res.raw, f) - except Exception as e: - print(e) - logging.error(set_num + ": " + str(e)) - -# read info file with missing pieces - -if Path("./info/"+set_num + ".json").is_file(): - with open("./info/" + set_num + ".json") as f: - data = json.load(f) -else: - shutil.copy("set_template.json", "./info/"+set_num+".json") - -## Minifigs ## -print('Savings minifigs') -response = json.loads(rebrick.lego.get_set_minifigs(set_num).read()) - -figures = {"figs": []} - -for x in response["results"]: - print(" " + x["set_name"]) - fig = { - "set_num": x["set_num"], - "name": x["set_name"], - "quantity": x["quantity"], - "set_img_url": x["set_img_url"], - "parts": [] - } - - res = requests.get(x["set_img_url"], stream = True) - - if res.status_code == 200: - with open("./static/figs/"+x["set_num"]+".jpg",'wb') as f: - shutil.copyfileobj(res.raw, f) - else: - print('Image Couldn\'t be retrieved for set ' + set_num) - logging.error('set_img_url: ' + set_num) - - response = json.loads(rebrick.lego.get_minifig_elements(x["set_num"]).read()) - - for i in response["results"]: - part = { - "name": i["part"]["name"], - "quantity": i["quantity"], - "color_name": i["color"]["name"], - "part_num": i["part"]["part_num"], - "part_img_url": i["part"]["part_img_url"] - - } - fig["parts"].append(part) - - try: - if i['element_id'] == None: - if not Path("./static/figs/p_"+i["part"]["part_num"]+".jpg").is_file(): - res = requests.get(i["part"]["part_img_url"], stream = True) - - if res.status_code == 200: - with open("./static/figs/p_"+i["part"]["part_num"]+".jpg",'wb') as f: - shutil.copyfileobj(res.raw, f) - else: - if not Path("./static/figs/"+i["part"]["part_num"]+".jpg").is_file(): - res = requests.get(i["part"]["part_img_url"], stream = True) - - if res.status_code == 200: - with open("./static/figs/"+i["part"]["part_num"]+".jpg",'wb') as f: - shutil.copyfileobj(res.raw, f) - if not Path("./static/figs/"+i["part"]["part_num"]+".jpg").is_file(): - res = requests.get(i["part"]["part_img_url"], stream = True) - - if res.status_code == 200: - with open("./static/figs/"+i["part"]["part_num"]+".jpg",'wb') as f: - shutil.copyfileobj(res.raw, f) - except Exception as e: - print(e) - logging.error(set_num + ": " + str(e)) - - figures["figs"].append(fig) - part = {} - fig = {} - -with open(set_path+'minifigs.json', 'w', encoding='utf-8') as f: - json.dump(figures, f, ensure_ascii=False, indent=4) -#### - -print('Done!') diff --git a/archive/set_template.json b/archive/set_template.json deleted file mode 100644 index 69f1698..0000000 --- a/archive/set_template.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "count": 1, - "unit": [ - { - "location": "", - "minifigs": "", - "bricks": { - "missing": [] - } - } - ] -} diff --git a/bricktracker/__init__.py b/bricktracker/__init__.py new file mode 100644 index 0000000..1f6518e --- /dev/null +++ b/bricktracker/__init__.py @@ -0,0 +1 @@ +VERSION = '0.1.0' diff --git a/bricktracker/app.py b/bricktracker/app.py new file mode 100644 index 0000000..5ba4414 --- /dev/null +++ b/bricktracker/app.py @@ -0,0 +1,101 @@ +import logging +import sys +import time +from zoneinfo import ZoneInfo + +from flask import current_app, Flask, g +from werkzeug.middleware.proxy_fix import ProxyFix + +from bricktracker.configuration_list import BrickConfigurationList +from bricktracker.login import LoginManager +from bricktracker.navbar import Navbar +from bricktracker.sql import close +from bricktracker.version import __version__ +from bricktracker.views.add import add_page +from bricktracker.views.admin import admin_page +from bricktracker.views.error import error_404 +from bricktracker.views.index import index_page +from bricktracker.views.instructions import instructions_page +from bricktracker.views.login import login_page +from bricktracker.views.minifigure import minifigure_page +from bricktracker.views.part import part_page +from bricktracker.views.set import set_page +from bricktracker.views.wish import wish_page + + +def setup_app(app: Flask) -> None: + # Load the configuration + BrickConfigurationList(app) + + # Set the logging level + if app.config['DEBUG'].value: + logging.basicConfig( + stream=sys.stdout, + level=logging.DEBUG, + format='[%(asctime)s] {%(filename)s:%(lineno)d} %(levelname)s - %(message)s', # noqa: E501 + ) + else: + logging.basicConfig( + stream=sys.stdout, + level=logging.INFO, + format='[%(asctime)s] %(levelname)s - %(message)s', + ) + + # Load the navbar + Navbar(app) + + # Setup the login manager + LoginManager(app) + + # I don't know :-) + app.wsgi_app = ProxyFix( + app.wsgi_app, + x_for=1, + x_proto=1, + x_host=1, + x_port=1, + x_prefix=1, + ) + + # Register errors + app.register_error_handler(404, error_404) + + # Register routes + app.register_blueprint(add_page) + app.register_blueprint(admin_page) + app.register_blueprint(index_page) + app.register_blueprint(instructions_page) + app.register_blueprint(login_page) + app.register_blueprint(minifigure_page) + app.register_blueprint(part_page) + app.register_blueprint(set_page) + app.register_blueprint(wish_page) + + # An helper to make global variables available to the + # request + @app.before_request + def before_request() -> None: + def request_time() -> str: + elapsed = time.time() - g.request_start_time + if elapsed < 1: + return '{elapsed:.0f}ms'.format(elapsed=elapsed*1000) + else: + return '{elapsed:.2f}s'.format(elapsed=elapsed) + + # Login manager + g.login = LoginManager + + # Execution time + g.request_start_time = time.time() + g.request_time = request_time + + # Register the timezone + g.timezone = ZoneInfo(current_app.config['TIMEZONE'].value) + + # Version + g.version = __version__ + + # Make sure all connections are closed at the end + @app.teardown_appcontext + def close_connections(exception, /) -> None: + close() diff --git a/bricktracker/config.py b/bricktracker/config.py new file mode 100644 index 0000000..740f7b4 --- /dev/null +++ b/bricktracker/config.py @@ -0,0 +1,61 @@ +from typing import Any, Final + +# Configuration map: +# - n: internal name (str) +# - e: extra environment name (str, optional=None) +# - d: default value (Any, optional=None) +# - c: cast to type (Type, optional=None) +# - s: interpret as a path within static (bool, optional=False) +# Easy to change an environment variable name without changing all the code +CONFIG: Final[list[dict[str, Any]]] = [ + {'n': 'AUTHENTICATION_PASSWORD', 'd': ''}, + {'n': 'AUTHENTICATION_KEY', 'd': ''}, + {'n': 'BRICKLINK_LINK_PART_PATTERN', 'd': 'https://www.bricklink.com/v2/catalog/catalogitem.page?P={number}'}, # noqa: E501 + {'n': 'BRICKLINK_LINKS', 'c': bool}, + {'n': 'DATABASE_PATH', 'd': './app.db'}, + {'n': 'DATABASE_TIMESTAMP_FORMAT', 'd': '%Y-%m-%d-%H-%M-%S'}, + {'n': 'DEBUG', 'c': bool}, + {'n': 'DEFAULT_TABLE_PER_PAGE', 'd': 25, 'c': int}, + {'n': 'DOMAIN_NAME', 'e': 'DOMAIN_NAME', 'd': ''}, + {'n': 'FILE_DATETIME_FORMAT', 'd': '%d/%m/%Y, %H:%M:%S'}, + {'n': 'HOST', 'd': '0.0.0.0'}, + {'n': 'INDEPENDENT_ACCORDIONS', 'c': bool}, + {'n': 'INSTRUCTIONS_ALLOWED_EXTENSIONS', 'd': ['.pdf'], 'c': list}, # noqa: E501 + {'n': 'INSTRUCTIONS_FOLDER', 'd': 'instructions', 's': True}, + {'n': 'HIDE_ADD_SET', 'c': bool}, + {'n': 'HIDE_ADD_BULK_SET', 'c': bool}, + {'n': 'HIDE_ADMIN', 'c': bool}, + {'n': 'HIDE_ALL_INSTRUCTIONS', 'c': bool}, + {'n': 'HIDE_ALL_MINIFIGURES', 'c': bool}, + {'n': 'HIDE_ALL_PARTS', 'c': bool}, + {'n': 'HIDE_ALL_SETS', 'c': bool}, + {'n': 'HIDE_MISSING_PARTS', 'c': bool}, + {'n': 'HIDE_WISHES', 'c': bool}, + {'n': 'MINIFIGURES_DEFAULT_ORDER', 'd': 'minifigures.name ASC'}, + {'n': 'MINIFIGURES_FOLDER', 'd': 'minifigs', 's': True}, + {'n': 'NO_THREADED_SOCKET', 'c': bool}, + {'n': 'PARTS_DEFAULT_ORDER', 'd': 'inventory.name ASC, inventory.color_name ASC, is_spare ASC'}, # noqa: E501 + {'n': 'PARTS_FOLDER', 'd': 'parts', 's': True}, + {'n': 'PORT', 'd': 3333, 'c': int}, + {'n': 'RANDOM', 'e': 'RANDOM', 'c': bool}, + {'n': 'REBRICKABLE_API_KEY', 'e': 'REBRICKABLE_API_KEY', 'd': ''}, + {'n': 'REBRICKABLE_IMAGE_NIL', 'd': 'https://rebrickable.com/static/img/nil.png'}, # noqa: E501 + {'n': 'REBRICKABLE_IMAGE_NIL_MINIFIGURE', 'd': 'https://rebrickable.com/static/img/nil_mf.jpg'}, # noqa: E501 + {'n': 'REBRICKABLE_LINK_MINIFIGURE_PATTERN', 'd': 'https://rebrickable.com/minifigs/{number}'}, # noqa: E501 + {'n': 'REBRICKABLE_LINK_PART_PATTERN', 'd': 'https://rebrickable.com/parts/{number}/_/{color}'}, # noqa: E501 + {'n': 'REBRICKABLE_LINK_SET_PATTERN', 'd': 'https://rebrickable.com/sets/{number}'}, # noqa: E501 + {'n': 'REBRICKABLE_LINKS', 'e': 'LINKS', 'c': bool}, + {'n': 'REBRICKABLE_PAGE_SIZE', 'd': 100, 'c': int}, + {'n': 'RETIRED_SETS_FILE_URL', 'd': 'https://docs.google.com/spreadsheets/d/1rlYfEXtNKxUOZt2Mfv0H17DvK7bj6Pe0CuYwq6ay8WA/gviz/tq?tqx=out:csv&sheet=Sorted%20by%20Retirement%20Date'}, # noqa: E501 + {'n': 'RETIRED_SETS_PATH', 'd': './retired_sets.csv'}, + {'n': 'SETS_DEFAULT_ORDER', 'd': 'set_number DESC, set_version ASC'}, + {'n': 'SETS_FOLDER', 'd': 'sets', 's': True}, + {'n': 'SKIP_SPARE_PARTS', 'c': bool}, + {'n': 'SOCKET_NAMESPACE', 'd': 'bricksocket'}, + {'n': 'SOCKET_PATH', 'd': '/bricksocket/'}, + {'n': 'THEMES_FILE_URL', 'd': 'https://cdn.rebrickable.com/media/downloads/themes.csv.gz'}, # noqa: E501 + {'n': 'THEMES_PATH', 'd': './themes.csv'}, + {'n': 'TIMEZONE', 'd': 'Etc/UTC'}, + {'n': 'USE_REMOTE_IMAGES', 'c': bool}, + {'n': 'WISHES_DEFAULT_ORDER', 'd': 'wishlist.rowid DESC'}, +] diff --git a/bricktracker/configuration.py b/bricktracker/configuration.py new file mode 100644 index 0000000..b9c3559 --- /dev/null +++ b/bricktracker/configuration.py @@ -0,0 +1,87 @@ +import os +from typing import Any, Type + + +# Configuration item +class BrickConfiguration(object): + name: str + cast: Type | None + default: Any + env_name: str + extra_name: str | None + mandatory: bool + static_path: bool + value: Any + + def __init__( + self, + /, + n: str, + e: str | None = None, + d: Any = None, + c: Type | None = None, + m: bool = False, + s: bool = False, + ): + # We prefix all default variable name with 'BK_' for the + # environment name to avoid interfering + self.name = n + self.env_name = 'BK_{name}'.format(name=n) + self.extra_name = e + self.default = d + self.cast = c + self.mandatory = m + self.static_path = s + + # Default for our booleans is False + if self.cast == bool: + self.default = False + + # Try default environment name + value = os.getenv(self.env_name) + if value is None: + # Try the extra name + if self.extra_name is not None: + value = os.getenv(self.extra_name) + + # Set the default + if value is None: + value = self.default + + # Special treatment + if value is not None: + # Comma seperated list + if self.cast == list and isinstance(value, str): + value = [item.strip() for item in value.split(',')] + self.cast = None + + # Boolean string + if self.cast == bool and isinstance(value, str): + value = value.lower() in ('true', 'yes', '1') + + # Static path fixup + if self.static_path and isinstance(value, str): + value = os.path.normpath(value) + + # Remove any leading slash or dots + value = value.lstrip('/.') + + # Remove static prefix + value = value.removeprefix('static/') + + if self.cast is not None: + self.value = self.cast(value) + else: + self.value = value + + # Tells whether the value is changed from its default + def is_changed(self, /) -> bool: + return self.value != self.default + + # Tells whether the value is secret + def is_secret(self, /) -> bool: + return self.name in [ + 'REBRICKABLE_API_KEY', + 'AUTHENTICATION_PASSWORD', + 'AUTHENTICATION_KEY' + ] diff --git a/bricktracker/configuration_list.py b/bricktracker/configuration_list.py new file mode 100644 index 0000000..bdc9f6e --- /dev/null +++ b/bricktracker/configuration_list.py @@ -0,0 +1,43 @@ +from typing import Generator + +from flask import current_app, Flask + +from .config import CONFIG +from .configuration import BrickConfiguration +from .exceptions import ConfigurationMissingException + + +# Application configuration +class BrickConfigurationList(object): + app: Flask + + # Load configuration + def __init__(self, app: Flask, /): + self.app = app + + # Process all configuration items + for config in CONFIG: + item = BrickConfiguration(**config) + self.app.config[item.name] = item + + # Check whether a str configuration is set + @staticmethod + def error_unless_is_set(name: str): + value = current_app.config[name].value + + if value is None or value == '': + raise ConfigurationMissingException( + '{name} must be defined'.format(name=name), + ) + + # Get all the configuration items from the app config + @staticmethod + def list() -> Generator[BrickConfiguration, None, None]: + keys = list(current_app.config.keys()) + keys.sort() + + for name in keys: + config = current_app.config[name] + + if isinstance(config, BrickConfiguration): + yield config diff --git a/bricktracker/exceptions.py b/bricktracker/exceptions.py new file mode 100644 index 0000000..3323e4e --- /dev/null +++ b/bricktracker/exceptions.py @@ -0,0 +1,23 @@ +# Something was not found +class NotFoundException(Exception): + ... + + +# Generic error exception +class ErrorException(Exception): + title: str = 'Error' + + +# Configuration error +class ConfigurationMissingException(ErrorException): + title: str = 'Configuration missing' + + +# Database error +class DatabaseException(ErrorException): + title: str = 'Database error' + + +# Download error +class DownloadException(ErrorException): + title: str = 'Download error' diff --git a/bricktracker/fields.py b/bricktracker/fields.py new file mode 100644 index 0000000..37d5a21 --- /dev/null +++ b/bricktracker/fields.py @@ -0,0 +1,10 @@ +from typing import Any + + +# SQLite record fields +class BrickRecordFields(object): + def __getattr__(self, name: str, /) -> Any: + return self.__dict__[name] + + def __setattr__(self, name: str, value: Any, /) -> None: + self.__dict__[name] = value diff --git a/bricktracker/instructions.py b/bricktracker/instructions.py new file mode 100644 index 0000000..6aaa050 --- /dev/null +++ b/bricktracker/instructions.py @@ -0,0 +1,137 @@ +from datetime import datetime, timezone +import logging +import os +from typing import TYPE_CHECKING + +from flask import current_app, g, url_for +import humanize +from werkzeug.datastructures import FileStorage +from werkzeug.utils import secure_filename + +from .exceptions import ErrorException +if TYPE_CHECKING: + from .set import BrickSet + +logger = logging.getLogger(__name__) + + +class BrickInstructions(object): + allowed: bool + brickset: 'BrickSet | None' + extension: str + filename: str + mtime: datetime + number: 'str | None' + name: str + size: int + + def __init__(self, file: os.DirEntry | str, /): + if isinstance(file, str): + self.filename = file + else: + self.filename = file.name + + # Store the file stats + stat = file.stat() + self.size = stat.st_size + self.mtime = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc) + + # Store the name and extension, check if extension is allowed + self.name, self.extension = os.path.splitext(self.filename) + self.extension = self.extension.lower() + self.allowed = self.extension in current_app.config['INSTRUCTIONS_ALLOWED_EXTENSIONS'].value # noqa: E501 + + # Placeholder + self.brickset = None + self.number = None + + # Extract the set number + if self.allowed: + # Normalize special chars to improve set detection + normalized = self.name.replace('_', '-') + normalized = normalized.replace(' ', '-') + + splits = normalized.split('-', 2) + + if len(splits) >= 2: + self.number = '-'.join(splits[:2]) + + # Delete an instruction file + def delete(self, /) -> None: + os.remove(self.path()) + + # Display the size in a human format + def human_size(self) -> str: + return humanize.naturalsize(self.size) + + # Display the time in a human format + def human_time(self) -> str: + return self.mtime.astimezone(g.timezone).strftime( + current_app.config['FILE_DATETIME_FORMAT'].value + ) + + # Compute the path of an instruction file + def path(self, /, filename=None) -> str: + if filename is None: + filename = self.filename + + return os.path.join( + current_app.static_folder, # type: ignore + current_app.config['INSTRUCTIONS_FOLDER'].value, + filename + ) + + # Rename an instructions file + def rename(self, filename: str, /) -> None: + # Add the extension + filename = '{name}{ext}'.format(name=filename, ext=self.extension) + + if filename != self.filename: + # Check if it already exists + target = self.path(filename=filename) + if os.path.isfile(target): + raise ErrorException('Cannot rename {source} to {target} as it already exists'.format( # noqa: E501 + source=self.filename, + target=filename + )) + + os.rename(self.path(), target) + + # Upload a new instructions file + def upload(self, file: FileStorage, /) -> None: + target = self.path(secure_filename(self.filename)) + + if os.path.isfile(target): + raise ErrorException('Cannot upload {target} as it already exists'.format( # noqa: E501 + target=self.filename + )) + + file.save(target) + + # Info + logger.info('The instruction file {file} has been imported'.format( + file=self.filename + )) + + # Compute the url for a set instructions file + def url(self, /) -> str: + if not self.allowed: + return '' + + folder: str = current_app.config['INSTRUCTIONS_FOLDER'].value + + # Compute the path + path = os.path.join(folder, self.filename) + + return url_for('static', filename=path) + + # Return the icon depending on the extension + def icon(self, /) -> str: + if self.extension == '.pdf': + return 'file-pdf-2-line' + elif self.extension in ['.doc', '.docx']: + return 'file-word-line' + elif self.extension in ['.png', '.jpg', '.jpeg']: + return 'file-image-line' + else: + return 'file-line' diff --git a/bricktracker/instructions_list.py b/bricktracker/instructions_list.py new file mode 100644 index 0000000..57329fe --- /dev/null +++ b/bricktracker/instructions_list.py @@ -0,0 +1,110 @@ +import logging +import os +from typing import Generator + +from flask import current_app + +from .exceptions import NotFoundException +from .instructions import BrickInstructions + +logger = logging.getLogger(__name__) + + +# Lego sets instruction list +class BrickInstructionsList(object): + all: dict[str, BrickInstructions] + rejected_total: int + sets: dict[str, list[BrickInstructions]] + sets_total: int + unknown_total: int + + def __init__(self, /, force=False): + # Load instructions only if there is none already loaded + all = getattr(self, 'all', None) + + if all is None or force: + logger.info('Loading instructions file list') + + BrickInstructionsList.all = {} + BrickInstructionsList.rejected_total = 0 + BrickInstructionsList.sets = {} + BrickInstructionsList.sets_total = 0 + BrickInstructionsList.unknown_total = 0 + + # Try to list the files in the instruction folder + try: + # Make a folder relative to static + folder: str = os.path.join( + current_app.static_folder, # type: ignore + current_app.config['INSTRUCTIONS_FOLDER'].value, + ) + + for file in os.scandir(folder): + instruction = BrickInstructions(file) + + # Unconditionnally add to the list + BrickInstructionsList.all[instruction.filename] = instruction # noqa: E501 + + if instruction.allowed: + if instruction.number: + # Instantiate the list if not existing yet + if instruction.number not in BrickInstructionsList.sets: # noqa: E501 + BrickInstructionsList.sets[instruction.number] = [] # noqa: E501 + + BrickInstructionsList.sets[instruction.number].append(instruction) # noqa: E501 + BrickInstructionsList.sets_total += 1 + else: + BrickInstructionsList.unknown_total += 1 + else: + BrickInstructionsList.rejected_total += 1 + + # Associate bricksets + # Not ideal, to avoid a circular import + from .set import BrickSet + from .set_list import BrickSetList + + # 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 + if ( + instruction.allowed and + instruction.number is not None and + instruction.brickset is None and + instruction.number in bricksets + ): + instruction.brickset = bricksets[instruction.number] + + # Ignore errors + except Exception: + pass + + # Grab instructions for a set + def get(self, number: str) -> list[BrickInstructions]: + if number in self.sets: + return self.sets[number] + else: + return [] + + # Grab instructions for a file + def get_file(self, name: str) -> BrickInstructions: + if name not in self.all: + raise NotFoundException('Instruction file {name} does not exist'.format( # noqa: E501 + name=name + )) + + return self.all[name] + + # List of all instruction files + def list(self, /) -> Generator[BrickInstructions, None, None]: + # Get the filenames and sort them + filenames = list(self.all.keys()) + filenames.sort() + + # Return the files + for filename in filenames: + yield self.all[filename] diff --git a/bricktracker/login.py b/bricktracker/login.py new file mode 100644 index 0000000..6511293 --- /dev/null +++ b/bricktracker/login.py @@ -0,0 +1,65 @@ +from flask import current_app, Flask +from flask_login import current_user, login_manager, UserMixin + + +# Login manager wrapper +class LoginManager(object): + # Login user + class User(UserMixin): + def __init__(self, name: str, password: str, /): + self.id = name + self.password = password + + def __init__(self, app: Flask, /): + # Setup basic authentication + app.secret_key = app.config['AUTHENTICATION_KEY'].value + + manager = login_manager.LoginManager() + manager.login_view = 'login.login' # type: ignore + manager.init_app(app) + + # User loader with only one user + @manager.user_loader + def user_loader(*arg) -> LoginManager.User: + return self.User( + 'admin', + app.config['AUTHENTICATION_PASSWORD'].value + ) + + # If the password is unset, globally disable + app.config['LOGIN_DISABLED'] = app.config['AUTHENTICATION_PASSWORD'].value == '' # noqa: E501 + + # Tells whether the user is authenticated, meaning: + # - Authentication disabled + # - or User is authenticated + @staticmethod + def is_authenticated() -> bool: + return ( + current_app.config['LOGIN_DISABLED'] or + current_user.is_authenticated + ) + + # Tells whether authentication is enabled + @staticmethod + def is_enabled() -> bool: + return not current_app.config['LOGIN_DISABLED'] + + # Tells whether we need the user authenticated, meaning: + # - Authentication enabled + # - and User not authenticated + @staticmethod + def is_not_authenticated() -> bool: + return ( + not current_app.config['LOGIN_DISABLED'] and + not current_user.is_authenticated + ) + + # Tell whether we are in read-only, meaning: + # - Authentication enabled + # - and User not authenticated + @staticmethod + def is_readonly() -> bool: + return ( + not current_app.config['LOGIN_DISABLED'] and + not current_user.is_authenticated + ) diff --git a/bricktracker/minifigure.py b/bricktracker/minifigure.py new file mode 100644 index 0000000..98375e3 --- /dev/null +++ b/bricktracker/minifigure.py @@ -0,0 +1,183 @@ +import os +from sqlite3 import Row +from typing import Any, Self, TYPE_CHECKING + +from flask import current_app, url_for + +from .exceptions import ErrorException, NotFoundException +from .part_list import BrickPartList +from .rebrickable_image import RebrickableImage +from .record import BrickRecord +if TYPE_CHECKING: + from .set import BrickSet + + +# Lego minifigure +class BrickMinifigure(BrickRecord): + brickset: 'BrickSet | None' + + # Queries + insert_query: str = 'minifigure/insert' + generic_query: str = 'minifigure/select/generic' + select_query: str = 'minifigure/select/specific' + + def __init__( + self, + /, + brickset: 'BrickSet | None' = None, + record: Row | dict[str, Any] | None = None, + ): + super().__init__() + + # Save the brickset + self.brickset = brickset + + # Ingest the record if it has one + if record is not None: + self.ingest(record) + + # Return the number just in digits format + def clean_number(self, /) -> str: + number: str = self.fields.fig_num + number = number.removeprefix('fig-') + number = number.lstrip('0') + + return number + + # Parts + def generic_parts(self, /) -> BrickPartList: + return BrickPartList().from_minifigure(self) + + # Parts + def parts(self, /) -> BrickPartList: + if self.brickset is None: + raise ErrorException('Part list for minifigure {number} requires a brickset'.format( # noqa: E501 + number=self.fields.fig_num, + )) + + return BrickPartList().load(self.brickset, minifigure=self) + + # Select a generic minifigure + def select_generic(self, fig_num: str, /) -> Self: + # Save the parameters to the fields + self.fields.fig_num = fig_num + + record = self.select(override_query=self.generic_query) + + if record is None: + raise NotFoundException( + 'Minifigure with number {number} was not found in the database'.format( # noqa: E501 + number=self.fields.fig_num, + ), + ) + + # Ingest the record + self.ingest(record) + + return self + + # Select a specific minifigure (with a set and an number) + def select_specific(self, brickset: 'BrickSet', fig_num: str, /) -> Self: + # Save the parameters to the fields + self.brickset = brickset + self.fields.fig_num = fig_num + + record = self.select() + + if record is None: + raise NotFoundException( + 'Minifigure with number {number} from set {set} was not found in the database'.format( # noqa: E501 + number=self.fields.fig_num, + set=self.brickset.fields.set_num, + ), + ) + + # Ingest the record + self.ingest(record) + + return self + + # Return a dict with common SQL parameters for a minifigure + def sql_parameters(self, /) -> dict[str, Any]: + parameters = super().sql_parameters() + + # Supplement from the brickset + if self.brickset is not None: + if 'u_id' not in parameters: + parameters['u_id'] = self.brickset.fields.u_id + + if 'set_num' not in parameters: + parameters['set_num'] = self.brickset.fields.set_num + + return parameters + + # Self url + def url(self, /) -> str: + return url_for( + 'minifigure.details', + number=self.fields.fig_num, + ) + + # Compute the url for minifigure part image + def url_for_image(self, /) -> str: + if not current_app.config['USE_REMOTE_IMAGES'].value: + if self.fields.set_img_url is None: + file = RebrickableImage.nil_minifigure_name() + else: + file = self.fields.fig_num + + folder: str = current_app.config['MINIFIGURES_FOLDER'].value + + # /!\ Everything is saved as .jpg, even if it came from a .png + # not changing this behaviour. + + # Grab the extension + # _, extension = os.path.splitext(self.part_img_url) + extension = '.jpg' + + # Compute the path + path = os.path.join(folder, '{number}{ext}'.format( + number=file, + ext=extension, + )) + + return url_for('static', filename=path) + + else: + if self.fields.set_img_url is None: + return current_app.config['REBRICKABLE_IMAGE_NIL_MINIFIGURE'].value # noqa: E501 + else: + return self.fields.set_img_url + + # Compute the url for the rebrickable page + def url_for_rebrickable(self, /) -> str: + if current_app.config['REBRICKABLE_LINKS'].value: + try: + return current_app.config['REBRICKABLE_LINK_MINIFIGURE_PATTERN'].value.format( # noqa: E501 + number=self.fields.fig_num.lower(), + ) + except Exception: + pass + + return '' + + # Normalize from Rebrickable + @staticmethod + def from_rebrickable( + data: dict[str, Any], + /, + brickset: 'BrickSet | None' = None, + **_, + ) -> dict[str, Any]: + record = { + 'fig_num': data['set_num'], + 'name': data['set_name'], + 'quantity': data['quantity'], + 'set_img_url': data['set_img_url'], + } + + if brickset is not None: + record['set_num'] = brickset.fields.set_num + record['u_id'] = brickset.fields.u_id + + return record diff --git a/bricktracker/minifigure_list.py b/bricktracker/minifigure_list.py new file mode 100644 index 0000000..16545bb --- /dev/null +++ b/bricktracker/minifigure_list.py @@ -0,0 +1,124 @@ +from typing import Any, Self, TYPE_CHECKING + +from flask import current_app + +from .minifigure import BrickMinifigure +from .record_list import BrickRecordList +if TYPE_CHECKING: + from .set import BrickSet + + +# Lego minifigures +class BrickMinifigureList(BrickRecordList[BrickMinifigure]): + brickset: 'BrickSet | None' + + # Queries + all_query: str = 'minifigure/list/all' + last_query: str = 'minifigure/list/last' + select_query: str = 'minifigure/list/from_set' + using_part_query: str = 'minifigure/list/using_part' + missing_part_query: str = 'minifigure/list/missing_part' + + def __init__(self, /): + super().__init__() + + # Placeholders + self.brickset = None + + # Load all minifigures + def all(self, /) -> Self: + for record in self.select( + override_query=self.all_query, + order=current_app.config['MINIFIGURES_DEFAULT_ORDER'].value + ): + minifigure = BrickMinifigure(record=record) + + self.records.append(minifigure) + + return self + + # Last added minifigure + def last(self, /, limit: int = 6) -> Self: + # Randomize + if current_app.config['RANDOM'].value: + order = 'RANDOM()' + else: + order = 'minifigures.rowid DESC' + + for record in self.select( + override_query=self.last_query, + order=order, + limit=limit + ): + minifigure = BrickMinifigure(record=record) + + self.records.append(minifigure) + + return self + + # Load minifigures from a brickset + def load(self, brickset: 'BrickSet', /) -> Self: + # Save the brickset + self.brickset = brickset + + # Load the minifigures from the database + for record in self.select( + order=current_app.config['MINIFIGURES_DEFAULT_ORDER'].value + ): + minifigure = BrickMinifigure(brickset=self.brickset, record=record) + + self.records.append(minifigure) + + return self + + # Return a dict with common SQL parameters for a minifigures list + def sql_parameters(self, /) -> dict[str, Any]: + parameters: dict[str, Any] = super().sql_parameters() + + if self.brickset is not None: + parameters['u_id'] = self.brickset.fields.u_id + parameters['set_num'] = self.brickset.fields.set_num + + return parameters + + # Minifigures missing a part + def missing_part( + self, + part_num: str, + color_id: int, + /, + element_id: int | None = None, + ) -> Self: + # Save the parameters to the fields + self.fields.part_num = part_num + self.fields.color_id = color_id + self.fields.element_id = element_id + + # Load the sets from the database + for record in self.select(override_query=self.missing_part_query): + minifigure = BrickMinifigure(record=record) + + self.records.append(minifigure) + + return self + + # Minifigure using a part + def using_part( + self, + part_num: str, + color_id: int, + /, + element_id: int | None = None, + ) -> Self: + # Save the parameters to the fields + self.fields.part_num = part_num + self.fields.color_id = color_id + self.fields.element_id = element_id + + # Load the sets from the database + for record in self.select(override_query=self.using_part_query): + minifigure = BrickMinifigure(record=record) + + self.records.append(minifigure) + + return self diff --git a/bricktracker/navbar.py b/bricktracker/navbar.py new file mode 100644 index 0000000..17853eb --- /dev/null +++ b/bricktracker/navbar.py @@ -0,0 +1,51 @@ +from typing import Any, Final + +from flask import Flask + +# Navbar map: +# - e: url endpoint (str) +# - t: title (str) +# - i: icon (str, optional=None) +# - f: flag name (str, optional=None) +NAVBAR: Final[list[dict[str, Any]]] = [ + {'e': 'set.list', 't': 'Sets', 'i': 'grid-line', 'f': 'HIDE_ALL_SETS'}, # noqa: E501 + {'e': 'add.add', 't': 'Add', 'i': 'add-circle-line', 'f': 'HIDE_ADD_SET'}, # noqa: E501 + {'e': 'part.list', 't': 'Parts', 'i': 'shapes-line', 'f': 'HIDE_ALL_PARTS'}, # noqa: E501 + {'e': 'part.missing', 't': 'Missing', 'i': 'error-warning-line', 'f': 'HIDE_MISSING_PARTS'}, # noqa: E501 + {'e': 'minifigure.list', 't': 'Minifigures', 'i': 'group-line', 'f': 'HIDE_ALL_MINIFIGURES'}, # noqa: E501 + {'e': 'instructions.list', 't': 'Instructions', 'i': 'file-line', 'f': 'HIDE_ALL_INSTRUCTIONS'}, # noqa: E501 + {'e': 'wish.list', 't': 'Wishlist', 'i': 'gift-line', 'f': 'HIDE_WISHES'}, + {'e': 'admin.admin', 't': 'Admin', 'i': 'settings-4-line', 'f': 'HIDE_ADMIN'}, # noqa: E501 +] + + +# Navbar configuration +class Navbar(object): + # Navbar item + class NavbarItem(object): + endpoint: str + title: str + icon: str | None + flag: str | None + + def __init__( + self, + *, + e: str, + t: str, + i: str | None = None, + f: str | None = None, + ): + self.endpoint = e + self.title = t + self.icon = i + self.flag = f + + # Load configuration + def __init__(self, app: Flask, /): + # Navbar storage + app.config['_NAVBAR'] = [] + + # Process all configuration items + for item in NAVBAR: + app.config['_NAVBAR'].append(self.NavbarItem(**item)) diff --git a/bricktracker/part.py b/bricktracker/part.py new file mode 100644 index 0000000..01e17cb --- /dev/null +++ b/bricktracker/part.py @@ -0,0 +1,304 @@ +import os +from sqlite3 import Row +from typing import Any, Self, TYPE_CHECKING +from urllib.parse import urlparse + +from flask import current_app, url_for + +from .exceptions import DatabaseException, ErrorException, NotFoundException +from .rebrickable_image import RebrickableImage +from .record import BrickRecord +from .sql import BrickSQL +if TYPE_CHECKING: + from .minifigure import BrickMinifigure + from .set import BrickSet + + +# Lego set or minifig part +class BrickPart(BrickRecord): + brickset: 'BrickSet | None' + minifigure: 'BrickMinifigure | None' + + # Queries + insert_query: str = 'part/insert' + generic_query: str = 'part/select/generic' + select_query: str = 'part/select/specific' + + def __init__( + self, + /, + brickset: 'BrickSet | None' = None, + minifigure: 'BrickMinifigure | None' = None, + record: Row | dict[str, Any] | None = None, + ): + super().__init__() + + # Save the brickset and minifigure + self.brickset = brickset + self.minifigure = minifigure + + # Ingest the record if it has one + if record is not None: + self.ingest(record) + + # Delete missing part + def delete_missing(self, /) -> None: + BrickSQL().execute_and_commit( + 'missing/delete/from_set', + parameters=self.sql_parameters() + ) + + # Set missing part + def set_missing(self, quantity: int, /) -> None: + parameters = self.sql_parameters() + parameters['quantity'] = quantity + + # Can't use UPSERT because the database has no keys + # Try to update + database = BrickSQL() + rows, _ = database.execute( + 'missing/update/from_set', + parameters=parameters, + ) + + # Insert if no row has been affected + if not rows: + rows, _ = database.execute( + 'missing/insert', + parameters=parameters, + ) + + if rows != 1: + raise DatabaseException( + 'Could not update the missing quantity for part {id}'.format( # noqa: E501 + id=self.fields.id + ) + ) + + database.commit() + + # Select a generic part + def select_generic( + self, + part_num: str, + color_id: int, + /, + element_id: int | None = None + ) -> Self: + # Save the parameters to the fields + self.fields.part_num = part_num + self.fields.color_id = color_id + self.fields.element_id = element_id + + record = self.select(override_query=self.generic_query) + + if record is None: + raise NotFoundException( + 'Part with number {number}, color ID {color} and element ID {element} was not found in the database'.format( # noqa: E501 + number=self.fields.part_num, + color=self.fields.color_id, + element=self.fields.element_id, + ), + ) + + # Ingest the record + self.ingest(record) + + return self + + # Select a specific part (with a set and an id, and option. a minifigure) + def select_specific( + self, + brickset: 'BrickSet', + id: str, + /, + minifigure: 'BrickMinifigure | None' = None, + ) -> Self: + # Save the parameters to the fields + self.brickset = brickset + self.minifigure = minifigure + self.fields.id = id + + record = self.select() + + if record is None: + raise NotFoundException( + 'Part with ID {id} from set {set} was not found in the database'.format( # noqa: E501 + id=self.fields.id, + set=self.brickset.fields.set_num, + ), + ) + + # Ingest the record + self.ingest(record) + + return self + + # Return a dict with common SQL parameters for a part + def sql_parameters(self, /) -> dict[str, Any]: + parameters = super().sql_parameters() + + # Supplement from the brickset + if 'u_id' not in parameters and self.brickset is not None: + parameters['u_id'] = self.brickset.fields.u_id + + if 'set_num' not in parameters: + if self.minifigure is not None: + parameters['set_num'] = self.minifigure.fields.fig_num + + elif self.brickset is not None: + parameters['set_num'] = self.brickset.fields.set_num + + return parameters + + # Update the missing part + def update_missing(self, missing: Any, /) -> None: + # If empty, delete it + if missing == '': + self.delete_missing() + + else: + # Try to understand it as a number + try: + missing = int(missing) + except Exception: + raise ErrorException('"{missing}" is not a valid integer'.format( # noqa: E501 + missing=missing + )) + + # If 0, delete it + if missing == 0: + self.delete_missing() + + else: + # If negative, it's an error + if missing < 0: + raise ErrorException('Cannot set a negative missing value') + + # Otherwise upsert it + # Not checking if it is too much, you do you + self.set_missing(missing) + + # Self url + def url(self, /) -> str: + return url_for( + 'part.details', + number=self.fields.part_num, + color=self.fields.color_id, + element=self.fields.element_id, + ) + + # Compute the url for the bricklink page + def url_for_bricklink(self, /) -> str: + if current_app.config['BRICKLINK_LINKS'].value: + try: + return current_app.config['BRICKLINK_LINK_PART_PATTERN'].value.format( # noqa: E501 + number=self.fields.part_num.lower(), + ) + except Exception: + pass + + return '' + + # Compute the url for the part image + def url_for_image(self, /) -> str: + if not current_app.config['USE_REMOTE_IMAGES'].value: + if self.fields.part_img_url is None: + file = RebrickableImage.nil_name() + else: + file = self.fields.part_img_url_id + + folder: str = current_app.config['PARTS_FOLDER'].value + + # /!\ Everything is saved as .jpg, even if it came from a .png + # not changing this behaviour. + + # Grab the extension + # _, extension = os.path.splitext(self.part_img_url) + extension = '.jpg' + + # Compute the path + path = os.path.join(folder, '{number}{ext}'.format( + number=file, + ext=extension, + )) + + return url_for('static', filename=path) + + else: + if self.fields.part_img_url is None: + return current_app.config['REBRICKABLE_IMAGE_NIL'].value + else: + return self.fields.part_img_url + + # Compute the url for missing part + def url_for_missing(self, /) -> str: + # Different URL for a minifigure part + if self.minifigure is not None: + return url_for( + 'set.missing_minifigure_part', + id=self.fields.u_id, + minifigure_id=self.minifigure.fields.fig_num, + part_id=self.fields.id, + ) + + return url_for( + 'set.missing_part', + id=self.fields.u_id, + part_id=self.fields.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_PART_PATTERN'].value.format( # noqa: E501 + number=self.fields.part_num.lower(), + color=self.fields.color_id, + ) + except Exception: + pass + + return '' + + # Normalize from Rebrickable + @staticmethod + def from_rebrickable( + data: dict[str, Any], + /, + brickset: 'BrickSet | None' = None, + minifigure: 'BrickMinifigure | None' = None, + **_, + ) -> dict[str, Any]: + record = { + 'set_num': data['set_num'], + 'id': data['id'], + 'part_num': data['part']['part_num'], + 'name': data['part']['name'], + 'part_img_url': data['part']['part_img_url'], + 'part_img_url_id': None, + 'color_id': data['color']['id'], + 'color_name': data['color']['name'], + 'quantity': data['quantity'], + 'is_spare': data['is_spare'], + 'element_id': data['element_id'], + } + + if brickset is not None: + record['u_id'] = brickset.fields.u_id + + if minifigure is not None: + record['set_num'] = data['fig_num'] + + # Extract the file name + if data['part']['part_img_url'] is not None: + part_img_url_file = os.path.basename( + urlparse(data['part']['part_img_url']).path + ) + + part_img_url_id, _ = os.path.splitext(part_img_url_file) + + if part_img_url_id is not None or part_img_url_id != '': + record['part_img_url_id'] = part_img_url_id + + return record diff --git a/bricktracker/part_list.py b/bricktracker/part_list.py new file mode 100644 index 0000000..52c3ad4 --- /dev/null +++ b/bricktracker/part_list.py @@ -0,0 +1,130 @@ +from typing import Any, Self, TYPE_CHECKING + +from flask import current_app + +from .part import BrickPart +from .record_list import BrickRecordList +if TYPE_CHECKING: + from .minifigure import BrickMinifigure + from .set import BrickSet + + +# Lego set or minifig parts +class BrickPartList(BrickRecordList[BrickPart]): + brickset: 'BrickSet | None' + minifigure: 'BrickMinifigure | None' + + # Queries + all_query: str = 'part/list/all' + last_query: str = 'part/list/last' + minifigure_query: str = 'part/list/from_minifigure' + missing_query: str = 'part/list/missing' + select_query: str = 'part/list/from_set' + + def __init__(self, /): + super().__init__() + + # Placeholders + self.brickset = None + self.minifigure = None + + # Load all parts + def all(self, /) -> Self: + for record in self.select( + override_query=self.all_query, + order=current_app.config['PARTS_DEFAULT_ORDER'].value + ): + part = BrickPart(record=record) + + self.records.append(part) + + return self + + # Load parts from a brickset or minifigure + def load( + self, + brickset: 'BrickSet', + /, + minifigure: 'BrickMinifigure | None' = None, + ) -> Self: + # Save the brickset and minifigure + self.brickset = brickset + self.minifigure = minifigure + + # Load the parts from the database + for record in self.select( + order=current_app.config['PARTS_DEFAULT_ORDER'].value + ): + part = BrickPart( + brickset=self.brickset, + minifigure=minifigure, + record=record, + ) + + if ( + current_app.config['SKIP_SPARE_PARTS'].value and + part.fields.is_spare + ): + continue + + self.records.append(part) + + return self + + # Load generic parts from a minifigure + def from_minifigure( + self, + minifigure: 'BrickMinifigure', + /, + ) -> Self: + # Save the minifigure + self.minifigure = minifigure + + # Load the parts from the database + for record in self.select( + override_query=self.minifigure_query, + order=current_app.config['PARTS_DEFAULT_ORDER'].value + ): + part = BrickPart( + minifigure=minifigure, + record=record, + ) + + if ( + current_app.config['SKIP_SPARE_PARTS'].value and + part.fields.is_spare + ): + continue + + self.records.append(part) + + return self + + # Load missing parts + def missing(self, /) -> Self: + for record in self.select( + override_query=self.missing_query, + order=current_app.config['PARTS_DEFAULT_ORDER'].value + ): + part = BrickPart(record=record) + + self.records.append(part) + + return self + + # Return a dict with common SQL parameters for a parts list + def sql_parameters(self, /) -> dict[str, Any]: + parameters: dict[str, Any] = {} + + # Set id + if self.brickset is not None: + parameters['u_id'] = self.brickset.fields.u_id + + # Use the minifigure number if present, + # otherwise use the set number + if self.minifigure is not None: + parameters['set_num'] = self.minifigure.fields.fig_num + elif self.brickset is not None: + parameters['set_num'] = self.brickset.fields.set_num + + return parameters diff --git a/bricktracker/rebrickable.py b/bricktracker/rebrickable.py new file mode 100644 index 0000000..3f34fdd --- /dev/null +++ b/bricktracker/rebrickable.py @@ -0,0 +1,156 @@ +import json +from typing import Any, Callable, Generic, Type, TypeVar, TYPE_CHECKING +from urllib.error import HTTPError + +from flask import current_app +from rebrick import lego + +from .exceptions import NotFoundException, ErrorException +if TYPE_CHECKING: + from .minifigure import BrickMinifigure + from .part import BrickPart + from .set import BrickSet + from .socket import BrickSocket + from .wish import BrickWish + +T = TypeVar('T', 'BrickSet', 'BrickPart', 'BrickMinifigure', 'BrickWish') + + +# An helper around the rebrick library, autoconverting +class Rebrickable(Generic[T]): + method: Callable + method_name: str + number: str + model: Type[T] + + socket: 'BrickSocket | None' + brickset: 'BrickSet | None' + minifigure: 'BrickMinifigure | None' + kind: str + + def __init__( + self, + method: str, + number: str, + model: Type[T], + /, + socket: 'BrickSocket | None' = None, + brickset: 'BrickSet | None' = None, + minifigure: 'BrickMinifigure | None' = None + ): + if not hasattr(lego, method): + raise ErrorException('{method} is not a valid method for the rebrick.lego module'.format( # noqa: E501 + method=method, + )) + + self.method = getattr(lego, method) + self.method_name = method + self.number = number + self.model = model + + self.socket = socket + self.brickset = brickset + self.minifigure = minifigure + + if self.minifigure is not None: + self.kind = 'Minifigure' + else: + self.kind = 'Set' + + # Get one element from the Rebrickable API + def get(self, /) -> T: + model_parameters = self.model_parameters() + + return self.model( + **model_parameters, + record=self.model.from_rebrickable( + self.load(), + brickset=self.brickset, + ), + ) + + # Get paginated elements from the Rebrickable API + def list(self, /) -> list[T]: + model_parameters = self.model_parameters() + + results: list[T] = [] + + # Bootstrap a first set of parameters + parameters: dict[str, Any] | None = { + 'page_size': current_app.config['REBRICKABLE_PAGE_SIZE'].value, + } + + # Read all pages + while parameters is not None: + response = self.load(parameters=parameters) + + # Grab the results + if 'results' not in response: + raise ErrorException('Missing "results" field from {method} for {number}'.format( # noqa: E501 + method=self.method_name, + number=self.number, + )) + + # Update the total + if self.socket is not None: + self.socket.total_progress(len(response['results']), add=True) + + # Convert to object + for result in response['results']: + results.append( + self.model( + **model_parameters, + record=self.model.from_rebrickable(result), + ) + ) + + # Check for a next page + if 'next' in response and response['next'] is not None: + parameters['page'] = response['next'] + else: + parameters = None + + return results + + # Load from the API + def load(self, /, parameters: dict[str, Any] = {}) -> dict[str, Any]: + # Inject the API key + parameters['api_key'] = current_app.config['REBRICKABLE_API_KEY'].value, # noqa: E501 + + try: + return json.loads( + self.method( + self.number, + **parameters, + ).read() + ) + + # HTTP errors + except HTTPError as e: + # Not found + if e.code == 404: + raise NotFoundException('{kind} {number} was not found on Rebrickable'.format( # noqa: E501 + kind=self.kind, + number=self.number, + )) + else: + # Re-raise as ErrorException + raise ErrorException(e) + + # Other errors + except Exception as e: + # Re-raise as ErrorException + raise ErrorException(e) + + # Get the model parameters + def model_parameters(self, /) -> dict[str, Any]: + parameters: dict[str, Any] = {} + + # Overload with objects + if self.brickset is not None: + parameters['brickset'] = self.brickset + + if self.minifigure is not None: + parameters['minifigure'] = self.minifigure + + return parameters diff --git a/bricktracker/rebrickable_image.py b/bricktracker/rebrickable_image.py new file mode 100644 index 0000000..fa52100 --- /dev/null +++ b/bricktracker/rebrickable_image.py @@ -0,0 +1,140 @@ +import os +from typing import TYPE_CHECKING +from urllib.parse import urlparse + +from flask import current_app +import requests +from shutil import copyfileobj + +from .exceptions import DownloadException +if TYPE_CHECKING: + from .minifigure import BrickMinifigure + from .part import BrickPart + from .set import BrickSet + + +# A set, part or minifigure image from Rebrickable +class RebrickableImage(object): + brickset: 'BrickSet' + minifigure: 'BrickMinifigure | None' + part: 'BrickPart | None' + + extension: str | None + + def __init__( + self, + brickset: 'BrickSet', + /, + minifigure: 'BrickMinifigure | None' = None, + part: 'BrickPart | None' = None, + ): + # Save all objects + self.brickset = brickset + self.minifigure = minifigure + self.part = part + + # Currently everything is saved as 'jpg' + self.extension = 'jpg' + + # Guess the extension + # url = self.url() + # if url is not None: + # _, extension = os.path.splitext(url) + # # TODO: Add allowed extensions + # if extension != '': + # self.extension = extension + + # Import the image from Rebrickable + def download(self, /) -> None: + path = self.path() + + # Avoid doing anything if the file exists + if os.path.exists(path): + return + + url = self.url() + if url is None: + return + + # Grab the image + response = requests.get(url, stream=True) + if response.ok: + with open(path, 'wb') as f: + copyfileobj(response.raw, f) + else: + raise DownloadException('could not get image {id} at {url}'.format( + id=self.id(), + url=url, + )) + + # Return the folder depending on the objects provided + def folder(self, /) -> str: + if self.part is not None: + return current_app.config['PARTS_FOLDER'].value + + if self.minifigure is not None: + return current_app.config['MINIFIGURES_FOLDER'].value + + return current_app.config['SETS_FOLDER'].value + + # Return the id depending on the objects provided + def id(self, /) -> str: + if self.part is not None: + if self.part.fields.part_img_url_id is None: + return RebrickableImage.nil_name() + else: + return self.part.fields.part_img_url_id + + if self.minifigure is not None: + if self.minifigure.fields.set_img_url is None: + return RebrickableImage.nil_minifigure_name() + else: + return self.minifigure.fields.fig_num + + return self.brickset.fields.set_num + + # Return the path depending on the objects provided + def path(self, /) -> str: + return os.path.join( + current_app.static_folder, # type: ignore + self.folder(), + '{id}.{ext}'.format(id=self.id(), ext=self.extension), + ) + + # Return the url depending on the objects provided + def url(self, /) -> str: + if self.part is not None: + if self.part.fields.part_img_url is None: + return current_app.config['REBRICKABLE_IMAGE_NIL'].value + else: + return self.part.fields.part_img_url + + if self.minifigure is not None: + if self.minifigure.fields.set_img_url is None: + return current_app.config['REBRICKABLE_IMAGE_NIL_MINIFIGURE'].value # noqa: E501 + else: + return self.minifigure.fields.set_img_url + + return self.brickset.fields.set_img_url + + # Return the name of the nil image file + @staticmethod + def nil_name() -> str: + filename, _ = os.path.splitext( + os.path.basename( + urlparse(current_app.config['REBRICKABLE_IMAGE_NIL'].value).path # noqa: E501 + ) + ) + + return filename + + # Return the name of the nil minifigure image file + @staticmethod + def nil_minifigure_name() -> str: + filename, _ = os.path.splitext( + os.path.basename( + urlparse(current_app.config['REBRICKABLE_IMAGE_NIL_MINIFIGURE'].value).path # noqa: E501 + ) + ) + + return filename diff --git a/bricktracker/rebrickable_minifigures.py b/bricktracker/rebrickable_minifigures.py new file mode 100644 index 0000000..b34284a --- /dev/null +++ b/bricktracker/rebrickable_minifigures.py @@ -0,0 +1,85 @@ +import logging +from typing import TYPE_CHECKING + +from flask import current_app + +from .minifigure import BrickMinifigure +from .rebrickable import Rebrickable +from .rebrickable_image import RebrickableImage +from .rebrickable_parts import RebrickableParts +if TYPE_CHECKING: + from .set import BrickSet + from .socket import BrickSocket + +logger = logging.getLogger(__name__) + + +# Minifigures from Rebrickable +class RebrickableMinifigures(object): + socket: 'BrickSocket' + brickset: 'BrickSet' + + def __init__(self, socket: 'BrickSocket', brickset: 'BrickSet', /): + # Save the socket + self.socket = socket + + # Save the objects + self.brickset = brickset + + # Import the minifigures from Rebrickable + def download(self, /) -> None: + self.socket.auto_progress( + message='Set {number}: loading minifigures from Rebrickable'.format( # noqa: E501 + number=self.brickset.fields.set_num, + ), + increment_total=True, + ) + + logger.debug('rebrick.lego.get_set_minifigs("{set_num}")'.format( + set_num=self.brickset.fields.set_num, + )) + + minifigures = Rebrickable[BrickMinifigure]( + 'get_set_minifigs', + self.brickset.fields.set_num, + BrickMinifigure, + socket=self.socket, + brickset=self.brickset, + ).list() + + # Process each minifigure + total = len(minifigures) + for index, minifigure in enumerate(minifigures): + # Insert into the database + self.socket.auto_progress( + message='Set {number}: inserting minifigure {current}/{total} into database'.format( # noqa: E501 + number=self.brickset.fields.set_num, + current=index+1, + total=total, + ) + ) + + # Insert into database + minifigure.insert(commit=False) + + # Grab the image + self.socket.progress( + message='Set {number}: downloading minifigure {current}/{total} image'.format( # noqa: E501 + number=self.brickset.fields.set_num, + current=index+1, + total=total, + ) + ) + + if not current_app.config['USE_REMOTE_IMAGES'].value: + RebrickableImage( + self.brickset, + minifigure=minifigure + ).download() + + # Load the inventory + RebrickableParts( + self.socket, + self.brickset, + minifigure=minifigure, + ).download() diff --git a/bricktracker/rebrickable_parts.py b/bricktracker/rebrickable_parts.py new file mode 100644 index 0000000..1049b92 --- /dev/null +++ b/bricktracker/rebrickable_parts.py @@ -0,0 +1,112 @@ +import logging +from typing import TYPE_CHECKING + +from flask import current_app + +from .part import BrickPart +from .rebrickable import Rebrickable +from .rebrickable_image import RebrickableImage +if TYPE_CHECKING: + from .minifigure import BrickMinifigure + from .set import BrickSet + from .socket import BrickSocket + +logger = logging.getLogger(__name__) + + +# A list of parts from Rebrickable +class RebrickableParts(object): + socket: 'BrickSocket' + brickset: 'BrickSet' + minifigure: 'BrickMinifigure | None' + + number: str + kind: str + method: str + + def __init__( + self, + socket: 'BrickSocket', + brickset: 'BrickSet', + /, + minifigure: 'BrickMinifigure | None' = None, + ): + # Save the socket + self.socket = socket + + # Save the objects + self.brickset = brickset + self.minifigure = minifigure + + if self.minifigure is not None: + self.number = self.minifigure.fields.fig_num + self.kind = 'Minifigure' + self.method = 'get_minifig_elements' + else: + self.number = self.brickset.fields.set_num + self.kind = 'Set' + self.method = 'get_set_elements' + + # Import the parts from Rebrickable + def download(self, /) -> None: + self.socket.auto_progress( + message='{kind} {number}: loading parts inventory from Rebrickable'.format( # noqa: E501 + kind=self.kind, + number=self.number, + ), + increment_total=True, + ) + + logger.debug('rebrick.lego.{method}("{number}")'.format( + method=self.method, + number=self.number, + )) + + inventory = Rebrickable[BrickPart]( + self.method, + self.number, + BrickPart, + socket=self.socket, + brickset=self.brickset, + minifigure=self.minifigure, + ).list() + + # Process each part + total = len(inventory) + for index, part in enumerate(inventory): + # Skip spare parts + if ( + current_app.config['SKIP_SPARE_PARTS'].value and + part.fields.is_spare + ): + continue + + # Insert into the database + self.socket.auto_progress( + message='{kind} {number}: inserting part {current}/{total} into database'.format( # noqa: E501 + kind=self.kind, + number=self.number, + current=index+1, + total=total, + ) + ) + + # Insert into database + part.insert(commit=False) + + # Grab the image + self.socket.progress( + message='{kind} {number}: downloading part {current}/{total} image'.format( # noqa: E501 + kind=self.kind, + number=self.number, + current=index+1, + total=total, + ) + ) + + if not current_app.config['USE_REMOTE_IMAGES'].value: + RebrickableImage( + self.brickset, + minifigure=self.minifigure, + part=part, + ).download() diff --git a/bricktracker/rebrickable_set.py b/bricktracker/rebrickable_set.py new file mode 100644 index 0000000..b6ccf16 --- /dev/null +++ b/bricktracker/rebrickable_set.py @@ -0,0 +1,214 @@ +import logging +import traceback +from typing import Any, TYPE_CHECKING +from uuid import uuid4 + +from flask import current_app + +from .exceptions import ErrorException, NotFoundException +from .rebrickable import Rebrickable +from .rebrickable_image import RebrickableImage +from .rebrickable_minifigures import RebrickableMinifigures +from .rebrickable_parts import RebrickableParts +from .set import BrickSet +from .sql import BrickSQL +from .wish import BrickWish +if TYPE_CHECKING: + from .socket import BrickSocket + +logger = logging.getLogger(__name__) + + +# A set from Rebrickable +class RebrickableSet(object): + socket: 'BrickSocket' + + def __init__(self, socket: 'BrickSocket', /): + # Save the socket + self.socket = socket + + # Import the set from Rebrickable + def download(self, data: dict[str, Any], /) -> None: + # Reset the progress + self.socket.progress_count = 0 + self.socket.progress_total = 0 + + # Load the set + 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 + brickset.fields.u_id = str(uuid4()) + + # Insert into database + brickset.insert(commit=False) + + if not current_app.config['USE_REMOTE_IMAGES'].value: + RebrickableImage(brickset).download() + + # Load the inventory + RebrickableParts(self.socket, brickset).download() + + # Load the minifigures + RebrickableMinifigures(self.socket, brickset).download() + + # Commit the transaction to the database + self.socket.auto_progress( + message='Set {number}: writing to the database'.format( + number=brickset.fields.set_num + ), + increment_total=True, + ) + + BrickSQL().commit() + + # Info + logger.info('Set {number}: imported (id: {id})'.format( + number=brickset.fields.set_num, + id=brickset.fields.u_id, + )) + + # Complete + self.socket.complete( + message='Set {number}: imported (Go to the set)'.format( # noqa: E501 + number=brickset.fields.set_num, + url=brickset.url() + ), + download=True + ) + + except Exception as e: + self.socket.fail( + message='Error while importing set {number}: {error}'.format( + number=brickset.fields.set_num, + error=e, + ) + ) + + logger.debug(traceback.format_exc()) + + # Load the set from Rebrickable + def load( + self, + data: dict[str, Any], + /, + from_download=False, + ) -> BrickSet | None: + # Reset the progress + self.socket.progress_count = 0 + self.socket.progress_total = 2 + + try: + self.socket.auto_progress(message='Parsing set number') + set_num = RebrickableSet.parse_number(str(data['set_num'])) + + self.socket.auto_progress( + message='Set {num}: loading from Rebrickable'.format( + num=set_num, + ), + ) + + logger.debug('rebrick.lego.get_set("{set_num}")'.format( + set_num=set_num, + )) + + brickset = Rebrickable[BrickSet]( + 'get_set', + set_num, + BrickSet, + ).get() + + short = brickset.short() + short['download'] = from_download + + self.socket.emit('SET_LOADED', short) + + if not from_download: + self.socket.complete( + message='Set {num}: loaded from Rebrickable'.format( + num=brickset.fields.set_num + ) + ) + + return brickset + except Exception as e: + self.socket.fail( + message='Could not load the set from Rebrickable: {error}. Data: {data}'.format( # noqa: E501 + error=str(e), + data=data, + ) + ) + + if not isinstance(e, (NotFoundException, ErrorException)): + logger.debug(traceback.format_exc()) + + return None + + # Make sense of the number from the data + @staticmethod + def parse_number(set_num: str, /) -> str: + number, _, version = set_num.partition('-') + + # Making sure both are integers + if version == '': + version = 1 + + try: + number = int(number) + except Exception: + raise ErrorException('Number "{number}" is not a number'.format( + number=number, + )) + + try: + version = int(version) + except Exception: + raise ErrorException('Version "{version}" is not a number'.format( + version=version, + )) + + # Make sure both are positive + if number < 0: + raise ErrorException('Number "{number}" should be positive'.format( + number=number, + )) + + if version < 0: + raise ErrorException('Version "{version}" should be positive'.format( # noqa: E501 + 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() diff --git a/bricktracker/record.py b/bricktracker/record.py new file mode 100644 index 0000000..a98db0d --- /dev/null +++ b/bricktracker/record.py @@ -0,0 +1,60 @@ +from sqlite3 import Row +from typing import Any, ItemsView + +from .fields import BrickRecordFields +from .sql import BrickSQL + + +# SQLite record +class BrickRecord(object): + select_query: str + insert_query: str + + # Fields + fields: BrickRecordFields + + def __init__(self, /): + self.fields = BrickRecordFields() + + # Load from a record + def ingest(self, record: Row | dict[str, Any], /) -> None: + # Brutally ingest the record + for key in record.keys(): + setattr(self.fields, key, record[key]) + + # Insert into the database + # If we do not commit immediately, we defer the execute() call + def insert(self, /, commit=True) -> None: + database = BrickSQL() + rows, q = database.execute( + self.insert_query, + parameters=self.sql_parameters(), + defer=not commit, + ) + + if commit: + database.commit() + + # Shorthand to field items + def items(self, /) -> ItemsView[str, Any]: + return self.fields.__dict__.items() + + # Get from the database using the query + def select(self, /, override_query: str | None = None) -> Row | None: + if override_query: + query = override_query + else: + query = self.select_query + + return BrickSQL().fetchone( + query, + parameters=self.sql_parameters() + ) + + # Generic SQL parameters from fields + def sql_parameters(self, /) -> dict[str, Any]: + parameters: dict[str, Any] = {} + for name, value in self.items(): + parameters[name] = value + + return parameters diff --git a/bricktracker/record_list.py b/bricktracker/record_list.py new file mode 100644 index 0000000..093d73b --- /dev/null +++ b/bricktracker/record_list.py @@ -0,0 +1,67 @@ +from sqlite3 import Row +from typing import Any, Generator, Generic, ItemsView, TypeVar, TYPE_CHECKING + +from .fields import BrickRecordFields +from .sql import BrickSQL +if TYPE_CHECKING: + from .minifigure import BrickMinifigure + from .part import BrickPart + from .set import BrickSet + from .wish import BrickWish + +T = TypeVar('T', 'BrickSet', 'BrickPart', 'BrickMinifigure', 'BrickWish') + + +# SQLite records +class BrickRecordList(Generic[T]): + select_query: str + records: list[T] + + # Fields + fields: BrickRecordFields + + def __init__(self, /): + self.fields = BrickRecordFields() + self.records = [] + + # Shorthand to field items + def items(self, /) -> ItemsView[str, Any]: + return self.fields.__dict__.items() + + # Get all from the database + def select( + self, + /, + override_query: str | None = None, + order: str | None = None, + limit: int | None = None, + ) -> list[Row]: + # Select the query + if override_query: + query = override_query + else: + query = self.select_query + + return BrickSQL().fetchall( + query, + parameters=self.sql_parameters(), + order=order, + limit=limit, + ) + + # Generic SQL parameters from fields + def sql_parameters(self, /) -> dict[str, Any]: + parameters: dict[str, Any] = {} + for name, value in self.items(): + parameters[name] = value + + return parameters + + # Make the list iterable + def __iter__(self, /) -> Generator[T, Any, Any]: + for record in self.records: + yield record + + # Make the sets measurable + def __len__(self, /) -> int: + return len(self.records) diff --git a/bricktracker/retired.py b/bricktracker/retired.py new file mode 100644 index 0000000..96a7646 --- /dev/null +++ b/bricktracker/retired.py @@ -0,0 +1,28 @@ +# Lego retired set +class BrickRetired(object): + theme: str + subtheme: str + number: str + name: str + age: str + piece_count: str + retirement_date: str + + def __init__( + self, + theme: str, + subtheme: str, + number: str, + name: str, + age: str, + piece_count: str, + retirement_date: str, + *_, + ): + self.theme = theme + self.subtheme = subtheme + self.number = number + self.name = name + self.age = age + self.piece_count = piece_count + self.retirement_date = retirement_date diff --git a/bricktracker/retired_list.py b/bricktracker/retired_list.py new file mode 100644 index 0000000..e76a699 --- /dev/null +++ b/bricktracker/retired_list.py @@ -0,0 +1,105 @@ +from datetime import datetime, timezone +import csv +import gzip +import logging +import os +from shutil import copyfileobj + +from flask import current_app, g +import humanize +import requests + +from .exceptions import ErrorException +from .retired import BrickRetired + +logger = logging.getLogger(__name__) + + +# Lego retired sets +class BrickRetiredList(object): + retired: dict[str, BrickRetired] + mtime: datetime | None + size: int | None + exception: Exception | None + + def __init__(self, /, force: bool = False): + # Load sets only if there is none already loaded + retired = getattr(self, 'retired', None) + + if retired is None or force: + logger.info('Loading retired sets list') + + BrickRetiredList.retired = {} + + # Try to read the themes from a CSV file + try: + with open(current_app.config['RETIRED_SETS_PATH'].value, newline='') as themes_file: # noqa: E501 + themes_reader = csv.reader(themes_file) + + # Ignore the header + next(themes_reader, None) + + for row in themes_reader: + retired = BrickRetired(*row) + BrickRetiredList.retired[retired.number] = retired + + # File stats + stat = os.stat(current_app.config['RETIRED_SETS_PATH'].value) + BrickRetiredList.size = stat.st_size + BrickRetiredList.mtime = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc) # noqa: E501 + + BrickRetiredList.exception = None + + # Ignore errors + except Exception as e: + BrickRetiredList.exception = e + BrickRetiredList.size = None + BrickRetiredList.mtime = None + + # Get a retirement date for a set + def get(self, number: str, /) -> str: + if number in self.retired: + return self.retired[number].retirement_date + else: + number, _, _ = number.partition('-') + + if number in self.retired: + return self.retired[number].retirement_date + else: + return '' + + # Display the size in a human format + def human_size(self) -> str: + if self.size is not None: + return humanize.naturalsize(self.size) + else: + return '' + + # Display the time in a human format + def human_time(self) -> str: + if self.mtime is not None: + return self.mtime.astimezone(g.timezone).strftime( + current_app.config['FILE_DATETIME_FORMAT'].value + ) + else: + return '' + + # Update the file + @staticmethod + def update() -> None: + response = requests.get( + current_app.config['RETIRED_SETS_FILE_URL'].value, + stream=True, + ) + + if not response.ok: + raise ErrorException('An error occured while downloading the retired sets file ({code})'.format( # noqa: E501 + code=response.status_code + )) + + content = gzip.GzipFile(fileobj=response.raw) + + with open(current_app.config['RETIRED_SETS_PATH'].value, 'wb') as f: + copyfileobj(content, f) + + logger.info('Retired sets list updated') diff --git a/bricktracker/set.py b/bricktracker/set.py new file mode 100644 index 0000000..77c9d47 --- /dev/null +++ b/bricktracker/set.py @@ -0,0 +1,233 @@ +import os +from sqlite3 import Row +from typing import Any, Self + +from flask import current_app, url_for + +from .exceptions import DatabaseException, NotFoundException +from .instructions import BrickInstructions +from .instructions_list import BrickInstructionsList +from .minifigure_list import BrickMinifigureList +from .part_list import BrickPartList +from .record import BrickRecord +from .sql import BrickSQL +from .theme_list import BrickThemeList + + +# Lego brick set +class BrickSet(BrickRecord): + instructions: list[BrickInstructions] + theme_name: str + + # Queries + select_query: str = 'set/select' + insert_query: str = 'set/insert' + + def __init__( + self, + /, + record: Row | dict[str, Any] | None = None, + ): + super().__init__() + + # Placeholders + self.theme_name = '' + self.instructions = [] + + # Ingest the record if it has one + if record is not None: + self.ingest(record) + + # Resolve the theme + self.resolve_theme() + + # Check for the instructions + self.resolve_instructions() + + # Delete a set + def delete(self, /) -> None: + database = BrickSQL() + parameters = self.sql_parameters() + + # Delete the set + database.execute('set/delete/set', parameters=parameters) + + # Delete the minifigures + database.execute( + 'minifigure/delete/all_from_set', parameters=parameters) + + # Delete the parts + database.execute( + 'part/delete/all_from_set', parameters=parameters) + + # Delete missing parts + database.execute('missing/delete/all_from_set', parameters=parameters) + + # Commit to the database + database.commit() + + # Minifigures + def minifigures(self, /) -> BrickMinifigureList: + return BrickMinifigureList().load(self) + + # Parts + def parts(self, /) -> BrickPartList: + return BrickPartList().load(self) + + # Add instructions to the set + def resolve_instructions(self, /) -> None: + if self.fields.set_num is not None: + self.instructions = BrickInstructionsList().get( + self.fields.set_num + ) + + # Add a theme to the set + def resolve_theme(self, /) -> None: + try: + id = self.fields.theme_id + except Exception: + id = 0 + + theme = BrickThemeList().get(id) + self.theme_name = theme.name + + # Return a short form of the set + def short(self, /) -> dict[str, Any]: + return { + 'name': self.fields.name, + 'set_img_url': self.fields.set_img_url, + 'set_num': self.fields.set_num, + } + + # Select a specific part (with a set and an id) + def select_specific(self, u_id: str, /) -> Self: + # Save the parameters to the fields + self.fields.u_id = u_id + + # Load from database + record = self.select() + + if record is None: + raise NotFoundException( + 'Set with ID {id} was not found in the database'.format( + id=self.fields.u_id, + ), + ) + + # Ingest the record + self.ingest(record) + + # Resolve the theme + self.resolve_theme() + + # Check for the instructions + self.resolve_instructions() + + return self + + # Update a checked state + def update_checked(self, name: str, status: bool, /) -> None: + parameters = self.sql_parameters() + parameters['status'] = status + + # Update the checked status + rows, _ = BrickSQL().execute_and_commit( + 'set/update_checked', + parameters=parameters, + name=name, + ) + + if rows != 1: + raise DatabaseException('Could not update the status {status} for set {number}'.format( # noqa: E501 + status=name, + number=self.fields.set_num, + )) + + # Self url + def url(self, /) -> str: + return url_for('set.details', id=self.fields.u_id) + + # Deletion url + def url_for_delete(self, /) -> str: + return url_for('set.delete', id=self.fields.u_id) + + # Actual deletion url + def url_for_do_delete(self, /) -> str: + return url_for('set.do_delete', id=self.fields.u_id) + + # Compute the url for the set image + def url_for_image(self, /) -> str: + if not current_app.config['USE_REMOTE_IMAGES'].value: + folder: str = current_app.config['SETS_FOLDER'].value + + # /!\ Everything is saved as .jpg, even if it came from a .png + # not changing this behaviour. + + # Grab the extension + # _, extension = os.path.splitext(self.fields.img_url) + extension = '.jpg' + # Grab the extension + _, extension = os.path.splitext(self.fields.set_img_url) + + # Compute the path + path = os.path.join(folder, '{number}{ext}'.format( + number=self.fields.set_num, + ext=extension, + )) + + return url_for('static', filename=path) + + else: + return self.fields.set_img_url + + # Compute the url for the set instructions + def url_for_instructions(self, /) -> str: + if len(self.instructions): + return url_for( + 'set.details', + id=self.fields.u_id, + open_instructions=True + ) + else: + return '' + + # Check minifigure collected url + def url_for_minifigures_collected(self, /) -> str: + return url_for('set.minifigures_collected', id=self.fields.u_id) + + # Compute the url for the rebrickable page + def url_for_rebrickable(self, /) -> str: + if current_app.config['REBRICKABLE_LINKS'].value: + try: + return current_app.config['REBRICKABLE_LINK_SET_PATTERN'].value.format( # noqa: E501 + number=self.fields.set_num.lower(), + ) + except Exception: + pass + + return '' + + # Check set checked url + def url_for_set_checked(self, /) -> str: + return url_for('set.set_checked', id=self.fields.u_id) + + # Check set collected url + def url_for_set_collected(self, /) -> str: + return url_for('set.set_collected', id=self.fields.u_id) + + # Normalize from Rebrickable + @staticmethod + def from_rebrickable(data: dict[str, Any], /, **_) -> dict[str, Any]: + return { + 'set_num': data['set_num'], + 'name': data['name'], + 'year': data['year'], + 'theme_id': data['theme_id'], + 'num_parts': data['num_parts'], + 'set_img_url': data['set_img_url'], + 'set_url': data['set_url'], + 'last_modified_dt': data['last_modified_dt'], + 'mini_col': False, + 'set_col': False, + 'set_check': False, + } diff --git a/bricktracker/set_list.py b/bricktracker/set_list.py new file mode 100644 index 0000000..0bb82ff --- /dev/null +++ b/bricktracker/set_list.py @@ -0,0 +1,146 @@ +from typing import Self + +from flask import current_app + +from .record_list import BrickRecordList +from .set import BrickSet + + +# All the sets from the database +class BrickSetList(BrickRecordList[BrickSet]): + themes: list[str] + + # Queries + generic_query: str = 'set/list/generic' + missing_minifigure_query: str = 'set/list/missing_minifigure' + missing_part_query: str = 'set/list/missing_part' + select_query: str = 'set/list/all' + using_minifigure_query: str = 'set/list/using_minifigure' + using_part_query: str = 'set/list/using_part' + + def __init__(self, /): + super().__init__() + + # Placeholders + self.themes = [] + + # All the sets + def all(self, /) -> Self: + themes = set() + + # Load the sets from the database + for record in self.select( + order=current_app.config['SETS_DEFAULT_ORDER'].value + ): + brickset = BrickSet(record=record) + + self.records.append(brickset) + themes.add(brickset.theme_name) + + # Convert the set into a list and sort it + self.themes = list(themes) + self.themes.sort() + + return self + + # A generic list of the different sets + def generic(self, /) -> Self: + for record in self.select(override_query=self.generic_query): + brickset = BrickSet(record=record) + + self.records.append(brickset) + + return self + + # Last added sets + def last(self, /, limit: int = 6) -> Self: + # Randomize + if current_app.config['RANDOM'].value: + order = 'RANDOM()' + else: + order = 'sets.rowid DESC' + + for record in self.select(order=order, limit=limit): + brickset = BrickSet(record=record) + + self.records.append(brickset) + + return self + + # Sets missing a minifigure + def missing_minifigure( + self, + fig_num: str, + /, + ) -> Self: + # Save the parameters to the fields + self.fields.fig_num = fig_num + + # Load the sets from the database + for record in self.select( + override_query=self.missing_minifigure_query + ): + brickset = BrickSet(record=record) + + self.records.append(brickset) + + return self + + # Sets missing a part + def missing_part( + self, + part_num: str, + color_id: int, + /, + element_id: int | None = None, + ) -> Self: + # Save the parameters to the fields + self.fields.part_num = part_num + self.fields.color_id = color_id + self.fields.element_id = element_id + + # Load the sets from the database + for record in self.select(override_query=self.missing_part_query): + brickset = BrickSet(record=record) + + self.records.append(brickset) + + return self + + # Sets using a minifigure + def using_minifigure( + self, + fig_num: str, + /, + ) -> Self: + # Save the parameters to the fields + self.fields.fig_num = fig_num + + # Load the sets from the database + for record in self.select(override_query=self.using_minifigure_query): + brickset = BrickSet(record=record) + + self.records.append(brickset) + + return self + + # Sets using a part + def using_part( + self, + part_num: str, + color_id: int, + /, + element_id: int | None = None, + ) -> Self: + # Save the parameters to the fields + self.fields.part_num = part_num + self.fields.color_id = color_id + self.fields.element_id = element_id + + # Load the sets from the database + for record in self.select(override_query=self.using_part_query): + brickset = BrickSet(record=record) + + self.records.append(brickset) + + return self diff --git a/bricktracker/socket.py b/bricktracker/socket.py new file mode 100644 index 0000000..44ddadf --- /dev/null +++ b/bricktracker/socket.py @@ -0,0 +1,231 @@ +import logging +from typing import Any, Final, Tuple + +from flask import copy_current_request_context, Flask, request +from flask_socketio import SocketIO + +from .configuration_list import BrickConfigurationList +from .login import LoginManager +from .rebrickable_set import RebrickableSet +from .sql import close as sql_close + +logger = logging.getLogger(__name__) + +# Messages valid through the socket +MESSAGES: Final[dict[str, str]] = { + 'ADD_SET': 'add_set', + 'COMPLETE': 'complete', + 'CONNECT': 'connect', + 'DISCONNECT': 'disconnect', + 'FAIL': 'fail', + 'IMPORT_SET': 'import_set', + 'LOAD_SET': 'load_set', + 'PROGRESS': 'progress', + 'SET_LOADED': 'set_loaded', +} + + +# Flask socket.io with our extra features +class BrickSocket(object): + app: Flask + socket: SocketIO + threaded: bool + + # Progress + progress_message: str + progress_total: int + progress_count: int + + def __init__( + self, + app: Flask, + *args, + threaded: bool = True, + **kwargs + ): + # Save the app + self.app = app + + # Progress + self.progress_message = '' + self.progress_count = 0 + self.progress_total = 0 + + # Save the threaded flag + self.threaded = threaded + + # Compute the namespace + self.namespace = '/{namespace}'.format( + namespace=app.config['SOCKET_NAMESPACE'].value + ) + + # Inject CORS if a domain is defined + if app.config['DOMAIN_NAME'].value != '': + kwargs['cors_allowed_origins'] = app.config['DOMAIN_NAME'].value + + # Instantiate the socket + self.socket = SocketIO( + self.app, + *args, + **kwargs, + path=app.config['SOCKET_PATH'].value, + async_mode='eventlet', + ) + + # Store the socket in the app config + self.app.config['_SOCKET'] = self + + # Setup the socket + @self.socket.on(MESSAGES['CONNECT'], namespace=self.namespace) + def connect() -> None: + self.connected() + + @self.socket.on(MESSAGES['DISCONNECT'], namespace=self.namespace) + def disconnect() -> None: + self.disconnected() + + @self.socket.on(MESSAGES['IMPORT_SET'], namespace=self.namespace) + def import_set(data: dict[str, Any], /) -> None: + # Needs to be authenticated + if LoginManager.is_not_authenticated(): + self.fail(message='You need to be authenticated') + return + + # Needs the Rebrickable API key + try: + BrickConfigurationList.error_unless_is_set('REBRICKABLE_API_KEY') # noqa: E501 + except Exception as e: + self.fail(message=str(e)) + return + + brickset = RebrickableSet(self) + + # Start it in a thread if requested + if self.threaded: + @copy_current_request_context + def do_download() -> None: + brickset.download(data) + + self.socket.start_background_task(do_download) + else: + brickset.download(data) + + @self.socket.on(MESSAGES['LOAD_SET'], namespace=self.namespace) + def load_set(data: dict[str, Any], /) -> None: + # Needs to be authenticated + if LoginManager.is_not_authenticated(): + self.fail(message='You need to be authenticated') + return + + # Needs the Rebrickable API key + try: + BrickConfigurationList.error_unless_is_set('REBRICKABLE_API_KEY') # noqa: E501 + except Exception as e: + self.fail(message=str(e)) + return + + brickset = RebrickableSet(self) + + # Start it in a thread if requested + if self.threaded: + @copy_current_request_context + def do_load() -> None: + brickset.load(data) + + self.socket.start_background_task(do_load) + else: + brickset.load(data) + + # Update the progress auto-incrementing + def auto_progress( + self, + /, + message: str | None = None, + increment_total=False, + ) -> None: + # Auto-increment + self.progress_count += 1 + + if increment_total: + self.progress_total += 1 + + self.progress(message=message) + + # Send a complete + def complete(self, /, **data: Any) -> None: + self.emit('COMPLETE', data) + + # Close any dangling connection + sql_close() + + # Socket is connected + def connected(self, /) -> Tuple[str, int]: + logger.debug('Socket: client connected') + + return '', 301 + + # Socket is disconnected + def disconnected(self, /) -> None: + logger.debug('Socket: client disconnected') + + # Emit a message through the socket + def emit(self, name: str, *arg, all=False) -> None: + # Emit to all sockets + if all: + to = None + else: + # Grab the request SID + # This keeps message isolated between clients (and tabs!) + try: + to = request.sid # type: ignore + except Exception: + logger.debug('Unable to load request.sid') + to = None + + logger.debug('Socket: {name}={args} (to: {to})'.format( + name=name, + args=arg, + to=to, + )) + + self.socket.emit( + MESSAGES[name], + *arg, + namespace=self.namespace, + to=to, + ) + + # Send a failed + def fail(self, /, **data: Any) -> None: + self.emit('FAIL', data) + + # Close any dangling connection + sql_close() + + # Update the progress + def progress(self, /, message: str | None = None) -> None: + # Save the las message + if message is not None: + self.progress_message = message + + # Prepare data + data: dict[str, Any] = { + 'message': self.progress_message, + 'count': self.progress_count, + 'total': self.progress_total, + } + + self.emit('PROGRESS', data) + + # Update the progress total only + def update_total(self, total: int, /, add: bool = False) -> None: + if add: + self.progress_total += total + else: + self.progress_total = total + + # Update the total + def total_progress(self, total: int, /, add: bool = False) -> None: + self.update_total(total, add=add) + + self.progress() diff --git a/bricktracker/sql.py b/bricktracker/sql.py new file mode 100644 index 0000000..0de78c8 --- /dev/null +++ b/bricktracker/sql.py @@ -0,0 +1,316 @@ +import logging +import os +import sqlite3 +from typing import Any, Tuple + +from .sql_stats import BrickSQLStats + +from flask import current_app, g +from jinja2 import Environment, FileSystemLoader +from werkzeug.datastructures import FileStorage + +logger = logging.getLogger(__name__) + + +# SQLite3 client with our extra features +class BrickSQL(object): + connection: sqlite3.Connection + cursor: sqlite3.Cursor + stats: BrickSQLStats + + def __init__(self, /): + # Instantiate the database connection in the Flask + # application context so that it can be used by all + # requests without re-opening connections + database = getattr(g, 'database', None) + + # Grab the existing connection if it exists + if database is not None: + self.connection = database + self.stats = getattr(g, 'database_stats', BrickSQLStats()) + else: + # Instantiate the stats + self.stats = BrickSQLStats() + + # Stats: connect + self.stats.connect += 1 + + logger.debug('SQLite3: connect') + self.connection = sqlite3.connect( + current_app.config['DATABASE_PATH'].value + ) + + # Setup the row factory to get pseudo-dicts rather than tuples + self.connection.row_factory = sqlite3.Row + + # Debug: Attach the debugger + # Uncomment manually because this is ultra verbose + # self.connection.set_trace_callback(print) + + # Save the connection globally for later use + g.database = self.connection + g.database_stats = self.stats + + # Grab a cursor + self.cursor = self.connection.cursor() + + # Clear the defer stack + def clear_defer(self, /) -> None: + g.database_defer = [] + + # Shorthand to commit + def commit(self, /) -> None: + # Stats: commit + self.stats.commit += 1 + + # Process the defered stack + for item in self.get_defer(): + self.raw_execute(item[0], item[1]) + + self.clear_defer() + + logger.debug('SQLite3: commit') + return self.connection.commit() + + # Defer a call to execute + def defer(self, query: str, parameters: dict[str, Any], /): + defer = self.get_defer() + + logger.debug('SQLite3: defer execute') + + # Add the query and parameters to the defer stack + defer.append((query, parameters)) + + # Save the defer stack + g.database_defer = defer + + # Shorthand to execute, returning number of affected rows + def execute( + self, + query: str, + /, + parameters: dict[str, Any] = {}, + defer: bool = False, + **context, + ) -> Tuple[int, str]: + # Stats: execute + self.stats.execute += 1 + + # Load the query + query = self.load_query(query, **context) + + # Defer + if defer: + self.defer(query, parameters) + + return -1, query + else: + result = self.raw_execute(query, parameters) + + # Stats: changed + if result.rowcount > 0: + self.stats.changed += result.rowcount + + return result.rowcount, query + + # Shorthand to executescript + def executescript(self, query: str, /, **context) -> None: + # Load the query + query = self.load_query(query, **context) + + # Stats: executescript + self.stats.executescript += 1 + + logger.debug('SQLite3: executescript') + self.cursor.executescript(query) + + # Shorthand to execute and commit + def execute_and_commit( + self, + query: str, + /, + parameters: dict[str, Any] = {}, + **context, + ) -> Tuple[int, str]: + rows, query = self.execute(query, parameters=parameters, **context) + self.commit() + + return rows, query + + # Shorthand to execute and fetchall + def fetchall( + self, + query: str, + /, + parameters: dict[str, Any] = {}, + **context, + ) -> list[sqlite3.Row]: + _, query = self.execute(query, parameters=parameters, **context) + + # Stats: fetchall + self.stats.fetchall += 1 + + logger.debug('SQLite3: fetchall: {query}'.format( + query=BrickSQL.clean_query(query) + )) + records = self.cursor.fetchall() + + # Stats: fetched + self.stats.fetched += len(records) + + return records + + # Shorthand to execute and fetchone + def fetchone( + self, + query: str, + /, + parameters: dict[str, Any] = {}, + **context, + ) -> sqlite3.Row | None: + _, query = self.execute(query, parameters=parameters, **context) + + # Stats: fetchone + self.stats.fetchone += 1 + + logger.debug('SQLite3: fetchone: {query}'.format( + query=BrickSQL.clean_query(query) + )) + record = self.cursor.fetchone() + + # Stats: fetched + if record is not None: + self.stats.fetched += len(record) + + return record + + # Grab the defer stack + def get_defer(self, /) -> list[Tuple[str, dict[str, Any]]]: + defer: list[Tuple[str, dict[str, Any]]] = getattr( + g, + 'database_defer', + [] + ) + + return defer + + # Load a query by name + def load_query(self, name: str, /, **context) -> str: + # Grab the existing environment if it exists + environment = getattr(g, 'database_loader', None) + + # Instantiate Jinja environment for SQL files + if environment is None: + environment = Environment( + loader=FileSystemLoader( + os.path.join(os.path.dirname(__file__), 'sql/') + ) + ) + + # Save the environment globally for later use + g.database_environment = environment + + # Grab the template + logger.debug('SQLite: loading {name} (context: {context})'.format( + name=name, + context=context, + )) + template = environment.get_template('{name}.sql'.format( + name=name, + )) + + return template.render(**context) + + # Raw execute the query without any options + def raw_execute( + self, + query: str, + parameters: dict[str, Any] + ) -> sqlite3.Cursor: + logger.debug('SQLite3: execute: {query}'.format( + query=BrickSQL.clean_query(query) + )) + + return self.cursor.execute(query, parameters) + + # Clean the query for debugging + @staticmethod + def clean_query(query: str, /) -> str: + cleaned: list[str] = [] + + for line in query.splitlines(): + # Keep the non-comment side + line, sep, comment = line.partition('--') + + # Clean the non-comment side + line = line.strip() + + if line: + cleaned.append(line) + + return ' '.join(cleaned) + + # Delete the database + @staticmethod + def delete() -> None: + os.remove(current_app.config['DATABASE_PATH'].value) + + # Info + logger.info('The database has been deleted') + + # Drop the database + @staticmethod + def drop() -> None: + BrickSQL().executescript('schema/drop') + + # Info + logger.info('The database has been dropped') + + # Count the database records + @staticmethod + def count_records() -> dict[str, int]: + database = BrickSQL() + + counters: dict[str, int] = {} + for table in ['sets', 'minifigures', 'inventory', 'missing']: + record = database.fetchone('schema/count', table=table) + + if record is not None: + counters[table] = record['count'] + + return counters + + # Initialize the database + @staticmethod + def initialize() -> None: + BrickSQL().executescript('migrations/init') + + # Info + logger.info('The database has been initialized') + + # Check if the database is initialized + @staticmethod + def is_init() -> bool: + return BrickSQL().fetchone('schema/is_init') is not None + + # Replace the database with a new file + @staticmethod + def upload(file: FileStorage, /) -> None: + file.save(current_app.config['DATABASE_PATH'].value) + + # Info + logger.info('The database has been imported using file {file}'.format( + file=file.filename + )) + + +# Close all existing SQLite3 connections +def close() -> None: + database: sqlite3.Connection | None = getattr(g, 'database', None) + + if database is not None: + logger.debug('SQLite3: close') + database.close() + + # Remove the database from the context + delattr(g, 'database') diff --git a/bricktracker/sql/migrations/init.sql b/bricktracker/sql/migrations/init.sql new file mode 100644 index 0000000..f243b8c --- /dev/null +++ b/bricktracker/sql/migrations/init.sql @@ -0,0 +1,61 @@ +-- 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 + ); + +COMMIT; \ No newline at end of file diff --git a/bricktracker/sql/minifigure/base/select.sql b/bricktracker/sql/minifigure/base/select.sql new file mode 100644 index 0000000..365019f --- /dev/null +++ b/bricktracker/sql/minifigure/base/select.sql @@ -0,0 +1,31 @@ +SELECT + minifigures.fig_num, + minifigures.set_num, + minifigures.name, + minifigures.quantity, + minifigures.set_img_url, + minifigures.u_id, + {% block total_missing %} + NULL AS total_missing, -- dummy for order: total_missing + {% endblock %} + {% block total_quantity %} + NULL AS total_quantity, -- dummy for order: total_quantity + {% endblock %} + {% block total_sets %} + NULL AS total_sets -- dummy for order: total_sets + {% endblock %} +FROM minifigures + +{% block join %}{% endblock %} + +{% block where %}{% endblock %} + +{% block group %}{% endblock %} + +{% if order %} +ORDER BY {{ order }} +{% endif %} + +{% if limit %} +LIMIT {{ limit }} +{% endif %} diff --git a/bricktracker/sql/minifigure/delete/all_from_set.sql b/bricktracker/sql/minifigure/delete/all_from_set.sql new file mode 100644 index 0000000..b04b2b5 --- /dev/null +++ b/bricktracker/sql/minifigure/delete/all_from_set.sql @@ -0,0 +1,2 @@ +DELETE FROM minifigures +WHERE u_id IS NOT DISTINCT FROM :u_id \ No newline at end of file diff --git a/bricktracker/sql/minifigure/insert.sql b/bricktracker/sql/minifigure/insert.sql new file mode 100644 index 0000000..b957645 --- /dev/null +++ b/bricktracker/sql/minifigure/insert.sql @@ -0,0 +1,15 @@ +INSERT INTO minifigures ( + fig_num, + set_num, + name, + quantity, + set_img_url, + u_id +) VALUES ( + :fig_num, + :set_num, + :name, + :quantity, + :set_img_url, + :u_id +) diff --git a/bricktracker/sql/minifigure/list/all.sql b/bricktracker/sql/minifigure/list/all.sql new file mode 100644 index 0000000..804387a --- /dev/null +++ b/bricktracker/sql/minifigure/list/all.sql @@ -0,0 +1,34 @@ +{% extends 'minifigure/base/select.sql' %} + +{% block total_missing %} +SUM(IFNULL(missing_join.total, 0)) AS total_missing, +{% endblock %} + +{% block total_quantity %} +SUM(IFNULL(minifigures.quantity, 0)) AS total_quantity, +{% endblock %} + +{% block total_sets %} +COUNT(minifigures.set_num) AS total_sets +{% endblock %} + +{% block join %} +-- LEFT JOIN + SELECT to avoid messing the total +LEFT JOIN ( + SELECT + set_num, + u_id, + SUM(quantity) AS total + FROM missing + GROUP BY + set_num, + u_id +) missing_join +ON minifigures.u_id IS NOT DISTINCT FROM missing_join.u_id +AND minifigures.fig_num IS NOT DISTINCT FROM missing_join.set_num +{% endblock %} + +{% block group %} +GROUP BY + minifigures.fig_num +{% endblock %} diff --git a/bricktracker/sql/minifigure/list/from_set.sql b/bricktracker/sql/minifigure/list/from_set.sql new file mode 100644 index 0000000..60c79f3 --- /dev/null +++ b/bricktracker/sql/minifigure/list/from_set.sql @@ -0,0 +1,6 @@ +{% extends 'minifigure/base/select.sql' %} + +{% block where %} +WHERE u_id IS NOT DISTINCT FROM :u_id +AND set_num IS NOT DISTINCT FROM :set_num +{% endblock %} diff --git a/bricktracker/sql/minifigure/list/last.sql b/bricktracker/sql/minifigure/list/last.sql new file mode 100644 index 0000000..2660af9 --- /dev/null +++ b/bricktracker/sql/minifigure/list/last.sql @@ -0,0 +1,17 @@ +{% extends 'minifigure/base/select.sql' %} + +{% block total_missing %} +SUM(IFNULL(missing.quantity, 0)) AS total_missing, +{% endblock %} + +{% block join %} +LEFT JOIN missing +ON minifigures.fig_num IS NOT DISTINCT FROM missing.set_num +AND minifigures.u_id IS NOT DISTINCT FROM missing.u_id +{% endblock %} + +{% block group %} +GROUP BY + minifigures.fig_num, + minifigures.u_id +{% endblock %} diff --git a/bricktracker/sql/minifigure/list/missing_part.sql b/bricktracker/sql/minifigure/list/missing_part.sql new file mode 100644 index 0000000..5616ca9 --- /dev/null +++ b/bricktracker/sql/minifigure/list/missing_part.sql @@ -0,0 +1,15 @@ +{% extends 'minifigure/base/select.sql' %} + +{% block where %} +WHERE minifigures.fig_num IN ( + SELECT + missing.set_num + FROM missing + + WHERE missing.color_id IS NOT DISTINCT FROM :color_id + AND missing.element_id IS NOT DISTINCT FROM :element_id + AND missing.part_num IS NOT DISTINCT FROM :part_num + + GROUP BY missing.set_num +) +{% endblock %} diff --git a/bricktracker/sql/minifigure/list/using_part.sql b/bricktracker/sql/minifigure/list/using_part.sql new file mode 100644 index 0000000..4cca817 --- /dev/null +++ b/bricktracker/sql/minifigure/list/using_part.sql @@ -0,0 +1,15 @@ +{% extends 'minifigure/base/select.sql' %} + +{% block where %} +WHERE minifigures.fig_num IN ( + SELECT + inventory.set_num + FROM inventory + + WHERE inventory.color_id IS NOT DISTINCT FROM :color_id + AND inventory.element_id IS NOT DISTINCT FROM :element_id + AND inventory.part_num IS NOT DISTINCT FROM :part_num + + GROUP BY inventory.set_num +) +{% endblock %} diff --git a/bricktracker/sql/minifigure/select/generic.sql b/bricktracker/sql/minifigure/select/generic.sql new file mode 100644 index 0000000..704ca5c --- /dev/null +++ b/bricktracker/sql/minifigure/select/generic.sql @@ -0,0 +1,38 @@ +{% extends 'minifigure/base/select.sql' %} + +{% block total_missing %} +SUM(IFNULL(missing_join.total, 0)) AS total_missing, +{% endblock %} + +{% block total_quantity %} +SUM(IFNULL(minifigures.quantity, 0)) AS total_quantity, +{% endblock %} + +{% block total_sets %} +COUNT(minifigures.set_num) AS total_sets +{% endblock %} + +{% block join %} +-- LEFT JOIN + SELECT to avoid messing the total +LEFT JOIN ( + SELECT + set_num, + u_id, + SUM(quantity) AS total + FROM missing + GROUP BY + set_num, + u_id +) missing_join +ON minifigures.u_id IS NOT DISTINCT FROM missing_join.u_id +AND minifigures.fig_num IS NOT DISTINCT FROM missing_join.set_num +{% endblock %} + +{% block where %} +WHERE fig_num IS NOT DISTINCT FROM :fig_num +{% endblock %} + +{% block group %} +GROUP BY + minifigures.fig_num +{% endblock %} diff --git a/bricktracker/sql/minifigure/select/specific.sql b/bricktracker/sql/minifigure/select/specific.sql new file mode 100644 index 0000000..02100b8 --- /dev/null +++ b/bricktracker/sql/minifigure/select/specific.sql @@ -0,0 +1,7 @@ +{% extends 'minifigure/base/select.sql' %} + +{% block where %} +WHERE fig_num IS NOT DISTINCT FROM :fig_num +AND u_id IS NOT DISTINCT FROM :u_id +AND set_num IS NOT DISTINCT FROM :set_num +{% endblock %} diff --git a/bricktracker/sql/missing/delete/all_from_set.sql b/bricktracker/sql/missing/delete/all_from_set.sql new file mode 100644 index 0000000..6ec5f55 --- /dev/null +++ b/bricktracker/sql/missing/delete/all_from_set.sql @@ -0,0 +1,2 @@ +DELETE FROM missing +WHERE u_id IS NOT DISTINCT FROM :u_id \ No newline at end of file diff --git a/bricktracker/sql/missing/delete/from_set.sql b/bricktracker/sql/missing/delete/from_set.sql new file mode 100644 index 0000000..66819d2 --- /dev/null +++ b/bricktracker/sql/missing/delete/from_set.sql @@ -0,0 +1,4 @@ +DELETE FROM missing +WHERE set_num IS NOT DISTINCT FROM :set_num +AND id IS NOT DISTINCT FROM :id +AND u_id IS NOT DISTINCT FROM :u_id diff --git a/bricktracker/sql/missing/insert.sql b/bricktracker/sql/missing/insert.sql new file mode 100644 index 0000000..a883f50 --- /dev/null +++ b/bricktracker/sql/missing/insert.sql @@ -0,0 +1,20 @@ +INSERT INTO missing ( + set_num, + id, + part_num, + part_img_url_id, + color_id, + quantity, + element_id, + u_id +) +VALUES( + :set_num, + :id, + :part_num, + :part_img_url_id, + :color_id, + :quantity, + :element_id, + :u_id +) diff --git a/bricktracker/sql/missing/update/from_set.sql b/bricktracker/sql/missing/update/from_set.sql new file mode 100644 index 0000000..26ef04d --- /dev/null +++ b/bricktracker/sql/missing/update/from_set.sql @@ -0,0 +1,5 @@ +UPDATE missing +SET quantity = :quantity +WHERE set_num IS NOT DISTINCT FROM :set_num +AND id IS NOT DISTINCT FROM :id +AND u_id IS NOT DISTINCT FROM :u_id diff --git a/bricktracker/sql/part/base/select.sql b/bricktracker/sql/part/base/select.sql new file mode 100644 index 0000000..648cfad --- /dev/null +++ b/bricktracker/sql/part/base/select.sql @@ -0,0 +1,43 @@ +SELECT + inventory.set_num, + inventory.id, + inventory.part_num, + inventory.name, + inventory.part_img_url, + inventory.part_img_url_id, + inventory.color_id, + inventory.color_name, + inventory.quantity, + inventory.is_spare, + inventory.element_id, + inventory.u_id, + {% block total_missing %} + NULL AS total_missing, -- dummy for order: total_missing + {% endblock %} + {% block total_quantity %} + NULL AS total_quantity, -- dummy for order: total_quantity + {% endblock %} + {% block total_spare %} + NULL AS total_spare, -- dummy for order: total_spare + {% endblock %} + {% block total_sets %} + NULL AS total_sets, -- dummy for order: total_sets + {% endblock %} + {% block total_minifigures %} + NULL AS total_minifigures -- dummy for order: total_minifigures + {% endblock %} +FROM inventory + +{% block join %}{% endblock %} + +{% block where %}{% endblock %} + +{% block group %}{% endblock %} + +{% if order %} +ORDER BY {{ order }} +{% endif %} + +{% if limit %} +LIMIT {{ limit }} +{% endif %} diff --git a/bricktracker/sql/part/delete/all_from_set.sql b/bricktracker/sql/part/delete/all_from_set.sql new file mode 100644 index 0000000..99c576a --- /dev/null +++ b/bricktracker/sql/part/delete/all_from_set.sql @@ -0,0 +1,2 @@ +DELETE FROM inventory +WHERE u_id IS NOT DISTINCT FROM :u_id \ No newline at end of file diff --git a/bricktracker/sql/part/insert.sql b/bricktracker/sql/part/insert.sql new file mode 100644 index 0000000..6e47df1 --- /dev/null +++ b/bricktracker/sql/part/insert.sql @@ -0,0 +1,27 @@ +INSERT INTO inventory ( + set_num, + id, + part_num, + name, + part_img_url, + part_img_url_id, + color_id, + color_name, + quantity, + is_spare, + element_id, + u_id +) VALUES ( + :set_num, + :id, + :part_num, + :name, + :part_img_url, + :part_img_url_id, + :color_id, + :color_name, + :quantity, + :is_spare, + :element_id, + :u_id +) diff --git a/bricktracker/sql/part/list/all.sql b/bricktracker/sql/part/list/all.sql new file mode 100644 index 0000000..5c48393 --- /dev/null +++ b/bricktracker/sql/part/list/all.sql @@ -0,0 +1,43 @@ +{% extends 'part/base/select.sql' %} + +{% block total_missing %} +SUM(IFNULL(missing.quantity, 0)) AS total_missing, +{% endblock %} + +{% block total_quantity %} +SUM(inventory.quantity * IFNULL(minifigures.quantity, 1)) AS total_quantity, +{% endblock %} + +{% block total_sets %} +COUNT(DISTINCT sets.u_id) AS total_sets, +{% endblock %} + +{% block total_minifigures %} +SUM(IFNULL(minifigures.quantity, 0)) AS total_minifigures +{% endblock %} + +{% block join %} +LEFT JOIN missing +ON inventory.set_num IS NOT DISTINCT FROM missing.set_num +AND inventory.id IS NOT DISTINCT FROM missing.id +AND inventory.part_num IS NOT DISTINCT FROM missing.part_num +AND inventory.color_id IS NOT DISTINCT FROM missing.color_id +AND inventory.element_id IS NOT DISTINCT FROM missing.element_id +AND inventory.u_id IS NOT DISTINCT FROM missing.u_id + +LEFT JOIN minifigures +ON inventory.set_num IS NOT DISTINCT FROM minifigures.fig_num +AND inventory.u_id IS NOT DISTINCT FROM minifigures.u_id + +LEFT JOIN sets +ON inventory.u_id IS NOT DISTINCT FROM sets.u_id +{% endblock %} + +{% block group %} +GROUP BY + inventory.part_num, + inventory.name, + inventory.color_id, + inventory.is_spare, + inventory.element_id +{% endblock %} diff --git a/bricktracker/sql/part/list/from_minifigure.sql b/bricktracker/sql/part/list/from_minifigure.sql new file mode 100644 index 0000000..9c2128b --- /dev/null +++ b/bricktracker/sql/part/list/from_minifigure.sql @@ -0,0 +1,28 @@ + +{% extends 'part/base/select.sql' %} + +{% block total_missing %} +SUM(IFNULL(missing.quantity, 0)) AS total_missing, +{% endblock %} + +{% block join %} +LEFT JOIN missing +ON missing.set_num IS NOT DISTINCT FROM inventory.set_num +AND missing.id IS NOT DISTINCT FROM inventory.id +AND missing.part_num IS NOT DISTINCT FROM inventory.part_num +AND missing.color_id IS NOT DISTINCT FROM inventory.color_id +AND missing.element_id IS NOT DISTINCT FROM inventory.element_id +{% endblock %} + +{% block where %} +WHERE inventory.set_num IS NOT DISTINCT FROM :set_num +{% endblock %} + +{% block group %} +GROUP BY + inventory.part_num, + inventory.name, + inventory.color_id, + inventory.is_spare, + inventory.element_id +{% endblock %} diff --git a/bricktracker/sql/part/list/from_set.sql b/bricktracker/sql/part/list/from_set.sql new file mode 100644 index 0000000..1e9d411 --- /dev/null +++ b/bricktracker/sql/part/list/from_set.sql @@ -0,0 +1,21 @@ + +{% extends 'part/base/select.sql' %} + +{% block total_missing %} +IFNULL(missing.quantity, 0) AS total_missing, +{% endblock %} + +{% block join %} +LEFT JOIN missing +ON inventory.set_num IS NOT DISTINCT FROM missing.set_num +AND inventory.id IS NOT DISTINCT FROM missing.id +AND inventory.part_num IS NOT DISTINCT FROM missing.part_num +AND inventory.color_id IS NOT DISTINCT FROM missing.color_id +AND inventory.element_id IS NOT DISTINCT FROM missing.element_id +AND inventory.u_id IS NOT DISTINCT FROM missing.u_id +{% endblock %} + +{% block where %} +WHERE inventory.u_id IS NOT DISTINCT FROM :u_id +AND inventory.set_num IS NOT DISTINCT FROM :set_num +{% endblock %} diff --git a/bricktracker/sql/part/list/missing.sql b/bricktracker/sql/part/list/missing.sql new file mode 100644 index 0000000..e85bbd4 --- /dev/null +++ b/bricktracker/sql/part/list/missing.sql @@ -0,0 +1,36 @@ +{% extends 'part/base/select.sql' %} + +{% block total_missing %} +SUM(IFNULL(missing.quantity, 0)) AS total_missing, +{% endblock %} + +{% block total_sets %} +COUNT(inventory.u_id) - COUNT(minifigures.u_id) AS total_sets, +{% endblock %} + +{% block total_minifigures %} +SUM(IFNULL(minifigures.quantity, 0)) AS total_minifigures +{% endblock %} + +{% block join %} +INNER JOIN missing +ON missing.set_num IS NOT DISTINCT FROM inventory.set_num +AND missing.id IS NOT DISTINCT FROM inventory.id +AND missing.part_num IS NOT DISTINCT FROM inventory.part_num +AND missing.color_id IS NOT DISTINCT FROM inventory.color_id +AND missing.element_id IS NOT DISTINCT FROM inventory.element_id +AND missing.u_id IS NOT DISTINCT FROM inventory.u_id + +LEFT JOIN minifigures +ON missing.set_num IS NOT DISTINCT FROM minifigures.fig_num +AND missing.u_id IS NOT DISTINCT FROM minifigures.u_id +{% endblock %} + +{% block group %} +GROUP BY + inventory.part_num, + inventory.name, + inventory.color_id, + inventory.is_spare, + inventory.element_id +{% endblock %} diff --git a/bricktracker/sql/part/select/generic.sql b/bricktracker/sql/part/select/generic.sql new file mode 100644 index 0000000..93b3866 --- /dev/null +++ b/bricktracker/sql/part/select/generic.sql @@ -0,0 +1,36 @@ +{% extends 'part/base/select.sql' %} + +{% block total_missing %} +SUM(IFNULL(missing.quantity, 0)) AS total_missing, +{% endblock %} + +{% block total_quantity %} +SUM(IFNULL((NOT inventory.is_spare) * inventory.quantity, 0)) AS total_quantity, +{% endblock %} + +{% block total_spare %} +SUM(IFNULL(inventory.is_spare * inventory.quantity, 0)) AS total_spare, +{% endblock %} + +{% block join %} +LEFT JOIN missing +ON inventory.set_num IS NOT DISTINCT FROM missing.set_num +AND inventory.id IS NOT DISTINCT FROM missing.id +AND inventory.part_num IS NOT DISTINCT FROM missing.part_num +AND inventory.color_id IS NOT DISTINCT FROM missing.color_id +AND inventory.element_id IS NOT DISTINCT FROM missing.element_id +AND inventory.u_id IS NOT DISTINCT FROM missing.u_id +{% endblock %} + +{% block where %} +WHERE inventory.part_num IS NOT DISTINCT FROM :part_num +AND inventory.color_id IS NOT DISTINCT FROM :color_id +AND inventory.element_id IS NOT DISTINCT FROM :element_id +{% endblock %} + +{% block group %} +GROUP BY + inventory.part_num, + inventory.color_id, + inventory.element_id +{% endblock %} diff --git a/bricktracker/sql/part/select/specific.sql b/bricktracker/sql/part/select/specific.sql new file mode 100644 index 0000000..1918b3f --- /dev/null +++ b/bricktracker/sql/part/select/specific.sql @@ -0,0 +1,24 @@ +{% extends 'part/base/select.sql' %} + +{% block join %} +LEFT JOIN missing +ON inventory.set_num IS NOT DISTINCT FROM missing.set_num +AND inventory.id IS NOT DISTINCT FROM missing.id +AND inventory.u_id IS NOT DISTINCT FROM missing.u_id +{% endblock %} + +{% block where %} +WHERE inventory.u_id IS NOT DISTINCT FROM :u_id +AND inventory.set_num IS NOT DISTINCT FROM :set_num +AND inventory.id IS NOT DISTINCT FROM :id +{% endblock %} + +{% block group %} +GROUP BY + inventory.set_num, + inventory.id, + inventory.part_num, + inventory.color_id, + inventory.element_id, + inventory.u_id +{% endblock %} diff --git a/bricktracker/sql/schema/count.sql b/bricktracker/sql/schema/count.sql new file mode 100644 index 0000000..6f2d241 --- /dev/null +++ b/bricktracker/sql/schema/count.sql @@ -0,0 +1,2 @@ +SELECT COUNT(*) AS count +FROM {{ table }} \ No newline at end of file diff --git a/bricktracker/sql/schema/drop.sql b/bricktracker/sql/schema/drop.sql new file mode 100644 index 0000000..450bc3d --- /dev/null +++ b/bricktracker/sql/schema/drop.sql @@ -0,0 +1,9 @@ +BEGIN transaction; + +DROP TABLE IF EXISTS wishlist; +DROP TABLE IF EXISTS sets; +DROP TABLE IF EXISTS inventory; +DROP TABLE IF EXISTS minifigures; +DROP TABLE IF EXISTS missing; + +COMMIT; \ No newline at end of file diff --git a/bricktracker/sql/schema/is_init.sql b/bricktracker/sql/schema/is_init.sql new file mode 100644 index 0000000..17a3a81 --- /dev/null +++ b/bricktracker/sql/schema/is_init.sql @@ -0,0 +1,4 @@ +SELECT name +FROM sqlite_master +WHERE type="table" +AND name="sets" diff --git a/bricktracker/sql/set/base/select.sql b/bricktracker/sql/set/base/select.sql new file mode 100644 index 0000000..4162375 --- /dev/null +++ b/bricktracker/sql/set/base/select.sql @@ -0,0 +1,52 @@ +SELECT + sets.set_num, + sets.name, + sets.year, + sets.theme_id, + sets.num_parts, + sets.set_img_url, + sets.set_url, + sets.last_modified_dt, + sets.mini_col, + sets.set_check, + sets.set_col, + sets.u_id, + {% block number %} + CAST(SUBSTR(sets.set_num, 1, INSTR(sets.set_num, '-') - 1) AS INTEGER) AS set_number, + CAST(SUBSTR(sets.set_num, 1, INSTR(sets.set_num, '-') + 1) AS INTEGER) AS set_version, + {% endblock %} + IFNULL(missing_join.total, 0) AS total_missing, + IFNULL(minifigures_join.total, 0) AS total_minifigures +FROM sets + +-- LEFT JOIN + SELECT to avoid messing the total +LEFT JOIN ( + SELECT + u_id, + SUM(quantity) AS total + FROM missing + {% block where_missing %}{% endblock %} + GROUP BY u_id +) missing_join +ON sets.u_id IS NOT DISTINCT FROM missing_join.u_id + +-- LEFT JOIN + SELECT to avoid messing the total +LEFT JOIN ( + SELECT + u_id, + SUM(quantity) AS total + FROM minifigures + {% block where_minifigures %}{% endblock %} + GROUP BY u_id +) minifigures_join +ON sets.u_id IS NOT DISTINCT FROM minifigures_join.u_id + +{% block where %}{% endblock %} + +{% if order %} +ORDER BY {{ order }} +{% endif %} + +{% if limit %} +LIMIT {{ limit }} +{% endif %} diff --git a/bricktracker/sql/set/delete/set.sql b/bricktracker/sql/set/delete/set.sql new file mode 100644 index 0000000..c4f3ebf --- /dev/null +++ b/bricktracker/sql/set/delete/set.sql @@ -0,0 +1,2 @@ +DELETE FROM sets +WHERE u_id IS NOT DISTINCT FROM :u_id \ No newline at end of file diff --git a/bricktracker/sql/set/insert.sql b/bricktracker/sql/set/insert.sql new file mode 100644 index 0000000..5858039 --- /dev/null +++ b/bricktracker/sql/set/insert.sql @@ -0,0 +1,27 @@ +INSERT INTO sets ( + set_num, + name, + year, + theme_id, + num_parts, + set_img_url, + set_url, + last_modified_dt, + mini_col, + set_check, + set_col, + u_id +) VALUES ( + :set_num, + :name, + :year, + :theme_id, + :num_parts, + :set_img_url, + :set_url, + :last_modified_dt, + :mini_col, + :set_check, + :set_col, + :u_id +) diff --git a/bricktracker/sql/set/list/all.sql b/bricktracker/sql/set/list/all.sql new file mode 100644 index 0000000..66e3549 --- /dev/null +++ b/bricktracker/sql/set/list/all.sql @@ -0,0 +1 @@ +{% extends 'set/base/select.sql' %} diff --git a/bricktracker/sql/set/list/generic.sql b/bricktracker/sql/set/list/generic.sql new file mode 100644 index 0000000..f4cfc12 --- /dev/null +++ b/bricktracker/sql/set/list/generic.sql @@ -0,0 +1,12 @@ +SELECT + sets.set_num, + sets.name, + sets.year, + sets.theme_id, + sets.num_parts, + sets.set_img_url, + sets.set_url +FROM sets + +GROUP BY + sets.set_num diff --git a/bricktracker/sql/set/list/missing_minifigure.sql b/bricktracker/sql/set/list/missing_minifigure.sql new file mode 100644 index 0000000..6a5fd28 --- /dev/null +++ b/bricktracker/sql/set/list/missing_minifigure.sql @@ -0,0 +1,13 @@ +{% extends 'set/base/select.sql' %} + +{% block where %} +WHERE sets.u_id IN ( + SELECT + missing.u_id + FROM missing + + WHERE missing.set_num IS NOT DISTINCT FROM :fig_num + + GROUP BY missing.u_id +) +{% endblock %} diff --git a/bricktracker/sql/set/list/missing_part.sql b/bricktracker/sql/set/list/missing_part.sql new file mode 100644 index 0000000..1d14daf --- /dev/null +++ b/bricktracker/sql/set/list/missing_part.sql @@ -0,0 +1,15 @@ +{% extends 'set/base/select.sql' %} + +{% block where %} +WHERE sets.u_id IN ( + SELECT + missing.u_id + FROM missing + + WHERE missing.color_id IS NOT DISTINCT FROM :color_id + AND missing.element_id IS NOT DISTINCT FROM :element_id + AND missing.part_num IS NOT DISTINCT FROM :part_num + + GROUP BY missing.u_id +) +{% endblock %} diff --git a/bricktracker/sql/set/list/using_minifigure.sql b/bricktracker/sql/set/list/using_minifigure.sql new file mode 100644 index 0000000..de98221 --- /dev/null +++ b/bricktracker/sql/set/list/using_minifigure.sql @@ -0,0 +1,13 @@ +{% extends 'set/base/select.sql' %} + +{% block where %} +WHERE sets.u_id IN ( + SELECT + inventory.u_id + FROM inventory + + WHERE inventory.set_num IS NOT DISTINCT FROM :fig_num + + GROUP BY inventory.u_id +) +{% endblock %} diff --git a/bricktracker/sql/set/list/using_part.sql b/bricktracker/sql/set/list/using_part.sql new file mode 100644 index 0000000..afa788d --- /dev/null +++ b/bricktracker/sql/set/list/using_part.sql @@ -0,0 +1,15 @@ +{% extends 'set/base/select.sql' %} + +{% block where %} +WHERE sets.u_id IN ( + SELECT + inventory.u_id + FROM inventory + + WHERE inventory.color_id IS NOT DISTINCT FROM :color_id + AND inventory.element_id IS NOT DISTINCT FROM :element_id + AND inventory.part_num IS NOT DISTINCT FROM :part_num + + GROUP BY inventory.u_id +) +{% endblock %} diff --git a/bricktracker/sql/set/select.sql b/bricktracker/sql/set/select.sql new file mode 100644 index 0000000..d9c10aa --- /dev/null +++ b/bricktracker/sql/set/select.sql @@ -0,0 +1,13 @@ +{% extends 'set/base/select.sql' %} + +{% block where_missing %} +WHERE u_id IS NOT DISTINCT FROM :u_id +{% endblock %} + +{% block where_minifigures %} +WHERE u_id IS NOT DISTINCT FROM :u_id +{% endblock %} + +{% block where %} +WHERE sets.u_id IS NOT DISTINCT FROM :u_id +{% endblock %} diff --git a/bricktracker/sql/set/update_checked.sql b/bricktracker/sql/set/update_checked.sql new file mode 100644 index 0000000..77a5cca --- /dev/null +++ b/bricktracker/sql/set/update_checked.sql @@ -0,0 +1,3 @@ +UPDATE sets +SET {{name}} = :status +WHERE u_id IS NOT DISTINCT FROM :u_id diff --git a/bricktracker/sql/wish/base/select.sql b/bricktracker/sql/wish/base/select.sql new file mode 100644 index 0000000..0d516c2 --- /dev/null +++ b/bricktracker/sql/wish/base/select.sql @@ -0,0 +1,20 @@ +SELECT + wishlist.set_num, + wishlist.name, + wishlist.year, + wishlist.theme_id, + wishlist.num_parts, + wishlist.set_img_url, + wishlist.set_url, + wishlist.last_modified_dt +FROM wishlist + +{% block where %}{% endblock %} + +{% if order %} +ORDER BY {{ order }} +{% endif %} + +{% if limit %} +LIMIT {{ limit }} +{% endif %} diff --git a/bricktracker/sql/wish/delete/wish.sql b/bricktracker/sql/wish/delete/wish.sql new file mode 100644 index 0000000..387ffd7 --- /dev/null +++ b/bricktracker/sql/wish/delete/wish.sql @@ -0,0 +1,2 @@ +DELETE FROM wishlist +WHERE set_num IS NOT DISTINCT FROM :set_num \ No newline at end of file diff --git a/bricktracker/sql/wish/insert.sql b/bricktracker/sql/wish/insert.sql new file mode 100644 index 0000000..9ace503 --- /dev/null +++ b/bricktracker/sql/wish/insert.sql @@ -0,0 +1,19 @@ +INSERT INTO wishlist ( + set_num, + name, + year, + theme_id, + num_parts, + set_img_url, + set_url, + last_modified_dt +) VALUES ( + :set_num, + :name, + :year, + :theme_id, + :num_parts, + :set_img_url, + :set_url, + :last_modified_dt +) diff --git a/bricktracker/sql/wish/list/all.sql b/bricktracker/sql/wish/list/all.sql new file mode 100644 index 0000000..e1e10c5 --- /dev/null +++ b/bricktracker/sql/wish/list/all.sql @@ -0,0 +1 @@ +{% extends 'wish/base/select.sql' %} diff --git a/bricktracker/sql/wish/select.sql b/bricktracker/sql/wish/select.sql new file mode 100644 index 0000000..2559843 --- /dev/null +++ b/bricktracker/sql/wish/select.sql @@ -0,0 +1,5 @@ +{% extends 'wish/base/select.sql' %} + +{% block where %} +WHERE wishlist.set_num IS NOT DISTINCT FROM :set_num +{% endblock %} diff --git a/bricktracker/sql_stats.py b/bricktracker/sql_stats.py new file mode 100644 index 0000000..a7896c6 --- /dev/null +++ b/bricktracker/sql_stats.py @@ -0,0 +1,36 @@ +# Some stats on the database +class BrickSQLStats(object): + # Functions + connect: int + commit: int + execute: int + executescript: int + fetchall: int + fetchone: int + + # Records + fetched: int + changed: int + + def __init__(self, /): + self.connect = 0 + self.commit = 0 + self.execute = 0 + self.executescript = 0 + self.fetchall = 0 + self.fetchone = 0 + self.fetched = 0 + self.changed = 0 + + # Print the stats + def print(self, /) -> str: + items: list[str] = [] + + for key, value in self.__dict__.items(): + if value: + items.append('{key}: {value}'.format( + key=key.capitalize(), + value=value, + )) + + return ' - '.join(items) diff --git a/bricktracker/theme.py b/bricktracker/theme.py new file mode 100644 index 0000000..3ee1068 --- /dev/null +++ b/bricktracker/theme.py @@ -0,0 +1,14 @@ +# Lego set theme +class BrickTheme(object): + id: int + name: str + parent: int | None + + def __init__(self, id: str | int, name: str, parent: str | None = None, /): + self.id = int(id) + self.name = name + + if parent is not None and parent != '': + self.parent = int(parent) + else: + self.parent = None diff --git a/bricktracker/theme_list.py b/bricktracker/theme_list.py new file mode 100644 index 0000000..d8a0b47 --- /dev/null +++ b/bricktracker/theme_list.py @@ -0,0 +1,104 @@ +from datetime import datetime, timezone +import csv +import gzip +import logging +import os +from shutil import copyfileobj + +from flask import current_app, g +import humanize +import requests + +from .exceptions import ErrorException +from .theme import BrickTheme + +logger = logging.getLogger(__name__) + + +# Lego sets themes +class BrickThemeList(object): + themes: dict[int, BrickTheme] + mtime: datetime | None + size: int | None + exception: Exception | None + + def __init__(self, /, force: bool = False): + # Load themes only if there is none already loaded + themes = getattr(self, 'themes', None) + + if themes is None or force: + logger.info('Loading themes list') + + BrickThemeList.themes = {} + + # Try to read the themes from a CSV file + try: + with open(current_app.config['THEMES_PATH'].value, newline='') as themes_file: # noqa: E501 + themes_reader = csv.reader(themes_file) + + # Ignore the header + next(themes_reader, None) + + for row in themes_reader: + theme = BrickTheme(*row) + BrickThemeList.themes[theme.id] = theme + + # File stats + stat = os.stat(current_app.config['THEMES_PATH'].value) + BrickThemeList.size = stat.st_size + BrickThemeList.mtime = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc) # noqa: E501 + + BrickThemeList.exception = None + + # Ignore errors + except Exception as e: + BrickThemeList.exception = e + BrickThemeList.size = None + BrickThemeList.mtime = None + + # Get a theme + def get(self, id: int, /) -> BrickTheme: + # Seed a fake entry if missing + if id not in self.themes: + BrickThemeList.themes[id] = BrickTheme( + id, + 'Unknown ({id})'.format(id=id) + ) + + return self.themes[id] + + # Display the size in a human format + def human_size(self) -> str: + if self.size is not None: + return humanize.naturalsize(self.size) + else: + return '' + + # Display the time in a human format + def human_time(self) -> str: + if self.mtime is not None: + return self.mtime.astimezone(g.timezone).strftime( + current_app.config['FILE_DATETIME_FORMAT'].value + ) + else: + return '' + + # Update the file + @staticmethod + def update() -> None: + response = requests.get( + current_app.config['THEMES_FILE_URL'].value, + stream=True, + ) + + if not response.ok: + raise ErrorException('An error occured while downloading the themes file ({code})'.format( # noqa: E501 + code=response.status_code + )) + + content = gzip.GzipFile(fileobj=response.raw) + + with open(current_app.config['THEMES_PATH'].value, 'wb') as f: + copyfileobj(content, f) + + logger.info('Theme list updated') diff --git a/bricktracker/version.py b/bricktracker/version.py new file mode 100644 index 0000000..1f356cc --- /dev/null +++ b/bricktracker/version.py @@ -0,0 +1 @@ +__version__ = '1.0.0' diff --git a/bricktracker/views/__init__.py b/bricktracker/views/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bricktracker/views/add.py b/bricktracker/views/add.py new file mode 100644 index 0000000..45b08a3 --- /dev/null +++ b/bricktracker/views/add.py @@ -0,0 +1,38 @@ +from flask import Blueprint, current_app, render_template +from flask_login import login_required + +from ..configuration_list import BrickConfigurationList +from .exceptions import exception_handler +from ..socket import MESSAGES + +add_page = Blueprint('add', __name__, url_prefix='/add') + + +# Add a set +@add_page.route('/', methods=['GET']) +@login_required +@exception_handler(__file__) +def add() -> str: + BrickConfigurationList.error_unless_is_set('REBRICKABLE_API_KEY') + + return render_template( + 'add.html', + path=current_app.config['SOCKET_PATH'].value, + namespace=current_app.config['SOCKET_NAMESPACE'].value, + messages=MESSAGES + ) + + +# Bulk add sets +@add_page.route('/bulk', methods=['GET']) +@login_required +@exception_handler(__file__) +def bulk() -> str: + BrickConfigurationList.error_unless_is_set('REBRICKABLE_API_KEY') + + return render_template( + 'bulk.html', + path=current_app.config['SOCKET_PATH'].value, + namespace=current_app.config['SOCKET_NAMESPACE'].value, + messages=MESSAGES + ) diff --git a/bricktracker/views/admin.py b/bricktracker/views/admin.py new file mode 100644 index 0000000..b5c12a8 --- /dev/null +++ b/bricktracker/views/admin.py @@ -0,0 +1,258 @@ +from datetime import datetime +import logging +import os + +from flask import ( + Blueprint, + current_app, + g, + redirect, + request, + render_template, + send_file, + url_for, +) +from flask_login import login_required +from werkzeug.wrappers.response import Response + +from ..configuration_list import BrickConfigurationList +from .exceptions import exception_handler +from ..instructions_list import BrickInstructionsList +from ..retired_list import BrickRetiredList +from ..sql import BrickSQL +from ..theme_list import BrickThemeList +from .upload import upload_helper + +logger = logging.getLogger(__name__) + +admin_page = Blueprint('admin', __name__, url_prefix='/admin') + + +# Admin +@admin_page.route('/', methods=['GET']) +@login_required +@exception_handler(__file__) +def admin() -> str: + counters: dict[str, int] = {} + exception: Exception | None = None + is_init: bool = False + + # This view needs to be protected against SQL errors + try: + is_init = BrickSQL.is_init() + + if is_init: + counters = BrickSQL.count_records() + except Exception as e: + exception = e + + # Warning + logger.warning('An exception occured while loading the admin page: {exception}'.format( # noqa: E501 + exception=str(e), + )) + + open_instructions = request.args.get('open_instructions', None) + open_logout = request.args.get('open_logout', None) + open_retired = request.args.get('open_retired', None) + open_theme = request.args.get('open_theme', None) + + open_database = ( + open_instructions is not None and + open_logout is not None and + open_logout is not None and + open_theme is not None + ) + + return render_template( + 'admin.html', + configuration=BrickConfigurationList.list(), + counters=counters, + error=request.args.get('error'), + exception=exception, + instructions=BrickInstructionsList(), + is_init=is_init, + open_database=open_database, + open_instructions=open_instructions, + open_logout=open_logout, + open_retired=open_retired, + open_theme=open_theme, + retired=BrickRetiredList(), + theme=BrickThemeList(), + ) + + +# Initialize the database +@admin_page.route('/init-database', methods=['POST']) +@login_required +@exception_handler(__file__, post_redirect='admin.admin') +def init_database() -> Response: + BrickSQL.initialize() + + # Reload the instructions + BrickInstructionsList(force=True) + + return redirect(url_for('admin.admin')) + + +# Delete the database +@admin_page.route('/delete-database', methods=['GET']) +@login_required +@exception_handler(__file__) +def delete_database() -> str: + return render_template( + 'admin.html', + delete_database=True, + error=request.args.get('error') + ) + + +# Actually delete the database +@admin_page.route('/delete-database', methods=['POST']) +@login_required +@exception_handler(__file__, post_redirect='admin.delete_database') +def do_delete_database() -> Response: + BrickSQL.delete() + + # Reload the instructions + BrickInstructionsList(force=True) + + return redirect(url_for('admin.admin')) + + +# Download the database +@admin_page.route('/download-database', methods=['GET']) +@login_required +@exception_handler(__file__) +def download_database() -> Response: + # Create a file name with a timestamp embedded + name, extension = os.path.splitext( + os.path.basename(current_app.config['DATABASE_PATH'].value) + ) + + # Info + logger.info('The database has been downloaded') + + return send_file( + current_app.config['DATABASE_PATH'].value, + as_attachment=True, + download_name='{name}-{timestamp}{extension}'.format( + name=name, + timestamp=datetime.now().astimezone(g.timezone).strftime( + current_app.config['DATABASE_TIMESTAMP_FORMAT'].value + ), + extension=extension + ) + ) + + +# Drop the database +@admin_page.route('/drop-database', methods=['GET']) +@login_required +@exception_handler(__file__) +def drop_database() -> str: + return render_template( + 'admin.html', + drop_database=True, + error=request.args.get('error') + ) + + +# Actually drop the database +@admin_page.route('/drop-database', methods=['POST']) +@login_required +@exception_handler(__file__, post_redirect='admin.drop_database') +def do_drop_database() -> Response: + BrickSQL.drop() + + # Reload the instructions + BrickInstructionsList(force=True) + + return redirect(url_for('admin.admin')) + + +# Import a database +@admin_page.route('/import-database', methods=['GET']) +@login_required +@exception_handler(__file__) +def import_database() -> str: + return render_template( + 'admin.html', + import_database=True, + error=request.args.get('error') + ) + + +# Actually import a database +@admin_page.route('/import-database', methods=['POST']) +@login_required +@exception_handler(__file__, post_redirect='admin.import_database') +def do_import_database() -> Response: + file = upload_helper( + 'database', + 'admin.import_database', + extensions=['.db'], + ) + + if isinstance(file, Response): + return file + + BrickSQL.upload(file) + + # Reload the instructions + BrickInstructionsList(force=True) + + return redirect(url_for('admin.admin')) + + +# Refresh the instructions cache +@admin_page.route('/refresh-instructions', methods=['GET']) +@login_required +@exception_handler(__file__) +def refresh_instructions() -> Response: + BrickInstructionsList(force=True) + + return redirect(url_for('admin.admin', open_instructions=True)) + + +# Refresh the retired sets cache +@admin_page.route('/refresh-retired', methods=['GET']) +@login_required +@exception_handler(__file__) +def refresh_retired() -> Response: + BrickRetiredList(force=True) + + return redirect(url_for('admin.admin', open_retired=True)) + + +# Refresh the themes cache +@admin_page.route('/refresh-themes', methods=['GET']) +@login_required +@exception_handler(__file__) +def refresh_themes() -> Response: + BrickThemeList(force=True) + + return redirect(url_for('admin.admin', open_theme=True)) + + +# Update the themes file +@admin_page.route('/update-retired', methods=['GET']) +@login_required +@exception_handler(__file__) +def update_retired() -> Response: + BrickRetiredList().update() + + BrickRetiredList(force=True) + + return redirect(url_for('admin.admin', open_retired=True)) + + +# Update the themes file +@admin_page.route('/update-themes', methods=['GET']) +@login_required +@exception_handler(__file__) +def update_themes() -> Response: + BrickThemeList().update() + + BrickThemeList(force=True) + + return redirect(url_for('admin.admin', open_theme=True)) diff --git a/bricktracker/views/error.py b/bricktracker/views/error.py new file mode 100644 index 0000000..1cce1c0 --- /dev/null +++ b/bricktracker/views/error.py @@ -0,0 +1,144 @@ +import logging +from sqlite3 import Error, OperationalError +import traceback +from typing import Tuple + +from flask import jsonify, redirect, request, render_template, url_for +from werkzeug.wrappers.response import Response + +from ..exceptions import DatabaseException, ErrorException, NotFoundException + +logger = logging.getLogger(__name__) + + +# Get the cleaned exception +def cleaned_exception(e: Exception, /) -> str: + trace = traceback.TracebackException.from_exception(e) + + cleaned: list[str] = [] + + # Hacky: stripped from the call to the decorator wrapper() or outer() + for line in trace.format(): + if 'in wrapper' not in line and 'in outer' not in line: + cleaned.append(line) + + return ''.join(cleaned) + + +# Generic error +def error( + error: Exception | None, + file: str, + /, + json: bool = False, + post_redirect: str | None = None, + **kwargs, +) -> str | Tuple[str | Response, int] | Response: + # Back to the index if no error (not sure if this can happen) + if error is None: + if json: + return jsonify({'error': 'error() called without an error'}) + else: + return redirect(url_for('index.index')) + + # Convert SQLite errors + if isinstance(error, (Error, OperationalError)): + error = DatabaseException(error) + + # Clear redirect if not POST or json + if json or request.method != 'POST': + post_redirect = None + + # Not found + if isinstance(error, NotFoundException): + return error_404( + error, + json=json, + post_redirect=post_redirect, + **kwargs + ) + + # Common error + elif isinstance(error, ErrorException): + # Error + logger.error('{title}: {error}'.format( + title=error.title, + error=str(error), + )) + + # Debug + logger.debug(cleaned_exception(error)) + + if json: + return jsonify({'error': str(error)}) + elif post_redirect is not None: + return redirect(url_for( + post_redirect, + error=str(error), + **kwargs, + )) + else: + return render_template( + 'error.html', + title=error.title, + error=str(error) + ) + + # Exception + else: + # Error + logger.error(cleaned_exception(error)) + + if error.__traceback__ is not None: + line = error.__traceback__.tb_lineno + else: + line = None + + if json: + return jsonify({ + 'error': 'Exception: {error}'.format(error=str(error)), + 'name': type(error).__name__, + 'line': line, + 'file': file, + }), 500 + elif post_redirect is not None: + return redirect(url_for( + post_redirect, + error=str(error), + **kwargs, + )) + else: + return render_template( + 'exception.html', + error=str(error), + name=type(error).__name__, + line=line, + file=file, + ) + + +# Error 404 +def error_404( + error: Exception, + /, + json: bool = False, + post_redirect: str | None = None, + **kwargs, +) -> Tuple[str | Response, int]: + # Warning + logger.warning('Not found: {error}'.format( + error=str(error), + )) + + if json: + return jsonify({ + 'error': 'Not found: {error}'.format(error=str(error)) + }), 404 + elif post_redirect is not None: + return redirect(url_for( + post_redirect, + error=str(error), + **kwargs + )), 404 + else: + return render_template('404.html', error=str(error)), 404 diff --git a/bricktracker/views/exceptions.py b/bricktracker/views/exceptions.py new file mode 100644 index 0000000..06120d4 --- /dev/null +++ b/bricktracker/views/exceptions.py @@ -0,0 +1,46 @@ +from functools import wraps +import logging +from typing import Callable, ParamSpec, Tuple, Union + +from werkzeug.wrappers.response import Response + +from .error import error + +logger = logging.getLogger(__name__) + +# Decorator type hinting is hard. +# What a view can return (str or Response or (Response, xxx)) +ViewReturn = Union[ + str, + Response, + Tuple[str | Response, int] +] + +# View signature (*arg, **kwargs -> (str or Response or (Response, xxx)) +P = ParamSpec('P') +ViewCallable = Callable[P, ViewReturn] + + +# Return the exception template or response if an exception occured +def exception_handler( + file: str, + /, + json: bool = False, + post_redirect: str | None = None +) -> Callable[[ViewCallable], ViewCallable]: + def outer(function: ViewCallable, /) -> ViewCallable: + @wraps(function) + def wrapper(*args, **kwargs) -> ViewReturn: + try: + return function(*args, **kwargs) + # Catch SQLite errors as database errors + except Exception as e: + return error( + e, + file, + json=json, + post_redirect=post_redirect, + **kwargs, + ) + return wrapper + return outer diff --git a/bricktracker/views/index.py b/bricktracker/views/index.py new file mode 100644 index 0000000..511e858 --- /dev/null +++ b/bricktracker/views/index.py @@ -0,0 +1,18 @@ +from flask import Blueprint, render_template + +from .exceptions import exception_handler +from ..minifigure_list import BrickMinifigureList +from ..set_list import BrickSetList + +index_page = Blueprint('index', __name__) + + +# Index +@index_page.route('/', methods=['GET']) +@exception_handler(__file__) +def index() -> str: + return render_template( + 'index.html', + brickset_collection=BrickSetList().last(), + minifigure_collection=BrickMinifigureList().last(), + ) diff --git a/bricktracker/views/instructions.py b/bricktracker/views/instructions.py new file mode 100644 index 0000000..6145914 --- /dev/null +++ b/bricktracker/views/instructions.py @@ -0,0 +1,128 @@ +from flask import ( + current_app, + Blueprint, + redirect, + render_template, + request, + url_for +) +from flask_login import login_required +from werkzeug.wrappers.response import Response +from werkzeug.utils import secure_filename + +from .exceptions import exception_handler +from ..instructions import BrickInstructions +from ..instructions_list import BrickInstructionsList +from .upload import upload_helper + +instructions_page = Blueprint( + 'instructions', + __name__, + url_prefix='/instructions' +) + + +# Index +@instructions_page.route('/', methods=['GET']) +@exception_handler(__file__) +def list() -> str: + return render_template( + 'instructions.html', + table_collection=BrickInstructionsList().list(), + ) + + +# Delete an instructions file +@instructions_page.route('//delete/', methods=['GET']) +@login_required +@exception_handler(__file__) +def delete(*, name: str) -> str: + return render_template( + 'instructions.html', + item=BrickInstructionsList().get_file(name), + delete=True, + error=request.args.get('error') + ) + + +# Actually delete an instructions file +@instructions_page.route('//delete/', methods=['POST']) +@login_required +@exception_handler(__file__, post_redirect='instructions.delete') +def do_delete(*, name: str) -> Response: + instruction = BrickInstructionsList().get_file(name) + + # Delete the instructions file + instruction.delete() + + # Reload the instructions + BrickInstructionsList(force=True) + + return redirect(url_for('instructions.list')) + + +# Rename an instructions file +@instructions_page.route('//rename/', methods=['GET']) +@login_required +@exception_handler(__file__) +def rename(*, name: str) -> str: + return render_template( + 'instructions.html', + item=BrickInstructionsList().get_file(name), + rename=True, + error=request.args.get('error') + ) + + +# Actually rename an instructions file +@instructions_page.route('//rename/', methods=['POST']) +@login_required +@exception_handler(__file__, post_redirect='instructions.rename') +def do_rename(*, name: str) -> Response: + instruction = BrickInstructionsList().get_file(name) + + # Grab the new filename + filename = secure_filename(request.form.get('filename', '')) + + if filename != '': + # Delete the instructions file + instruction.rename(filename) + + # Reload the instructions + BrickInstructionsList(force=True) + + return redirect(url_for('instructions.list')) + + +# Upload an instructions file +@instructions_page.route('/upload/', methods=['GET']) +@login_required +@exception_handler(__file__) +def upload() -> str: + return render_template( + 'instructions.html', + upload=True, + error=request.args.get('error') + ) + + +# Actually upload an instructions file +@instructions_page.route('/upload', methods=['POST']) +@login_required +@exception_handler(__file__, post_redirect='instructions.upload') +def do_upload() -> Response: + file = upload_helper( + 'file', + 'instructions.upload', + extensions=current_app.config['INSTRUCTIONS_ALLOWED_EXTENSIONS'].value, + ) + + if isinstance(file, Response): + return file + + BrickInstructions(file.filename).upload(file) # type: ignore + + # Reload the instructions + BrickInstructionsList(force=True) + + return redirect(url_for('instructions.list')) diff --git a/bricktracker/views/login.py b/bricktracker/views/login.py new file mode 100644 index 0000000..8b56315 --- /dev/null +++ b/bricktracker/views/login.py @@ -0,0 +1,86 @@ +import logging + +from flask import ( + Blueprint, + current_app, + redirect, + render_template, + request, + url_for +) +from flask_login import ( + AnonymousUserMixin, + current_user, + login_user, + logout_user +) +from werkzeug.wrappers.response import Response + +from .exceptions import exception_handler +from ..login import LoginManager + +logger = logging.getLogger(__name__) + +login_page = Blueprint('login', __name__) + + +# Index +@login_page.route('/login', methods=['GET']) +@exception_handler(__file__) +def login() -> str: + return render_template( + 'login.html', + next=request.args.get('next'), + wrong_password=request.args.get('wrong_password'), + ) + + +# Authenticate the user +@login_page.route('/login', methods=['POST']) +@exception_handler(__file__) +def do_login() -> Response: + # Grab our unique user + user: LoginManager.User = current_app.login_manager.user_callback() # type: ignore # noqa: E501 + + # Security: Does not check if the next url is compromised + next = request.args.get('next') + + # Grab the password + password: str = request.form.get('password', '') + + if password == '' or user.password != password: + return redirect(url_for('login.login', wrong_password=True, next=next)) + + # Set the user as logged in + login_user(user) + + # Info + logger.info('{user}: logged in'.format( + user=user.id, + )) + + # Disconnect all sockets + current_app.config['_SOCKET'].emit('DISCONNECT', all=True) + + # Redirect the user + return redirect(next or url_for('index.index')) + + +# Logout +@login_page.route('/logout', methods=['GET']) +@exception_handler(__file__) +def logout() -> Response: + if not isinstance(current_user, AnonymousUserMixin): + id = current_user.id + + logout_user() + + # Info + logger.info('{user}: logged out'.format( + user=id, + )) + + # Disconnect all sockets + current_app.config['_SOCKET'].emit('DISCONNECT', all=True) + + return redirect(url_for('index.index')) diff --git a/bricktracker/views/minifigure.py b/bricktracker/views/minifigure.py new file mode 100644 index 0000000..29c5822 --- /dev/null +++ b/bricktracker/views/minifigure.py @@ -0,0 +1,30 @@ +from flask import Blueprint, render_template + +from .exceptions import exception_handler +from ..minifigure import BrickMinifigure +from ..minifigure_list import BrickMinifigureList +from ..set_list import BrickSetList + +minifigure_page = Blueprint('minifigure', __name__, url_prefix='/minifigures') + + +# Index +@minifigure_page.route('/', methods=['GET']) +@exception_handler(__file__) +def list() -> str: + return render_template( + 'minifigures.html', + table_collection=BrickMinifigureList().all(), + ) + + +# Minifigure details +@minifigure_page.route('//details') +@exception_handler(__file__) +def details(*, number: str) -> str: + return render_template( + 'minifigure.html', + item=BrickMinifigure().select_generic(number), + using=BrickSetList().using_minifigure(number), + missing=BrickSetList().missing_minifigure(number), + ) diff --git a/bricktracker/views/part.py b/bricktracker/views/part.py new file mode 100644 index 0000000..2505122 --- /dev/null +++ b/bricktracker/views/part.py @@ -0,0 +1,60 @@ +from flask import Blueprint, render_template + +from .exceptions import exception_handler +from ..minifigure_list import BrickMinifigureList +from ..part import BrickPart +from ..part_list import BrickPartList +from ..set_list import BrickSetList + +part_page = Blueprint('part', __name__, url_prefix='/parts') + + +# Index +@part_page.route('/', methods=['GET']) +@exception_handler(__file__) +def list() -> str: + return render_template( + 'parts.html', + table_collection=BrickPartList().all() + ) + + +# Missing +@part_page.route('/missing', methods=['GET']) +@exception_handler(__file__) +def missing() -> str: + return render_template( + 'missing.html', + table_collection=BrickPartList().missing() + ) + + +# Part details +@part_page.route('///details', defaults={'element': None}, methods=['GET']) # noqa: E501 +@part_page.route('////details', methods=['GET']) # noqa: E501 +@exception_handler(__file__) +def details(*, number: str, color: int, element: int | None) -> str: + return render_template( + 'part.html', + item=BrickPart().select_generic(number, color, element_id=element), + sets_using=BrickSetList().using_part( + number, + color, + element_id=element + ), + sets_missing=BrickSetList().missing_part( + number, + color, + element_id=element + ), + minifigures_using=BrickMinifigureList().using_part( + number, + color, + element_id=element + ), + minifigures_missing=BrickMinifigureList().missing_part( + number, + color, + element_id=element + ), + ) diff --git a/bricktracker/views/set.py b/bricktracker/views/set.py new file mode 100644 index 0000000..2b589b0 --- /dev/null +++ b/bricktracker/views/set.py @@ -0,0 +1,195 @@ +import logging + +from flask import ( + Blueprint, + jsonify, + render_template, + redirect, + request, + url_for, +) +from flask_login import login_required +from werkzeug.wrappers.response import Response + +from .exceptions import exception_handler +from ..minifigure import BrickMinifigure +from ..part import BrickPart +from ..set import BrickSet +from ..set_list import BrickSetList + +logger = logging.getLogger(__name__) + +set_page = Blueprint('set', __name__, url_prefix='/sets') + + +# List of all sets +@set_page.route('/', methods=['GET']) +@exception_handler(__file__) +def list() -> str: + return render_template('sets.html', collection=BrickSetList().all()) + + +# Change the set checked status of one set +@set_page.route('//checked', methods=['POST']) +@login_required +@exception_handler(__file__, json=True) +def set_checked(*, id: str) -> Response: + state: bool = request.json.get('state', False) # type: ignore + + brickset = BrickSet().select_specific(id) + brickset.update_checked('set_check', state) + + # Info + logger.info('Set {number} ({id}): changed set checked status to {state}'.format( # noqa: E501 + number=brickset.fields.set_num, + id=brickset.fields.u_id, + state=state, + )) + + return jsonify({'state': state}) + + +# Change the set collected status of one set +@set_page.route('//collected', methods=['POST']) +@login_required +@exception_handler(__file__, json=True) +def set_collected(*, id: str) -> Response: + state: bool = request.json.get('state', False) # type: ignore + + brickset = BrickSet().select_specific(id) + brickset.update_checked('set_col', state) + + # Info + logger.info('Set {number} ({id}): changed set collected status to {state}'.format( # noqa: E501 + number=brickset.fields.set_num, + id=brickset.fields.u_id, + state=state, + )) + + return jsonify({'state': state}) + + +# Ask for deletion of a set +@set_page.route('//delete', methods=['GET']) +@login_required +@exception_handler(__file__) +def delete(*, id: str) -> str: + return render_template( + 'delete.html', + item=BrickSet().select_specific(id), + error=request.args.get('error'), + ) + + +# Actually delete of a set +@set_page.route('//delete', methods=['POST']) +@exception_handler(__file__, post_redirect='set.delete') +def do_delete(*, id: str) -> Response: + brickset = BrickSet().select_specific(id) + brickset.delete() + + # Info + logger.info('Set {number} ({id}): deleted'.format( + number=brickset.fields.set_num, + id=brickset.fields.u_id, + )) + + return redirect(url_for('set.deleted', id=id)) + + +# Set is deleted +@set_page.route('//deleted', methods=['GET']) +@exception_handler(__file__) +def deleted(*, id: str) -> str: + return render_template( + 'success.html', + message='Set "{id}" has been successfuly deleted.'.format(id=id), + ) + + +# Details of one set +@set_page.route('//details', methods=['GET']) +@exception_handler(__file__) +def details(*, id: str) -> str: + return render_template( + 'set.html', + item=BrickSet().select_specific(id), + open_instructions=request.args.get('open_instructions'), + ) + + +# Change the minifigures collected status of one set +@set_page.route('/sets//minifigures/collected', methods=['POST']) +@login_required +@exception_handler(__file__, json=True) +def minifigures_collected(*, id: str) -> Response: + state: bool = request.json.get('state', False) # type: ignore + + brickset = BrickSet().select_specific(id) + brickset.update_checked('mini_col', state) + + # Info + logger.info('Set {number} ({id}): changed minifigures collected status to {state}'.format( # noqa: E501 + number=brickset.fields.set_num, + id=brickset.fields.u_id, + state=state, + )) + + return jsonify({'state': state}) + + +# Update the missing pieces of a minifig part +@set_page.route('//minifigures//parts//missing', methods=['POST']) # noqa: E501 +@login_required +@exception_handler(__file__, json=True) +def missing_minifigure_part( + *, + id: str, + minifigure_id: str, + part_id: str +) -> Response: + brickset = BrickSet().select_specific(id) + minifigure = BrickMinifigure().select_specific(brickset, minifigure_id) + part = BrickPart().select_specific( + brickset, + part_id, + minifigure=minifigure, + ) + + missing = request.json.get('missing', '') # type: ignore + + part.update_missing(missing) + + # Info + logger.info('Set {number} ({id}): updated minifigure ({minifigure}) part ({part}) missing count to {missing}'.format( # noqa: E501 + number=brickset.fields.set_num, + id=brickset.fields.u_id, + minifigure=minifigure.fields.fig_num, + part=part.fields.id, + missing=missing, + )) + + return jsonify({'missing': missing}) + + +# Update the missing pieces of a part +@set_page.route('//parts//missing', methods=['POST']) +@login_required +@exception_handler(__file__, json=True) +def missing_part(*, id: str, part_id: str) -> Response: + brickset = BrickSet().select_specific(id) + part = BrickPart().select_specific(brickset, part_id) + + missing = request.json.get('missing', '') # type: ignore + + part.update_missing(missing) + + # Info + logger.info('Set {number} ({id}): updated part ({part}) missing count to {missing}'.format( # noqa: E501 + number=brickset.fields.set_num, + id=brickset.fields.u_id, + part=part.fields.id, + missing=missing, + )) + + return jsonify({'missing': missing}) diff --git a/bricktracker/views/upload.py b/bricktracker/views/upload.py new file mode 100644 index 0000000..ff9b3ff --- /dev/null +++ b/bricktracker/views/upload.py @@ -0,0 +1,38 @@ +import os + +from flask import redirect, request, url_for +from werkzeug.datastructures import FileStorage +from werkzeug.wrappers.response import Response + +from ..exceptions import ErrorException + + +# Helper for a standard file upload process +def upload_helper( + name: str, + endpoint: str, + /, + extensions: list[str] = [], +) -> FileStorage | Response: + # Bogus submit + if name not in request.files: + return redirect(url_for(endpoint)) + + file = request.files[name] + + # Empty submit + if not file or file.filename is None or file.filename == '': + return redirect(url_for(endpoint, empty_file=True)) + + # Not allowed extension + # Security: not really + if len(extensions): + _, extension = os.path.splitext(file.filename) + + if extension not in extensions: + raise ErrorException('{file} extension is not an allowed. Expected: {allowed}'.format( # noqa: E501 + file=file.filename, + allowed=', '.join(extensions) + )) + + return file diff --git a/bricktracker/views/wish.py b/bricktracker/views/wish.py new file mode 100644 index 0000000..b0c763b --- /dev/null +++ b/bricktracker/views/wish.py @@ -0,0 +1,47 @@ +from flask import Blueprint, redirect, render_template, request, url_for +from flask_login import login_required +from werkzeug.wrappers.response import Response + +from .exceptions import exception_handler +from ..retired_list import BrickRetiredList +from ..wish import BrickWish +from ..wish_list import BrickWishList + +wish_page = Blueprint('wish', __name__, url_prefix='/wishlist') + + +# Index +@wish_page.route('/', methods=['GET']) +@exception_handler(__file__) +def list() -> str: + return render_template( + 'wishes.html', + table_collection=BrickWishList().all(), + retired=BrickRetiredList(), + error=request.args.get('error') + ) + + +# Add a set to the wishlit +@wish_page.route('/add', methods=['POST']) +@login_required +@exception_handler(__file__, post_redirect='wish.list') +def add() -> Response: + # Grab the set number + number: str = request.form.get('number', '') + + if number != '': + BrickWishList.add(number) + + return redirect(url_for('wish.list')) + + +# Delete a set from the wishlit +@wish_page.route('/delete/', methods=['POST']) +@login_required +@exception_handler(__file__, post_redirect='wish.list') +def delete(*, number: str) -> Response: + brickwish = BrickWish().select_specific(number) + brickwish.delete() + + return redirect(url_for('wish.list')) diff --git a/bricktracker/wish.py b/bricktracker/wish.py new file mode 100644 index 0000000..3851562 --- /dev/null +++ b/bricktracker/wish.py @@ -0,0 +1,81 @@ +from sqlite3 import Row +from typing import Any, Self + +from flask import url_for + +from .exceptions import NotFoundException +from .set import BrickSet +from .sql import BrickSQL + + +# Lego brick wished set +class BrickWish(BrickSet): + # Queries + select_query: str = 'wish/select' + insert_query: str = 'wish/insert' + + def __init__( + self, + /, + record: Row | dict[str, Any] | None = None, + ): + # Don't init BrickSet, init the parent of BrickSet directly + super(BrickSet, self).__init__() + + # Placeholders + self.theme_name = '' + + # Ingest the record if it has one + if record is not None: + self.ingest(record) + + # Resolve the theme + self.resolve_theme() + + # Delete a wished set + def delete(self, /) -> None: + BrickSQL().execute_and_commit( + 'wish/delete/wish', + parameters=self.sql_parameters() + ) + + # Select a specific part (with a set and an id) + def select_specific(self, set_num: str, /) -> Self: + # Save the parameters to the fields + self.fields.set_num = set_num + + # Load from database + record = self.select() + + if record is None: + raise NotFoundException( + 'Wish with number {number} was not found in the database'.format( # noqa: E501 + number=self.fields.set_num, + ), + ) + + # Ingest the record + self.ingest(record) + + # Resolve the theme + self.resolve_theme() + + return self + + # Deletion url + def url_for_delete(self, /) -> str: + return url_for('wish.delete', number=self.fields.set_num) + + # Normalize from Rebrickable + @staticmethod + def from_rebrickable(data: dict[str, Any], /, **_) -> dict[str, Any]: + return { + 'set_num': data['set_num'], + 'name': data['name'], + 'year': data['year'], + 'theme_id': data['theme_id'], + 'num_parts': data['num_parts'], + 'set_img_url': data['set_img_url'], + 'set_url': data['set_url'], + 'last_modified_dt': data['last_modified_dt'], + } diff --git a/bricktracker/wish_list.py b/bricktracker/wish_list.py new file mode 100644 index 0000000..266ee43 --- /dev/null +++ b/bricktracker/wish_list.py @@ -0,0 +1,37 @@ +from typing import Self + +from flask import current_app + +from bricktracker.exceptions import NotFoundException + +from .rebrickable_set import RebrickableSet +from .record_list import BrickRecordList +from .wish import BrickWish + + +# All the wished sets from the database +class BrickWishList(BrickRecordList[BrickWish]): + # Queries + select_query: str = 'wish/list/all' + + # All the wished sets + def all(self, /) -> Self: + # Load the wished sets from the database + for record in self.select( + order=current_app.config['WISHES_DEFAULT_ORDER'].value + ): + brickwish = BrickWish(record=record) + + self.records.append(brickwish) + + return self + + # Add a set to the wishlist + @staticmethod + def add(set_num: str, /) -> None: + # Check if it already exists + try: + set_num = RebrickableSet.parse_number(set_num) + BrickWish().select_specific(set_num) + except NotFoundException: + RebrickableSet.wish(set_num) diff --git a/database.py b/database.py deleted file mode 100644 index c6ae19e..0000000 --- a/database.py +++ /dev/null @@ -1,89 +0,0 @@ -import sqlite3 -from pathlib import Path # creating folders -import sys - -conn = sqlite3.connect('app.db') -cursor = conn.cursor() - -if len(sys.argv) > 1: - - cursor.execute('DELETE FROM sets where u_id="' +sys.argv[1]+ '";') - conn.commit() - - cursor.execute('DELETE FROM inventory where u_id="' +sys.argv[1]+ '";') - conn.commit() - - cursor.execute('DELETE FROM minifigures where u_id="' +sys.argv[1]+ '";') - conn.commit() - - cursor.execute('DELETE FROM missing where u_id="' +sys.argv[1]+ '";') - conn.commit() - - cursor.close() - conn.close() - - exit() - - - - - -cursor.execute('''DROP TABLE sets''') -cursor.execute('''DROP TABLE inventory''') -cursor.execute('''DROP TABLE minifigures''') -cursor.execute('''DROP TABLE missing''') - -cursor.execute('''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 -)''') - -cursor.execute('''CREATE TABLE IF NOT EXISTS inventory ( - set_num TEXT, - id INTEGER, - part_num INTEGER, - 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 -)''') - -cursor.execute('''CREATE TABLE IF NOT EXISTS minifigures ( - fig_num TEXT, - set_num TEXT, - name TEXT, - quantity INTEGER, - set_img_url TEXT, - u_id TEXT -)''') - -cursor.execute('''CREATE TABLE IF NOT EXISTS missing ( - set_num TEXT, - id INTEGER, - part_num INTEGER, - part_img_url_id TEXT, - color_id INTEGER, - quantity INTEGER, - element_id INTEGER, - u_id TEXT -)''') - - - -conn.close() - diff --git a/db.py b/db.py deleted file mode 100644 index b31121c..0000000 --- a/db.py +++ /dev/null @@ -1,116 +0,0 @@ -import os -import sqlite3 - -def initialize_database(): - db_path = 'app.db' - tables = ['sets', 'inventory', 'minifigures', 'missing'] - row_counts = {} - - # Connect to the database (this will create the file if it doesn't exist) - conn = sqlite3.connect(db_path) - cursor = conn.cursor() - - # Create the required tables if they do not exist - cursor.execute('''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 - )''') - - cursor.execute('''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 - )''') - - cursor.execute('''CREATE TABLE IF NOT EXISTS inventory ( - set_num TEXT, - id INTEGER, - part_num INTEGER, - 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 - )''') - - cursor.execute('''CREATE TABLE IF NOT EXISTS minifigures ( - fig_num TEXT, - set_num TEXT, - name TEXT, - quantity INTEGER, - set_img_url TEXT, - u_id TEXT - )''') - - cursor.execute('''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 the changes - conn.commit() - conn.close() - -def get_rows(): - db_path = 'app.db' - tables = ['sets', 'inventory', 'minifigures', 'missing'] - row_counts = {} - - # Connect to the database (this will create the file if it doesn't exist) - conn = sqlite3.connect(db_path) - cursor = conn.cursor() - - # Get the row count for each table - for table in tables: - cursor.execute(f"SELECT COUNT(*) FROM {table}") - row_count = cursor.fetchone()[0] - row_counts[table] = row_count - - # Close the connection - conn.close() - - return row_counts - - -def delete_tables(): - db_path = 'app.db' - tables = ['sets', 'inventory', 'minifigures', 'missing'] - row_counts = {} - - # Connect to the database (this will create the file if it doesn't exist) - conn = sqlite3.connect(db_path) - cursor = conn.cursor() - - cursor.execute('''DROP TABLE sets''') - cursor.execute('''DROP TABLE inventory''') - cursor.execute('''DROP TABLE minifigures''') - cursor.execute('''DROP TABLE missing''') - - # Close the connection - conn.close() - diff --git a/dl.sh b/dl.sh deleted file mode 100755 index 549349a..0000000 --- a/dl.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/bash - -wget -O "static/instructions/$1.pdf" "$2" - diff --git a/downloadRB.py b/downloadRB.py deleted file mode 100644 index bbc094a..0000000 --- a/downloadRB.py +++ /dev/null @@ -1,87 +0,0 @@ -import requests -import gzip -import shutil -import os -import sys -from urllib.parse import urlparse - -def get_nil_images(): - image_urls = [ - "https://rebrickable.com/static/img/nil_mf.jpg", - "https://rebrickable.com/static/img/nil.png", - "https://docs.google.com/spreadsheets/d/1rlYfEXtNKxUOZt2Mfv0H17DvK7bj6Pe0CuYwq6ay8WA/gviz/tq?tqx=out:csv&sheet=Sorted%20by%20Retirement" - ] - static_folder = "static" - - # Create the static folder if it does not exist - if not os.path.exists(static_folder): - os.makedirs(static_folder) - - for url in image_urls: - # Extract the output filename from the URL - parsed_url = urlparse(url) - output_file = os.path.join(static_folder, os.path.basename(parsed_url.path)) - - # Download the image - response = requests.get(url, stream=True) - response.raise_for_status() # Check for any request errors - - # Save the image to the static folder - with open(output_file, 'wb') as f: - f.write(response.content) - - print(f"Downloaded {output_file}") - -def get_retired_sets(): - - urls = [ - "https://docs.google.com/spreadsheets/d/1rlYfEXtNKxUOZt2Mfv0H17DvK7bj6Pe0CuYwq6ay8WA/gviz/tq?tqx=out:csv&sheet=Sorted%20by%20Retirement%20Date" - ] - - for url in urls: - # Extract the output filename from the URL - parsed_url = urlparse(url) - output_file = os.path.basename(parsed_url.path) - - # Download the image - response = requests.get(url, stream=True) - response.raise_for_status() # Check for any request errors - - # Save the image to the static folder - with open('retired_sets.csv', 'wb') as f: - f.write(response.content) - - print(f"Downloaded {output_file}") - - -def download_and_unzip(url: str): - # Extract the output filename from the URL - parsed_url = urlparse(url) - output_file = os.path.basename(parsed_url.path).replace('.gz', '') - - # Download the file - response = requests.get(url, stream=True) - response.raise_for_status() # Check for any request errors - - # Write the gzipped file to the local file system - gz_file = output_file + '.gz' - with open(gz_file, 'wb') as f: - f.write(response.content) - - # Unzip the file - with gzip.open(gz_file, 'rb') as f_in: - with open(output_file, 'wb') as f_out: - shutil.copyfileobj(f_in, f_out) - - # Optionally remove the .gz file after extraction - os.remove(gz_file) - -# Usage -if __name__ == "__main__": - if len(sys.argv) != 2: - print("Usage: python3 downloadRB.py ") - sys.exit(1) - - url = sys.argv[1] - download_and_unzip(url) - diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..7add85e --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env sh + +# Host +if [ -z "$BK_HOST" ] +then + export BK_HOST="0.0.0.0" +fi + +# Port +if [ -z "$BK_PORT" ] +then + export BK_PORT=3333 +fi + +# Execute the WSGI server +gunicorn --bind "${BK_SERVER}:${BK_PORT}" "app:app" --worker-class "eventlet" "$@" diff --git a/lego.sh b/lego.sh deleted file mode 100644 index 469ec0e..0000000 --- a/lego.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/bin/bash - -wget https://cdn.rebrickable.com/media/downloads/themes.csv.gz -gzip -f -d themes.csv.gz - - -wget https://cdn.rebrickable.com/media/downloads/sets.csv.gz -gzip -f -d sets.csv.gz - -wget https://cdn.rebrickable.com/media/downloads/colors.csv.gz -gzip -f -d colors.csv.gz - -cd static/ -wget https://rebrickable.com/static/img/nil_mf.jpg -wget https://rebrickable.com/static/img/nil.png -cd .. diff --git a/requirements.txt b/requirements.txt index 892bbe4..c6ac3b2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,10 +1,11 @@ +eventlet flask flask_socketio +flask-login +gunicorn +humanize +jinja2 pathlib plotly -pandas -numpy rebrick requests -eventlet -gunicorn diff --git a/static/brick.png b/static/brick.png new file mode 100644 index 0000000000000000000000000000000000000000..ee1b452ae0126aefae54724fc38df98011c73181 GIT binary patch literal 10192 zcmZ{KWk6J4(Dzw#Ny()oq+@9b=@1YR=>};Q>6C5+VQB<}B?JsoDHVyOkq{9j1!)jL z>6Ume|L1u>ydU2Eu=n0`&dizl&CEG7=iUU}`x<1#jKlx{GR-@;^#OoEw=e)FfG(Ck zWlqoq52dWF3_xuv$(b!a^vq^|M_(I&U~T{+V*xmYnj+T#@D~AK-39=eX8_Q7Jj0JujrZz~%H{`hP4(93cx3qjEi ztF#d8&!}q6<)p1kbI71E=r`;`8l8yt(?~>dI;gOfzi`UX6*n;B&b;R~C@I+ivv04; zCJS_wlB}d7Z5-oH^RH8a zw+gokeK!M!%PUJ8RT`|v3JS?5CQ`|de;D9i1UPgaux3VBb6-A?l$p2YQJfYyWjDMC zQ!&K6;;C!B|V?Io=Yez_m( zJ*2T8vTcp0*&Rw=j3`e;v#}&{)80Kcb5uDo-&Lc@fI@Mx92MD@PmBexSFAmUPTzrv+P0%BrL3|>dnQ%$RczN4y zTRVveMbLBZ9Z_~+)teyCcxm{P%YN_v7m7qoX1K3INrc8t?HENh>XOSS(ZzekjryG9 z^48oZAURB(xt-qom98a^MqyEm{3Y5kgY{MMa9O;lI=#Rb`owADOW)gXU#HW}AX-@L zdl*!`vtFoNAVzW?&esd{!vAZ@C7j=S9)%evhsIZO%14S{-1HDntX)hhZWO=^tlHsb z{GbUY$7-U*;}(hgV^>Suj+hgwwfPu7^vksyMB(+4+J9oW?OoX1_LfEB-2B_Z>D#_o z^+iv)@VS=S5sKvI=L7UyES}e;csqsdNykhFp7}WGxd}r8i}TR#MyPOaagW$fB`~)I z%$oX`L=rqu%9O=1UmSTgQ^HH%rA=>;B#5H#c`wGei?7xuNPYP7H+6`E)}X1Kg?MsH zq+Jr#G-lUGEm$+TI!P48*kkM?61q99K^R(;u9>*V(jN+qHW)Mw)gt-d(Roh%=Mb<7u&zn7K|H9uNsbMRPj*rPF6AgD=QVMzO9wX*^__SOsuCTj zpZqTAR&Swrn!dksCfcuYwupQzLPlafLYsTpw78!PJQ3O$TRi$s^33}nr#9h%p#8pX zeQsx&J$*Y0@;%POb3#944L`r`bnscX z$KS8)B*Wq)z>K-4xm~uSd4cq$q0snTfCuKDH0MdUD5hDF1N1Gkw7k+ld%BM|a!LDd zT(gg^<%5AbTi9&>hn*B@1HN(8aTiu+;C8F$E6yS_p!*=Q%CG$OM=t5w(=Bpm)k@j1 z$)|9Tsj1S!5oD5IOH(O!mQ3XuZp}R*u+p$p2*BFnbIF!RSjncGV$f^T7=cCnCUKeC z;ll-We+R5PWbNPC-tC8$j)@}j347PAAaPA>325KOi<5d6EK8Gy`b6(w5{?Eo?3zyk z;sc%p^fUY&8vExrLXZozZ~Z7cuh>j76FWmeE_sx|^s&w`V2%bnQP079cigqt@RU#< zCMOQXnMjx=5+}01MroF7_P5(s!QxC-6Q8{9B}-6$5k2#^74prT$;!(JlYPPxl6|+< ztuNPG1(^>8#lkOfPtMvB!j;1>j&|iQ*#vr}k~UlHjqe|ag6A306Nk<_gm`#`d*twW zvz^a@hoSa%qQIdQ7W5h`prNY3O;mt{oRpeE_54N4=eL7eJ^Za!H#tw4Msw(r=@=?g z8958*m}q8SR3Vo5&V>F<;34^m!Bj!i!hyT_I>S*6D5M20(Iwv1ZrDUX_v0K z8v6v-mj{MvOD}mu2j1=CMDU%WMkq&0jjD`H5cF{HS$d&<0Z8L$()r?Ty9M@!dPKb8 zW~o@qO(?C<4w^i&Za`uZ>HnG8{Sn-qYoqJ3DEelyMs3`@^3tS=j|IMnF?TB1H%qSm z^NlK1;v+w2d;NSaLBuzA7g8#VN+0L-*w2-%zje>ma<^xE`#2k7LM}V=eerS?dDSns z)@bZ6seY_Ru^kwemiH@CQqBB)g&P$!Og=xmLmHSpWqI*C@5hws(7!Wu%O^D|L<>*r z+d^CAYKNQ4A#Yor^z5pHulD{pVY_%H5&1`QVLs8rQLg_rW5KV@L3x3$9Mm)Hkd!NL zL&Jx4PQD$y6!a$5dEw`d!Id^^L&jZeev)r$B@qIrl&}OFr@0vqBF!Lpe;qR1*`uEH zP-;a@F4ID(D+_q}dwR2hm^BiY7%9Jp987rdM^mhDuhd498Q%tB)BQd2H|uqB42d(^ z1EJwIeRmKlN9YUV{ma|dH-lw1X~OQcFL0lH@g(96wUnFSj-yaZB%S7x59Wc@SjJ)Vtz+zNW-)4Z^Sg?AIe>Ow_Hc4t}*h*CH*<0 z=^0L%TdF^1I;$3YbRrardhyohsG2zHxoM*8uzLPzrRqNqy7PwEHdK(%tj<nni z%6BKZsXy#YIRh@lF%|USGlk^s90CbL&gjl(l$APqCuWC7$8J-_cWWyz1q&YBVn45l zY$uyE+9m~eEG4gr>iOq}NaDjdKW~25vNoc{bE_J04Q^1O%|!Pt%PQLvwoy|YW06Ti z9&@X*78JvoqL)bt%CM+n+38B`zz=<{>x><>Ybw24vtQ>mM@TG#Bw?R~HB_+z56^;V zXuUkw_gi*w;jIto)#NZz-*FEvGsq{TKDr)U8xFI^ax)k%^=uLIZnp6xD_Srn zT;Ci6>d(Wq`ai@rC6xuRx3RckP1A}7y>nf?MPsp(UoMH^`5UH7iieA4zTbjBtqEH{L+wPeiH7~TO_V4gjxcN=m9;|3w?xc!tMR4d+pKrg zIgT32V|nJ>M^GdzIXw-75BuB1=dm=?x8iNv%iryd+F~qZy3_LM5Rq#)eI`QH#9s|s z*QF%#Y%{;6jF=Sy+qhV{jdl2Z>`a=$dy<}?tk_a3ErpR6jpQ%mR7Qz}Z9W9Tw*2lN zUY5t5j-bv2j(0z^P>5ZAT&7O`kw$Mq({kRU9I{Mw8tw9q)pDwuDwMyOm0xCdMedo| zwSnU^o?uI991u704u9#|m+4_T)VH!CH)Qzh+s5O^s|d9h2?-13zW7F@)HMcCc^7y7 z6!)0dUx=nBZL!{$Tk z4si8laaUClZ!V1aT^uj!IRIN7+a#^U&mD_5GZ8TKJ>X<@59Y>xbj1FJ**Mq_@^EN{aEK;w z$m=hb-#m)2$J~7r#0>N~iE;<&+}*S?zV->k8i;InQz z|1#FY<^;WcKut02|9}wWPUcfTZh3)XYDoWQ(Mx8L$ZJ83sqZRp%l4Z*l4$)_{^h&~ zfF&B!vvenH_YCbd)TQU2{I!^0{(#r;S{lEv6H19`YDT88{#obvgU5fzUPSA1-wg0@ zX}^^6p`O04TzMVnw$6snlyb{kzqo&<_xg-9gB;fWmaKZSA>U%Y>6kUXbJK&Vr~@-g zU=gcr{2Ck4$TB4FHTmnHB{P%}D2-4Hf1F+->iEUjun_X&)&39%I3B~%E8}dO_!;3w zckSo6sDhUCxr`gtd$U@0_TOO#zRT=6^o~ge;4r4uXCT!PlbBIjRjJohDadll*S)k)@w@RF$bOo%;fELl?F_BaWXD zfzep0^>&^B$>82FwC6*kOu5T)862fYZeADdXtZjvpnlawg@4{H@azs1KZbVtF&SOh z)8lOtPemT)!!S#%bC+{^>lpyjwh1!DFcg! zRt-v0R`g-!?!HJZ6NZ_J7@iK`9h@o$Qt+FjnyX!q_aJwj%eB!b+G!VU3?2|^q zReX;&J8g1Unqnz2C&e9`o?MMD&xWHE4s_XSN=ZuAs0OK!Ma%2#wr>nAuTmd=Se{8nZVN-uDcL0@}G%tx`L;mbUny{%&O3WKFNhOa#S%Y+IvkIv@Nfv5_SA32B^-Qk;;ZLovBxKLulC(?{KSXfj;|jP zx=9eVJ1U8i(d^Odb2V!Tf5@6~SsBmKc{f(!5H&O!Inw0XZej-dhN$JgE}s5`efECp zhY>{4sB#!>fcfsV`iq#~?iX{T{R95&k9<*_wXJ;YaZZ{XI-}*dJ>L_5?DoZ`xH%j6 z_386}F<)7%)VH9*EJSAdj3Bb+xCi;|3 zgN4%1!pq4bZ6wK2lTIfVFNRMv#8PjzWPRjG8`zghNpSK1&fBbEFXhLbDT^y?rebG0 z-46N)RH^w+M*yFMD7oi{wOiD?teTJ| z#W6iAIi?X!3v>jL{SHQ<%Z2ZicL)fykWQwjDUpNF^4xA${(Uu`-gAoiNsp3OHZZiy zVyfUOpjS@~PnqsnlX_o{Pf_r6AgH(4Zo^gK7wbL6Zz9h=%2E{dZ)@X5QU=Jvo>)b#|Xga(5QTM-+rSijhrhop?zN6CX$q0NN5r5-gGPlmj$>w#5RJw+B#lD8I#ffiKW#k9+ zbSG$5?jcS6jY^JXIP_X(@6c?$=Qae5?HE;fjM0OYtn4H6CW99$>ad0v3`Y@f71L)| zBjkN`*b0WWEl9vEC5mS3Ms@d6@;^tmRNBmIO7F4!n#!^;r6Sui%M;X`sQHN16lZNZ zuknMh>_!=%#Qgrn{5&p(-BNzxu zS-3a}X7KYk$a??y&@|KTZZHFg^B888j2_CF9Zh@oWwo9VJRM-a8F*A{R6vU#?TkOd zc$DnKnm9X{VZ4Hx^v-X|K8ZAk@U8u&D6uKrw^~mP9L!>QXwhCF-pJ15^F2Q{-e?Xh zEzlm;YaCF~rFk>dRX(a4cMrKl2&Io{JD(ETESHJ~R%IW)Trfd^SM~6tXDCX$W>O`Q znK_T924hIj={u7a**TBp2*}!^*=nlI6a{MfRzOXIiM!PO@+U@knBn(87lHQjj7cQy zSkoWV%Q+mU0ZRNZm6(PSen#;QlXv;;+ttkD%z_^nK%`PEzv->La%ah+YS?o(gzcR< z_D(aer;JS4>YsP0@C8V~lfTLWR^Q*u=+OunSp++B9(}`AVDu-lkBxn{NMuNYR{;ah z<49`wvaR0>-A-^*_=nqWh*-GjrdF$i`?iA^{0nI%SU-E?qFzlslzeVk|CIF}HPNP5 zEk*m%v=JKlmG1-NtQ42qrdF$Vs&ci1$)a{ZMJT-c}PP1KF2fF^QP#hxSNv1!gkx4Wm3lu z949CD4PXu}ZIjvs#O=(uKx=;{y~18ZlVrH=)93MGZp#;FY@jvdqchJKN@+mJu>6O zJ2i<+1r({C-O?eRnuX6Tfzn0antxOaZ3?jt5r?4D-Yq)7J?zbd?Cj04)bg16ar6F? zBw6;-%UFU*)lZ^URqumWR&5{?CqJ;+2b^O+d57=oaZQG%5Jno9#tvtjpueUtwnfa+ z|L)-aaKR#6Z(ldkfL|-=Bi|L^cMlOHkEvo4DC5TrzXKx4YRBp$NEKOF z`{}so_3L(uky9kG6t6Nb z5mjBg?F;x zMbTkf{;V6TWYKGw4L`bu7TNif*(}xow3l7Rbs-C#2;D?0I}Y4+uO8TRPaspPa%Wh!Y#M9zo329O#!DJ5;69tY3vD55+)j}b|DE)V2woe zh!2mFe>Mi&fABEE3*21+5@6YulI6VsaG16z4SGp;O1Q|dQ&q9JMT$Ch#{uyLovR$+ zGuA-Fr3StvK@f=(h8Oc=+nw3`TTgZO)mfiP>kYl3^42CnQc_)10FPyv0nhnGEude7U__K2Q%AEw^`UP}NwxcOaw`z)q9bFp>h> zJjg7GDETpW*+g>v+FJckGqLc%^xIDSXv|I&+;O&`Z!# zJSsQ@I`(jSgN|{`S&jRbx>d!C@qGL5e@XTDj3t^IvbUbYCDxwdn!7`l;g~wvn?@JM z_aE-r=WMkHl#jdZ{%j<8^Vn5g&|^%(PfKHkDpJS^@__tK_L=GDca1B@>U>Vf{HrA2 zS7@~cc3cojP}DOw?|Jnu&EN1tv{kTP zHJufPKV!-378LKg42WW^fsH7d;sF@YoQNbTPIB}5SX>v)aYgTWcD<G4J$<^IVYW50BrEl z+l@vEHyg*Z{K}b2smO1jhdt@i>N*$bfEv3HXlhG_tB6BENw%0bUIU!Gj9IWORS_IlWDg41aw?@v9wA^4s zq0IFc0|i+dyO~h`HVs-=1t-lu&NyH+HrM1(+VXoTxj?y5Tg$roRp_=1vUd+87~2uU z5%>l3_~jg}Wq5{%AH%Og!h!NWH<5RW4cy8v`^flENiRJ0EG*;IaF-O1Qn%`>XfeO(XG$S(m`s*e?95{)8k@*SkShilr%~FpXYT=#Eq+^ ztfJI3mIrA|=`K_BMY-V$}M+o7X7`a0(caw$~%D87q83YrK!3XT(V#bHX>Cye$_>q`x0Vke2V z&e7a47+JbW4#AR@Xppa%m36uC`ywu`$x`$10IMw*MS>6ac-rs%lt2YMZ@Qc6bc!p* zVoBYo+g!Qe?k}DYh^syF_*a+_cb~vbDu@Z~=|IOtyNTnNXN5RNty+h84|QOKLr2ht zW65;~k4Oj(hpGtOne&6X-8SpRbMcKtS|XA!88!37xk$yO`!HDKdOm5}+Hm*JHkFz4 z`t^aUZaPn!%zJsZjO4IL7gy#lRYY!70hebo0&1Pn+&cu=8%o>ktuScvl*;gVHPH*t zC(Uz)CPb7v^MBT&=i~9=j8>u6uEO_x1!42sJgnsNdk{>&MQ1#{;41_#ML; zYInf%@xq~;&1<2?i(y6G|6Y$78E_)kXhkOPAZ|e3KrK0^?tK)p;lg(d*-6*O&IxoY zJ6A()2xHzFi*>MDmYK$CI9uWY7q$H-e3I5=t*|;cWF^^}6BjN5^mcS#^qyqe)hyaVA+A(eJvxw*3RKT(#LVA+* zIPvdPs3ep@*g=My&oI4A$V>;0`4?b-yGEmCuKvsgSJTzfK}HiR-kED%CtY*z{30P*QG9@?u2`&K#7 zX+TMmwFidmB)j4{;IQY;h8g~7tK1Ilb4n1v&+kaikq}^nm9%^m(AykWgAyI~!eZSN z0&3xUVQaouEyOh^3OqaExKp8^q>@(vJnLPZ=G+XY6YOTPg#bQ4&IL!(r|3f;k6QWt+0y6b8y|VWzP;Xz#O5 z5~RvC7?=!gOEbFkfkr!ZMhApMsjX3hzPgjypq@2BkpS)0N^KeoEig4FB zJd%h6Yv_)!CA*sH9~Go0WFuuGvsfd7D|~3EZKJGf;dI7@YNlL3nlI0BZ>L4!v0W!M zR2{uyCIt_#_%duB2fLgxE|7MD9P_p$ z>tp@q8n!|9GsjwFClw5W!5Z4biYEn-ym@M-IDqsuq`0md6CzSAFv|>KO9|2(_pH<) zIy+CD_M7Ohod3-(jT8n5TYCrPZLt_NQz}63h8^Ub^@KJr6>lfguTD_q@ZBs{9IBH- zy=zWVs>vF>@%U(d#p8d3HqLtRAAmFgarkD4g>^R%8%7X0^dZkpfw<@AtWvhxu@5nhbL \ No newline at end of file diff --git a/static/save.svg b/static/save.svg deleted file mode 100644 index 46c7299..0000000 --- a/static/save.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/static/scripts/grid.js b/static/scripts/grid.js new file mode 100644 index 0000000..6246817 --- /dev/null +++ b/static/scripts/grid.js @@ -0,0 +1,253 @@ +// Sort button +class BrickGridSortButton { + constructor(button, grid) { + this.button = button; + this.grid = grid; + this.data = this.button.dataset; + + // Setup + button.addEventListener("click", ((grid, button) => (e) => { + grid.sort(button); + })(grid, this)); + } + + // Active + active() { + this.button.classList.remove("btn-outline-primary"); + this.button.classList.add("btn-primary"); + } + + // Inactive + inactive() { + delete this.button.dataset.sortOrder; + this.button.classList.remove("btn-primary"); + this.button.classList.add("btn-outline-primary"); + } + + // Toggle sorting + toggle(order) { + // Cleanup + delete this.button.dataset.sortOrder; + + let icon = this.button.querySelector("i.ri"); + if (icon) { + this.button.removeChild(icon); + } + + // Set order + if (order) { + this.active(); + + this.button.dataset.sortOrder = order; + + icon = document.createElement("i"); + icon.classList.add("ri", "ms-1", `ri-sort-${order}`); + + this.button.append(icon); + } + } +} + +// Grid class +class BrickGrid { + constructor(id) { + this.id = id; + + // Grid elements (built based on the initial id) + this.html_grid = document.getElementById(id); + this.html_sort = document.getElementById(`${id}-sort`); + this.html_search = document.getElementById(`${id}-search`); + this.html_filter = document.getElementById(`${id}-filter`); + this.html_theme = document.getElementById(`${id}-theme`); + + // Sort buttons + this.html_sort_buttons = {}; + if (this.html_sort) { + this.html_sort.querySelectorAll("button[data-sort-attribute]").forEach(button => { + this.html_sort_buttons[button.id] = new BrickGridSortButton(button, this); + }); + } + + // Clear button + this.html_clear = document.querySelector("button[data-sort-clear]") + if (this.html_clear) { + this.html_clear.addEventListener("click", ((grid) => (e) => { + grid.clear(e.currentTarget) + })(this)) + } + + // Filter setup + if (this.html_search) { + this.html_search.addEventListener("keyup", ((grid) => () => { + grid.filter(); + })(this)); + } + + if (this.html_filter) { + this.html_filter.addEventListener("change", ((grid) => () => { + grid.filter(); + })(this)); + } + + if (this.html_theme) { + this.html_theme.addEventListener("change", ((grid) => () => { + grid.filter(); + })(this)); + } + + // Cookie setup + const cookies = document.cookie.split(";").reduce((acc, cookieString) => { + const [key, value] = cookieString.split("=").map(s => s.trim().replace(/^"|"$/g, "")); + if (key && value) { + acc[key] = decodeURIComponent(value); + } + return acc; + }, {}); + + // Initial sort + if ("sort-id" in cookies && cookies["sort-id"] in this.html_sort_buttons) { + const current = this.html_sort_buttons[cookies["sort-id"]]; + + if("sort-order" in cookies) { + current.button.setAttribute("data-sort-order", cookies["sort-order"]); + } + + this.sort(current, true); + } + } + + // Clear + clear(current) { + // Cleanup all + for (const [id, button] of Object.entries(this.html_sort_buttons)) { + button.toggle(); + button.inactive(); + } + + // Clear cookies + document.cookie = `sort-id=""; Path=/; SameSite=strict`; + document.cookie = `sort-order=""; Path=/; SameSite=strict`; + + // Reset sorting + tinysort(current.dataset.sortTarget, { + selector: "div", + attr: "data-index", + order: "asc", + }); + + } + + // Filter + filter() { + var filters = {}; + + // Check if there is a search filter + if (this.html_search && this.html_search.value != "") { + filters["search"] = this.html_search.value.toLowerCase(); + } + + // Check if there is a set filter + if (this.html_filter && this.html_filter.value != "") { + if (this.html_filter.value.startsWith("-")) { + filters["filter"] = this.html_filter.value.substring(1); + filters["filter-target"] = "0"; + } else { + filters["filter"] = this.html_filter.value; + filters["filter-target"] = "1"; + } + } + + // Check if there is a theme filter + if (this.html_theme && this.html_theme.value != "") { + filters["theme"] = this.html_theme.value; + } + + // Filter all cards + if (this.html_grid) { + const cards = this.html_grid.querySelectorAll("div > div.card"); + cards.forEach(current => { + // Set filter + if ("filter" in filters) { + if (current.getAttribute("data-" + filters["filter"]) != filters["filter-target"]) { + current.parentElement.classList.add("d-none"); + return; + } + } + + // Theme filter + if ("theme" in filters) { + if (current.getAttribute("data-theme") != filters["theme"]) { + current.parentElement.classList.add("d-none"); + return; + } + } + + // Check all searchable fields for a match + if ("search" in filters) { + for (let attribute of ["data-name", "data-number", "data-parts", "data-theme", "data-year"]) { + if (current.getAttribute(attribute).includes(filters["search"])) { + current.parentElement.classList.remove("d-none"); + return; + } + } + + // If no match, we need to hide it + current.parentElement.classList.add("d-none"); + return; + } + + // If we passed all filters, we need to display it + current.parentElement.classList.remove("d-none"); + }) + } + } + + // Sort + sort(current, no_flip=false) { + const target = current.data.sortTarget; + const attribute = current.data.sortAttribute; + const natural = current.data.sortNatural; + + // Cleanup all + for (const [id, button] of Object.entries(this.html_sort_buttons)) { + if (button != current) { + button.toggle(); + button.inactive(); + } + } + + // Sort + if (target && attribute) { + let order = current.data.sortOrder; + + // First ordering + if (!no_flip) { + if(!order) { + if (current.data.sortDesc) { + order = "desc" + } else { + order = "asc" + } + } else { + // Flip the sorting order + order = (order == "desc") ? "asc" : "desc"; + } + } + + // Toggle the ordering + current.toggle(order); + + // Store cookies + document.cookie = `sort-id="${encodeURIComponent(current.button.id)}"; Path=/; SameSite=strict`; + document.cookie = `sort-order="${encodeURIComponent(order)}"; Path=/; SameSite=strict`; + + // Do the sorting + tinysort(target, { + selector: "div", + attr: "data-" + attribute, + natural: natural == "true", + order: order, + }); + } + } +} diff --git a/static/scripts/set.js b/static/scripts/set.js new file mode 100644 index 0000000..88966d2 --- /dev/null +++ b/static/scripts/set.js @@ -0,0 +1,116 @@ +// Clean a status indicator +const clean_status = (status) => { + const to_remove = Array.from(status.classList.values()).filter((name) => name.startsWith('ri-') || name.startsWith('text-') || name.startsWith('bg-')) + + if (to_remove.length) { + status.classList.remove(...to_remove); + } +} + +// Change the status of a set checkbox +const change_set_checkbox_status = async (el, kind, id, url) => { + const status = document.getElementById(`status-${kind}-${id}`); + + try { + // Set the status to unknown + if (status) { + clean_status(status) + status.classList.add("ri-question-line", "text-warning"); + } + + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + state: el.checked + }) + }); + + if (!response.ok) { + throw new Error(`Response status: ${response.status}`); + } + + const json = await response.json(); + + if ("error" in json) { + throw new Error(`Error received: ${json.error}`) + } + + // Set the status to OK + if (status) { + clean_status(status) + status.classList.add("ri-checkbox-circle-line", "text-success"); + } + + // Update the card + const card = document.getElementById(`set-${id}`); + if (card) { + // Not going through dataset to avoid converting + card.setAttribute(`data-${kind}`, Number(el.checked)); + } + } catch (error) { + console.log(error.message); + + // Set the status to not OK + if (status) { + clean_status(status) + status.classList.add("ri-alert-line", "text-danger"); + } + } +} + +// Change the amount of missing parts +const change_part_missing_amount = async (el, set_id, part_id, url) => { + const status = document.getElementById(`status-part-${set_id}-${part_id}`); + + try { + // Set the status to unknown + if (status) { + clean_status(status) + status.classList.add("ri-question-line", "bg-warning-subtle"); + } + + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + missing: el.value + }) + }); + + if (!response.ok) { + throw new Error(`Response status: ${response.status}`); + } + + const json = await response.json(); + + if ("error" in json) { + throw new Error(`Error received: ${json.error}`); + } + + // Set the status to OK + if (status) { + clean_status(status) + status.classList.add("ri-checkbox-circle-line", "text-success", "bg-success-subtle"); + } + + // Update the sort data + const sort = document.getElementById(`sort-part-${set_id}-${part_id}`); + if (sort) { + sort.dataset.sort = el.value; + } + + } catch (error) { + console.log(error.message); + + // Set the status to not OK + if (status) { + clean_status(status) + status.classList.add("ri-alert-line", "text-danger", "bg-danger-subtle"); + } + } +} diff --git a/static/scripts/socket.js b/static/scripts/socket.js new file mode 100644 index 0000000..514adf0 --- /dev/null +++ b/static/scripts/socket.js @@ -0,0 +1,454 @@ +// Socket class +class BrickSocket { + constructor(id, path, namespace, messages, bulk=false) { + this.id = id; + this.path = path; + this.namespace = namespace; + this.messages = messages; + this.bulk = bulk; + + this.disabled = false; + this.socket = undefined; + + // Listeners + this.add_listener = undefined; + this.confirm_listener = undefined; + + // Form elements (built based on the initial id) + this.html_button = document.getElementById(id); + this.html_complete = document.getElementById(`${id}-complete`); + this.html_count = document.getElementById(`${id}-count`); + this.html_fail = document.getElementById(`${id}-fail`); + this.html_input = document.getElementById(`${id}-set`); + this.html_no_confim = document.getElementById(`${id}-no-confirm`); + this.html_progress = document.getElementById(`${id}-progress`); + this.html_progress_bar = document.getElementById(`${id}-progress-bar`); + this.html_progress_message = document.getElementById(`${id}-progress-message`); + this.html_spinner = document.getElementById(`${id}-spinner`); + this.html_status = document.getElementById(`${id}-status`); + this.html_status_icon = document.getElementById(`${id}-status-icon`); + + // Card elements + this.html_card = document.getElementById(`${id}-card`); + this.html_card_number = document.getElementById(`${id}-card-number`); + this.html_card_name = document.getElementById(`${id}-card-name`); + this.html_card_image_container = document.getElementById(`${id}-card-image-container`); + this.html_card_image = document.getElementById(`${id}-card-image`); + this.html_card_footer = document.getElementById(`${id}-card-footer`); + this.html_card_confirm = document.getElementById(`${id}-card-confirm`); + this.html_card_dismiss = document.getElementById(`${id}-card-dismiss`); + + if (this.html_button) { + this.add_listener = ((bricksocket) => (e) => { + if (!bricksocket.disabled) { + bricksocket.toggle(false); + + // Split and save the list if bulk + if (bricksocket.bulk) { + bricksocket.read_set_list() + } + + if (bricksocket.bulk || (bricksocket.html_no_confim && bricksocket.html_no_confim.checked)) { + bricksocket.import_set(true); + } else { + bricksocket.load_set(); + } + } + })(this); + + this.html_button.addEventListener("click", this.add_listener); + } + + if (this.html_card_dismiss && this.html_card) { + this.html_card_dismiss.addEventListener("click", ((card) => (e) => { + card.classList.add("d-none"); + })(this.html_card)); + } + + // Socket status + window.setInterval(((bricksocket) => () => { + bricksocket.status(); + })(this), 500); + + // Setup the socket + this.setup(); + } + + // Clear form + clear() { + this.clear_status(); + + if (this.html_count) { + this.html_count.classList.add("d-none"); + } + + if(this.html_progress_bar) { + this.html_progress.setAttribute("aria-valuenow", "0"); + this.html_progress_bar.setAttribute("style", "width: 0%"); + this.html_progress_bar.textContent = ""; + } + + this.spinner(false); + + if (this.html_card) { + this.html_card.classList.add("d-none"); + } + + if (this.html_card_footer) { + this.html_card_footer.classList.add("d-none"); + + if (this.html_card_confirm) { + this.html_card_footer.classList.add("d-none"); + } + } + } + + // Clear status message + clear_status() { + if (this.html_complete) { + this.html_complete.classList.add("d-none"); + + if (this.bulk) { + this.html_complete.innerHTML = ""; + } else { + this.html_complete.textContent = ""; + } + } + + if (this.html_fail) { + this.html_fail.classList.add("d-none"); + this.html_fail.textContent = ""; + } + } + + // Upon receiving a complete message + complete(data) { + if(this.html_progress_bar) { + this.html_progress.setAttribute("aria-valuenow", "100"); + this.html_progress_bar.setAttribute("style", "width: 100%"); + this.html_progress_bar.textContent = "100%"; + } + + if (this.bulk) { + if (this.html_complete) { + this.html_complete.classList.remove("d-none"); + + // Create a message (not ideal as it is template inside code) + const success = document.createElement("div"); + success.classList.add("alert", "alert-success"); + success.setAttribute("role", "alert"); + success.innerHTML = `Success: ${data.message}` + + this.html_complete.append(success) + } + + // Import the next set + this.import_set(true, undefined, true); + } else { + this.spinner(false); + + if (this.html_complete) { + this.html_complete.classList.remove("d-none"); + this.html_complete.innerHTML = `Success: ${data.message}`; + } + + if (this.html_fail) { + this.html_fail.classList.add("d-none"); + } + } + } + + // Update the count + count(count, total) { + if (this.html_count) { + this.html_count.classList.remove("d-none"); + + // If there is no total, display a question mark instead + if (total == 0) { + total = "?" + } + + this.html_count.textContent = `(${count}/${total})`; + } + } + + // Upon receiving a fail message + fail(data) { + this.spinner(false); + + if (this.html_fail) { + this.html_fail.classList.remove("d-none", ); + this.html_fail.innerHTML = `Error: ${data.message}`; + } + + if (!this.bulk && this.html_complete) { + this.html_complete.classList.add("d-none"); + } + + if (this.html_progress_bar) { + this.html_progress_bar.classList.remove("progress-bar-animated"); + } + + if (this.bulk && this.html_input) { + if (this.set_list_last_number !== undefined) { + this.set_list.unshift(this.set_list_last_number); + this.set_list_last_number = undefined; + } + + this.html_input.value = this.set_list.join(', '); + } + } + + // Import a set + import_set(no_confirm, number, from_complete=false) { + if (this.html_input) { + if (!this.bulk || !from_complete) { + // Reset the progress + if (no_confirm) { + this.clear(); + } else { + this.clear_status(); + } + } + + // Grab from the list if bulk + if (this.bulk) { + number = this.set_list.shift() + + // Abort if nothing left to process + if (number === undefined) { + // Clear the input + this.html_input.value = ""; + + // Settle the form + this.spinner(false); + this.toggle(true); + + return; + } + + // Save the pulled number + this.set_list_last_number = number; + } + + this.spinner(true); + + this.socket.emit(this.messages.IMPORT_SET, { + set_num: (number !== undefined) ? number : this.html_input.value, + }); + } else { + this.fail("Could not find the input field for the set number"); + } + } + + // Load a set + load_set() { + if (this.html_input) { + // Reset the progress + this.clear() + this.spinner(true); + + this.socket.emit(this.messages.LOAD_SET, { + set_num: this.html_input.value + }); + } else { + this.fail("Could not find the input field for the set number"); + } + } + + // Update the progress + progress(data={}) { + let total = data["total"]; + let count = data["count"] + + // Fix the total if bogus + if (!total || isNaN(total) || total <= 1) { + total = 0; + } + + // Fix the count if bogus + if (!count || isNaN(count) || count <= 1) { + count = 1; + } + + this.count(count, total); + this.progress_message(data["message"]); + + if (this.html_progress && this.html_progress_bar) { + // Infinite progress bar + if (!total) { + this.html_progress.setAttribute("aria-valuenow", "100"); + this.html_progress_bar.classList.add("progress-bar-striped", "progress-bar-animated"); + this.html_progress_bar.setAttribute("style", "width: 100%"); + this.html_progress_bar.textContent = ""; + } else { + if (count > total) { + total = count; + } + + const progress = (count - 1) * 100 / total; + + this.html_progress.setAttribute("aria-valuenow", progress); + this.html_progress_bar.classList.remove("progress-bar-striped", "progress-bar-animated"); + this.html_progress_bar.setAttribute("style", `width: ${progress}%`); + this.html_progress_bar.textContent = `${progress.toFixed(2)}%`; + } + } + } + + // Update the progress message + progress_message(message) { + if (this.html_progress_message) { + this.html_progress_message.classList.remove("d-none"); + this.html_progress_message.textContent = message; + } + } + + // Bulk: read the input as a list + read_set_list() { + this.set_list = []; + + if (this.html_input) { + const value = this.html_input.value; + this.set_list = value.split(",").map((el) => el.trim()) + } + } + + // Set is loaded + set_loaded(data) { + if (this.html_card) { + this.html_card.classList.remove("d-none"); + + if (this.html_card_number) { + this.html_card_number.textContent = data["set_num"]; + } + + if (this.html_card_name) { + this.html_card_name.textContent = data["name"]; + } + + if (this.html_card_image_container) { + this.html_card_image_container.setAttribute("style", `background-image: url(${data["set_img_url"]})`); + } + + if (this.html_card_image) { + this.html_card_image.setAttribute("src", data["set_img_url"]); + this.html_card_image.setAttribute("alt", data["set_num"]); + } + + if (this.html_card_footer) { + this.html_card_footer.classList.add("d-none"); + + if (!data.download) { + this.html_card_footer.classList.remove("d-none"); + + if (this.html_card_confirm) { + if (this.confirm_listener !== undefined) { + this.html_card_confirm.removeEventListener("click", this.confirm_listener); + } + + this.confirm_listener = ((bricksocket, number) => (e) => { + if (!bricksocket.disabled) { + bricksocket.toggle(false); + bricksocket.import_set(false, number); + } + })(this, data["set_num"]); + + this.html_card_confirm.addEventListener("click", this.confirm_listener); + } + } + } + } + } + + // Setup the actual socket + setup() { + if (this.socket === undefined) { + this.socket = io.connect(`${window.location.origin}/${this.namespace}`, { + path: this.path, + transports: ["websocket"], + }); + + // Complete + this.socket.on(this.messages.COMPLETE, ((bricksocket) => (data) => { + bricksocket.complete(data); + if (!bricksocket.bulk) { + bricksocket.toggle(true); + } + })(this)); + + // Fail + this.socket.on(this.messages.FAIL, ((bricksocket) => (data) => { + bricksocket.fail(data); + bricksocket.toggle(true); + })(this)); + + // Progress + this.socket.on(this.messages.PROGRESS, ((bricksocket) => (data) => { + bricksocket.progress(data); + })(this)); + + // Set loaded + this.socket.on(this.messages.SET_LOADED, ((bricksocket) => (data) => { + bricksocket.set_loaded(data); + })(this)); + } + } + + // Toggle the spinner + spinner(show) { + if (this.html_spinner) { + if (show) { + this.html_spinner.classList.remove("d-none"); + } else { + this.html_spinner.classList.add("d-none"); + } + } + } + + // Toggle the status + status() { + if (this.html_status) { + if (this.socket === undefined) { + this.html_status.textContent = "Socket is not initialized"; + if (this.html_status_icon) { + this.html_status_icon.classList.remove("ri-checkbox-circle-fill", "ri-close-circle-fill"); + this.html_status_icon.classList.add("ri-question-fill"); + } + } else if (this.socket.connected) { + this.html_status.textContent = "Socket is connected"; + if (this.html_status_icon) { + this.html_status_icon.classList.remove("ri-question-fill", "ri-close-circle-fill"); + this.html_status_icon.classList.add("ri-checkbox-circle-fill"); + } + } else { + this.html_status.textContent = "Socket is disconnected"; + if (this.html_status_icon) { + this.html_status_icon.classList.remove("ri-question-fill", "ri-checkbox-circle-fill"); + this.html_status_icon.classList.add("ri-close-circle-fill"); + } + } + } + } + + // Toggle clicking on the button, or sending events + toggle(enabled) { + this.disabled = !enabled; + + if (this.html_button) { + this.html_button.disabled = !enabled; + } + + if (this.html_input) { + this.html_input.disabled = !enabled; + } + + if (this.html_card_confirm) { + this.html_card_confirm.disabled = !enabled; + } + + if (this.html_card_dismiss) { + this.html_card_dismiss.disabled = !enabled; + } + } +} diff --git a/static/style.css b/static/style.css deleted file mode 100644 index 793cbad..0000000 --- a/static/style.css +++ /dev/null @@ -1,69 +0,0 @@ -/* The Modal (background) */ -.modal { - display: none; /* Hidden by default */ - position: fixed; /* Stay in place */ - z-index: 1; /* Sit on top */ - padding-top: 100px; /* Location of the box */ - left: 0; - top: 0; - width: 100%; /* Full width */ - height: 100%; /* Full height */ - overflow: auto; /* Enable scroll if needed */ - background-color: rgb(0,0,0); /* Fallback color */ - background-color: rgba(0,0,0,0.4); /* Black w/ opacity */ -} - -/* Modal Content */ -.modal-content { - position: relative; - background-color: #fefefe; - margin: auto; - padding: 0; - border: 1px solid #888; - width: 80%; - box-shadow: 0 4px 8px 0 rgba(0,0,0,0.2),0 6px 20px 0 rgba(0,0,0,0.19); - -webkit-animation-name: animatetop; - -webkit-animation-duration: 0.4s; - animation-name: animatetop; - animation-duration: 0.4s -} - -/* Add Animation */ -@-webkit-keyframes animatetop { - from {top:-300px; opacity:0} - to {top:0; opacity:1} -} - -@keyframes animatetop { - from {top:-300px; opacity:0} - to {top:0; opacity:1} -} - -/* The Close Button */ -.close { - color: white; - float: right; - font-size: 28px; - font-weight: bold; -} - -.close:hover, -.close:focus { - color: #000; - text-decoration: none; - cursor: pointer; -} - -.modal-header { - padding: 2px 16px; - background-color: #5cb85c; - color: white; -} - -.modal-body {padding: 2px 16px;} - -.modal-footer { - padding: 2px 16px; - background-color: #5cb85c; - color: white; -} diff --git a/static/styles.css b/static/styles.css new file mode 100644 index 0000000..9ac95de --- /dev/null +++ b/static/styles.css @@ -0,0 +1,61 @@ +.card-img { + background-repeat: no-repeat; + background-size: cover; +} + +.card-img img { + max-height: 150px; + height: 100%; + width: 100%; + object-fit:contain; + -webkit-backdrop-filter: blur(8px) contrast(60%); + backdrop-filter: blur(8px) contrast(60%); +} + +.card-img img.card-last-img { + max-height: 100px; +} + +.card-check { + font-size: 12px; +} + +.card-solo > .card-img img { + max-height:300px !important; +} + +.card-solo > .card-img img.card-medium-img { + max-height:200px !important; +} + +.card-solo .card-check { + font-size: inherit !important; +} + +.accordion-img { + max-height: 50px; + max-width: 50px; + height: 100%; + width: 100%; + object-fit:contain; +} + +.table-img { + height: 75px; + width: 75px; + object-fit:contain; +} + +.table-td-missing { + max-width: 150px; +} + +/* Fixes for sortable.js */ +.sortable { + --th-color: #000 !important; + --th-bg:#fff !important; +} + +.sortable thead th { + font-weight: bold !important; +} diff --git a/templates/404.html b/templates/404.html new file mode 100644 index 0000000..85c66f8 --- /dev/null +++ b/templates/404.html @@ -0,0 +1,20 @@ +{% extends 'base.html' %} + +{% block title %} - 404!{% endblock %} + +{% block main %} +
+ +
+{% endblock %} \ No newline at end of file diff --git a/templates/add.html b/templates/add.html new file mode 100644 index 0000000..a4a4917 --- /dev/null +++ b/templates/add.html @@ -0,0 +1,76 @@ +{% extends 'base.html' %} + +{% block title %} - Add a set{% endblock %} + +{% block main %} +
+ {% if not config['HIDE_ADD_BULK_SET'].value %} + + {% endif %} +
+
+
+
+
Add a set
+
+
+ + +
+ + +
+
+ + +
+
+
+

+ Progress + + + Loading... + +

+
+
+
+

+
+
+
+
+
+ + +
+
+
+ +
+ +
+
+
+ +
+
+
+
+{% include 'set/socket.html' %} +{% endblock %} + +{% block scripts %} + +{% endblock %} \ No newline at end of file diff --git a/templates/admin.html b/templates/admin.html new file mode 100644 index 0000000..0f4df0d --- /dev/null +++ b/templates/admin.html @@ -0,0 +1,36 @@ +{% extends 'base.html' %} + +{% block title %} - Administration{% endblock %} + +{% block main %} +
+
+
+
+
+
+
Administration
+
+
+ {% if delete_database %} + {% include 'admin/database/delete.html' %} + {% elif drop_database %} + {% include 'admin/database/drop.html' %} + {% elif import_database %} + {% include 'admin/database/import.html' %} + {% else %} + {% include 'admin/logout.html' %} + {% include 'admin/instructions.html' %} + {% include 'admin/theme.html' %} + {% include 'admin/retired.html' %} + {% include 'admin/database.html' %} + {% include 'admin/configuration.html' %} + {% endif %} +
+ +
+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/templates/admin/configuration.html b/templates/admin/configuration.html new file mode 100644 index 0000000..44cabdf --- /dev/null +++ b/templates/admin/configuration.html @@ -0,0 +1,31 @@ +{% import 'macro/accordion.html' as accordion %} + +{{ accordion.header('Configuration variables', 'configuration', 'admin', icon='list-settings-line') }} +
    + {% for entry in configuration %} +
  • + {{ entry.name }}: + {% if entry.value == none or entry.value == '' %} + Unset + {% elif entry.value == true %} + True + {% elif entry.value == false %} + False + {% else %} + {% if entry.is_secret() %} + Set + {% else %} + {{ entry.value }} + {% endif %} + {% endif %} + {% if not entry.not_from_env %} + Env: {{ entry.env_name }} + {% if entry.extra_name %}Env: {{ entry.extra_name }}{% endif %} + {% if entry.is_changed() %} + Changed + {% endif %} + {% endif %} +
  • + {% endfor %} +
+{{ accordion.footer() }} diff --git a/templates/admin/database.html b/templates/admin/database.html new file mode 100644 index 0000000..1710d7d --- /dev/null +++ b/templates/admin/database.html @@ -0,0 +1,48 @@ +{% import 'macro/accordion.html' as accordion %} + +{{ accordion.header('Database', 'database', 'admin', expanded=open_database, icon='database-2-line') }} +
Status
+{% if exception %}{% endif %} +{% if error %}{% endif %} +{% if not is_init %} + +{% else %} +

The database is initialized.

+

+ Download the database file +

+
Records
+
+
    +
  • + Sets {{ counters['sets'] }} +
  • +
  • + Minifigures {{ counters['minifigures'] }} +
  • +
  • + Parts {{ counters['inventory'] }} +
  • +
  • + Missing {{ counters['missing'] }} +
  • +
+
+{% endif %} +{{ accordion.footer() }} + +{{ accordion.header('Database danger zone', 'database-danger', 'admin', danger=true, class='text-end') }} +{% if error %}{% endif %} + Import a database file +
+ + + Drop the database + Delete the database file +{{ accordion.footer() }} diff --git a/templates/admin/database/delete.html b/templates/admin/database/delete.html new file mode 100644 index 0000000..675e39d --- /dev/null +++ b/templates/admin/database/delete.html @@ -0,0 +1,10 @@ +{% import 'macro/accordion.html' as accordion %} + +{{ accordion.header('Database danger zone', 'database-danger', 'admin', expanded=true, danger=true, class='text-end') }} +
+ {% if error %}{% endif %} + + Back to the admin + + +{{ accordion.footer() }} diff --git a/templates/admin/database/drop.html b/templates/admin/database/drop.html new file mode 100644 index 0000000..059ea62 --- /dev/null +++ b/templates/admin/database/drop.html @@ -0,0 +1,10 @@ +{% import 'macro/accordion.html' as accordion %} + +{{ accordion.header('Database danger zone', 'database-danger', 'admin', expanded=true, danger=true, class='text-end') }} +
+ {% if error %}{% endif %} + + Back to the admin + + +{{ accordion.footer() }} diff --git a/templates/admin/database/import.html b/templates/admin/database/import.html new file mode 100644 index 0000000..cd0fa2a --- /dev/null +++ b/templates/admin/database/import.html @@ -0,0 +1,16 @@ +{% import 'macro/accordion.html' as accordion %} + +{{ accordion.header('Database danger zone', 'database-danger', 'admin', expanded=true, danger=true) }} +
+ {% if error %}{% endif %} + +
+ + +
+
+ Back to the admin + +
+ +{{ accordion.footer() }} diff --git a/templates/admin/instructions.html b/templates/admin/instructions.html new file mode 100644 index 0000000..17c1e4f --- /dev/null +++ b/templates/admin/instructions.html @@ -0,0 +1,32 @@ +{% import 'macro/accordion.html' as accordion %} + +{{ accordion.header('Instructions', 'instructions', 'admin', expanded=open_instructions, icon='file-line') }} +
Folder
+

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

+
Counters
+

+

+
    +
  • + Sets {{ instructions.sets | length }} +
  • +
  • + Instructions for sets {{ instructions.sets_total }} +
  • +
  • + Unknown {{ instructions.unknown_total }} +
  • +
  • + Rejected files {{ instructions.rejected_total }} +
  • +
+
+

+
Refresh
+

+ Refresh the instructions cache +

+{{ accordion.footer() }} diff --git a/templates/admin/logout.html b/templates/admin/logout.html new file mode 100644 index 0000000..364cac9 --- /dev/null +++ b/templates/admin/logout.html @@ -0,0 +1,9 @@ +{% import 'macro/accordion.html' as accordion %} + +{% if g.login.is_enabled() %} + {{ accordion.header('Authentication', 'authentication', 'admin', expanded=open_logout, icon='list-settings-line') }} +

+ Logout +

+ {{ accordion.footer() }} +{% endif %} \ No newline at end of file diff --git a/templates/admin/retired.html b/templates/admin/retired.html new file mode 100644 index 0000000..121e47f --- /dev/null +++ b/templates/admin/retired.html @@ -0,0 +1,26 @@ +{% import 'macro/accordion.html' as accordion %} + +{{ accordion.header('Retired sets', 'retired', 'admin', expanded=open_retired, icon='calendar-close-line') }} +
File
+{% if retired.exception %}{% endif %} +

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

+
Counters
+

+

+
    +
  • + Retired sets {{ retired.retired | length }} +
  • +
+
+

+
Refresh
+

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

+{{ accordion.footer() }} diff --git a/templates/admin/theme.html b/templates/admin/theme.html new file mode 100644 index 0000000..c935e6f --- /dev/null +++ b/templates/admin/theme.html @@ -0,0 +1,26 @@ +{% import 'macro/accordion.html' as accordion %} + +{{ accordion.header('Themes', 'theme', 'admin', expanded=open_theme, icon='price-tag-3-line') }} +
File
+{% if theme.exception %}{% endif %} +

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

+
Counters
+

+

+
    +
  • + Themes {{ theme.themes | length }} +
  • +
+
+

+
Refresh
+

+ Refresh the themes cache + Update the themes file +

+{{ accordion.footer() }} diff --git a/templates/base.html b/templates/base.html index f3b384f..e0da3fd 100644 --- a/templates/base.html +++ b/templates/base.html @@ -1,383 +1,88 @@ - - - + - {{ tmp }} - {{ title }} - - - - - - - - + + + BrickTracker{% block title %}{% endblock %} + + + + + + + -
- - - - - - - - {% for i in json_file['unit'] %} - - {% endfor %} - - - - {% for brick in inventory_file.results %} - {% if brick.is_spare == False %} - - {% if brick.element_id == None %} - - {% else %} - - {% endif %} - - - - - {% for i in json_file['unit'] %} - - - {% endfor %} - - {% endif %} - {% endfor %} - -
IDNameColorQtyMissing ({{ loop.index }})
{{ brick.part.part_num }}{{ brick.part.name }}{{ brick.color.name }}{{ brick.quantity }} -
- {% set ns = namespace(count='') %} - -
- - - - - - - {% for j in json_file['unit'][loop.index0]['bricks']['missing'] %} - {% if j['brick']['ID'] == brick.part.part_num and j['brick']['color_name'] == brick.color.name %} - - {% if j['brick']['is_spare']|lower == brick.is_spare|lower %} - - {% set ns.count = j['brick']['amount'] %} - {% endif %} - {% endif %} - {% endfor %} -
- - {{ ns.count }} - - -
-
-
-
- - -{% if minifigs_file.figs | length > 0 %} - -

Minifigs

-{% for fig in minifigs_file.figs %} - -

{{ fig.name}}

- -X {{ fig.quantity }} - -
- - - - - - - - - - - {% for part in fig.parts %} - - - - - - {% for i in json_file['unit'] %} - - - {% endfor %} - - {% endfor %} - -
IDNameTotal Qty
{{ part.part_num }}{{ part.name }}{{ part.quantity * fig.quantity }} -
- {% set ns = namespace(count='') %} - -
- - - - - - - {% for j in json_file['unit'][loop.index0]['bricks']['missing'] %} - {% if j['brick']['ID'] == part.part_num and j['brick']['color_name'] == part.color_name %} - - - {% endif %} - {% endfor %} -
- - {{ ns.count }} - - -
-
-
-
-
- - - -{% endfor %} -{% endif %} - - - - - - - - - - - {% endblock %} - - - -{% block scripts %} - - - - -{% endblock %} - diff --git a/templates/bulk.html b/templates/bulk.html new file mode 100644 index 0000000..1974e1b --- /dev/null +++ b/templates/bulk.html @@ -0,0 +1,68 @@ +{% extends 'base.html' %} + +{% block title %} - Bulk add sets{% endblock %} + +{% block main %} +
+
+
+
+
+
Bulk add sets
+
+
+ +
+
+ + +
+
+ + +
+
+
+

+ Progress + + + Loading... + +

+
+
+
+

+
+
+
+
+
+ + +
+
+
+ +
+
+
+
+ +
+
+
+
+{% with bulk=true %} + {% include 'set/socket.html' %} +{% endwith %} +{% endblock %} + +{% block scripts %} + +{% endblock %} \ No newline at end of file diff --git a/templates/config.html b/templates/config.html deleted file mode 100644 index 62def2c..0000000 --- a/templates/config.html +++ /dev/null @@ -1,90 +0,0 @@ - - - - - - Set Overview - - - - - - -

Database

- {% if not db_is_there %} -

Database does not exists

-
- -
- {% else %} - - {% endif %} -

Rebrickable Data

- -

Data is last updated:

- - - - - - - - - {% for file, date in creation_dates.items() %} - - - - - {% endfor %} - -
FileLast Updated
{{ file }}{{ date }}
-
- - -
- - -

-----------

- -

Recreate Database

-

Drop the tables in the database and recreate them. This will delete all your data!

-
- - -
- - - - diff --git a/templates/create.html b/templates/create.html deleted file mode 100644 index d0d4ddc..0000000 --- a/templates/create.html +++ /dev/null @@ -1,169 +0,0 @@ - - - - - - - - - - - - -
-
-
-
- -
- -
-
- - - - -
-
- -
-
-
- -
-
- - - - - - - - - - - diff --git a/templates/dashboard.html b/templates/dashboard.html deleted file mode 100644 index bea13ef..0000000 --- a/templates/dashboard.html +++ /dev/null @@ -1,60 +0,0 @@ - - - - - - LEGO Dashboard - - - - -

LEGO Dashboard

- -
-

Sets by Theme

- {{ graphs['sets_by_theme']|safe }} -
- -
-

Sets Released Per Year

- {{ graphs['sets_by_year']|safe }} -
- -
-

Most Frequent Parts

- {{ graphs['parts']|safe }} -
- -
-

Minifigures by Set

- {{ graphs['minifigs']|safe }} -
- -
-

Missing Parts by Set

- {{ graphs['missing_parts']|safe }} -
- - - diff --git a/templates/delete.html b/templates/delete.html new file mode 100644 index 0000000..12848a9 --- /dev/null +++ b/templates/delete.html @@ -0,0 +1,24 @@ +{% extends 'base.html' %} + +{% block title %} - Delete a set {{ item.fields.set_num }} ({{ item.fields.u_id }}){% endblock %} + +{% block main %} +
+
+
+ {% with solo=true, delete=true %} + {% include 'set/card.html' %} + {% endwith %} +
+
+
+ +{% endblock %} + +{% block scripts %} + +{% endblock %} \ No newline at end of file diff --git a/templates/error.html b/templates/error.html new file mode 100644 index 0000000..2ea963a --- /dev/null +++ b/templates/error.html @@ -0,0 +1,18 @@ +{% extends 'base.html' %} + +{% block title %} - Error!{% endblock %} + + +{% block main %} +
+ +
+{% endblock %} \ No newline at end of file diff --git a/templates/exception.html b/templates/exception.html new file mode 100644 index 0000000..846e754 --- /dev/null +++ b/templates/exception.html @@ -0,0 +1,19 @@ +{% extends 'base.html' %} + +{% block title %} - Exception!{% endblock %} + +{% block main %} +
+ +
+{% endblock %} \ No newline at end of file diff --git a/templates/frontpage.html b/templates/frontpage.html deleted file mode 100644 index c27a709..0000000 --- a/templates/frontpage.html +++ /dev/null @@ -1,593 +0,0 @@ - - - - - - Set Overview - - - - - - - - - - - - - - - - - - - -
- - -
- - - - - - - -
-
- - - -
- - - -
-
- - {% for i in set_list %} - {% if json_file[i['set_num']]['count'] == 1 %} -
-
- {% else %} -
-
- {% endif %} - -
-
- - {{ i['set_num'] }} {{ i['name'] }} - -
-
-
- Parts: - {{ i['num_parts'] }} -
-
-
-
-
-
- - Image - -
-
- - {% for j in json_file[i['set_num']]['unit'] %} -
-

Set #{{ loop.index }}

-
- -
- -
- -
- -
-
- {% endfor %} -
- -
-
- - {% endfor %} -
- - - - - - - - - - - - diff --git a/templates/index.html b/templates/index.html index d6bbc91..2fdf640 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1,931 +1,42 @@ - - - - - - Set Overview - - +{% extends 'base.html' %} - - - - - -
- - - - - +{% block main %} +
+

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

+ {% if brickset_collection | length %} +
+ {% for item in brickset_collection %} +
+ {% with solo=false, tiny=true, last=true %} + {% include 'set/card.html' %} + {% endwith %} +
+ {% endfor %}
-
- {% for i in set_list %} -
-
-
-
-

- {% if links == 'True' %} - {{ i[0] }} {{ i[1] }}
- {% else %} - {{ i[0] }} {{ i[1] }}
- {% endif %} - {{ i[3] }} ({{ i[2] }}) - - Parts: {{ i[4] }} -

-
- -
-
-
-
- - Image - -
-
- -
-
- {% if i[0] in minifigs %} - -
- {% endif %} - -
- -
- - - -
-
-
-
- - - {% set ns = namespace(found=false) %} - {% for file in files %} - {% if ns.found is sameas false and file.startswith(i[0]) %} - - - {% set ns.found = true %} - {% endif %} - {% endfor %} - -
-
-
- - - - - {% endfor %} -
- - - + {% else %} + {% include 'set/empty.html' %} + {% endif %} + {% if minifigure_collection | length %} +

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

+
+ {% for item in minifigure_collection %} +
+ {% with solo=false, tiny=true, last=true %} + {% include 'minifigure/card.html' %} + {% endwith %} +
+ {% endfor %} +
+ {% endif %} +
+{% endblock %} \ No newline at end of file diff --git a/templates/instructions.html b/templates/instructions.html new file mode 100644 index 0000000..54bb4c0 --- /dev/null +++ b/templates/instructions.html @@ -0,0 +1,25 @@ +{% extends 'base.html' %} + +{% block title %} - All instructions{% endblock %} + +{% block main %} + {% if upload %} + {% include 'instructions/upload.html' %} + {% elif rename %} + {% include 'instructions/rename.html' %} + {% elif delete %} + {% include 'instructions/delete.html' %} + {% else %} +
+ {% if g.login.is_authenticated() %} +

+ Upload an instructions file + Refresh the instructions cache +

+ {% endif %} + {% with all=true %} + {% include 'instructions/table.html' %} + {% endwith %} +
+ {% endif %} +{% endblock %} \ No newline at end of file diff --git a/templates/instructions/delete.html b/templates/instructions/delete.html new file mode 100644 index 0000000..cc1ba69 --- /dev/null +++ b/templates/instructions/delete.html @@ -0,0 +1,35 @@ +{% import 'macro/accordion.html' as accordion %} +{% import 'macro/card.html' as card %} + +
+
+
+
Instructions
+
+
+ {{ accordion.header('Instructions danger zone', 'instructions-delete', 'instructions', expanded=true, danger=true) }} + {% if item.brickset %} +
+ {% with item=item.brickset %} + {% include 'set/mini.html' %} + {% endwith %} +
+ {% endif %} +
+ {% if error %}{% endif %} + +
+ Back to the instructions + +
+
+ {{ accordion.footer() }} +
+ +
+
+ diff --git a/templates/instructions/rename.html b/templates/instructions/rename.html new file mode 100644 index 0000000..cfc5626 --- /dev/null +++ b/templates/instructions/rename.html @@ -0,0 +1,41 @@ +{% import 'macro/accordion.html' as accordion %} +{% import 'macro/card.html' as card %} + +
+
+
+
Instructions
+
+
+ {{ accordion.header('Management', 'instructions-rename', 'instructions', expanded=true) }} + {% if item.brickset %} +
+ {% with item=item.brickset %} + {% include 'set/mini.html' %} + {% endwith %} +
+ {% endif %} +
+ {% if error %}{% endif %} +
+ (current name: {{ item.filename }}) +
+ + {{ item.extension }} +
+
+ +
+ {{ accordion.footer() }} +
+ +
+
+ diff --git a/templates/instructions/table.html b/templates/instructions/table.html new file mode 100644 index 0000000..8f7c505 --- /dev/null +++ b/templates/instructions/table.html @@ -0,0 +1,51 @@ +{% import 'macro/table.html' as table %} + +
+ + + + + + + {% if g.login.is_authenticated() %} + + {% endif %} + + + + {% for item in table_collection %} + + + + {% if item.brickset %} + {{ table.image(item.brickset.url_for_image(), caption=item.brickset.fields.name, alt=item.brickset.fields.set_num) }} + {% else %} + + {% endif %} + {% if g.login.is_authenticated() %} + + {% endif %} + + {% endfor %} + +
Filename Set Image Actions
+ {% if item.allowed %} + + {%- endif -%} + {{ item.filename }} + {%- if item.allowed -%} + + {% endif %} + {{ item.human_size() }} + {{ item.human_time() }} + + {% if item.number %} {{ item.number }}{% endif %} + {% if item.brickset %}{{ item.brickset.fields.name }}{% endif %} + + Rename + Delete +
+
+{% if all %} + {{ table.dynamic('instructions', no_sort='2,3')}} +{% endif %} diff --git a/templates/instructions/upload.html b/templates/instructions/upload.html new file mode 100644 index 0000000..db877d8 --- /dev/null +++ b/templates/instructions/upload.html @@ -0,0 +1,42 @@ +{% import 'macro/accordion.html' as accordion %} +{% import 'macro/card.html' as card %} + +
+
+
+
Instructions
+
+
+ {{ accordion.header('Management', 'instructions-upload', 'instructions', expanded=true) }} +
+ {% if error %}{% endif %} + +
+ +
+ + {{ ', '.join(config['INSTRUCTIONS_ALLOWED_EXTENSIONS'].value) }} +
+
+
+ Back to the instructions + +
+
+ {{ accordion.footer() }} +
+ +
+
diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..8ee8a6c --- /dev/null +++ b/templates/login.html @@ -0,0 +1,37 @@ +{% extends 'base.html' %} + +{% block title %} - Login{% endblock %} + +{% block main %} +
+
+
+
+
+
+
Login
+
+
+ {% if wrong_password %} + + {% endif %} +
+
+ + +
+
+ +
+
+
+ +
+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/templates/macro/accordion.html b/templates/macro/accordion.html new file mode 100644 index 0000000..20cd701 --- /dev/null +++ b/templates/macro/accordion.html @@ -0,0 +1,69 @@ +{% macro header(title, id, parent, size=none, total=none, quantity=none, expanded=false, icon=none, class=none, danger=none, image=none, alt=none) %} + {% if danger %} + {% set icon='alert-fill' %} + {% endif %} +
+

+ +

+
+
+{% endmacro %} + +{% macro footer() %} +
+
+
+{% endmacro %} + +{% macro cards(card_collection, title, id, parent, target, icon=none) %} + {% set size=card_collection | length %} + {% if size %} + {{ header(title, id, parent, size=size, icon=icon) }} +
+ {% for item in card_collection %} +
+ {% with solo=false, tiny=true %} + {% include target %} + {% endwith %} +
+ {% endfor %} +
+ {{ footer() }} + {% endif %} +{% endmacro %} + +{% macro table(table_collection, title, id, parent, target, total=none, quantity=none, icon=none, image=none, alt=none, details=none, no_missing=none, read_only_missing=none) %} + {% set size=table_collection | length %} + {% if size %} + {{ header(title, id, parent, size=size, total=total, quantity=quantity, icon=icon, class='p-0', image=image, alt=alt) }} + {% if details %} +

+ {% if image %} + + + + {% endif %} + {% if icon %}{% endif %} Details +

+ {% endif %} + {% with solo=true, all=false %} + {% include target %} + {% endwith %} + {{ footer() }} + {% endif %} +{% endmacro %} diff --git a/templates/macro/badge.html b/templates/macro/badge.html new file mode 100644 index 0000000..70b10f1 --- /dev/null +++ b/templates/macro/badge.html @@ -0,0 +1,80 @@ +{% macro badge(check=none, url=none, solo=false, last=false, color='primary', blank=none, icon=none, alt=none, collapsible=none, text=none, tooltip=none) %} + {% if check or url %} + {% if url %} + + {% if icon %}{% endif %} + {% if collapsible and not last %} {{ collapsible }} {% endif %} + {% if text %}{{ text }}{% endif %} + {% if url %} + + {% else %} + + {% endif %} + {% endif %} +{% endmacro %} + +{% macro bricklink(item, solo=false, last=false) %} + {{ badge(url=item.url_for_bricklink(), solo=solo, last=last, blank=true, color='light border', icon='external-link-line', collapsible='Bricklink', alt='Bricklink') }} +{% endmacro %} + +{% macro instructions(item, solo=false, last=false) %} + {{ badge(url=item.url_for_instructions(), solo=solo, last=last, blank=true, color='light border', icon='file-line', collapsible='Instructions:', text=item.instructions | length, alt='Instructions') }} +{% endmacro %} + +{% macro parts(parts, solo=false, last=false) %} + {{ badge(check=parts, solo=solo, last=last, color='success', icon='shapes-line', collapsible='Parts:', text=parts, alt='Parts') }} +{% endmacro %} + +{% macro quantity(quantity, solo=false, last=false) %} + {{ badge(check=quantity, solo=solo, last=last, color='success', icon='close-line', collapsible='Quantity:', text=quantity, alt='Quantity') }} +{% endmacro %} + +{% macro set(set, solo=false, last=false, url=None, id=None) %} + {% if id %} + {% set url=url_for('set.details', id=id) %} + {% endif %} + {{ badge(check=set, url=url, solo=solo, last=last, color='secondary', icon='hashtag', collapsible='Set:', text=set, alt='Set') }} +{% endmacro %} + +{% macro theme(theme, solo=false, last=false) %} + {% if last %} + {% set tooltip=theme %} + {% else %} + {% set text=theme %} + {% endif %} + {{ badge(check=theme, solo=solo, last=last, color='primary', icon='price-tag-3-line', text=text, alt='Theme', tooltip=tooltip) }} +{% endmacro %} + +{% macro total_quantity(quantity, solo=false, last=false) %} + {{ badge(check=quantity, solo=solo, last=last, color='success', icon='functions', collapsible='Quantity:', text=quantity, alt='Quantity') }} +{% endmacro %} + +{% macro total_minifigures(minifigures, solo=false, last=false) %} + {{ badge(check=minifigures, solo=solo, last=last, color='info', icon='group-line', collapsible='Minifigures:', text=minifigures, alt='Minifigures') }} +{% endmacro %} + +{% macro total_missing(missing, solo=false, last=false) %} + {{ badge(check=missing, solo=solo, last=last, color='danger', icon='error-warning-line', collapsible='Missing:', text=missing, alt='Missing') }} +{% endmacro %} + +{% macro total_sets(sets, solo=false, last=false) %} + {{ badge(check=sets, solo=solo, last=last, color='secondary', icon='hashtag', collapsible='Sets:', text=sets, alt='Sets') }} +{% endmacro %} + +{% macro total_spare(spare, solo=false, last=false) %} + {{ badge(check=spare, solo=solo, last=last, color='warning', icon='loop-left-line', collapsible='Spare:', text=spare, alt='Spare') }} +{% endmacro %} + +{% macro rebrickable(item, solo=false, last=false) %} + {{ badge(url=item.url_for_rebrickable(), solo=solo, last=last, blank=true, color='light border', icon='external-link-line', collapsible='Rebrickable', alt='Rebrickable') }} +{% endmacro %} + +{% macro year(year, solo=false, last=false) %} + {{ badge(check=year, solo=solo, last=last, color='secondary', icon='calendar-line', collapsible='Year:', text=year, alt='Year') }} +{% endmacro %} diff --git a/templates/macro/card.html b/templates/macro/card.html new file mode 100644 index 0000000..3b52220 --- /dev/null +++ b/templates/macro/card.html @@ -0,0 +1,24 @@ +{% macro header(item, name, solo=false, number=none, color=none, icon='hashtag') %} + +{% endmacro %} + +{% macro image(item, solo=false, last=false, caption=none, alt=none, medium=none) %} + {% set image_url=item.url_for_image() %} +
+ + + +
+{% endmacro %} diff --git a/templates/macro/form.html b/templates/macro/form.html new file mode 100644 index 0000000..88c2588 --- /dev/null +++ b/templates/macro/form.html @@ -0,0 +1,13 @@ +{% macro checkbox(kind, id, text, url, checked, delete=false) %} + {% if g.login.is_readonly() %} + + {{ text }} + {% else %} + + + {% endif %} +{% endmacro %} diff --git a/templates/macro/table.html b/templates/macro/table.html new file mode 100644 index 0000000..396d811 --- /dev/null +++ b/templates/macro/table.html @@ -0,0 +1,76 @@ +{% macro header(color=false, quantity=false, missing=false, missing_parts=false, sets=false, minifigures=false) %} + + + Image + Name + {% if color %} + Color + {% endif %} + {% if quantity %} + Quantity + {% endif %} + {% if missing %} + Missing + {% endif %} + {% if missing_parts %} + Missing parts + {% endif %} + {% if sets %} + Sets + {% endif %} + {% if minifigures %} + Minifigures + {% endif %} + + +{% endmacro %} + +{% macro bricklink(item) %} + {% set url=item.url_for_bricklink() %} + {% if url %} + + Bricklink + + {% endif %} +{% endmacro %} + +{% macro image(image, caption=none, alt=none, accordion=false) %} + + + + + +{% endmacro %} + +{% macro rebrickable(item) %} + {% set url=item.url_for_rebrickable() %} + {% if url %} + + Rebrickable + + {% endif %} +{% endmacro %} + +{% macro dynamic(id, no_sort=none, number=none) %} + +{% endmacro %} \ No newline at end of file diff --git a/templates/minifigs.html b/templates/minifigs.html deleted file mode 100644 index 59b9b7b..0000000 --- a/templates/minifigs.html +++ /dev/null @@ -1,215 +0,0 @@ - - - - - - Set Overview - - - - - - - - - -
-
- - - - - - - - - - - {% for brick in missing_list %} - - - - - - - {% endfor %} - -
Num.NameQty
{{ brick[1] }} - {{ brick[0] }}{{ brick[1] }}{{ brick[2] }}
-
-
- - - - - - diff --git a/templates/minifigure.html b/templates/minifigure.html new file mode 100644 index 0000000..3359897 --- /dev/null +++ b/templates/minifigure.html @@ -0,0 +1,20 @@ +{% extends 'base.html' %} + +{% block title %} - Minifigure {{ item.fields.name }}{% endblock %} + +{% block main %} +
+
+
+ {% with solo=true, read_only_missing=true %} + {% include 'minifigure/card.html' %} + {% endwith %} +
+
+
+ +{% endblock %} diff --git a/templates/minifigure/card.html b/templates/minifigure/card.html new file mode 100644 index 0000000..9b8172b --- /dev/null +++ b/templates/minifigure/card.html @@ -0,0 +1,28 @@ +{% import 'macro/accordion.html' as accordion %} +{% import 'macro/badge.html' as badge %} +{% import 'macro/card.html' as card %} + +
+ {{ card.header(item, item.fields.name, solo=solo, number=item.clean_number(), icon='user-line') }} + {{ card.image(item, solo=solo, last=last, caption=item.fields.name, alt=item.fields.fig_num, medium=true) }} +
+ {% if last %} + {{ badge.set(item.fields.set_num, solo=solo, last=last, id=item.fields.u_id) }} + {{ badge.quantity(item.fields.quantity, solo=solo, last=last) }} + {% endif %} + {{ badge.total_sets(using | length, solo=solo, last=last) }} + {{ badge.total_quantity(item.fields.total_quantity, solo=solo, last=last) }} + {{ badge.total_missing(item.fields.total_missing, solo=solo, last=last) }} + {% if not last %} + {{ badge.rebrickable(item, solo=solo, last=last) }} + {% endif %} +
+ {% if solo %} +
+ {{ accordion.table(item.generic_parts(), 'Parts', item.fields.fig_num, 'minifigure-details', 'part/table.html', icon='shapes-line', alt=item.fields.fig_num, read_only_missing=read_only_missing)}} + {{ accordion.cards(using, 'Sets using this minifigure', 'using-inventory', 'minifigure-details', 'set/card.html', icon='hashtag') }} + {{ accordion.cards(missing, 'Sets missing parts of this minifigure', 'missing-inventory', 'minifigure-details', 'set/card.html', icon='error-warning-line') }} +
+ + {% endif %} +
diff --git a/templates/minifigure/table.html b/templates/minifigure/table.html new file mode 100644 index 0000000..287d7c9 --- /dev/null +++ b/templates/minifigure/table.html @@ -0,0 +1,26 @@ +{% import 'macro/table.html' as table %} + +
+ + {{ table.header(quantity=true, missing_parts=true, sets=true) }} + + {% for item in table_collection %} + + {{ table.image(item.url_for_image(), caption=item.fields.name, alt=item.fields.fig_num) }} + + + + + + {% endfor %} + +
+ {{ item.fields.name }} + {% if all %} + {{ table.rebrickable(item) }} + {% endif %} + {{ item.fields.total_quantity }}{{ item.fields.total_missing }}{{ item.fields.total_sets }}
+
+{% if all %} + {{ table.dynamic('minifigures', no_sort='0', number='2, 3, 4')}} +{% endif %} diff --git a/templates/minifigures.html b/templates/minifigures.html new file mode 100644 index 0000000..5b42357 --- /dev/null +++ b/templates/minifigures.html @@ -0,0 +1,11 @@ +{% extends 'base.html' %} + +{% block title %} - All minifigures{% endblock %} + +{% block main %} +
+ {% with all=true %} + {% include 'minifigure/table.html' %} + {% endwith %} +
+{% endblock %} \ No newline at end of file diff --git a/templates/missing.html b/templates/missing.html index 85218e3..659205b 100644 --- a/templates/missing.html +++ b/templates/missing.html @@ -1,276 +1,11 @@ - - - - - - Set Overview - - - - - - - - -
-
-

Missing Pieces

-
- - - - - - - - - - - - - {% for brick in missing_list %} - - {% if brick[4] == 'nil' %} - - {% else %} - - {% endif %} - - - - - - - {% endfor %} - -
Part NumColorElement IDQtySets
{{ brick[3] }}{{ brick[3] }}{{ brick[0] }}{{ brick[1] }}{{ brick[2] }}{{ brick[4] }} - {% set set_numbers = brick[5].split(',') %} - {% for i in range(0, set_numbers|length, 2) %} - {{ set_numbers[i] }}{% if i != set_numbers|length - 2 %},{% endif %} - {% endfor %} -
-
-
-
- - -