Add local Rebrickable catalogue mirror models

This commit is contained in:
jessikitty committed 2026-10-12 21:23:54 +11:00
1 parent 9d7fb66c6a
commit 63c3ece271
1 file changed
+182
+182
View File
@@ -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'<PartCatalogCategory {self.id} {self.name}>'
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'<PartCatalogItem {self.part_num} {self.name}>'
@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'<CatalogSync {self.id} {self.status}>'
@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()