diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..82afab6 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,34 @@ +# Static files +static/instructions +static/minifigs +static/parts +static/sets + +# Docker +Dockerfile +compose.yaml + +# Documentation +docs/ +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..d3a6c5d 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 '/app/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 '/app/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 '/app/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 '/app/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..0d192d5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,23 @@ -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 +/local/ + +# Apple idiocy +.DS_Store diff --git a/Dockerfile b/Dockerfile index ed6f7b6..3c1742b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,11 @@ -FROM python:slim +FROM python:3-slim + WORKDIR /app -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"] + +# Python library requirements +RUN pip --no-cache-dir install -r requirements.txt + +ENTRYPOINT ["./entrypoint.sh"] diff --git a/README.md b/README.md index 2a52b60..5b24ce5 100644 --- a/README.md +++ b/README.md @@ -2,230 +2,31 @@ A web application for organizing and tracking LEGO sets, parts, and minifigures. Uses the Rebrickable API to fetch LEGO data and allows users to track missing pieces and collection status. -> **Screenshots at the end of the readme!** - Buy Me A Coffee ## Features - Track multiple LEGO sets with their parts and minifigures - Mark sets as checked/collected +- Mark minifigures as collected for a set - Track missing pieces - View parts inventory across sets - View minifigures across sets -- Automatic updates for LEGO data (themes, colors, sets) - Wishlist to keep track of what to buy -## Prerequisites +## Prefered setup: pre-build docker image -- Docker -- Docker Compose -- Rebrickable API key (from [Rebrickable](https://rebrickable.com/api/)) +Use the provided [compose.yaml](compose.yaml) file. -## Setup - -1. Clone the repository: -```bash -git clone https://gitea.baerentsen.space/FrederikBaerentsen/BrickTracker.git -cd BrickTracker -mkdir static/{sets,instructions,parts,minifigs} -``` - -2. Create a `.env` file with your configuration: -``` -REBRICKABLE_API_KEY=your_api_key_here -DOMAIN_NAME=https://your.domain.com -LINKS=True -RANDOM=True -``` - -- If using locally, set `DOMAIN_NAME` to `http://localhost:3333`. - -- `LINKS`: Set to `True` in order for set numbers to be links to Rebrickable on the front page. - -- `RANDOM`: Set to `True` in order for the sets to shuffle on the frontpage each load. - -3. Deploy with Docker Compose: -```bash -docker compose up -d -``` - -4. Access the web interface at `http://localhost:3333` - -5. The database is created, csv files are downloaded and you will be redirected to the `/create` page for inputting a set number. - -## Setup using pre-build Docker image - -1. Setup folders and files: -```bash -mkdir BrickTracker -cd BrickTracker -mkdir -p static/{sets,instructions,parts,minifigs} -touch app.db -``` - -2. Create Docker Compose file: -```bash -services: - bricktracker: - container_name: BrickTracker - restart: unless-stopped - image: gitea.baerentsen.space/frederikbaerentsen/bricktracker:latest - ports: - - "3333:3333" - volumes: - - ./static/parts:/app/static/parts - - ./static/instructions:/app/static/instructions - - ./static/sets:/app/static/sets - - ./static/minifigs:/app/static/minifigs - - ./app.db:/app/app.db - environment: - - REBRICKABLE_API_KEY=your_api_key_here - - DOMAIN_NAME=https://your.domain.com - - LINKS=True #optional, enables set numbers to be Rebrickable links on the front page - - RANDOM=False #optional, set to True if you want your front page to be shuffled on load -``` - -If using locally, set `DOMAIN_NAME` to `http://localhost:3333`. - -3. Deploy with Docker Compose: -```bash -docker compose up -d -``` - -4. Access the web interface at `http://localhost:3333` - -5. The database is created, csv files are downloaded and you will be redirected to the `/create` page for inputting a set number. - -6. csv files are downloaded inside the container. If you delete the container, go to `/config` and redownload them again. +See [setup](docs/setup.md). ## Usage -### Adding Sets -1. Go to the Create page -2. Enter a LEGO set number (e.g., "42115") -3. Wait for the set to be downloaded and processed +See [first steps](docs/first-steps.md). -### Managing Sets -- Mark sets as checked/collected using the checkboxes -- Track missing pieces by entering quantities in the parts table - - Note, the checkbox for missing pieces is updated automatically, if the set has missing pieces. It cannot be manually checked off. -- View all missing pieces across sets in the Missing page -- View complete parts inventory in the Parts page -- View all minifigures in the Minifigures page - -### Instructions - -Instructions can be added to the `static/instructions` folder. Instructions **must** be named: - -- SetNumber.pdf: `10312-1.pdf` or `7001-1.pdf`. Sets with multiple versions (eg. collectible minifigures use `-1`, `-2` etc) like `71039-1.pdf` and `71039-2.pdf`. -- SetNumber-pdf_number.pdf: `10294-1-1.pdf`, `10294-1-2.pdf` and `10294-1-3.pdf` for all three PDFs of the `10294-1` set. - -Instructions are not automatically downloaded! - -Instructions can be uploaded from the webinterface (desktop-only) using the `Upload` button in the navbar. - -## Docker Configuration - -The application uses two main configuration files: - -### docker-compose.yml -```yaml -services: - bricktracker: - container_name: BrickTracker - restart: unless-stopped - build: . - ports: - - "3333:3333" - volumes: - - .:/app - env_file: - - .env -``` - -### Dockerfile -```dockerfile -FROM python:slim -WORKDIR /app -COPY requirements.txt . -RUN pip install -r requirements.txt -COPY . . -RUN bash lego.sh -CMD ["gunicorn","--bind","0.0.0.0:3333","app:app","--worker-class","eventlet"] -``` - -## Development - -The application is built with: -- Flask (Python web framework) -- SQLite (Database) -- Socket.IO (Real-time updates) -- Rebrickable API (LEGO data) - -Key files: -- `app.py`: Main application code -- `db.py`: Database operations -- `downloadRB.py`: Rebrickable data download utilities - -## Notes - -- The application stores images locally in the `static` directory -- Database is stored in `app.db` (SQLite) -- LEGO data is cached in CSV files from Rebrickable -- Images are downloaded from Rebrickable when entering a set and then stored locally. -- The code is AS-IS! I am not a professional programmer and this has been a hobby projects for a long time. Don't expect anything neat! - -## Screenshots - -### Front page -![](https://xbackbone.baerentsen.space/LaMU8/koLAhiWe94.png/raw) - -Search your inventory and sort by theme, year, parts, id, name or sort by missing pieces. If you download instructions as PDF, add them to a specific folder and they show up [under each set](https://xbackbone.baerentsen.space/LaMU8/ZIyIQUdo31.png/raw) - -### Inventory - -![](https://xbackbone.baerentsen.space/LaMU8/MeXaYuVI44.png/raw) - -Filter by color, quantity, name. Add if a piece is missing. Press images to [show them](https://xbackbone.baerentsen.space/LaMU8/FIFOQicE66.png/raw). Filter by only [missing pieces](https://xbackbone.baerentsen.space/LaMU8/LUQeTETA28.png). Minifigures and their parts are listed [at the end](https://xbackbone.baerentsen.space/LaMU8/nEPujImi75.png/raw). - -### Missing pieces - -![](https://xbackbone.baerentsen.space/LaMU8/YEPEKOsE50.png/raw) - -List of all your missing pieces, with links to bricklink and rebrickable. - -### All parts - -![](https://xbackbone.baerentsen.space/LaMU8/TApONAkA94.png/raw) -List of all parts in your inventory. - -### Minifigures - -![](https://xbackbone.baerentsen.space/LaMU8/RuWoduFU08.png/raw) - -List of all minifigures in your inventory and quantity. - -### Multiple sets - -![](https://xbackbone.baerentsen.space/LaMU8/BUHAYOYe40.png/raw) - -Each set is given a unique ID, such that you can have multiple copies of a set with different pieces missing in each copy. Sets can also easily be [deleted](https://xbackbone.baerentsen.space/LaMU8/xeroHupE22.png/raw) from the inventory. - -### Add set - -![](https://xbackbone.baerentsen.space/LaMU8/lAlUcOhE38.png/raw) - -Sets are added from rebrickable using your own API key. Set numbers are checked against sets.csv from rebrickable and can be updated from the [config page](https://xbackbone.baerentsen.space/LaMU8/lErImaCE12.png/raw). When a set is added, all images from rebrickable are downloaded and stored locally, so if multiple sets contains the same part/color, only one image is downloaded and stored. This also make no calls to rebrickable when just browsing and using the site. Only time rebrickable to used it when up adding a new set. - -### Wishlist - -![](https://xbackbone.baerentsen.space/LaMU8/qUYeHAKU93.png/raw) - -Sets are added from rebrickable and again checked against sets.csv. If you can't add a brand new set, consider updating your data from the [`/config` page](https://xbackbone.baerentsen.space/LaMU8/lErImaCE12.png/raw). Press the delete button to remove a set. Known Issue: If multiple sets of the same number is added, they will all be deleted. - -Wishlist uses *unofficial* retirement data. - -Each set number, links to bricklink for pricecheck. +## Documentation +Most of the pages should be self explanatory to use. +However, you can find more specific documentation in the [docs](docs/) folder. +You can find screenshots of the application in the [bricktracker](docs/bricktracker.md) documentation file. 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..2077083 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 +from bricktracker.app import setup_app # noqa: E402 +from bricktracker.socket import BrickSocket # noqa: E402 + +logger = logging.getLogger(__name__) + +# Create the Flask app 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 -if os.getenv("RANDOM") == 'True': - RANDOM = True -else: - RANDOM = False +# Setup the app +setup_app(app) -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) +# Create the socket +s = BrickSocket( + app, + threaded=not app.config['NO_THREADED_SOCKET'].value, +) if __name__ == '__main__': - socketio.run(app.run(host='0.0.0.0', debug=True, port=3333)) + # 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..0e93a88 --- /dev/null +++ b/bricktracker/configuration_list.py @@ -0,0 +1,46 @@ +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): + config: BrickConfiguration = current_app.config[name] + + if config.value is None or config.value == '': + raise ConfigurationMissingException( + '{name} must be defined (using the {environ} environment variable)'.format( # noqa: E501 + name=config.name, + environ=config.env_name + ), + ) + + # Get all the configuration items from the app config + @staticmethod + def list() -> Generator[BrickConfiguration, None, None]: + keys = list(current_app.config.keys()) + keys.sort() + + 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..87e6683 --- /dev/null +++ b/bricktracker/login.py @@ -0,0 +1,55 @@ +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 + ) diff --git a/bricktracker/minifigure.py b/bricktracker/minifigure.py new file mode 100644 index 0000000..7a7adf6 --- /dev/null +++ b/bricktracker/minifigure.py @@ -0,0 +1,166 @@ +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 + + return RebrickableImage.static_url(file, 'MINIFIGURES_FOLDER') + else: + if self.fields.set_img_url is None: + return current_app.config['REBRICKABLE_IMAGE_NIL_MINIFIGURE'].value # noqa: E501 + 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..dd00c2d --- /dev/null +++ b/bricktracker/minifigure_list.py @@ -0,0 +1,132 @@ +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' + order: str + + # 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 + + # Store the order for this list + self.order = current_app.config['MINIFIGURES_DEFAULT_ORDER'].value + + # Load all minifigures + def all(self, /) -> Self: + for record in self.select( + override_query=self.all_query, + order=self.order + ): + minifigure = BrickMinifigure(record=record) + + self.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=self.order): + 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 minifigures from the database + for record in self.select( + override_query=self.missing_part_query, + order=self.order + ): + 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 minifigures from the database + for record in self.select( + override_query=self.using_part_query, + order=self.order + ): + 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..e14b6c0 --- /dev/null +++ b/bricktracker/part.py @@ -0,0 +1,288 @@ +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, + ) + 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 + + return RebrickableImage.static_url(file, 'PARTS_FOLDER') + 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, + 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..d6e6945 --- /dev/null +++ b/bricktracker/part_list.py @@ -0,0 +1,132 @@ +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' + order: str + + # 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 + + # Store the order for this list + self.order = current_app.config['PARTS_DEFAULT_ORDER'].value + + # Load all parts + def all(self, /) -> Self: + for record in self.select( + override_query=self.all_query, + order=self.order + ): + part = BrickPart(record=record) + + self.records.append(part) + + 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=self.order): + 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=self.order + ): + 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=self.order + ): + 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..513e4b2 --- /dev/null +++ b/bricktracker/rebrickable_image.py @@ -0,0 +1,160 @@ +import os +from typing import TYPE_CHECKING +from urllib.parse import urlparse + +from flask import current_app, url_for +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 + + # Return the static URL for an image given a name and folder + @staticmethod + def static_url(name: str, folder_name: str) -> str: + folder: str = current_app.config[folder_name].value + + # /!\ 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, '{name}{ext}'.format( + name=name, + ext=extension, + )) + + return url_for('static', filename=path) 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..2f1778e --- /dev/null +++ b/bricktracker/set.py @@ -0,0 +1,218 @@ +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 .rebrickable_image import RebrickableImage +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: + return RebrickableImage.static_url( + self.fields.set_num, + 'SETS_FOLDER' + ) + 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..6bbd1bf --- /dev/null +++ b/bricktracker/set_list.py @@ -0,0 +1,161 @@ +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] + order: 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 = [] + + # Store the order for this list + self.order = current_app.config['SETS_DEFAULT_ORDER'].value + + # All the sets + def all(self, /) -> Self: + themes = set() + + # Load the sets from the database + for record in self.select(order=self.order): + 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, + order=self.order + ): + 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, + order=self.order + ): + 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, + order=self.order + ): + 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, + order=self.order + ): + 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, + order=self.order + ): + 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..77aa7b5 --- /dev/null +++ b/bricktracker/sql.py @@ -0,0 +1,312 @@ +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') + 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') + 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..85defbb --- /dev/null +++ b/bricktracker/sql/migrations/init.sql @@ -0,0 +1,66 @@ +-- FROM sqlite3 app.db .schema > init.sql with extra IF NOT EXISTS and transaction +BEGIN transaction; + +CREATE TABLE IF NOT EXISTS wishlist ( + set_num TEXT, + name TEXT, + year INTEGER, + theme_id INTEGER, + num_parts INTEGER, + set_img_url TEXT, + set_url TEXT, + last_modified_dt TEXT + ); +CREATE TABLE IF NOT EXISTS sets ( + set_num TEXT, + name TEXT, + year INTEGER, + theme_id INTEGER, + num_parts INTEGER, + set_img_url TEXT, + set_url TEXT, + last_modified_dt TEXT, + mini_col BOOLEAN, + set_check BOOLEAN, + set_col BOOLEAN, + u_id TEXT + ); +CREATE TABLE IF NOT EXISTS inventory ( + set_num TEXT, + id INTEGER, + part_num TEXT, + name TEXT, + part_img_url TEXT, + part_img_url_id TEXT, + color_id INTEGER, + color_name TEXT, + quantity INTEGER, + is_spare BOOLEAN, + element_id INTEGER, + u_id TEXT + ); +CREATE TABLE IF NOT EXISTS minifigures ( + fig_num TEXT, + set_num TEXT, + name TEXT, + quantity INTEGER, + set_img_url TEXT, + u_id TEXT + ); +CREATE TABLE IF NOT EXISTS missing ( + set_num TEXT, + id INTEGER, + part_num TEXT, + part_img_url_id TEXT, + color_id INTEGER, + quantity INTEGER, + element_id INTEGER, + u_id TEXT + ); + +-- Fix a bug where 'None' was inserted in missing instead of NULL +UPDATE missing +SET element_id = NULL +WHERE element_id = 'None'; + +COMMIT; \ No newline at end of file diff --git a/bricktracker/sql/minifigure/base/select.sql b/bricktracker/sql/minifigure/base/select.sql 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..99eee8f --- /dev/null +++ b/bricktracker/sql/minifigure/list/missing_part.sql @@ -0,0 +1,30 @@ +{% 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 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 %} + +{% block group %} +GROUP BY + minifigures.fig_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..9b6b82c --- /dev/null +++ b/bricktracker/sql/minifigure/list/using_part.sql @@ -0,0 +1,24 @@ +{% extends 'minifigure/base/select.sql' %} + +{% block total_quantity %} +SUM(minifigures.quantity) AS total_quantity, +{% endblock %} + +{% block where %} +WHERE minifigures.fig_num IN ( + SELECT + inventory.set_num + FROM inventory + + WHERE inventory.color_id IS NOT DISTINCT FROM :color_id + AND inventory.element_id IS NOT DISTINCT FROM :element_id + AND inventory.part_num IS NOT DISTINCT FROM :part_num + + GROUP BY inventory.set_num +) +{% endblock %} + +{% block group %} +GROUP BY + minifigures.fig_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/count_none.sql b/bricktracker/sql/missing/count_none.sql new file mode 100644 index 0000000..3f4073b --- /dev/null +++ b/bricktracker/sql/missing/count_none.sql @@ -0,0 +1,3 @@ +SELECT count(*) AS count +FROM missing +WHERE element_id = 'None' diff --git a/bricktracker/sql/missing/delete/all_from_set.sql b/bricktracker/sql/missing/delete/all_from_set.sql 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..fbc84e8 --- /dev/null +++ b/bricktracker/sql/part/select/generic.sql @@ -0,0 +1,40 @@ +{% extends 'part/base/select.sql' %} + +{% block total_missing %} +SUM(IFNULL(missing.quantity, 0)) AS total_missing, +{% endblock %} + +{% block total_quantity %} +SUM((NOT inventory.is_spare) * inventory.quantity * IFNULL(minifigures.quantity, 1)) AS total_quantity, +{% endblock %} + +{% block total_spare %} +SUM(inventory.is_spare * inventory.quantity * IFNULL(minifigures.quantity, 1)) AS total_spare, +{% endblock %} + +{% block join %} +LEFT JOIN missing +ON inventory.set_num IS NOT DISTINCT FROM missing.set_num +AND inventory.id IS NOT DISTINCT FROM missing.id +AND inventory.part_num IS NOT DISTINCT FROM missing.part_num +AND inventory.color_id IS NOT DISTINCT FROM missing.color_id +AND inventory.element_id IS NOT DISTINCT FROM missing.element_id +AND inventory.u_id IS NOT DISTINCT FROM missing.u_id + +LEFT JOIN minifigures +ON inventory.set_num IS NOT DISTINCT FROM minifigures.fig_num +AND inventory.u_id IS NOT DISTINCT FROM minifigures.u_id +{% endblock %} + +{% block where %} +WHERE inventory.part_num IS NOT DISTINCT FROM :part_num +AND inventory.color_id IS NOT DISTINCT FROM :color_id +AND inventory.element_id IS NOT DISTINCT FROM :element_id +{% 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..77b8c63 --- /dev/null +++ b/bricktracker/views/admin.py @@ -0,0 +1,317 @@ +from datetime import datetime +import logging +import os + +from flask import ( + Blueprint, + current_app, + g, + redirect, + request, + render_template, + send_file, + url_for, +) +from flask_login import login_required +from werkzeug.wrappers.response import Response + +from ..configuration_list import BrickConfigurationList +from .exceptions import exception_handler +from ..instructions_list import BrickInstructionsList +from ..minifigure import BrickMinifigure +from ..part import BrickPart +from ..rebrickable_image import RebrickableImage +from ..retired_list import BrickRetiredList +from ..set import BrickSet +from ..sql import BrickSQL +from ..theme_list import BrickThemeList +from .upload import upload_helper + +logger = logging.getLogger(__name__) + +admin_page = Blueprint('admin', __name__, url_prefix='/admin') + + +# Admin +@admin_page.route('/', methods=['GET']) +@login_required +@exception_handler(__file__) +def admin() -> str: + counters: dict[str, int] = {} + count_none: int = 0 + exception: Exception | None = None + is_init: bool = False + nil_minifigure_name: str = '' + nil_minifigure_url: str = '' + nil_part_name: str = '' + nil_part_url: str = '' + + # This view needs to be protected against SQL errors + try: + is_init = BrickSQL.is_init() + + if is_init: + counters = BrickSQL.count_records() + + record = BrickSQL().fetchone('missing/count_none') + if record is not None: + count_none = record['count'] + + nil_minifigure_name = RebrickableImage.nil_minifigure_name() + nil_minifigure_url = RebrickableImage.static_url( + nil_minifigure_name, + 'MINIFIGURES_FOLDER' + ) + + nil_part_name = RebrickableImage.nil_name() + nil_part_url = RebrickableImage.static_url( + nil_part_name, + 'PARTS_FOLDER' + ) + + except Exception as e: + exception = e + + # Warning + logger.warning('An exception occured while loading the admin page: {exception}'.format( # noqa: E501 + exception=str(e), + )) + + open_image = request.args.get('open_image', None) + open_instructions = request.args.get('open_instructions', None) + open_logout = request.args.get('open_logout', None) + open_retired = request.args.get('open_retired', None) + open_theme = request.args.get('open_theme', None) + + open_database = ( + open_image is None and + open_instructions is None and + open_logout is None and + open_retired is None and + open_theme is None + ) + + return render_template( + 'admin.html', + configuration=BrickConfigurationList.list(), + counters=counters, + count_none=count_none, + error=request.args.get('error'), + exception=exception, + instructions=BrickInstructionsList(), + is_init=is_init, + nil_minifigure_name=nil_minifigure_name, + nil_minifigure_url=nil_minifigure_url, + nil_part_name=nil_part_name, + nil_part_url=nil_part_url, + open_database=open_database, + open_image=open_image, + open_instructions=open_instructions, + open_logout=open_logout, + open_retired=open_retired, + open_theme=open_theme, + retired=BrickRetiredList(), + theme=BrickThemeList(), + ) + + +# Initialize the database +@admin_page.route('/init-database', methods=['POST']) +@login_required +@exception_handler(__file__, post_redirect='admin.admin') +def init_database() -> Response: + BrickSQL.initialize() + + # Reload the instructions + BrickInstructionsList(force=True) + + return redirect(url_for('admin.admin')) + + +# Delete the database +@admin_page.route('/delete-database', methods=['GET']) +@login_required +@exception_handler(__file__) +def delete_database() -> str: + return render_template( + 'admin.html', + delete_database=True, + error=request.args.get('error') + ) + + +# Actually delete the database +@admin_page.route('/delete-database', methods=['POST']) +@login_required +@exception_handler(__file__, post_redirect='admin.delete_database') +def do_delete_database() -> Response: + BrickSQL.delete() + + # Reload the instructions + BrickInstructionsList(force=True) + + return redirect(url_for('admin.admin')) + + +# Download the database +@admin_page.route('/download-database', methods=['GET']) +@login_required +@exception_handler(__file__) +def download_database() -> Response: + # Create a file name with a timestamp embedded + name, extension = os.path.splitext( + os.path.basename(current_app.config['DATABASE_PATH'].value) + ) + + # Info + logger.info('The database has been downloaded') + + return send_file( + current_app.config['DATABASE_PATH'].value, + as_attachment=True, + download_name='{name}-{timestamp}{extension}'.format( + name=name, + timestamp=datetime.now().astimezone(g.timezone).strftime( + current_app.config['DATABASE_TIMESTAMP_FORMAT'].value + ), + extension=extension + ) + ) + + +# Drop the database +@admin_page.route('/drop-database', methods=['GET']) +@login_required +@exception_handler(__file__) +def drop_database() -> str: + return render_template( + 'admin.html', + drop_database=True, + error=request.args.get('error') + ) + + +# Actually drop the database +@admin_page.route('/drop-database', methods=['POST']) +@login_required +@exception_handler(__file__, post_redirect='admin.drop_database') +def do_drop_database() -> Response: + BrickSQL.drop() + + # Reload the instructions + BrickInstructionsList(force=True) + + return redirect(url_for('admin.admin')) + + +# Import a database +@admin_page.route('/import-database', methods=['GET']) +@login_required +@exception_handler(__file__) +def import_database() -> str: + return render_template( + 'admin.html', + import_database=True, + error=request.args.get('error') + ) + + +# Actually import a database +@admin_page.route('/import-database', methods=['POST']) +@login_required +@exception_handler(__file__, post_redirect='admin.import_database') +def do_import_database() -> Response: + file = upload_helper( + 'database', + 'admin.import_database', + extensions=['.db'], + ) + + if isinstance(file, Response): + return file + + BrickSQL.upload(file) + + # Reload the instructions + BrickInstructionsList(force=True) + + return redirect(url_for('admin.admin')) + + +# Refresh the instructions cache +@admin_page.route('/refresh-instructions', methods=['GET']) +@login_required +@exception_handler(__file__) +def refresh_instructions() -> Response: + BrickInstructionsList(force=True) + + return redirect(url_for('admin.admin', open_instructions=True)) + + +# Refresh the retired sets cache +@admin_page.route('/refresh-retired', methods=['GET']) +@login_required +@exception_handler(__file__) +def refresh_retired() -> Response: + BrickRetiredList(force=True) + + return redirect(url_for('admin.admin', open_retired=True)) + + +# Refresh the themes cache +@admin_page.route('/refresh-themes', methods=['GET']) +@login_required +@exception_handler(__file__) +def refresh_themes() -> Response: + BrickThemeList(force=True) + + return redirect(url_for('admin.admin', open_theme=True)) + + +# Update the default images +@admin_page.route('/update-image', methods=['GET']) +@login_required +@exception_handler(__file__) +def update_image() -> Response: + # Abusing the object to create a 'nil' minifigure + RebrickableImage( + BrickSet(), + minifigure=BrickMinifigure(record={ + 'set_img_url': None, + }) + ).download() + + # Abusing the object to create a 'nil' part + RebrickableImage( + BrickSet(), + part=BrickPart(record={ + 'part_img_url': None, + 'part_img_url_id': None + }) + ).download() + + return redirect(url_for('admin.admin', open_image=True)) + + +# Update the themes file +@admin_page.route('/update-retired', methods=['GET']) +@login_required +@exception_handler(__file__) +def update_retired() -> Response: + BrickRetiredList().update() + + BrickRetiredList(force=True) + + return redirect(url_for('admin.admin', open_retired=True)) + + +# Update the themes file +@admin_page.route('/update-themes', methods=['GET']) +@login_required +@exception_handler(__file__) +def update_themes() -> Response: + BrickThemeList().update() + + BrickThemeList(force=True) + + return redirect(url_for('admin.admin', open_theme=True)) diff --git a/bricktracker/views/error.py b/bricktracker/views/error.py new file mode 100644 index 0000000..c9f47e6 --- /dev/null +++ b/bricktracker/views/error.py @@ -0,0 +1,145 @@ +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} (path: {path})'.format( + error=str(error), + path=request.path, + )) + + 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..18556b5 --- /dev/null +++ b/bricktracker/views/login.py @@ -0,0 +1,91 @@ +import logging + +from flask import ( + Blueprint, + current_app, + g, + 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 | Response: + # Do not log if logged in + if g.login.is_authenticated(): + return redirect(url_for('index.index')) + + 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/compose.legacy.yml b/compose.legacy.yml new file mode 100644 index 0000000..adcb044 --- /dev/null +++ b/compose.legacy.yml @@ -0,0 +1,18 @@ +services: + bricktracker: + container_name: BrickTracker + restart: unless-stopped + image: gitea.baerentsen.space/frederikbaerentsen/bricktracker:1.0.0 + ports: + - "3333:3333" + volumes: + - ./static/parts:/app/static/parts + - ./static/instructions:/app/static/instructions + - ./static/sets:/app/static/sets + - ./static/minifigs:/app/static/minifigs + - ./app.db:/app/app.db + environment: + - REBRICKABLE_API_KEY=your_api_key_here + - DOMAIN_NAME=https://your.domain.com + - LINKS=True #optional, enables set numbers to be Rebrickable links on the front page + - RANDOM=False #optional, set to True if you want your front page to be shuffled on load diff --git a/compose.local.yaml b/compose.local.yaml new file mode 100644 index 0000000..bf7a8d6 --- /dev/null +++ b/compose.local.yaml @@ -0,0 +1,22 @@ +services: + bricktracker: + container_name: BrickTracker + restart: unless-stopped + build: . + ports: + - "3334:3333" + volumes: + - ./local:/local + - ./local/instructions:/app/static/instructions/ + - ./local/minifigures:/app/static/minifigures/ + - ./local/parts:/app/static/parts/ + - ./local/sets:/app/static/sets/ + environment: + BK_DEBUG: true + BK_DATABASE_PATH: /local/app.db + BK_INSTRUCTIONS_FOLDER: instructions + BK_MINIFIGURES_FOLDER: minifigures + BK_PARTS_FOLDER: parts + BK_RETIRED_SETS_PATH: /local/retired_sets.csv + BK_SETS_FOLDER: sets + BK_THEMES_PATH: /local/themes.csv diff --git a/compose.yaml b/compose.yaml index 408e480..180a189 100644 --- a/compose.yaml +++ b/compose.yaml @@ -2,10 +2,26 @@ services: bricktracker: container_name: BrickTracker restart: unless-stopped - build: . + image: gitea.baerentsen.space/frederikbaerentsen/bricktracker:1.0.0 ports: - "3333:3333" volumes: - - .:/app - env_file: - - .env + - data:/data/ + - instructions:/app/static/instructions/ + - minifigures:/app/static/minifigures/ + - parts:/app/static/parts/ + - sets:/app/static/sets/ + # Or define those in your .env file + environment: + BK_DATABASE_PATH: /data/app.db + BK_MINIFIGURES_FOLDER: minifigures + BK_RETIRED_SETS_PATH: /data/retired_sets.csv + BK_THEMES_PATH: /data/themes.csv + env_file: ".env" + +volumes: + data: + instructions: + minifigures: + parts: + sets: 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/docs/authentication.md b/docs/authentication.md new file mode 100644 index 0000000..a49ad0a --- /dev/null +++ b/docs/authentication.md @@ -0,0 +1,55 @@ +# Authentication + +> **Note**
+> The following page is based on version `1.0.0` of BrickTracker. + +> **Warning**
+> This is a lightweight access control feature and does not provide any strong layer of security to the application. + +By default, every feature of the application is available. +Although it does not support individual accounts, it is possible to protect every "dangerous" feature under a password. +This can be useful if you want other people to access your inventory of sets in a "read-only" fashion. + +To set up the authentication, you need to set the two following environment variables: + +- `BK_AUTHENTICATION_KEY`: a secret for the server to encrypt the session cookie. See [.env.sample](../.env.sample) for how to generate the value +- `BK_AUTHENTICATION_PASSWORD`: the actual password + +> **Warning**
+> The password is stored in **plaintext**. Be mindful. + +Once the authentication is set up, you should see a ![read-only](images/authentication-01.png) pill on the right side of the menu bar. + + + +If you click on it, or if you try to access a page requiring authentication, you will not be greeted with a login prompt. + +![](images/authentication-02.png) + +Once authenticated the pill will switch to ![authenticated](images/authentication-03.png). + +## Login out + +If you need to log out, click the ![authenticated](images/authentication-03.png) pill. +Then press the **Logout** button in the Admin page. + +![](images/authentication-05.png) + +## Features require authentication + +If set up, the following will require an authentication: + +- Sets + - Add + - Bulk add + - Delete + - Change status + - Change the amount of missing parts +- Instructions + - Upload + - Rename + - Delete +- Wishlist + - Add + - Delete +- Admin diff --git a/docs/bricktracker.md b/docs/bricktracker.md new file mode 100644 index 0000000..9792f98 --- /dev/null +++ b/docs/bricktracker.md @@ -0,0 +1,130 @@ +# BrickTracker + +> **Note**
+> The following page is based on version `1.0.0` of BrickTracker. + +## Frontpage + +![](images/bricktracker-01.png) + +If you are using `BK_RANDOM`, a random selection of sets and minifigures. +Otherwise, the latest added sets and minifigures. + +You can click the card name or image to access a set or minifigure detail. + +## Sets + +> **Info**
+> This does not do any pagination and loads **everything**. It can be slow depending on how many sets you have. + +![](images/bricktracker-02.png) + +Displays all your sets, with quick look at theme, year, parts, missing parts, minifigures count and status. +You can search, filter and sort the grid. The sort order will be stored in a cookie and re-use on next page load. + +You can click the card name or image to access a set detail. + +### Multiple sets + +Each added set has a unique ID and you can have multiple copies of the same set added to your inventory. + +![](images/bricktracker-13.png) + +## Set details + +![](images/bricktracker-03.png) + +Gives you more detail about a specific set, including instructions and parts list. + +## Minifigure details + +![](images/bricktracker-04.png) + +Gives you generic detail about a minifigure across all your sets. + +## Part details + +![](images/bricktracker-05.png) + +Gives you generic detail about a part across all your sets. + +## Add + +![](images/bricktracker-09.png) + +If you are authenticated, lets you add a new set to your inventory. +See [first steps](first-steps.md). + +### Bulk add + +![](images/bricktracker-10.png) + +If you need to add many sets at once, you can use the bulk add tool. +Instead of a set number, it takes a comma separated list of set numbers. +It will then process each number of the list sequentially as if you were doing it yourself one by one. +If an error occur, it will put back in the input field the list of number that were not processed. + +## Parts + +> **Info**
+> This does not do any pagination and loads **everything**. It can be slow depending on how many sets you have. + +![](images/bricktracker-06.png) + +Lists all your parts with details on quantity missing, number of sets missing it, number of minifigures missing it. +You can sort columns, and search the table. + +Clicking on a part image will open it fullscreen. +Clicking on a part name will load its details. + +## Missing (parts) + +> **Info**
+> This does not do any pagination and loads **everything**. It can be slow depending on how missing parts you have. + +![](images/bricktracker-07.png) + +Lists all your missing parts with details on total quantity, quantity missing, number of sets using it, number of minifigures using it. +You can sort columns, and search the table. + +Clicking on a part image will open it fullscreen. +Clicking on a part name will load its details. + +## Minifigures + +> **Info**
+> This does not do any pagination and loads **everything**. It can be slow depending on how many minifigures you have. + +![](images/bricktracker-08.png) + +Lists all your minifigures with details on quantity missing, number of missing parts, number of sets using it. +You can sort columns, and search the table. + +Clicking on a minifigure image will open it fullscreen. +Clicking on a minifigure name will load its details. + +## Instructions + +> **Info**
+> This does not do any pagination and loads **everything**. It can be slow depending on how many instructions you have. + +![](images/bricktracker-08.png) + +Lists all your instructions and if they are matching a existing set, will display its name and image. +If you are authenticated, you can upload new instruction files, rename existing ones or delete them. +You can sort columns, and search the table. + +Clicking on a set image will open it fullscreen. + +## Wishlist + +> **Info**
+> This does not do any pagination and loads **everything**. It can be slow depending on how many wished sets you have. + +![](images/bricktracker-12.png) + +Lists all your wished with details on theme, year, parts and *unofficial* retirement date. +If you are authenticated, you can add new wished sets or delete existing ones. +You can sort columns, and search the table. + +Clicking on a set image will open it fullscreen. diff --git a/docs/common-errors.md b/docs/common-errors.md new file mode 100644 index 0000000..91ebb49 --- /dev/null +++ b/docs/common-errors.md @@ -0,0 +1,84 @@ +# Common errors/problems + +> **Note**
+> The following page is based on version `1.0.0` of BrickTracker. + +## I need a password to access some pages + +You have setup lightweight authentication. Your password is in your environement `BK_AUTHENTICATION_PASSWORD` variable. + +## I cannot access the Add page (Configuration missing!) + +![](images/common-errors-01.png) + +You need to pass the `BK_REBRICKABLE_API_KEY` environment to your application, depending on how you run the application. +For instance: + +- Docker: `docker run -e BK_REBRICKABLE_API_KEY=xxxx` +- Docker compose (directly in `compose.yaml`): + +``` +services: + bricktracker: + environment: + - BK_AUTHENTICATION_KEY=xxxx +``` + +- Docker compose (with an environement file, for instance `.env`) + +``` +-- .env +BK_AUTHENTICATION_KEY=xxxx + +-- compose.yaml +services: + bricktracker: + env_file: ".env" +``` + +> **Warning**
+> Do not use quotes (", ') around your environment variables. +> Docker will interpret them has being part of the **value** of the environment variable. +> For instance... +> +> ``` +> services: +> bricktracker: +> environment: +> - BK_AUTHENTICATION_KEY="xxxx" +> ``` +> +> ...will make Docker believe that your API key is `"xxxx"`. + +## The socket is disconnected + +![](images/common-errors-02.png) + +If you are seeing the socket disconnected while you are on the **Add** or **Bulk add** pages, it can mean: + +- The application is not running anymore (it somehow stopped after you accessed the page): check that the application is running and its log for errors, +- You restarted the application and it can take some time for the socket to be back up: just wait a bit it should automatically re-connect, +- You are using the CORS allowed origin restriction (via the `BK_DOMAIN_NAME` environment variable) with a mismatching value: you should see a log line similar to this following one in your application logs. + +``` +http://127.0.0.1:3333 is not an accepted origin. (further occurrences of this error will be logged with level INFO) +[2025-01-18 18:15:52,980] ERROR - http://127.0.0.1:3333 is not an accepted origin. (further occurrences of this error will be logged with level INFO) +``` + +Make sure the value you have set is matching the URL of your application. +If it is not the case, adjust the value and restart the application. + + +## No such file or directory: '' when adding a set + +![](images/common-errors-03.png) + +The application doestake care of creating folders for static images and expects them to be writable. +Make sure that the folder exists, and if it exists that it is writable by the application. + +## I'm seeing Unknown () instead of the set theme + +![](images/common-errors-04.png) + +Either the theme is too recent for your version of the themes file, or your theme file has not be initialized. +Head to the **Admin** page, **Themes** section and update the file. diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..752b4ba --- /dev/null +++ b/docs/development.md @@ -0,0 +1,47 @@ +# Development + +> **Note**
+> The following page is based on version `1.0.0` of BrickTracker. + +The application is written in Python version 3. +It uses: + +- `flask` (Web framework) +- `flask_socketio` (Socket.IO: Websocket library for real-time client-server communication) +- `flask-login` (Lightweight Flask authentication library) +- `sqlite3` (Light database management system) +- `rebrick` API (Library to interact with the Rebrickable.com API) + +## Running a local debug instance + +You can use the [compose.local.yaml](../compose.local.yaml) file to build and run an instance with debug enabled and on a different port (`3334`). + +``` +$ mkdir local + +$ docker compose -f compose.local.yaml build +[+] Building 0.6s (10/10) FINISHED docker:default + => [bricktracker internal] load build definition from Dockerfile 0.0s + => => transferring dockerfile: 211B 0.0s + => [bricktracker internal] load metadata for docker.io/library/python:3-slim 0.4s + => [bricktracker internal] load .dockerignore 0.0s + => => transferring context: 307B 0.0s + => [bricktracker 1/4] FROM docker.io/library/python:3-slim@sha256:23a81be7b258c8f516f7a60e80943cace4350deb8204cf107c7993e343610d47 0.0s + => [bricktracker internal] load build context 0.0s + => => transferring context: 9.02kB 0.0s + => CACHED [bricktracker 2/4] WORKDIR /app 0.0s + => CACHED [bricktracker 3/4] COPY . . 0.0s + => CACHED [bricktracker 4/4] RUN pip --no-cache-dir install -r requirements.txt 0.0s + => [bricktracker] exporting to image 0.0s + => => exporting layers 0.0s + => => writing image sha256:5e913b01b10a51afae952187a45593d4c4a35635d084c8e9ba012476f21b2d0b 0.0s + => => naming to docker.io/library/bricktracker-bricktracker 0.0s + => [bricktracker] resolving provenance for metadata file 0.0s + +$ docker compose -f compose.local.yaml up -d +[+] Running 2/2 + ✔ Network bricktracker_default Created 0.4s + ✔ Container BrickTracker Started 11.2s +``` + +You should have the app available on port `3334` (not `3333`!). For instance at http://127.0.0.1:3334. diff --git a/docs/first-steps.md b/docs/first-steps.md new file mode 100644 index 0000000..b1e625e --- /dev/null +++ b/docs/first-steps.md @@ -0,0 +1,70 @@ +# First steps + +> **Note**
+> The following page is based on version `1.0.0` of BrickTracker. + +## Database initialization + +Once the application is running and you can access it, you should be greated with an error message. +This is perfectly normal as you are running the application for the first time and you need to initialize the database. + +![](images/first-steps-01.png) + +Click the **Administration** button to access the administration panel. +The **Database** section should be opened by default. + +![](images/first-steps-02.png) + +Press the **Initialize the database** button. + +![](images/first-steps-03.png) + +## Themes initialization + +To have the themes identifier resolved into names, you need to do an initial update of the file. +Open the **Themes** section. +You will see an error message, this is expected. + +![](images/first-steps-07.png) + +Press the **Update the themes file** button to download an initial version of the themes file. + +![](images/first-steps-08.png) + +If everything went well you should see no more error message and some counters. + +## Add a set + +> **Important**
+> Make sure you have set up your Rebrickable API key (`BK_REBRICKABLE_KEY`) for this to work (see [common errors](common-errors.md)). + +> **Important**
+> If you are using the CORS allowed origin restriction (`BK_DOMAIN_NAME`), make sure it is matching your application URL (see [common errors](common-errors.md)). + +Use the menu bar to navigate to the **Add** page, make sure the socket is in a **connected** state. + +![](images/first-steps-04.png) + +Input a set number in the **Set number** field and press the **Add** button. +You can either use the set number (e.g `608`) and it will automitcally assume that you want version `1` (e.g `608-1`), +or you can use the complete number with the version (e.g `608-2`) + +It will load information about the set you are about to add, but not add it yet. + +![](images/first-steps-05.png) + +Use the **Confirm add** button to add the set, or the **Dismiss** button if it is not the one you wanted. + +> **Note**
+> If you do not want to go through the confirmation process, check the **Add without confirmation** checkbox and the +> set will be added when you press the **Add** button. + +![](images/first-steps-06.png) + +If everything goes well you should see a **Success** message with a direct link to your set. + +![](images/first-steps-09.png) + +## Learn more + +Consult the rest of the files in the [docs](.) folder. diff --git a/docs/images/authentication-01.png b/docs/images/authentication-01.png new file mode 100644 index 0000000..1a16ab7 Binary files /dev/null and b/docs/images/authentication-01.png differ diff --git a/docs/images/authentication-02.png b/docs/images/authentication-02.png new file mode 100644 index 0000000..b4fcb6a Binary files /dev/null and b/docs/images/authentication-02.png differ diff --git a/docs/images/authentication-03.png b/docs/images/authentication-03.png new file mode 100644 index 0000000..21d28f4 Binary files /dev/null and b/docs/images/authentication-03.png differ diff --git a/docs/images/authentication-05.png b/docs/images/authentication-05.png new file mode 100644 index 0000000..49b5d85 Binary files /dev/null and b/docs/images/authentication-05.png differ diff --git a/docs/images/bricktracker-01.png b/docs/images/bricktracker-01.png new file mode 100644 index 0000000..6e3ce62 Binary files /dev/null and b/docs/images/bricktracker-01.png differ diff --git a/docs/images/bricktracker-02.png b/docs/images/bricktracker-02.png new file mode 100644 index 0000000..39019a3 Binary files /dev/null and b/docs/images/bricktracker-02.png differ diff --git a/docs/images/bricktracker-03.png b/docs/images/bricktracker-03.png new file mode 100644 index 0000000..5b4e3c4 Binary files /dev/null and b/docs/images/bricktracker-03.png differ diff --git a/docs/images/bricktracker-04.png b/docs/images/bricktracker-04.png new file mode 100644 index 0000000..10dbc9d Binary files /dev/null and b/docs/images/bricktracker-04.png differ diff --git a/docs/images/bricktracker-05.png b/docs/images/bricktracker-05.png new file mode 100644 index 0000000..344b644 Binary files /dev/null and b/docs/images/bricktracker-05.png differ diff --git a/docs/images/bricktracker-06.png b/docs/images/bricktracker-06.png new file mode 100644 index 0000000..063e222 Binary files /dev/null and b/docs/images/bricktracker-06.png differ diff --git a/docs/images/bricktracker-07.png b/docs/images/bricktracker-07.png new file mode 100644 index 0000000..8c06c2c Binary files /dev/null and b/docs/images/bricktracker-07.png differ diff --git a/docs/images/bricktracker-08.png b/docs/images/bricktracker-08.png new file mode 100644 index 0000000..8f71052 Binary files /dev/null and b/docs/images/bricktracker-08.png differ diff --git a/docs/images/bricktracker-09.png b/docs/images/bricktracker-09.png new file mode 100644 index 0000000..a8d610e Binary files /dev/null and b/docs/images/bricktracker-09.png differ diff --git a/docs/images/bricktracker-10.png b/docs/images/bricktracker-10.png new file mode 100644 index 0000000..cfb8d7e Binary files /dev/null and b/docs/images/bricktracker-10.png differ diff --git a/docs/images/bricktracker-11.png b/docs/images/bricktracker-11.png new file mode 100644 index 0000000..187fce8 Binary files /dev/null and b/docs/images/bricktracker-11.png differ diff --git a/docs/images/bricktracker-12.png b/docs/images/bricktracker-12.png new file mode 100644 index 0000000..492af33 Binary files /dev/null and b/docs/images/bricktracker-12.png differ diff --git a/docs/images/bricktracker-13.png b/docs/images/bricktracker-13.png new file mode 100644 index 0000000..d336c1d Binary files /dev/null and b/docs/images/bricktracker-13.png differ diff --git a/docs/images/common-errors-01.png b/docs/images/common-errors-01.png new file mode 100644 index 0000000..d6963f9 Binary files /dev/null and b/docs/images/common-errors-01.png differ diff --git a/docs/images/common-errors-02.png b/docs/images/common-errors-02.png new file mode 100644 index 0000000..15cd595 Binary files /dev/null and b/docs/images/common-errors-02.png differ diff --git a/docs/images/common-errors-03.png b/docs/images/common-errors-03.png new file mode 100644 index 0000000..5d79773 Binary files /dev/null and b/docs/images/common-errors-03.png differ diff --git a/docs/images/common-errors-04.png b/docs/images/common-errors-04.png new file mode 100644 index 0000000..6348ea2 Binary files /dev/null and b/docs/images/common-errors-04.png differ diff --git a/docs/images/first-steps-01.png b/docs/images/first-steps-01.png new file mode 100644 index 0000000..927b19c Binary files /dev/null and b/docs/images/first-steps-01.png differ diff --git a/docs/images/first-steps-02.png b/docs/images/first-steps-02.png new file mode 100644 index 0000000..749a480 Binary files /dev/null and b/docs/images/first-steps-02.png differ diff --git a/docs/images/first-steps-03.png b/docs/images/first-steps-03.png new file mode 100644 index 0000000..b8f83ca Binary files /dev/null and b/docs/images/first-steps-03.png differ diff --git a/docs/images/first-steps-04.png b/docs/images/first-steps-04.png new file mode 100644 index 0000000..c1942b1 Binary files /dev/null and b/docs/images/first-steps-04.png differ diff --git a/docs/images/first-steps-05.png b/docs/images/first-steps-05.png new file mode 100644 index 0000000..3a27961 Binary files /dev/null and b/docs/images/first-steps-05.png differ diff --git a/docs/images/first-steps-06.png b/docs/images/first-steps-06.png new file mode 100644 index 0000000..9381efa Binary files /dev/null and b/docs/images/first-steps-06.png differ diff --git a/docs/images/first-steps-07.png b/docs/images/first-steps-07.png new file mode 100644 index 0000000..0af61f8 Binary files /dev/null and b/docs/images/first-steps-07.png differ diff --git a/docs/images/first-steps-08.png b/docs/images/first-steps-08.png new file mode 100644 index 0000000..f1ef9f4 Binary files /dev/null and b/docs/images/first-steps-08.png differ diff --git a/docs/images/first-steps-09.png b/docs/images/first-steps-09.png new file mode 100644 index 0000000..704189e Binary files /dev/null and b/docs/images/first-steps-09.png differ diff --git a/docs/images/set-01.png b/docs/images/set-01.png new file mode 100644 index 0000000..d37a99b Binary files /dev/null and b/docs/images/set-01.png differ diff --git a/docs/images/set-02.png b/docs/images/set-02.png new file mode 100644 index 0000000..773b4f0 Binary files /dev/null and b/docs/images/set-02.png differ diff --git a/docs/images/set-03.png b/docs/images/set-03.png new file mode 100644 index 0000000..15efc3d Binary files /dev/null and b/docs/images/set-03.png differ diff --git a/docs/images/set-04.png b/docs/images/set-04.png new file mode 100644 index 0000000..f97d726 Binary files /dev/null and b/docs/images/set-04.png differ diff --git a/docs/images/set-05.png b/docs/images/set-05.png new file mode 100644 index 0000000..246c1e4 Binary files /dev/null and b/docs/images/set-05.png differ diff --git a/docs/images/set-06.png b/docs/images/set-06.png new file mode 100644 index 0000000..f3069c1 Binary files /dev/null and b/docs/images/set-06.png differ diff --git a/docs/images/set-07.png b/docs/images/set-07.png new file mode 100644 index 0000000..50bbfe5 Binary files /dev/null and b/docs/images/set-07.png differ diff --git a/docs/images/set-08.png b/docs/images/set-08.png new file mode 100644 index 0000000..26a2c65 Binary files /dev/null and b/docs/images/set-08.png differ diff --git a/docs/images/set-09.png b/docs/images/set-09.png new file mode 100644 index 0000000..385ae50 Binary files /dev/null and b/docs/images/set-09.png differ diff --git a/docs/set.md b/docs/set.md new file mode 100644 index 0000000..eba2183 --- /dev/null +++ b/docs/set.md @@ -0,0 +1,62 @@ +# Managing your sets + +> **Note**
+> The following page is based on version `1.0.0` of BrickTracker. + +## Set image + +If you click the set image, it will open fullscreen. + +![](images/set-06.png) + +## Set status + +You can track the following status for your sets: + +- Set is checked +- Set is collected +- Set minifigures are collected + +Simply click on the checkbox or label wherever applicable and it will be immediately saved to the database. +A little status mark tells you if the operation was successful. + +![](images/set-01.png) + +# Instructions + +If you have uploaded instructions with a name matching the set, they will be available to consult on the set page. + +![](images/set-04.png) + +## Parts list + +When displaying a set, you can see the list of parts making the set. +For each part, you can mark how many of those pieces are missing. +As soon as you leave the field it will be immediately saved to the database. +A little status mark tells you if the operation was successful. + +![](images/set-02.png) + +You can sort the part list by clicking the header of each column of the table. + +![](images/set-03.png) + +If you click a part image, it will open fullscreen. + +![](images/set-05.png) + +## Minifigures list + +If the set includes minifigures, they will be least after the parts list. +For each minifigure kind, you will see the number of minifigures and their part list. +The part list works exactly like the set parts list. + +![](images/set-07.png) + +If you click the minifigure image near the **Details** button, it will open fullscreen. + +![](images/set-08.png) + +You can also check the details of the minifigure pressing by clicking on the **Details** button. + +![](images/set-09.png) diff --git a/docs/setup.md b/docs/setup.md new file mode 100644 index 0000000..a058233 --- /dev/null +++ b/docs/setup.md @@ -0,0 +1,94 @@ +# Setup + +> **Note**
+> The following page is based on version `1.0.0` of BrickTracker. + +## Prerequisites + +Check your operating system documentation to install `docker` and `docker compose`. + +## A note on environment variables + +You need to pass the `BK_` environment to your application, depending on how you run the application. +For instance: + +- Docker: `docker run -e BK_=xxxx` +- Docker compose (directly in `compose.yaml`): + +``` +services: + bricktracker: + environment: + - BK_=xxxx +``` + +- Docker compose (with an environement file, for instance `.env`) + +``` +-- .env +BK_=xxxx + +-- compose.yaml +services: + bricktracker: + env_file: ".env" +``` + +> **Warning**
+> Do not use quotes (", ') around your environment variables. +> Docker will interpret them has being part of the **value** of the environment variable. +> For instance... +> +> ``` +> services: +> bricktracker: +> environment: +> - BK_AUTHENTICATION_KEY="xxxx" +> ``` +> +> ...will make Docker believe that your API key is `"xxxx"`. + +## Environment file and customization options + +The [.env.sample](../.env.sample) file provides ample documentation on all the configurable options. Have a look at it. +You can make a copy of `.env.sample` as `.env` with your options or create an `.env` file from scratch. + +## Database file + +To accomodate for the original version of BrickTracker, the default database path is `./app.db`. +This is not ideal for a setup with volumes because it is a single file. +You can use a combination of a volume and the `BK_DATABASE_PATH` environment variable to accomodate for that. +For instance: + +``` +services: + bricktracker: + volumes: + - database:/database/ + environment: + BK_DATABASE_PATH: /database/app.db + +volumes: + database: +``` + +## Rebrickable API key + +Although not mandatory for the application to run, it is necessary to add any data to the application. +It is set up with the `BK_REBRICKABLE_API_KEY` environment variable (or `REBRICKABLE_API_KEY` to accomodate for the original version of BrickTracker). + +## Static image and instructions files folders + +Since the images and instruction files are to be served by the webserver, it is expected they reside in the `/app/static` folder. +You can control the name/path of each image type relative to `app/static` with the `BK_*_FOLDER` variables. + +## CSV files + +Some CSV files are used to resolve informations like theme names or retired set dates. +In the original version of BrickTracker they were either shipped with the container or residing at the root of the application, meaning that any update to it would not survive a container update. + +You can use the `BK_RETIRED_SET_PATH` and `BK_THEMES_PATH` to relocate them into a volume. + +## Authentication + +See [authentication](authentication.md) 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 100755 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..aedd691 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,10 +1,10 @@ +eventlet flask flask_socketio -pathlib -plotly -pandas -numpy +flask-login +gunicorn +humanize +jinja2 rebrick requests -eventlet -gunicorn +tzdata \ No newline at end of file diff --git a/static/brick.png b/static/brick.png new file mode 100644 index 0000000..ee1b452 Binary files /dev/null and b/static/brick.png differ diff --git a/static/gitea.svg b/static/gitea.svg new file mode 100644 index 0000000..b5836fe --- /dev/null +++ b/static/gitea.svg @@ -0,0 +1 @@ + \ 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..581b09d --- /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.socket !== undefined && bricksocket.socket.connected) { + 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..9e172bc --- /dev/null +++ b/templates/admin.html @@ -0,0 +1,39 @@ +{% 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' %} + {% if not config['USE_REMOTE_IMAGES'].value %} + {% include 'admin/image.html' %} + {% endif %} + {% 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..bbe02d9 --- /dev/null +++ b/templates/admin/database.html @@ -0,0 +1,60 @@ +{% 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 %} + {% if count_none %} + + {% endif %} +

The database file is: {{ config['DATABASE_PATH'].value }}. 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/image.html b/templates/admin/image.html new file mode 100644 index 0000000..8b5d535 --- /dev/null +++ b/templates/admin/image.html @@ -0,0 +1,14 @@ +{% import 'macro/accordion.html' as accordion %} + +{{ accordion.header('Default images', 'image', 'admin', expanded=open_image, icon='image-line') }} +

+ If you do not see the following image, you either do not need them or you are coming from the original version of BrickTracker and/or they have been moved. +

+
+ {{ nil_minifigure_name }} + {{ nil_part_name }} +
+

+ Update the images +

+{{ 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..2985f46 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..c622c86 --- /dev/null +++ b/templates/macro/accordion.html @@ -0,0 +1,65 @@ +{% macro header(title, id, parent, 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, 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, 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, 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..2e2f8bb --- /dev/null +++ b/templates/macro/form.html @@ -0,0 +1,13 @@ +{% macro checkbox(kind, id, text, url, checked, delete=false) %} + {% if g.login.is_authenticated() %} + + + {% else %} + + {{ text }} + {% 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..79ed414 --- /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.quantity(item.fields.total_quantity, solo=solo, last=last) }} + {{ badge.total_sets(using | length, 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 %} -
-
-
-
- - -