diff --git a/app/models/part_label.py b/app/models/part_label.py new file mode 100644 index 0000000..680fa31 --- /dev/null +++ b/app/models/part_label.py @@ -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'' + + @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