""" Queue of "go and fetch the instructions" jobs for official sets. One row per set per attempt run. The queue lives in the database rather than in memory so a restart doesn't lose the backlog, and so the admin page can show what happened to every set without scraping the log file. Only official sets ever get queued — MOCs have no Brickset entry, so there is nothing to fetch. Created by: Jess Rogerson (yelling commands at Claude.AI) """ from datetime import datetime from app import db # queued - waiting its turn # running - being worked on right now # done - at least one instruction file was saved # empty - Brickset has no instructions on file for this set # failed - gave up after max attempts # cancelled - removed from the queue by an admin STATUSES = ('queued', 'running', 'done', 'empty', 'failed', 'cancelled') OPEN_STATUSES = ('queued', 'running') STATUS_BADGES = { 'queued': 'secondary', 'running': 'primary', 'done': 'success', 'empty': 'warning', 'failed': 'danger', 'cancelled': 'dark', } class InstructionFetchJob(db.Model): """A single set queued for an automatic instruction download.""" __tablename__ = 'instruction_fetch_jobs' id = db.Column(db.Integer, primary_key=True) set_id = db.Column(db.Integer, db.ForeignKey('sets.id'), nullable=False, index=True) status = db.Column(db.String(12), nullable=False, default='queued', server_default='queued', index=True) attempts = db.Column(db.Integer, nullable=False, default=0, server_default='0') files_added = db.Column(db.Integer, nullable=False, default=0, server_default='0') # 'brickset' for now; the column exists so a future LEGO-direct or # Rebrickable path can be told apart in the history. source = db.Column(db.String(20), nullable=True) message = db.Column(db.Text, nullable=True) queued_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow, index=True) # Retry backoff: a job is invisible to the worker until this passes. not_before = db.Column(db.DateTime, nullable=True) started_at = db.Column(db.DateTime, nullable=True) finished_at = db.Column(db.DateTime, nullable=True) queued_by_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) lego_set = db.relationship( 'Set', backref=db.backref('fetch_jobs', lazy='dynamic', cascade='all, delete-orphan')) queued_by = db.relationship('User', foreign_keys=[queued_by_id]) def __repr__(self): return f'' # -- helpers ------------------------------------------------------------ @property def badge(self): return STATUS_BADGES.get(self.status, 'secondary') @property def is_open(self): return self.status in OPEN_STATUSES @classmethod def open_for_set(cls, set_id): """The queued/running job for a set, if there is one.""" return cls.query.filter(cls.set_id == set_id, cls.status.in_(OPEN_STATUSES)).first() @classmethod def counts(cls): """Row counts per status, with every status present.""" from sqlalchemy import func rows = db.session.query(cls.status, func.count(cls.id)).group_by( cls.status).all() counts = {status: 0 for status in STATUSES} for status, count in rows: counts[status] = count counts['total'] = sum(counts[s] for s in STATUSES) return counts def to_dict(self): lego_set = self.lego_set return { 'id': self.id, 'set_id': self.set_id, 'set_number': lego_set.set_number if lego_set else None, 'set_name': lego_set.set_name if lego_set else None, 'status': self.status, 'badge': self.badge, 'attempts': self.attempts, 'files_added': self.files_added, 'source': self.source, 'message': self.message, 'queued_at': self.queued_at.isoformat() if self.queued_at else None, 'finished_at': (self.finished_at.isoformat() if self.finished_at else None), }