From 63c3ece27155084b8c57ceb167d99e8f805aa894 Mon Sep 17 00:00:00 2001 From: jessikitty Date: Mon, 12 Oct 2026 21:23:54 +1100 Subject: [PATCH] Add local Rebrickable catalogue mirror models --- app/models/part_catalog_item.py | 182 ++++++++++++++++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 app/models/part_catalog_item.py diff --git a/app/models/part_catalog_item.py b/app/models/part_catalog_item.py new file mode 100644 index 0000000..0462ce4 --- /dev/null +++ b/app/models/part_catalog_item.py @@ -0,0 +1,182 @@ +""" +Local mirror of the Rebrickable parts catalogue. + +This is reference data, not labels. The Update button fills it from +Rebrickable's published CSV dumps so that creating a label is picking a part +off a local list rather than hitting the API for every lookup. + +Two tables: + + * part_catalog_categories - Rebrickable's own part categories, kept + separate from PartCategory (which is YOUR drawer taxonomy). The two are + linked by an optional mapping so an import can drop parts straight into + the right drawer group. + * part_catalog_items - one row per Rebrickable part. + +Roughly 60,000 parts land here. Everything is indexed for prefix search and +nothing is fetched at render time. + +Created by: Jess Rogerson (yelling commands at Claude.AI) +""" + +from datetime import datetime + +from app import db + + +class PartCatalogCategory(db.Model): + """A Rebrickable part category (their numbering, not yours).""" + + __tablename__ = 'part_catalog_categories' + + # Rebrickable's own id, used verbatim so re-syncs match on it. + id = db.Column(db.Integer, primary_key=True, autoincrement=False) + name = db.Column(db.String(200), nullable=False, index=True) + + # Optional link into YOUR drawer taxonomy. Set it once and every part in + # this Rebrickable category can be imported straight to the right place. + drawer_category_id = db.Column( + db.Integer, db.ForeignKey('part_categories.id'), nullable=True, + index=True) + + updated_at = db.Column(db.DateTime, nullable=False, + default=datetime.utcnow, onupdate=datetime.utcnow) + + drawer_category = db.relationship('PartCategory', foreign_keys=[drawer_category_id]) + items = db.relationship('PartCatalogItem', backref='catalog_category', + lazy='dynamic') + + def __repr__(self): + return f'' + + def item_count(self): + return self.items.count() + + +class PartCatalogItem(db.Model): + """One part from Rebrickable's catalogue.""" + + __tablename__ = 'part_catalog_items' + + id = db.Column(db.Integer, primary_key=True) + + # Rebrickable's part_num. Unique, and the key a re-sync matches on. + part_num = db.Column(db.String(32), unique=True, nullable=False, + index=True) + name = db.Column(db.String(300), nullable=False, index=True) + category_id = db.Column(db.Integer, + db.ForeignKey('part_catalog_categories.id'), + nullable=True, index=True) + part_material = db.Column(db.String(40), nullable=True) + + # Derived once at sync time so the browse list can sort and filter on it + # without re-parsing 60k names on every page load. + dimensions = db.Column(db.String(40), nullable=True, index=True) + + # Rebrickable CDN image. Not downloaded during a sync - that would be + # 60,000 requests. Cached locally only when a part becomes a label. + image_url = db.Column(db.String(500), nullable=True) + + # Set when a PartLabel has been created from this row, so the browse + # list can show what you have already labelled. + has_label = db.Column(db.Boolean, nullable=False, default=False, + index=True) + + synced_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) + + def __repr__(self): + return f'' + + @property + def display_name(self): + return self.name + + @property + def rebrickable_url(self): + return f'https://rebrickable.com/parts/{self.part_num}/' + + @property + def preview_url(self): + """ + A thumbnail for the browse list. + + The CSV dumps carry no image column, and fetching 60,000 image URLs + from the API during a sync would be absurd, so this falls back to + Rebrickable's predictable LDraw render path. That 404s for parts + with no LDraw model - the template hides broken thumbnails. Once a + part becomes a label we fetch its real image_url via the API. + """ + if self.image_url: + return self.image_url + return ('https://cdn.rebrickable.com/media/parts/ldraw/15/' + f'{self.part_num}.png') + + def to_dict(self): + return { + 'part_num': self.part_num, + 'name': self.name, + 'dimensions': self.dimensions, + 'image_url': self.image_url, + 'category': self.catalog_category.name if self.catalog_category else None, + 'has_label': self.has_label, + } + + +class CatalogSync(db.Model): + """ + One run of the Update button. + + Kept so the page can show what happened last time, and so a sync that + is still running can be polled for progress instead of blocking the + request for several minutes. + """ + + __tablename__ = 'part_catalog_syncs' + + id = db.Column(db.Integer, primary_key=True) + started_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) + finished_at = db.Column(db.DateTime, nullable=True) + + # running | done | failed + status = db.Column(db.String(20), nullable=False, default='running') + stage = db.Column(db.String(120), nullable=True) + progress = db.Column(db.Integer, nullable=False, default=0) # 0-100 + + categories_added = db.Column(db.Integer, nullable=False, default=0) + parts_added = db.Column(db.Integer, nullable=False, default=0) + parts_updated = db.Column(db.Integer, nullable=False, default=0) + message = db.Column(db.Text, nullable=True) + + started_by_id = db.Column(db.Integer, db.ForeignKey('users.id'), + nullable=True) + started_by = db.relationship('User', foreign_keys=[started_by_id]) + + def __repr__(self): + return f'' + + @property + def is_running(self): + return self.status == 'running' + + @property + def duration(self): + end = self.finished_at or datetime.utcnow() + return (end - self.started_at).total_seconds() + + def to_dict(self): + return { + 'id': self.id, + 'status': self.status, + 'stage': self.stage, + 'progress': self.progress, + 'categories_added': self.categories_added, + 'parts_added': self.parts_added, + 'parts_updated': self.parts_updated, + 'message': self.message, + 'duration': round(self.duration), + 'finished': self.status != 'running', + } + + @staticmethod + def latest(): + return CatalogSync.query.order_by(CatalogSync.id.desc()).first()