Beispiel #1
0
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}>)'
Beispiel #2
0
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')
Beispiel #3
0
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)
Beispiel #4
0
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)
Beispiel #5
0
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}>'
Beispiel #6
0
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'), )
Beispiel #7
0
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)
Beispiel #8
0
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
Beispiel #9
0
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
Beispiel #10
0
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)