Files

202 lines
7.0 KiB
Python

"""
Parts drawer labelling: the category tree and the labels themselves.
Two tables:
* part_categories - a self-referencing tree, two levels deep in the seed
data but the model imposes no depth limit. Seeded from
app/data/part_taxonomy.json, then yours to edit.
* part_labels - one physical label. A label belongs to a category and
describes what lives in one drawer or bag.
A label deliberately has no quantity or inventory field. This is a labelling
system, not a parts inventory - the drawer tells you how many you have.
Created by: Jess Rogerson (yelling commands at Claude.AI)
"""
from datetime import datetime
import re
from app import db
def slugify(value):
"""Lowercase, hyphenated, ASCII-ish. Used as the stable category key."""
value = (value or '').strip().lower()
value = re.sub(r'[^a-z0-9]+', '-', value)
return value.strip('-') or 'category'
class PartCategory(db.Model):
"""A node in the parts taxonomy."""
__tablename__ = 'part_categories'
id = db.Column(db.Integer, primary_key=True)
# Display code, e.g. "08.02". Free text: renumber however you like.
code = db.Column(db.String(16), nullable=True, index=True)
name = db.Column(db.String(120), nullable=False)
# Stable key used by the seeder so re-seeding never duplicates a row.
slug = db.Column(db.String(140), unique=True, nullable=False, index=True)
parent_id = db.Column(db.Integer, db.ForeignKey('part_categories.id'),
nullable=True, index=True)
# Hex colour used as the accent stripe on printed labels.
colour = db.Column(db.String(7), nullable=True)
sort_order = db.Column(db.Integer, nullable=False, default=0)
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
children = db.relationship(
'PartCategory',
backref=db.backref('parent', remote_side=[id]),
lazy='select',
order_by='PartCategory.sort_order, PartCategory.code, PartCategory.name',
cascade='all, delete-orphan',
)
labels = db.relationship('PartLabel', backref='category', lazy='dynamic')
def __repr__(self):
return f'<PartCategory {self.code} {self.name}>'
@property
def full_name(self):
"""'Technic > Pins' - one level of parent is enough on a label."""
if self.parent:
return f'{self.parent.name} > {self.name}'
return self.name
@property
def display_code(self):
return self.code or ''
def accent_colour(self):
"""Own colour, else the parent's, else the LIM red."""
if self.colour:
return self.colour
if self.parent and self.parent.colour:
return self.parent.colour
return '#d11013'
def label_count(self, recursive=True):
total = self.labels.count()
if recursive:
for child in self.children:
total += child.label_count(recursive=True)
return total
@staticmethod
def roots():
return (PartCategory.query
.filter(PartCategory.parent_id.is_(None))
.order_by(PartCategory.sort_order, PartCategory.code,
PartCategory.name)
.all())
@staticmethod
def choices():
"""Flat (id, indented label) list for <select> menus."""
out = []
def walk(node, depth):
prefix = '\u00a0\u00a0' * depth + ('\u21b3 ' if depth else '')
code = f'{node.code} ' if node.code else ''
out.append((node.id, f'{prefix}{code}{node.name}'))
for child in node.children:
walk(child, depth + 1)
for root in PartCategory.roots():
walk(root, 0)
return out
def descendant_ids(self):
"""This category plus everything beneath it."""
ids = [self.id]
for child in self.children:
ids.extend(child.descendant_ids())
return ids
class PartLabel(db.Model):
"""One printable label for a drawer or bag."""
__tablename__ = 'part_labels'
id = db.Column(db.Integer, primary_key=True)
# What the part is
part_number = db.Column(db.String(32), nullable=True, index=True)
# Comma-separated alternates/moulds that live in the same drawer.
alt_part_numbers = db.Column(db.String(255), nullable=True)
name = db.Column(db.String(160), nullable=False)
# "2 x 4", "1 x 2 x 3" - kept as text because LEGO geometry is not tidy.
dimensions = db.Column(db.String(40), nullable=True)
colour_name = db.Column(db.String(60), nullable=True)
category_id = db.Column(db.Integer, db.ForeignKey('part_categories.id'),
nullable=True, index=True)
# Where it lives
location = db.Column(db.String(80), nullable=True, index=True)
# Artwork: a local file under UPLOAD_FOLDER/parts, or a remote URL
# (Rebrickable CDN). Local wins when both are set.
image = db.Column(db.String(255), nullable=True)
image_url = db.Column(db.String(500), nullable=True)
notes = db.Column(db.Text, nullable=True)
# 'manual' or 'rebrickable'
source = db.Column(db.String(20), nullable=False, default='manual')
created_by_id = db.Column(db.Integer, db.ForeignKey('users.id'),
nullable=True)
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow,
onupdate=datetime.utcnow)
created_by = db.relationship('User', foreign_keys=[created_by_id])
def __repr__(self):
return f'<PartLabel {self.part_number or "?"} {self.name}>'
def get_image(self):
"""URL for the label artwork, or None."""
if self.image:
path = self.image.replace('\\', '/').lstrip('/')
return f'/static/uploads/{path}'
return self.image_url or None
@property
def all_part_numbers(self):
numbers = [self.part_number] if self.part_number else []
if self.alt_part_numbers:
numbers += [n.strip() for n in self.alt_part_numbers.split(',')
if n.strip()]
return numbers
@property
def part_numbers_text(self):
return ', '.join(self.all_part_numbers)
@property
def display_title(self):
"""Name with dimensions folded in, for the big line on the label."""
if self.dimensions and self.dimensions.lower() not in (self.name or '').lower():
return f'{self.name} {self.dimensions}'
return self.name
def to_dict(self):
return {
'id': self.id,
'part_number': self.part_number,
'alt_part_numbers': self.alt_part_numbers,
'name': self.name,
'dimensions': self.dimensions,
'colour_name': self.colour_name,
'category_id': self.category_id,
'category': self.category.full_name if self.category else None,
'location': self.location,
'image': self.get_image(),
'notes': self.notes,
'source': self.source,
}