Add PartCategory and PartLabel models
This commit is contained in:
1 parent
ca5d3eb1c9
commit
3a29df60d9
1 file changed
+201
@@ -0,0 +1,201 @@
|
|||||||
|
"""
|
||||||
|
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,
|
||||||
|
}
|
||||||
Reference in new issue
Block a user