class Customer(Entity): """A consumer of listings.""" id = jb.Column(db.Integer, db.ForeignKey('entity.id', ondelete='CASCADE'), primary_key=True, label='Customer ID') email = jb.Column(db.Text, unique=True, label='Email', format = 'email') city = jb.Column(db.Text, label='City') state = jb.Column(db.String(2), label='State') zip = jb.Column(db.String(10), label='Zip/postal code') class Preview(mm.Schema): id = mmf.Int() type = mmf.Str() title = mmf.Str(attribute='name') description = mmf.Function(func=lambda m: f'{m.email}\n' if m.email else '' + f'{m.city}, {m.state} {m.zip}') def __repr__(self): return f'<{type(self).__name__} {self.name or self.email}>)'
class Vendor(Entity): """An owner of listings.""" id = jb.Column(db.Integer, db.ForeignKey('entity.id', ondelete='CASCADE'), primary_key=True, label='Vendor ID') url = jb.Column(URL, unique=True, label='Website', format='url') avg_shipping = jb.Column(db.Float, nullable=False, default=0, label='Avg. shipping', format='percent') avg_tax = jb.Column(db.Float, nullable=False, default=0, label='Avg. tax', format='percent') ext_id = jb.Column(db.Integer, db.ForeignKey('extension.id'), label='Extension ID') extension = jb.relationship('Extension', label='Extension') listings = jb.relationship('Listing', back_populates='vendor', passive_deletes=True, uselist=True, lazy='dynamic', label='Listings') class Preview(mm.Schema): id = mmf.Int() type = mmf.Str() title = mmf.Str(attribute='name') description = mmf.Str(attribute='url') image = mmf.Str(attribute='image_url')
class Base(jb.JsonMixin, Model): """Custom base class for all models.""" extra = jb.Column(JSONB, default=dict, nullable=False, label='Extra data') type = jb.Column(sa.String(64), nullable=False, label='Object type') class __schema__(mm.Schema): extra = mmf.Dict() type = mmf.String() def __init__(self, *args, **kwargs): self.extra = {} super().__init__(*args, **kwargs) def __repr__(self): return f'<{type(self).__name__} {self.id}>' @declared_attr def __mapper_args__(cls): return { 'polymorphic_identity': cls.__name__, 'polymorphic_on': cls.type } @declared_attr def __tablename__(cls): return to_snake_case(cls.__name__) @classmethod def all_subclasses(cls): return all_subclasses(cls) @classmethod def full_name(cls): return '.'.join((cls.__module__, cls.__name__)) def update(self, *args, **kwargs): """Convenience method for using from_json() with keyword args or a dictionary.""" extra = super().update(*args, **kwargs) if self.extra is None: self.extra = extra else: self.extra.update(extra)
class QuantityMap(db.Model): """A mapping of a quantity and its textual representation.""" id = jb.Column(db.Integer, primary_key=True, label='QuantityMap ID') quantity = jb.Column(db.Integer, nullable=False, default=1, label='Quantity') text = jb.Column(db.String(64), nullable=False, unique=True, label='Description') def __repr__(self): return f'<QuantityMap {self.text} = {self.quantity}>' @classmethod def __declare_last__(cls): db.event.listen(flask_sqlalchemy.SignallingSession, 'before_flush', cls._maybe_update_products) @staticmethod def _maybe_update_products(session, context, instances): qmaps = [obj for obj in session.new if isinstance(obj, QuantityMap)] + \ [obj for obj in session.dirty if isinstance(obj, QuantityMap)] for qmap in qmaps: qmap.update_products() def update_products(self): """Update all products affected by this quantity map.""" Listing.query.filter( db.or_( Listing.quantity_desc.ilike(self.text), Listing.title.op('~*')(f'[[:<:]]{self.text}[[:>:]]'))).update( { 'quantity': self.quantity, 'last_modified': datetime.utcnow() }, synchronize_session=False)
class Entity(db.Model, PolymorphicMixin, SearchMixin): """Represents an owner of Listings (a vendor, market, or business) or a customer.""" id = jb.Column(db.Integer, primary_key=True, label='Entity ID') name = jb.Column(db.Text, unique=True, nullable=False, label='Entity name') image_url = jb.Column(db.Text, label='Image URL', format='url') # Relationships accounts = jb.relationship('FinancialAccount', back_populates='owner', uselist=True, lazy='dynamic', label='Accounts') financials = jb.relationship('FinancialEvent', back_populates='originator', uselist=True, lazy='dynamic', label='Financial events') inventories = jb.relationship('Inventory', back_populates='owner', uselist=True, lazy='dynamic', label='Inventories') orders_from = jb.relationship( 'Order', primaryjoin='Order.source_id == Entity.id', back_populates='source', uselist=True, lazy='dynamic', label='Fulfilled orders' ) orders_to = jb.relationship( 'Order', primaryjoin='Order.dest_id == Entity.id', back_populates='destination', uselist=True, lazy='dynamic', label='Received orders' ) class Preview(mm.Schema): id = mmf.Int() type = mmf.Str() title = mmf.Str(attribute='name') image = mmf.Str(attribute='image_url') def __repr__(self): return f'<{type(self).__name__} {self.name}>'
class ListingDetails(db.Model): """Contains a Listing's transient details, like price, rank, rating, etc.""" id = jb.Column(db.Integer, primary_key=True, label='ListingDetails ID') listing_id = jb.Column(db.Integer, db.ForeignKey('listing.id', ondelete='CASCADE'), nullable=False, label='Listing ID') timestamp = jb.Column(db.DateTime, default=lambda: datetime.utcnow(), label='Timestamp') price = jb.Column(CURRENCY, label='Price') rank = jb.Column(db.Integer, label='Rank') rating = jb.Column(db.Float, label='Rating') listing = jb.relationship('Listing', back_populates='details', label='Listing') __table_args__ = (sa.UniqueConstraint('timestamp', 'listing_id'), )
class Task(db.Model, SearchMixin): """Stores info on recurring tasks.""" id = jb.Column(db.Integer, primary_key=True) name = jb.Column(db.Text, nullable=False, unique=True) ext_id = jb.Column(db.Integer, db.ForeignKey('extension.id', ondelete='CASCADE')) action = jb.Column(db.String(64), nullable=False) context = jb.Column(JSONB, default=dict) schedule = jb.Column(JSONB, default=dict) extension = jb.relationship('Extension', back_populates='tasks') def __init__(self, *args, **kwargs): self.params = {} self.options = {} self.schedule = {} super().__init__(*args, **kwargs) def __repr__(self): return f'<{type(self).__name__} {self.name or self.id}>' def send(self, **kwargs): return TaskInstance.create_from(self, **kwargs)
class Listing(db.Model, SearchMixin): """A description of a product for sale.""" id = jb.Column(db.Integer, primary_key=True, label='Listing ID') vendor_id = jb.Column(db.Integer, db.ForeignKey('vendor.id', ondelete='CASCADE'), nullable=False, label='Vendor ID') sku = jb.Column(db.String(64), nullable=False, label='SKU') title = jb.Column(db.Text, label='Title') brand = jb.Column(db.Text, label='Brand') model = jb.Column(db.Text, label='Model') quantity = jb.Column(db.Integer, default=1, label='Quantity') quantity_desc = jb.Column(db.Text, label='Quantity Description') features = jb.Column(db.Text, label='Features') description = jb.Column(db.Text, label='Description') detail_url = jb.Column(URL, label='Detail page URL', format='url') image_url = jb.Column(URL, label='Image URL', format='url') last_modified = jb.Column(db.DateTime, default=lambda: str(datetime.utcnow()), onupdate=datetime.utcnow, label='Last modified') __table_args__ = (sa.UniqueConstraint('vendor_id', 'sku'), ) # Pass-through properties price = detail_property('price', field=mmf.Decimal, label='Price') rank = detail_property('rank', field=mmf.Integer, label='Rank') rating = detail_property('rating', field=mmf.Float, label='Rating', format='percent') details = jb.relationship('ListingDetails', order_by=ListingDetails.timestamp.asc(), back_populates='listing', passive_deletes=True, uselist=True, label='Detail history') # Relationships vendor = jb.relationship('Vendor', back_populates='listings', label='Vendor') inventories = jb.relationship('Inventory', back_populates='listing', uselist=True, label='Inventories') inventory = jb.relationship( 'Inventory', primaryjoin= 'and_(Inventory.listing_id == Listing.id, Inventory.owner_id == Listing.vendor_id)', uselist=False, label='Inventory') class Preview(mm.Schema): id = mmf.Int() type = mmf.Str() title = mmf.Str(attribute='title') image = mmf.Str(attribute='image_url') description = mmf.Function( lambda obj: f'{obj.vendor.name if obj.vendor else obj.vendor_id} {obj.sku}') def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.suppress_guessing = False if self.inventory is None: self.inventory = Inventory(listing=self, owner=self.vendor) db.session.add(self.inventory) def __repr__(self): vnd_name = self.vendor.name if self.vendor else None return f'<{type(self).__name__} {vnd_name} {self.sku}>' @sa.orm.reconstructor def __init_on_load__(self): self.suppress_guessing = False # Event handlers @classmethod def __declare_last__(cls): db.event.listen(flask_sqlalchemy.SignallingSession, 'before_flush', cls._maybe_guess_quantity) @staticmethod def _maybe_guess_quantity(session, context, instances): def should_guess(o): if isinstance(o, Listing) and not o.suppress_guessing: insp = db.inspect(o) return insp.attrs['title'].history.has_changes() or \ insp.attrs['quantity_desc'].history.has_changes() else: return False new_listings = [ listing for listing in session.new if should_guess(listing) ] mod_listings = [ listing for listing in session.dirty if should_guess(listing) ] for listing in new_listings + mod_listings: listing.guess_quantity() # Custom search methods def similarity_query(self): brand, model, title = self.brand, self.model, self.title brand_match, model_match, title_match = None, None, None if brand: brand_match = { 'multi_match': { 'query': brand, 'fuzziness': 'AUTO', 'fields': ['brand^2', 'title'] } } if model: model_match = { 'multi_match': { 'query': model, 'fuzziness': 'AUTO', 'fields': ['model^3', 'title'] } } if title: title_match = { 'multi_match': { 'query': title, 'fuzziness': 'AUTO', 'fields': ['brand^2', 'model^3', 'title'] } } if model_match: must = [model_match] should = [m for m in (brand_match, title_match) if m is not None] elif brand_match: must = [brand_match] should = [title_match] if title_match else [] elif title_match: must = [title_match] should = [] else: must, should = [], [] return { 'must': must, 'should': should, 'must_not': [{ 'ids': { 'values': [self.id] } }] } # Hybrid properties @sa.ext.hybrid.hybrid_property def estimated_cost(self): """The price plus tax and shipping, based on the vendor's averages.""" if self.price is not None: tax = self.price * Decimal(self.vendor.avg_tax) shipping = self.price * Decimal(self.vendor.avg_shipping) return quantize_decimal(self.price + tax + shipping) return None @estimated_cost.expression def estimated_cost(cls): return db.select([ db.cast( ListingDetails.price * (1 + Vendor.avg_shipping + Vendor.avg_tax), CURRENCY) ]).where( db.and_(ListingDetails.listing_id == cls.id, Vendor.id == cls.vendor_id)).order_by( ListingDetails.timestamp.desc()).limit(1).label( 'estimated_cost') @sa.ext.hybrid.hybrid_property def estimated_unit_cost(self): """The estimated cost divided by the listing quantity.""" cost = self.estimated_cost if None in (cost, self.quantity): return None return cost / self.quantity @estimated_unit_cost.expression def estimated_unit_cost(cls): return cls.estimated_cost / cls.quantity def guess_quantity(self): """Guess listing quantity based on QuantityMap data.""" if self.quantity_desc: qmap = QuantityMap.query.filter_by(text=self.quantity_desc).first() if qmap and self.quantity is None: self.quantity = qmap.quantity return elif self.quantity is None: all_qmaps = QuantityMap.query.order_by( db.func.char_length(QuantityMap.text).desc()).all() for qmap in all_qmaps: if re.search(f'(\W|\A){qmap.text}(\W|\Z)', self.title, re.IGNORECASE): self.quantity = qmap.quantity self.quantity_desc = qmap.text break
class Extension(db.Model, SearchMixin): """A module of code.""" id = jb.Column(db.Integer, primary_key=True, label='Extension ID') name = jb.Column(db.Text, nullable=False, unique=True, label='Name') module = jb.Column(db.Text, nullable=False, unique=True, label='Module (ext.)') exports = jb.Column(JSONB, default=dict, nullable=False, label='Exports') tasks = jb.relationship('Task', back_populates='extension', uselist=True, label='Extensions') instances = jb.relationship('TaskInstance', back_populates='extension', uselist=True, label='Instances') class Preview(mm.Schema): id = mmf.Int() type = mmf.Str() title = mmf.Str(attribute='name') description = mmf.Function(lambda obj: f'ext.{obj.module}') def __repr__(self): name = self.name or self.module or self.id return f'<{type(self).__name__} {name}>' def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._m = None self.load_module() @sa.orm.reconstructor def __init_on_load___(self): self._m = None self.load_module() @property def m(self): if self._m: return self._m self.load_module() return self._m @m.setter def m(self, name): if name is None: self._m = None self.module = None self.exports = {} return self.load_module(name) def load_module(self, name=None): """Inspects the currently loaded module and updates the name and functions attributes.""" name = name or self.module if name is None: self.m = None return mod = importlib.import_module('ext.' + name) symbols = [getattr(mod, s) for s in dir(mod) if not s.startswith('_')] actors = [s for s in symbols if isinstance(s, ColanderActor) and getattr(s, 'public', False)] self._m = mod self.name = self.name or self.module self.exports = { a.__class__.__name__: { 'doc': getattr(a, '__doc__', a.__class__.__doc__), 'schema': mmjs.JSONSchema().dump(a.Schema()).data['definitions']['Schema'] } for a in actors} def send(self, action, name=None, context=None, **params): """Call an actor asynchronously and returns the message ID.""" # Auto-generate a name if none provided if name is None: name = f'{action}: {context or params}' # Get the actor and use it to build a context message = self.message(action, **params) ctx = TaskContext(message, data=context) ctx.send() instance = TaskInstance(context_id=ctx.id, name=name, extension=self) db.session.add(instance) db.session.commit() return instance def message(self, action, **kwargs): """Return a message from the given action, which can be sent later.""" if action not in self.exports: raise ValueError(f'Unknown export: {action}') message = getattr(self.m, action).message(**kwargs) return message def call(self, action, *args, **kwargs): raise NotImplementedError
class TaskInstance(db.Model, SearchMixin): """Keeps track of a task context.""" id = jb.Column(db.Integer, primary_key=True) name = jb.Column(db.Text) context_id = jb.Column(db.Text, unique=True) extension_id = jb.Column(db.Integer, db.ForeignKey('extension.id'), nullable=False) extension = jb.relationship('Extension', back_populates='instances') def __init__(self, *, action=None, data=None, status=None, **kwargs): if action is not None: # Get the extension if 'extension' in kwargs: extension = kwargs.get['extension'] elif 'extension_id' in kwargs: extension = Extension.query.filter_by(id=kwargs['extension_id']).one() elif '.' in action: module = action.split('.')[-2] extension = Extension.query.filter_by(module=module).one() else: raise ValueError(f'extension, extension_id, or qualified action is requirecd.') # Get a message for the action action = action.split('.')[-1] message = extension.message(action) # Create a task context ctx = TaskContext(message, data=data, status=status) kwargs['context_id'] = ctx.id if 'name' not in kwargs: kwargs['name'] = f'{action}: {ctx.id}' super().__init__(**kwargs) self._context = TaskContext(id=self.context_id) @classmethod def from_json(cls, data): return cls(**data) @sa.orm.reconstructor def reconstructor(self): self._context = TaskContext(id=self.context_id) @classmethod def before_commit(cls, session): """Hold on to any deleted trackers so we can expire their contexts.""" session._deleted_trackers = [obj for obj in session.deleted if isinstance(obj, cls)] @classmethod def after_commit(cls, session): """Expire any deleted contexts.""" for tracker in session._deleted_trackers: tracker.expire() @classmethod def register_hooks(cls): """Register lifecycle hooks with the database session.""" db.event.listen(flsa.SignallingSession, 'before_commit', cls.before_commit) db.event.listen(flsa.SignallingSession, 'after_commit', cls.after_commit) @property def ctx(self): return self._context @jb.property(label='Context', format='object', field=mmf.Dict) def data(self): return self._context.data if self._context else {} @jb.property(label='Unsent messages', format='array', field=ListOf(mmf.String)) def messages(self): messages = self._context.messages return [dict(msg.asdict()) for msg in messages] @jb.property(label='Sent messages', format='array', field=ListOf(mmf.String)) def sent(self): return self._context.sent @jb.property(label='Completed messages', format='array', field=ListOf(mmf.String)) def completed(self): return self._context.completed @jb.property(label='Child contexts', format='array', field=ListOf(mmf.String)) def children(self): return self._context.children @jb.property(label='Errors', format='object', field=mmf.Dict) def errors(self): return self._context.errors @jb.property(label='Message counts', format='object', field=mmf.Dict) def counts(self): return self._context.counts @jb.property(label='Progress', format='array', field=ListOf(mmf.Int)) def progress(self): return self._context.progress() @jb.property(label='Status', format='string', field=mmf.String) def status(self): return self._context.status @status.setter def status(self, value): self._context.status = value def expire(self, seconds=None): return self._context.expire(seconds)