class Station: """This model has Model.Station as a namespace. It is intented for storing known stations extracted from a stations.csv file.""" id = Integer(primary_key=True) name = Text(nullable=False) slug = Text(nullable=False)
class History(Declarations.Mixin.DramatiqMessageStatus): """History of the state change for the message""" id = Integer(primary_key=True) message = Many2One(model=Declarations.Model.Dramatiq.Message, one2many="histories", nullable=False, foreign_key_options={'ondelete': 'cascade'}) error = Text()
class PredictionModel(Mixin.IdColumn, Mixin.TrackModel): """PredictionModel""" model_name = String(label='Model name', nullable=False) model_file_path = Text(label='Serialized model path', nullable=False) def __str__(self): return 'Model {} at {}'.format(self.model_name, self.model_file_path) def __repr__(self): msg = '<PredictionModel: model_name={self.model_name}, model_file_path={self.model_file_path}>' return msg.format(self=self)
class PredictionModel(Mixin.IdColumn, Mixin.TrackModel): """A stat model takes features in and output a prediction""" model_name = String(label='Model name', unique=True, nullable=False) model_file_path = Text(label='Serialized model path', nullable=False) # todo: find better data type def predict(self, features): return self.model_executor.predict(features) def __str__(self): return 'Model {} at {}'.format(self.model_name, self.model_file_path) def __repr__(self): msg = '<PredictionModel: model_name={self.model_name}, model_file_path={self.model_file_path}>' return msg.format(self=self)
class Message: id = Integer(primary_key=True) create_date = DateTime(nullable=False, default=datetime.now) edit_date = DateTime(nullable=False, default=datetime.now, auto_update=True) content_type = String(default='application/json', nullable=False) message = LargeBinary(nullable=False) sequence = Integer(default=100, nullable=False) error = Text() queue = String(nullable=False) model = String(nullable=False) method = String(nullable=False) def consume(self): """Try to consume on message to import it in database""" logger.info('consume %r', self) error = "" try: Model = self.registry.get(self.model) savepoint = self.registry.begin_nested() status = getattr(Model, self.method)(body=self.message.decode('utf-8')) savepoint.commit() except Exception as e: savepoint.rollback() logger.exception('Error while trying to consume message %r', self.id) status = MessageStatus.ERROR error = str(e) if status is MessageStatus.ERROR or status is None: logger.info('%s Finished with an error %r', self, error) self.error = error else: self.delete() @classmethod def consume_all(cls): """Try to consume all the message, ordered by the sequence""" query = cls.query().order_by(cls.sequence) for consumer in query.all(): try: consumer.consume() except Exception: pass
class Template: """Template class """ id = Integer(label="Identifier", primary_key=True) create_date = DateTime(default=datetime.now, nullable=False) edit_date = DateTime(default=datetime.now, nullable=False, auto_update=True) code = String(label="Template code", unique=True, nullable=False) name = String(label="Template name", size=256) description = Text(label="Template description") properties = Jsonb(label="Template properties") def __str__(self): return "%s : %s" % (self.code, self.name) def __repr__(self): return "<Template(code=%s, name=%s)>" % ( self.code, self.name)
class Line: """Represent an assessment for a :class:`Node <Node>` instance. This is an inert model, meant to be filled through some user interface. If the corresponding :class:`Node` is a leaf, then :attr:`location` could be any container under the Node's :attr:`location <Node.location>`. But if the :class:`Node` is split, then the :attr:`location` must be identical to the Node's :attr:`location <Node.location>`, otherwise the simplification of reconciliation :class:`Actions <.action.Action>` can't work properly. """ id = Integer(label="Identifier", primary_key=True) """Primary key.""" node = Many2One(model=Wms.Inventory.Node, one2many='lines', nullable=False) location = Many2One(model=Wms.PhysObj, nullable=False) type = Many2One(model=Wms.PhysObj.Type, nullable=False) code = Text() properties = Jsonb() quantity = Integer(nullable=False)
class Apparition(Operation): """Inventory Operation to record unexpected physical objects. This is similar to Arrival, but has a distinct functional meaning. Apparitions can exist only in the ``done`` :ref:`state <op_states>`. Another difference with Arrivals is that Apparitions have a :attr:`quantity` field. """ TYPE = 'wms_apparition' id = Integer(label="Identifier", primary_key=True, autoincrement=False, foreign_key=Operation.use('id').options(ondelete='cascade')) """Primary key.""" goods_type = Many2One(model='Model.Wms.PhysObj.Type') """Observed :class:`PhysObj Type <anyblok_wms_base.core.physobj.Type>`. """ quantity = Integer() """The number of identical PhysObj that have appeared. Here, identical means "same type, code and properties" """ goods_properties = Jsonb() """Observed :class:`Properties <anyblok_wms_base.core.physobj.Properties>`. They are copied over to the newly created :class:`PhysObj <anyblok_wms_base.core.physobj.PhysObj>`. Then the Properties can evolve on the PhysObj, while this Apparition field will keep the exact values that were observed during inventory. """ goods_code = Text() """Observed :attr:`PhysObj code <anyblok_wms_base.core.physobj.PhysObj.code>`. """ location = Many2One(model='Model.Wms.PhysObj') """Location of appeared PhysObj. This will be the location of the initial Avatars. """ inputs_number = 0 """This Operation is a purely creative one.""" def specific_repr(self): return ("goods_type={self.goods_type!r}, " "location={self.location!r}").format(self=self) @classmethod def check_create_conditions(cls, state, dt_execution, location=None, **kwargs): """Forbid creation with wrong states, check location is a container. :raises: :class:`OperationForbiddenState <anyblok_wms_base.exceptions.OperationForbiddenState>` if state is not ``'done'`` :class:`OperationContainerExpected <anyblok_wms_base.exceptions.OperationContainerExpected>` if location is not a container. """ if state != 'done': raise OperationForbiddenState( cls, "Apparition can exist only in the 'done' state", forbidden=state) if location is None or not location.is_container(): raise OperationContainerExpected(cls, "location field value {offender}", offender=location) super(Apparition, cls).check_create_conditions(state, dt_execution, **kwargs) def after_insert(self): """Create the PhysObj and their Avatars. In the ``wms-core`` implementation, the :attr:`quantity` field gives rise to as many PhysObj records. """ PhysObj = self.registry.Wms.PhysObj self_props = self.goods_properties if self_props is None: props = None else: props = PhysObj.Properties.create(**self_props) for _ in range(self.quantity): PhysObj.Avatar.insert(obj=PhysObj.insert(type=self.goods_type, properties=props, code=self.goods_code), location=self.location, reason=self, state='present', dt_from=self.dt_execution)
class Exemple: id = Integer(primary_key=True) text = Text()
class Action: """Represent a reconciliation Action for a :class:`Node <Node>` instance. """ id = Integer(label="Identifier", primary_key=True) """Primary key.""" node = Many2One(model=Wms.Inventory.Node, one2many='actions', nullable=False) OPERATIONS = ( ('app', 'wms_inventory_action_app'), ('disp', 'wms_inventory_action_disp'), ('telep', 'wms_inventory_action_telep'), ) type = Selection(selections=OPERATIONS, nullable=False) location = Many2One(model=Wms.PhysObj, nullable=False) destination = Many2One(model=Wms.PhysObj) """Optional destination container. This is useful if :attr:`type` is ``telep`` only. """ physobj_type = Many2One(model=Wms.PhysObj.Type, nullable=False) physobj_code = Text() physobj_properties = Jsonb() quantity = Integer(nullable=False) def __repr__(self): fmt = ("Wms.Inventory.Action(type={self.type!r}, " "node={self.node!r}, location_code={self.location.code!r}, ") if self.type == 'telep': fmt += "destination_code={self.destination.code!r}, " fmt += ("quantity={self.quantity}, " "physobj_type_code={self.physobj_type.code!r}, " "physobj_code={self.physobj_code!r}, " "physobj_properties={self.physobj_properties!r})") return fmt.format(self=self) __str__ = __repr__ @classmethod def simplify(cls, node): App = orm.aliased(cls, name='app') Disp = orm.aliased(cls, name='disp') # TODO, compare properties matching = (cls.registry.query(App, Disp).filter( App.node == node, App.type == 'app', Disp.node == node, Disp.type == 'disp', Disp.physobj_type_id == App.physobj_type_id, or_(Disp.physobj_code == App.physobj_code, and_(Disp.physobj_code.is_(None), App.physobj_code.is_(None)))).all()) for app, disp in matching: if app.type == 'telep' or disp.type == 'telep': # one is already rewritten continue diff_qty = app.quantity - disp.quantity dest = app.location if diff_qty >= 0: disp.update(type='telep', destination=dest) if diff_qty: app.quantity = diff_qty else: app.delete() else: app.update(type='telep', location=disp.location, destination=dest) disp.quantity = -diff_qty def customize_operation_fields(self, operation_fields): """Hook to modify fields of Operations spawned by :meth:`apply` This is meant for easy override by applications. :param dict operation_fields: prefilled by :meth:`apply` with the minimal required values in the generic case. This methods mutates it in place :returns: None The typical customization would consist of putting additional fields that make sense for the local business logic, but this method isn't limited to that. """ return def apply(self): """Perform Inventory Operations for the current Action. :return: tuple of the newly created Operations The new Operations will all point to the related Inventory. """ Operation = self.registry.Wms.Operation op_fields = dict(state='done', inventory=self.node.inventory) if self.type == 'app': Op = Operation.Apparition op_fields.update(physobj_type=self.physobj_type, physobj_code=self.physobj_code, physobj_properties=self.physobj_properties, quantity=self.quantity, location=self.location) elif self.type == 'disp': Op = Operation.Disparition else: Op = Operation.Teleportation op_fields['new_location'] = self.destination self.customize_operation_fields(op_fields) if self.type == 'app': return (Op.create(**op_fields), ) return tuple( Op.create(input=av, **op_fields) for av in self.choose_affected()) def choose_affected(self): """Choose Physical Objects to be taken for Disparition/Teleportation. if :attr:`physobj_code` is ``None``, we match only Physical Objects whose ``code`` is also ``None``. That's because the code should come directly from existing PhysObj records (that weren't reflected in Inventory Lines). Same remark would go for Properties, but: TODO implement Properties TODO adapt to wms-quantity """ PhysObj = self.registry.Wms.PhysObj Avatar = PhysObj.Avatar avatars_q = (Avatar.query().filter_by( location=self.location, state='present').join(PhysObj, Avatar.obj_id == PhysObj.id).filter( PhysObj.type == self.physobj_type, PhysObj.code == self.physobj_code)) Reservation = getattr(self.registry.Wms, 'Reservation', None) if Reservation is not None: avatars_q = (avatars_q.outerjoin( Reservation, Reservation.physobj_id == Avatar.obj_id).outerjoin( Reservation.request_item).order_by( Reservation.RequestItem.request_id.desc())) avatars = avatars_q.limit(self.quantity).all() if len(avatars) != self.quantity: raise ActionInputsMissing( self, len(avatars), "Couldn't find enough Avatars " "(only {nb_found} over {nb_expected}) " "to choose from in application of {action}") return avatars
class PhysObj: """Main data type to represent physical objects managed by the system. The instances of this model are also the ultimate representation of the PhysObj "staying the same" or "becoming different" under the Operations, which is, ultimately, a subjective decision that has to be left to downstream libraires and applications, or even end users. For instance, everybody agrees that moving something around does not make it different. Therefore, the Move Operation uses the same PhysObj record in its outcome as in its input. On the other hand, changing a property could be considered enough an alteration of the physical object to consider it different, or not (think of recording some measurement that had not be done earlier.) """ id = Integer(label="Identifier", primary_key=True) """Primary key.""" type = Many2One(model='Model.Wms.PhysObj.Type', nullable=False, index=True) """The :class:`PhysObj Type <.type.Type>`""" code = Text(label="Identifying code", index=True) """Uniquely identifying code. This should be about what one is ready to display as a barcode for handling the PhysObj. It's also meant to be shared with other applications if needed (rather than ids which are only locally unique). """ properties = Many2One(label="Properties", index=True, model='Model.Wms.PhysObj.Properties') """Link to :class:`Properties`. .. seealso:: :ref:`physobj_properties` for functional aspects. .. warning:: don't ever mutate the contents of :attr:`properties` directly, unless what you want is precisely to affect all the PhysObj records that use them directly. Besides their :attr:`type` and the fields meant for the Wms Base bloks logic, the PhysObj Model bears flexible data, called *properties*, that are to be manipulated as key/value pairs through the :meth:`get_property` and :meth:`set_property` methods. As far as ``wms_core`` is concerned, values of properties can be of any type, yet downstream applications and libraries can choose to make them direct fields of the :class:`Properties` model. Properties can be shared among several PhysObj records, for efficiency. The :meth:`set_property` implements the necessary Copy-on-Write mechanism to avoid unintentionnally modify the properties of many PhysObj records. Technically, this data is deported into the :class:`Properties` Model (see there on how to add additional properties). The properties column value can be None, so that we don't pollute the database with empty lines of Property records, although this is subject to change in the future. """ def __str__(self): if self.code is None: fmt = "(id={self.id}, type={self.type})" else: # I expect direct assignment onto string litteral to be more # efficient than a string manipulation fmt = "(id={self.id}, code={self.code}, type={self.type})" return fmt.format(self=self) def __repr__(self): if self.code is None: fmt = "Wms.PhysObj(id={self.id}, type={self.type!r})" else: # I expect direct assignment onto string litteral to be more # efficient than a string manipulation fmt = ("Wms.PhysObj(id={self.id}, code={self.code!r}, " "type={self.type!r})") return fmt.format(self=self) def has_type(self, goods_type): """Tell whether ``self`` has the given type. :param .type.Type goods_type: :return: ``True`` if the :attr:`type` attribute is ``goods_type`` or on of its descendants. :rtype bool: """ return self.type.is_sub_type(goods_type) def get_property(self, k, default=None): """Property getter, works like :meth:`dict.get`. Actually I'd prefer to simply implement the dict API, but we can't direcly inherit from UserDict yet. This is good enough to provide the abstraction needed for current internal wms_core calls. """ props = self.properties val = _missing if props is None else props.get(k, _missing) if val is _missing: return self.type.get_property(k, default=default) return val def merged_properties(self): """Return all Properties, merged with the Type properties. :rtype: dict To retrieve just one Property, prefer :meth:`get_property`, which is meant to be more efficient. """ props = self.properties type_props = self.type.merged_properties() if props is None: return type_props return dict_merge(props.as_dict(), type_props) def _maybe_duplicate_props(self): """Internal method to duplicate Properties Duplication occurs iff there are other PhysObj with the same Properties instance. The caller must have already checked that ``self.properties`` is not ``None``. """ cls = self.__class__ existing = self.properties if (cls.query(cls.id) .filter(cls.properties == existing, cls.id != self.id) .limit(1).count()): self.properties = existing.duplicate() def set_property(self, k, v): """Property setter. See remarks on :meth:`get_property`. This method implements a simple Copy-on-Write mechanism. Namely, if the properties are referenced by other PhysObj records, it will duplicate them before actually setting the wished value. """ existing_props = self.properties if existing_props is None: self.properties = self.registry.Wms.PhysObj.Properties( flexible=dict()) elif existing_props.get(k) != v: self._maybe_duplicate_props() self.properties.set(k, v) def update_properties(self, mapping): """Update Properties in one shot, similar to :meth:`dict.update` :param mapping: a :class:`dict` like object, or an iterable of (key, value) pairs This method implements a simple Copy-on-Write mechanism. Namely, if the properties are referenced by other PhysObj records, it will duplicate them before actually setting the wished value. """ items_meth = getattr(mapping, 'items', None) if items_meth is None: items = mapping else: items = mapping.items() existing_props = self.properties if existing_props is None: self.properties = self.registry.Wms.PhysObj.Properties.create( **{k: v for k, v in items}) return actual_upd = [] for k, v in items: if existing_props.get(k, _missing) != v: actual_upd.append((k, v)) if not actual_upd: return self._maybe_duplicate_props() self.properties.update(actual_upd) def has_property(self, name): """Check if a Property with given name is present.""" props = self.properties if props is not None and name in props: return True return self.type.has_property(name) def has_properties(self, names): """Check in one shot if Properties with given names are present.""" if not names: return True props = self.properties if props is None: return self.type.has_properties(names) return self.type.has_properties(n for n in names if n not in props) def has_property_values(self, mapping): """Check that all key/value pairs of mapping are in properties.""" if not mapping: return True props = self.properties if props is None: return self.type.has_property_values(mapping) return all(self.get_property(k, default=_missing) == v for k, v in mapping.items()) @classmethod def flatten_containers_subquery(cls, top=None, additional_states=None, at_datetime=None): """Return an SQL subquery flattening the containment graph. Containing PhysObj can themselves be placed within a container through the standard mechanism: by having an Avatar whose location is the surrounding container. This default implementation issues a recursive CTE (``WITH RECURSIVE``) that climbs down along this, returning just the ``id`` column This subquery cannot be used directly: it is meant to be used as part of a wider query; see :mod:`unit tests <anyblok_wms_base.core.physobj.tests.test_containers>`) for nice examples with or without joins. .. note:: This subquery itself does not restrict its results to actually be containers! Only its use in joins as locations of Avatars will, and that's considered good enough, as filtering on actual containers would be more complicated (resolving behaviour inheritance) and is useless for quantity queries. Applicative code relying on this method for other reasons than quantity counting should therefore add its own ways to restrict to actual containers if needed. :param top: if specified, the query starts at this Location (inclusive) For some applications with a large and complicated containing hierarchy, joining on this CTE can become a performance problem. Quoting `PostgreSQL documentation on CTEs <https://www.postgresql.org/docs/10/static/queries-with.html>`_: However, the other side of this coin is that the optimizer is less able to push restrictions from the parent query down into a WITH query than an ordinary subquery. The WITH query will generally be evaluated as written, without suppression of rows that the parent query might discard afterwards. If that becomes a problem, it is still possible to override the present method: any subquery whose results have the same columns can be used by callers instead of the recursive CTE. Examples: 1. one might design a flat Location hierarchy using prefixing on :attr:`code` to express inclusion instead of the standard Avatar mechanism. :attr:`parent`. See :meth:`this test <.tests.test_containers.TestContainers.test_override_recursion>` for a proof of this concept. 2. one might make a materialized view out of the present recursive CTE, refreshing as soon as needed. """ Avatar = cls.Avatar query = cls.registry.session.query cte = cls.query(cls.id) if top is None: cte = (cte.outerjoin(Avatar, Avatar.obj_id == cls.id) .filter(Avatar.location_id.is_(None))) else: cte = cte.filter_by(id=top.id) cte = cte.cte(name="container", recursive=True) parent = orm.aliased(cte, name='parent') child = orm.aliased(cls, name='child') tail = (query(child.id) .join(Avatar, Avatar.obj_id == child.id) .filter(Avatar.location_id == parent.c.id)) # taking additional states and datetime query into account # TODO, this location part is very redundant with what's done in # Wms.quantity() itself for the PhysObj been counted, # we should refactor if additional_states is None: tail = tail.filter(Avatar.state == 'present') else: tail = tail.filter( Avatar.state.in_(('present', ) + tuple(additional_states))) if at_datetime is DATE_TIME_INFINITY: tail = tail.filter(Avatar.dt_until.is_(None)) elif at_datetime is not None: tail = tail.filter(Avatar.dt_from <= at_datetime, or_(Avatar.dt_until.is_(None), Avatar.dt_until > at_datetime)) cte = cte.union_all(tail) return cte def is_container(self): """Tell whether the :attr:`type` is a container one. :rtype: bool """ return self.type.is_container() def current_avatar(self): """The Avatar giving the current position of ``self`` in reality :return: the Avatar, or ``None``, in case ``self`` is not yet or no more physically present. """ return self.Avatar.query().filter_by(obj=self, state='present').first() def eventual_avatar(self): """The Avatar giving the latest foreseeable position of ``self``. :return: the Avatar, or ``None``, in case - ``self`` is planned to leave the system. - ``self`` has already left the system (only ``past`` avatars) There are more complicated corner cases, but they shouldn't arise in real operation and the results are considered to be dependent on the implementation of this method, hence not part of any stability promises. Simplest example: a single avatar, with ``state='present'`` and ``dt_until`` not ``None`` (normally a subsequent avatar, in the ``planned`` state should explain the bounded time range). """ Avatar = self.Avatar return (Avatar.query() .filter(Avatar.state.in_(('present', 'future'))) .filter_by(obj=self, dt_until=None) .first())
class Arrival(Mixin.WmsSingleOutcomeOperation, Operation): """Operation to describe physical arrival of goods in some location. Arrivals store data about the expected or arrived physical objects: properties, code… These are copied over to the corresponding PhysObj records in all cases and stay inert after the fact. In case the Arrival state is ``planned``, these are obviously only unchecked values, but in case it is ``done``, the actual meaning can depend on the application: - maybe the application won't use the ``planned`` state at all, and will only create Arrival after checking them, - maybe the application will inspect the Arrival properties, compare them to reality, update them on the created PhysObj and cancel downstream operations if needed, before calling :meth:`execute`. TODO maybe provide higher level facilities for validation scenarios. """ TYPE = 'wms_arrival' id = Integer(label="Identifier", primary_key=True, autoincrement=False, foreign_key=Operation.use('id').options(ondelete='cascade')) """Primary key.""" physobj_type = Many2One(model='Model.Wms.PhysObj.Type') """Expected :class:`PhysObj Type <anyblok_wms_base.core.physobj.Type>`. """ physobj_properties = Jsonb(label="Properties of arrived PhysObj") """Expected :class:`Properties <anyblok_wms_base.core.physobj.Properties>`. They are copied over to the newly created :class:`PhysObj <anyblok_wms_base.core.physobj.PhysObj>` as soon as the Arrival is planned, and aren't updated by :meth:`execute`. Matching them with reality is the concern of separate validation processes, and this field can serve for later assessments after the fact. """ physobj_code = Text(label="Code to set on arrived PhysObj") """Expected :attr:`PhysObj code <anyblok_wms_base.core.physobj.PhysObj.code>`. Can be ``None`` in case the arrival process issues the code only at the time of actual arrival. """ location = Many2One(model='Model.Wms.PhysObj') """Will be the location of the initial Avatar.""" goods_type = Function(fget='_goods_type_get', fset='_goods_type_set', fexpr='_goods_type_expr') """Compatibility wrapper. Before version 0.9.0, :attr:`physobj_type` was ``goods_type``. This does not extend to compatibility of the former low level ``goods_type_id`` column. """ goods_properties = Function(fget='_goods_properties_get', fset='_goods_properties_set', fexpr='_goods_properties_expr') """Compatibility wrapper. Before version 0.9.0, :attr:`physobj_properties` was ``goods_properties``. """ goods_code = Function(fget='_goods_code_get', fset='_goods_code_set', fexpr='_goods_code_expr') """Compatibility wrapper. Before version 0.9.0, :attr:`physobj_code` was ``goods_code``. """ inputs_number = 0 """This Operation is a purely creative one.""" destination_field = 'location' def specific_repr(self): return ("physobj_type={self.physobj_type!r}, " "location={self.location!r}").format(self=self) def _goods_col_get(self, suffix): deprecation_warn_goods_col(self, suffix) return getattr(self, 'physobj_' + suffix) def _goods_col_set(self, suffix, value): deprecation_warn_goods_col(self, suffix) setattr(self, 'physobj_' + suffix, value) @classmethod def _goods_col_expr(cls, suffix): deprecation_warn_goods_col(cls, suffix) return getattr(cls, 'physobj_' + suffix) def _goods_type_get(self): return self._goods_col_get('type') def _goods_type_set(self, value): self._goods_col_set('type', value) @classmethod def _goods_type_expr(cls): return cls._goods_col_expr('type') def _goods_properties_get(self): return self._goods_col_get('properties') def _goods_properties_set(self, value): self._goods_col_set('properties', value) @classmethod def _goods_properties_expr(cls): return cls._goods_col_expr('properties') def _goods_code_get(self): return self._goods_col_get('code') def _goods_code_set(self, value): self._goods_col_set('code', value) @classmethod def _goods_code_expr(cls): return cls._goods_col_expr('code') @classmethod def check_create_conditions(cls, state, dt_execution, location=None, **kwargs): """Ensure that ``location`` is indeed a container.""" super(Arrival, cls).check_create_conditions(state, dt_execution, **kwargs) if location is None or not location.is_container(): raise OperationContainerExpected( cls, "location field value {offender}", offender=location) def after_insert(self): PhysObj = self.registry.Wms.PhysObj self_props = self.physobj_properties if self_props is None: props = None else: props = PhysObj.Properties.create(**self_props) goods = PhysObj.insert(type=self.physobj_type, properties=props, code=self.physobj_code) PhysObj.Avatar.insert( obj=goods, location=self.location, outcome_of=self, state='present' if self.state == 'done' else 'future', dt_from=self.dt_execution, ) def execute_planned(self): self.outcome.update(state='present', dt_from=self.dt_execution) @classmethod def refine_with_trailing_unpack(cls, arrivals, pack_type, dt_pack_arrival=None, dt_unpack=None, pack_properties=None, pack_code=None): """Replace some Arrivals by the Arrival of a pack followed by an Unpack. This is useful in cases where it is impossible to predict ahead how incoming goods will actually be packed: the arrivals of individual items can first be planned, and once more is known about the form of delivery, this classmethod can replace some of them with the Arrival of a parcel and the subsequent Unpack. Together with :meth:`refine_with_trailing_move <anyblok_wms_base.core.operation.base.Operation.refine_with_trailing_move>`, this can handle the use case detailed in :ref:`improvement_operation_superseding`. :param arrivals: the Arrivals considered to be superseded by the Unpack. It is possible that only a subset of them are superseded, and conversely that the Unpack has more outcomes than the superseded Arrivals. For more details about the matching, see :meth:`Unpack.plan_for_outcomes <anyblok_wms_base.core.operation.unpack.Unpack.plan_for_outcomes>` :param pack_type: :attr:`anyblok_wms_base.core.PhysObj.main.PhysObj.type` of the expected pack. :param pack_properties: optional properties of the expected Pack. This optional parameter is of great importance in the case of parcels with variable contents, since it allows to set the ``contents`` Property. :param str pack_code: Optional code of the expected Pack. :param datetime dt_pack_arrival: expected date/time for the Arrival of the pack. If not specified, a default one will be computed. :param datetime dt_unpack: expected date/time for the Unpack Operation. If not specified, a default one will be computed. """ # noqa (unbreakable meth crosslink) for arr in arrivals: arr.check_alterable() if not arrivals: raise OperationError(cls, "got empty collection of arrivals " "to refine: {arrivals!r}", arrivals=arrivals) arr_iter = iter(arrivals) location = next(arr_iter).location if not all(arr.location == location for arr in arr_iter): raise OperationError(cls, "can't rewrite arrivals to different " "locations, got {nb_locs} different ones in " "{arrivals}", nb_locs=len(set(arr.location for arr in arrivals)), arrivals=arrivals) Wms = cls.registry.Wms Unpack = Wms.Operation.Unpack # check that the arrivals happen in the same locations if dt_pack_arrival is None: # max minimizes the number of date/time shifts to perform # upon later execution, min is more optimistic dt_pack_arrival = min(arr.dt_execution for arr in arrivals) pack_arr = cls.create(location=location, dt_execution=dt_pack_arrival, physobj_type=pack_type, physobj_properties=pack_properties, physobj_code=pack_code, state='planned') arrivals_outcomes = {arr.outcome: arr for arr in arrivals} unpack, attached_avatars = Unpack.plan_for_outcomes( pack_arr.outcomes, arrivals_outcomes.keys(), dt_execution=dt_unpack) for att in attached_avatars: arrivals_outcomes[att].delete() return unpack
class Type: """Types of PhysObj. For a full functional discussion, see :ref:`physobj_type`. """ id = Integer(label="Identifier", primary_key=True) """Primary key""" code = Text(label=u"Identifying code", index=True, unique=True, nullable=False) """Uniquely identifying code. As a convenience, and for sharing with other applications. """ label = Text(label=u"Label") behaviours = Jsonb() """ Flexible field to encode how represented objects interact with the system. Notably, PhysObj Types specify with this flexible field how various :class:`Operations <anyblok_wms_base.core.operation.base.Operation>` will treat the represented physical object. .. seealso:: :class:`Unpack <anyblok_wms_base.core.operation.unpack.Unpack>` for a complex example. But behaviours are in no means in one to one correspondence with Operation classes, nor do they need to be related to Operations. Any useful information that depends on the Type only is admissible to encode as a behaviour. The value is a key/value mapping (behaviour name/value). .. warning:: direct read access to a behaviour is to be avoided in favour of :meth:`get_behaviour` (see :ref:`improvement_goods_type_hierarchy`). This field is also open for downstream libraries and applications to make use of it to define some of their specific logic, but care must be taken not to conflict with the keys used by ``wms-core`` and other bloks (TODO introduce namespacing, then ? at least make a list available by using constants from an autodocumented module) """ properties = Jsonb(label="Properties") """PhysObj Types also have flexible properties. These are usually read from the PhysObj themselves (where they act as default values if not defined on the PhysObj), and are useful with generic Types, i.e., those that have children. Operations that handle Properties can do interesting things by using properties that actually come from Type information. """ parent = Many2One(model='Model.Wms.PhysObj.Type') """This field expresses the hierarchy of PhysObj Types.""" def __str__(self): return "(id={self.id}, code={self.code!r})".format(self=self) def __repr__(self): return "Wms.PhysObj.Type" + str(self) # TODO PERF cache ? def get_behaviour(self, name, default=None): """Get the value of the behaviour with given name. This method is the preferred way to access a given behaviour. It resolves the wished behaviour by looking it up within the :attr:`behaviours` :class:`dict`, and recursively on its parent. It also takes care of corner cases, such as when :attr:`behaviours` is ``None`` as a whole. """ behaviours = self.behaviours parent = self.parent if parent is None: parent_beh = _missing else: parent_beh = self.parent.get_behaviour(name, default=_missing) if behaviours is None: beh = _missing else: beh = behaviours.get(name, _missing) if beh is _missing: if parent_beh is _missing: return default return parent_beh if parent_beh is _missing: return beh return dict_merge(beh, parent_beh) def is_sub_type(self, gt): """True if ``self`` is a sub type of ``gt``, inclusively. TODO PERF the current implementation recurses over ancestors. A subsequent implementation could add caching and/or recursive SQL queries. """ if self == gt: return True parent = self.parent if parent is None: return False return parent.is_sub_type(gt) def is_container(self): return self.get_behaviour('container') is not None def get_property(self, k, default=None): """Read a property value recursively. If the current Type does not have the wished property key, but has a parent, then the lookup continues on the parent. """ props = self.properties val = _missing if props is None else props.get(k, _missing) if val is _missing: parent = self.parent if parent is None: return default return parent.get_property(k, default=default) return val def merged_properties(self): """Return this Type properties, merged with its parent.""" parent = self.parent properties = self.properties if parent is None: return properties if properties is not None else {} return dict_merge(properties, parent.merged_properties()) def has_property_values(self, mapping): return all(self.get_property(k, default=_missing) == v for k, v in mapping.items()) def has_property(self, name): if self.properties is not None and name in self.properties: return True parent = self.parent if parent is not None: return parent.has_property(name) return False def has_properties(self, wanted_props): if not wanted_props: return True properties = self.properties if properties is None: missing = wanted_props else: missing = (p for p in wanted_props if p not in properties) parent = self.parent if parent is None: for x in missing: # could be a generator, a list etc. return False return True return parent.has_properties(missing)
class Family: """Product.Family class """ FAMILY_CODE = None family_schema = None template_schema = None item_schema = None id = Integer(label="Identifier", primary_key=True) create_date = DateTime(default=datetime.now, nullable=False) edit_date = DateTime(default=datetime.now, nullable=False, auto_update=True) code = String(label="Family code", unique=True, nullable=False) name = String(label="Family name", size=256) description = Text(label="Family description") properties = Jsonb(label="Family properties") family_code = Selection(selections='get_family_codes') items = Function(fget="fget_items") @classmethod def get_family_codes(cls): return dict() def fget_items(self): """Returns a list of products instance from this family """ return self.registry.InstrumentedList( set([i for t in self.templates for i in t.items])) @classmethod def create(cls, **kwargs): data = kwargs.copy() if cls.family_schema: sch = cls.family_schema(registry=cls.registry) data = sch.load(kwargs) return cls.insert(**data) def amend(self, **kwargs): data = kwargs.copy() properties = data.pop('properties', dict()) if properties: for k, v in properties.items(): self.properties[k] = v if self.family_schema: sch = self.family_schema(registry=self.registry) data.update(dict(properties=self.properties)) data = sch.load(data) self.update(**data) return self @classmethod def query(cls, *args, **kwargs): query = super(Family, cls).query(*args, **kwargs) if cls.__registry_name__ != 'Model.Product.Family': query = query.filter(cls.family_code == cls.FAMILY_CODE) return query @classmethod def define_mapper_args(cls): mapper_args = super(Family, cls).define_mapper_args() if cls.__registry_name__ == 'Model.Product.Family': mapper_args.update({'polymorphic_on': cls.family_code}) mapper_args.update({'polymorphic_identity': cls.FAMILY_CODE}) return mapper_args def __str__(self): return "%s : %s" % (self.code, self.name) def __repr__(self): return "<Product.Family(code=%s, name=%s)>" % (self.code, self.name)
class Job: """The job is an execution of an instance of task""" STATUS_NEW = "new" STATUS_WAITING = "waiting" STATUS_RUNNING = "running" STATUS_FAILED = "failed" STATUS_DONE = "done" STATUSES = [ (STATUS_NEW, "New"), (STATUS_WAITING, "Waiting"), (STATUS_RUNNING, "Running"), (STATUS_FAILED, "Failed"), (STATUS_DONE, "Done"), ] uuid = UUID(primary_key=True, nullable=False, default=uuid1, binary=False) create_at = DateTime(default=datetime.now, nullable=False) update_at = DateTime(default=datetime.now, nullable=False, auto_update=True) run_at = DateTime() data = Json(nullable=False) status = Selection(selections=STATUSES, default=STATUS_NEW, nullable=False) task = Many2One(model=Declarations.Model.Dramatiq.Task, nullable=False) main_job = Many2One(model='Model.Dramatiq.Job', one2many="sub_jobs") error = Text() @actor_send() def run(cls, job_uuid=None): """dramatiq actor to execute a specific task""" autocommit = EnvironmentManager.get('job_autocommit', True) try: job = cls.query().filter(cls.uuid == job_uuid).one() job.status = cls.STATUS_RUNNING if autocommit: cls.registry.commit() # use to save the state job.task.run(job) if autocommit: cls.registry.commit() # use to save the state except Exception as e: logger.error(str(e)) cls.registry.rollback() job.status = cls.STATUS_FAILED job.error = str(e) if autocommit: cls.registry.commit() # use to save the state raise e def lock(self): """lock the job to be sure that only one thread execute the run_next""" Job = self.__class__ while True: try: Job.query().with_for_update(nowait=True).filter( Job.uuid == self.uuid).one() break except OperationalError: sleep(1) def call_main_job(self): """Call the main job if exist to do the next action of the main job""" if self.main_job: self.main_job.lock() self.main_job.task.run_next(self.main_job)
class Assembly(Mixin.WmsSingleOutcomeOperation, Operation): """Assembly/Pack Operation. This operation covers simple packing and assembly needs : those for which a single outcome is produced from the inputs, which must also be in the same Location. The behaviour is specified on the :attr:`outcome's PhysObj Type <outcome_type>` (see :attr:`Assembly specification <specification>`); it amounts to describe the expected inputs, and how to build the Properties of the outcome (see :meth:`outcome_properties`). All Property related parameters in the specification are bound to the state to be reached or passed through. A given Type can be assembled in different ways: the :attr:`Assembly specification <specification>` is chosen within the ``assembly`` Type behaviour according to the value of the :attr:`name` field. :meth:`Specific hooks <specific_outcome_properties>` are available for use-cases that aren't covered by the specification format (example: to forward Properties with non uniform values from the inputs to the outcome). The :attr:`name` is the main dispatch key for these hooks, which don't depend on the :attr:`outcome's Good Type <outcome_type>`. """ TYPE = 'wms_assembly' id = Integer(label="Identifier", primary_key=True, autoincrement=False, foreign_key=Operation.use('id').options(ondelete='cascade')) outcome_type = Many2One(model='Model.Wms.PhysObj.Type', nullable=False) """The :class:`PhysObj Type <anyblok_wms_base.core.physobj.Type>` to produce. """ name = Text(nullable=False, default=DEFAULT_ASSEMBLY_NAME) """The name of the assembly, to be looked up in behaviour. This field has a default value to accomodate the common case where there's only one assembly for the given :attr:`outcome_type`. .. note:: the default value is not enforced before flush, this can prove out to be really inconvenient for downstream code. TODO apply the default value in :meth:`check_create_conditions` for convenience ? """ parameters = Jsonb() """Extra parameters specific to this instance. This :class:`dict` is merged with the parameters from the :attr:`outcome_type` behaviour to build the final :attr:`specification`. """ match = Jsonb() """Field use to store the result of inputs matching Assembly Operations match their actual inputs (set at creation) with the ``inputs`` part of :attr:`specification`. This field is used to store the result, so that it's available for further logic (for instance in the :meth:`property setting hooks <specific_outcome_properties>`). This field's value is either ``None`` (before matching) or a list of lists: for each of the inputs specification, respecting ordering, the list of ids of the matching Avatars. """ @property def extra_inputs(self): matched = set(av_id for m in self.match for av_id in m) return (av for av in self.inputs if av.id not in matched) def specific_repr(self): return ("outcome_type={self.outcome_type!r}, " "name={self.name!r}").format(self=self) @classmethod def check_create_conditions(cls, state, dt_execution, inputs=None, outcome_type=None, name=None, **kwargs): super(Assembly, cls).check_create_conditions(state, dt_execution, inputs=inputs, **kwargs) behaviour = outcome_type.behaviours.get('assembly') if behaviour is None: raise OperationError( cls, "No assembly specified for type {outcome_type!r}", outcome_type=outcome_type) spec = behaviour.get(name) if spec is None: raise OperationError( cls, "No such assembly: {name!r} for type {outcome_type!r}", name=name, outcome_type=outcome_type) cls.check_inputs_locations(inputs, outcome_type=outcome_type, name=name) @classmethod def check_inputs_locations(cls, inputs, **kwargs): """Check consistency of inputs locations. This method is singled out for easy override by applicative code. Indeed applicative code can consider that the inputs may be in a bunch of related locations, with a well defined output location. In particular, it receives keyword arguments ``kwargs`` that we don't need in this default implementation. """ loc = inputs[0].location if any(inp.location != loc for inp in inputs[1:]): raise OperationInputsError( cls, "Inputs {inputs} are in different Locations: {locations!r}", inputs=inputs, # in the passing case, building a set would have been # useless overhead locations=set(inp.location for inp in inputs)) def extract_property(self, extracted, goods, prop, exc_details=None): """Extract the wished property from goods, forbidding conflicts. :param str prop: Property name :param dict extracted: the specified property value is read from `goods` and stored there, if not already present with a different value :param exc_details: If specified the index and value of the input specifification this comes from, for exception raising (the exception will assume that the conflict arises in the global forward_properties directive). :raises: AssemblyPropertyConflict """ candidate_value = goods.get_property(prop, default=_missing) if candidate_value is _missing: return try: existing = extracted[prop] except KeyError: extracted[prop] = candidate_value else: if existing != candidate_value: raise AssemblyPropertyConflict(self, exc_details, prop, existing, candidate_value) def forward_properties(self, state, for_creation=False): """Forward properties from the inputs to the outcome This is done according to the global specification :param state: the Assembly state that we are reaching. :param bool for_creation: if ``True``, means that this is part of the creation process, i.e, there's no previous state. :raises: AssemblyPropertyConflict if forwarding properties changes an already set value. """ spec = self.specification Avatar = self.registry.Wms.PhysObj.Avatar from_state = None if for_creation else self.state glob_fwd = merge_state_sub_parameters(spec.get('inputs_properties'), from_state, state, ('forward', 'set')) inputs_spec = spec.get('inputs', ()) forwarded = {} for i, (match_item, input_spec) in enumerate(zip(self.match, inputs_spec)): input_fwd = merge_state_sub_parameters( input_spec.get('properties'), from_state, state, ('forward', 'set')) for av_id in match_item: goods = Avatar.query().get(av_id).obj for fp in itertools.chain(input_fwd, glob_fwd): self.extract_property(forwarded, goods, fp, exc_details=(i, input_spec)) for extra in self.extra_inputs: for fp in glob_fwd: self.extract_property(forwarded, extra.obj, fp) return forwarded def check_inputs_properties(self, state, for_creation=False): """Apply global and per input Property requirements according to state. All property requirements between the current state (or None if we are at creation) and the wished state are checked. :param state: the state that the Assembly is about to reach :param for_creation: if True, the current value of the :attr:`state` field is ignored, and all states up to the wished state are considered. :raises: :class:`AssemblyWrongInputProperties` """ spec = self.specification global_props_spec = spec.get('inputs_properties') if global_props_spec is None: return req_props, req_prop_values = merge_state_sub_parameters( global_props_spec, None if for_creation else self.state, state, ('required', 'set'), ('required_values', 'dict'), ) for avatar in self.inputs: goods = avatar.obj if (not goods.has_properties(req_props) or not goods.has_property_values(req_prop_values)): raise AssemblyWrongInputProperties(self, avatar, req_props, req_prop_values) Avatar = self.registry.Wms.PhysObj.Avatar for i, (match_item, input_spec) in enumerate( zip(self.match, spec.get('inputs', ()))): req_props, req_prop_values = merge_state_sub_parameters( input_spec.get('properties'), None if for_creation else self.state, state, ('required', 'set'), ('required_values', 'dict'), ) for av_id in match_item: goods = Avatar.query().get(av_id).obj if (not goods.has_properties(req_props) or not goods.has_property_values(req_prop_values)): raise AssemblyWrongInputProperties(self, avatar, req_props, req_prop_values, spec_item=(i, input_spec)) def match_inputs(self, state, for_creation=False): """Compare input Avatars to specification and apply Properties rules. :param state: the state for which to perform the matching :return: extra_inputs, an iterable of inputs that are left once all input specifications are met. :raises: :class:`anyblok_wms_base.exceptions.AssemblyInputNotMatched`, :class:`anyblok_wms_base.exceptions.AssemblyForbiddenExtraInputs` """ # let' stress that the incoming ordering shouldn't matter # from this method's point of view. And indeed, only in tests can # it come from the will of a caller. In reality, it'll be due to # factors that are random wrt the specification. inputs = set(self.inputs) spec = self.specification PhysObjType = self.registry.Wms.PhysObj.Type types_by_code = dict() from_state = None if for_creation else self.state match = self.match = [] for i, expected in enumerate(spec['inputs']): match_item = [] match.append(match_item) req_props, req_prop_values = merge_state_sub_parameters( expected.get('properties'), from_state, state, ('required', 'set'), ('required_values', 'dict'), ) type_code = expected['type'] expected_id = expected.get('id') expected_code = expected.get('code') gtype = types_by_code.get(type_code) if gtype is None: gtype = PhysObjType.query().filter_by(code=type_code).one() types_by_code[type_code] = gtype for _ in range(expected['quantity']): for candidate in inputs: goods = candidate.obj if (not goods.has_type(gtype) or not goods.has_properties(req_props) or not goods.has_property_values(req_prop_values)): continue if expected_id is not None and goods.id != expected_id: continue if (expected_code is not None and goods.code != expected_code): continue inputs.discard(candidate) match_item.append(candidate.id) break else: raise AssemblyInputNotMatched(self, (expected, i), from_state=from_state, to_state=state) if inputs and not spec.get('allow_extra_inputs'): raise AssemblyExtraInputs(self, inputs) return inputs # TODO PERF cache ? @property def specification(self): """The Assembly specification The Assembly specification is merged from two sources: - within the ``assembly`` part of the behaviour field of :attr:`outcome_type`, the subdict associated with :attr:`name`; - optionally, the instance specific :attr:`parameters`. Here's an example, for an Assembly whose :attr:`name` is ``'soldering'``, also displaying most standard parameters. Individual aspects of these parameters are discussed in detail afterwards, as well as the merging logic. On the :attr:`outcome_type`:: behaviours = { … 'assembly': { 'soldering': { 'outcome_properties': { 'planned': {'built_here': ['const', True]}, 'started': {'spam': ['const', 'eggs']}, 'done': {'serial': ['sequence', 'SOLDERINGS']}, }, 'inputs': [ {'type': 'GT1', 'quantity': 1, 'properties': { 'planned': { 'required': ['x'], }, 'started': { 'required': ['foo'], 'required_values': {'x': True}, 'requirements': 'match', # default is 'check' }, 'done': { 'forward': ['foo', 'bar'], 'requirements': 'check', } }, {'type': 'GT2', 'quantity': 2 }, {'type': 'GT3', 'quantity': 1, } ], 'inputs_spec_type': { 'planned': 'check', # default is 'match' 'started': 'match', # default is 'check' for # 'started' and 'done' states }, 'for_contents': ['all', 'descriptions'], 'allow_extra_inputs': True, 'inputs_properties': { 'planned': { 'required': … 'required_values': … 'forward': … }, 'started': … 'done': … } } … } } On the Assembly instance:: parameters = { 'outcome_properties': { 'started': {'life': ['const', 'brian']} }, 'inputs': [ {}, {'code': 'ABC'}, {'id': 1234}, ] 'inputs_properties': { 'planned': { 'forward': ['foo', 'bar'], }, }, } .. note:: Non standard parameters can be specified, for use in :meth:`Specific hooks <specific_outcome_properties>`. **Inputs** The ``inputs`` part of the specification is primarily a list of expected inputs, with various criteria (PhysObj Type, quantity, PhysObj code and Properties). Besides requiring them in the first place, these criteria are also used to :meth:`qualify (match) the inputs <match_inputs>` (note that Operation inputs are unordered in general, while this ``inputs`` parameter is). This spares the calling code the need to keep track of that qualification after selecting the goods in the first place. The result of that matching is stored in the :attr:`match` field, is kept for later Assembly state changes and can be used by application code, e.g., for operator display purposes. Assemblies can also have extra inputs, according to the value of the ``allow_extra_inputs`` boolean parameter. This is especially useful for generic packing scenarios. Having both specified and extra inputs is supported (imagine packing client parcels with specified wrapping, a greetings card plus variable contents). The ``type`` criterion applies the PhysObj Type hierarchy, hence it's possible to create a generic packing Assembly for a whole family of PhysObj Types (e.g., adult trekking shoes). Similarly, all Property requirements take the properties inherited from the PhysObj Types into account. **Global Property specifications** The Assembly :attr:`specification` can have the following key/value pairs: * ``outcome_properties``: a dict whose keys are Assembly states, and values are dicts of Properties to set on the outcome; the values are pairs ``(TYPE, EXPRESSION)``, evaluated by passing as positional arguments to :meth:`eval_typed_expr`. * ``inputs_properties``: a dict whose keys are Assembly states, and values are themselves dicts with key/values: + required: list of properties that must be present on all inputs while reaching or passing through the given Assembly state, whatever their values + required_values: dict of Property key/value pairs that all inputs must bear while reaching or passing through the given Assembly state. + forward: list of properties to forward to the outcome while reaching or passing through the given Assembly state. **Per input Property checking, matching and forwarding** The same parameters as in ``inputs_properties`` can also be specified inside each :class:`dict` that form the ``inputs`` list of the :meth:`Assembly specification <spec>`), as the ``properties`` sub parameter. In that case, the Property requirements are used either as matching criteria on the inputs, or as a check on already matched PhysObj, according to the value of the ``inputs_spec_type`` parameter (default is ``'match'`` in the ``planned`` Assembly state, and ``'check'`` in the other states). Example:: 'inputs_spec_type': { 'started': 'match', # default is 'check' for # 'started' and 'done' states }, 'inputs': [ {'type': 'GT1', 'quantity': 1, 'properties': { 'planned': {'required': ['x']}, 'started': { 'required_values': {'x': True}, }, 'done': { 'forward': ['foo', 'bar'], }, … ] During matching, per input specifications are applied in order, but remember that the ordering of ``self.inputs`` itself is to be considered random. In case ``inputs_spec_type`` is ``'check'``, the checking is done on the PhysObj matched by previous states, thus avoiding a potentially costly rematching. In the above example, matching will be performed in the ``'planned'`` and ``'started'`` states, but a simple check will be done if going from the ``started`` to the ``done`` state. It is therefore possible to plan an Assembly with partial information about its inputs (waiting for some Observation, or a previous Assembly to be done), and to refine that information, which can be displayed to operators, or have consequences on the Properties of the outcome, at each state change. In many cases, rematching the inputs for all state changes is unnecessary. That's why, to avoid paying the computational cost three times, the default value is ``'check'`` for the ``done`` and ``started`` states. The result of matching is stored in the :attr:`match` field. In all cases, if a given Property is to be forwarded from several inputs to the outcome and its values on these inputs aren't equal, :class:`AssemblyPropertyConflict` will be raised. **Passing through states** Following the general expectations about states of Operations, if an Assembly is created directly in the ``done`` state, it will apply the ``outcome_properties`` for the ``planned``, ``started`` and ``done`` states. Also, the matching and checks of input Properties for the ``planned``, ``started`` and ``done`` state will be performed, in that order. In other words, it behaves exactly as if it had been first planned, then started, and finally executed. Similarly, if a planned Assembly is executed (without being started first), then outcome Properties, matches and checks related to the ``started`` state are performed before those of the ``done`` state. **for_contents: building the contents Property** The outcome of the Assembly bears the special :data:`contents property <anyblok_wms_base.constants.CONTENTS_PROPERTY>`, also used by :class:`Operation.Unpack <anyblok_wms_base.core.operation.unpack.Unpack>`. This makes the reversal of Assemblies by Unpacks possible (with care in the behaviour specifications), and also can be used by applicative code to use information about the inputs even after the Assembly is done. The building of the contents Property is controlled by the ``for_contents`` parameter, which itself is either ``None`` or a pair of strings, whose first element indicates which inputs to list, and the second how to list them. The default value of ``for_contents`` is :attr:`DEFAULT_FOR_CONTENTS`. If ``for_contents`` is ``None``, no contents Property will be set on the outcome. Use this if it's unnecessary pollution, for instance if it is custom set by specific hooks anyway, or if no Unpack for disassembly is ever to be wished. *for_contents: possible values of first element:* * ``'all'``: all inputs will be listed * ``'extra'``: only the actual inputs that aren't specified in the behaviour will be listed. This is useful in cases where the Unpack behaviour already takes the specified ones into account. Hence, the variable parts of Assembly and Unpack are consistent. *for_contents: possible values of second element:* * ``'descriptions'``: include PhysObj' Types, those Properties that aren't recoverable by an Unpack from the Assembly outcome, together with appropriate ``forward_properties`` for those who are (TODO except those that come from a global ``forward`` in the Assembly specification) * ``'records'``: same as ``descriptions``, but also includes the record ids, so that an Unpack following the Assembly would not give rise to new PhysObj records, but would reuse the existing ones, hence keep the promise that the PhysObj records are meant to track the "sameness" of the physical objects. **Merging logic** All sub parameters are merged according to the expected type. For instance, ``required`` and ``forward`` in the various Property parameters are merged as a :class:`set`. As displayed in the example above, if there's an ``inputs`` part in :attr:`parameters`, it must be made of exactly the same number of ``dicts`` as within the :attr:`outcome_type` behaviour. More precisely, these lists are merged using the :func:`zip` Python builtin, which results in a truncation to the shortest. Of course, not having an ``inputs`` part in :attr:`parameters` does *not* result in empty ``inputs``. .. seealso:: :attr:`SPEC_LIST_MERGE` and :func:`dict_merge <anyblok_wms_base.utils.dict_merge>`. **Specific hooks** While already powerful, the Property manipulations described above are not expected to fit all situations. This is obviously true for the rule forbidding the forwarding of values that aren't equal for all relevant inputs: in some use cases, one would want to take the minimum of theses values, sum them, keep them as a list, or all of these at once… On the other hand, the specification is already complicated enough as it is. Therefore, the core will stick to these still relatively simple primitives, but will also provide the means to perform custom logic, through :meth:`assembly-specific hooks <specific_outcome_properties>` """ type_spec = self.outcome_type.get_behaviour('assembly')[self.name] if self.parameters is None: return type_spec return dict_merge(self.parameters, type_spec, list_merge=self.SPEC_LIST_MERGE) SPEC_LIST_MERGE = dict(inputs_properties={ '*': dict( required=('set', None), forward=('set', None), ), }, inputs=('zip', { '*': dict(properties={ '*': dict( required=('set', None), forward=('set', None), ), }, ), })) DEFAULT_FOR_CONTENTS = ('extra', 'records') """Default value of the ``for_contents`` part of specification. See :meth:`outcome_properties` for the meaning of the values. """ def outcome_properties(self, state, for_creation=False): """Method responsible for properties on the outcome. For the given state that is been reached, this method returns a dict of Properties to apply on the outcome. :param state: The Assembly state that we are reaching. :param bool for_creation: if ``True``, means that this is part of the creation process, i.e, there's no previous state. :rtype: :class:`Model.Wms.PhysObj.Properties <anyblok_wms_base.core.physobj.Properties>` :raises: :class:`AssemblyInputNotMatched` if one of the :attr:`input specifications <specification>` is not matched by ``self.inputs``, :class:`AssemblyPropertyConflict` in case of conflicting values for the outcome. The :meth:`specific hook <specific_outcome_properties>` gets called at the very end of the process, giving it higher precedence than any other source of Properties. """ spec = self.specification assembled_props = self.forward_properties(state, for_creation=for_creation) contents = self.build_contents(assembled_props) if contents: assembled_props[CONTENTS_PROPERTY] = contents prop_exprs = merge_state_parameter( spec.get('outcome_properties'), None if for_creation else self.state, state, 'dict') assembled_props.update( (k, self.eval_typed_expr(*v)) for k, v in prop_exprs.items()) assembled_props.update( self.specific_outcome_properties(assembled_props, state, for_creation=for_creation)) return assembled_props props_hook_fmt = "outcome_properties_{name}" def specific_outcome_properties(self, assembled_props, state, for_creation=False): """Hook for per-name specific update of Properties on outcome. At the time of Operation creation or execution, this calls a specific method whose name is derived from the :attr:`name` field, :attr:`by this format <props_hook_fmt>`, if that method exists. Applicative code is meant to override the present Model to provide the specific method. The signature to implement is identical to the present one: :param state: The Assembly state that we are reaching. :param dict assembled_props: a :class:`dict` of already prepared Properties for this state. :param bool for_creation: if ``True``, means that this is part of the creation process, i.e, there's no previous state. :return: the properties to set or update :rtype: any iterable that can be passed to :meth:`dict.update`. """ meth = getattr(self, self.props_hook_fmt.format(name=self.name), None) if meth is None: return () return meth(assembled_props, state, for_creation=for_creation) def build_contents(self, forwarded_props): """Construction of the ``contents`` property This is part of :meth`outcome_properties` """ contents_spec = self.specification.get('for_contents', self.DEFAULT_FOR_CONTENTS) if contents_spec is None: return what, how = contents_spec if what == 'extra': for_unpack = self.extra_inputs elif what == 'all': for_unpack = self.inputs contents = [] # sorting here and later is for tests reproducibility for avatar in sorted(for_unpack, key=lambda av: av.id): goods = avatar.obj props = goods.properties unpack_outcome = dict( type=goods.type.code, quantity=1, # TODO hook for wms_quantity ) if props is not None: unpack_outcome_fwd = [] for k, v in props.as_dict().items(): if k in forwarded_props: unpack_outcome_fwd.append(k) else: unpack_outcome.setdefault('properties', {})[k] = v unpack_outcome_fwd.sort() if unpack_outcome_fwd: unpack_outcome['forward_properties'] = unpack_outcome_fwd contents.append(unpack_outcome) if how == 'records': # Adding physobj id so that a forthcoming unpack # would produce the very same physical objects. # TODO this *must* be discarded in case of Departures with # EDI, and maybe some other ones. How to do that cleanly and # efficiently ? unpack_outcome['local_physobj_ids'] = [goods.id] return contents def check_match_inputs(self, to_state, for_creation=False): """Check or match inputs according to specification. :rtype bool: :return: ``True`` iff a match has been performed """ spec = self.specification.get('inputs_spec_type') if spec is None: spec = {} spec.setdefault('planned', 'match') cm = merge_state_parameter(spec, None if for_creation else self.state, to_state, 'check_match') (self.match_inputs if cm.is_match else self.check_inputs_properties)( to_state, for_creation=for_creation) return cm.is_match def after_insert(self): state = self.state outcome_state = 'present' if state == 'done' else 'future' dt_exec = self.dt_execution input_upd = dict(dt_until=dt_exec) if state == 'done': input_upd.update(state='past') # TODO PERF bulk update ? for inp in self.inputs: inp.update(**input_upd) self.check_match_inputs(state, for_creation=True) PhysObj = self.registry.Wms.PhysObj PhysObj.Avatar.insert(obj=PhysObj.insert( type=self.outcome_type, properties=PhysObj.Properties.create( **self.outcome_properties(state, for_creation=True))), location=self.outcome_location(), outcome_of=self, state=outcome_state, dt_from=dt_exec, dt_until=None) def outcome_location(self): """Find where the new assembled physical object should appear. In this default implementation, we insist on the inputs being in a common location (see :meth:`check_inputs_locations` and we decide this is the location of the outcome. Applicative code is welcomed to refine this by overriding this method. """ return next(iter(self.inputs)).location def execute_planned(self): """Check or rematch inputs, update properties and states. """ self.check_match_inputs('done') # TODO PERF direct update query would probably be faster for inp in self.inputs: inp.state = 'past' outcome = self.outcome outcome.obj.update_properties(self.outcome_properties('done')) outcome.state = 'present' def eval_typed_expr(self, etype, expr): """Evaluate a typed expression. :param expr: the expression to evaluate :param etype: the type or ``expr``. *Possible values for etype* * ``'const'``: ``expr`` is considered to be a constant and gets returned directly. Any Python value that is JSON serializable is admissible. * ``'sequence'``: ``expr`` must be the code of a ``Model.System.Sequence`` instance. The return value is the formatted value of that sequence, after incrementation. """ if etype == 'const': return expr elif etype == 'sequence': return self.registry.System.Sequence.nextvalBy(code=expr.strip()) raise UnknownExpressionType(self, etype, expr) def is_reversible(self): """Assembly can be reverted by Unpack. """ return self.outcome_type.get_behaviour("unpack") is not None def plan_revert_single(self, dt_execution, follows=()): unpack_inputs = [out for op in follows for out in op.outcomes] # self.outcomes has actually only those outcomes that aren't inputs # of downstream operations # TODO maybe change that for API clarity unpack_inputs.extend(self.outcomes) return self.registry.Wms.Operation.Unpack.create( dt_execution=dt_execution, inputs=unpack_inputs) def input_location_altered(self): """Being in-place, an Assembly must propagate changes of locations. Also it should recheck that all inputs are in the same place. """ self.check_inputs_locations(self.inputs, name=self.name, outcome_type=self.outcome_type, parameters=self.parameters) outcome = self.outcome outcome.location = self.inputs[0].location for follower in self.followers: follower.input_location_altered()
class ReductionCard: code = Text(nullable=False, primary_key=True) name = Text(nullable=False)
class Arrival(Operation): """Operation to describe physical arrival of goods in some location. Arrivals store data about the expected or arrived physical objects: properties, code… These are copied over to the corresponding PhysObj records in all cases and stay inert after the fact. In case the Arrival state is ``planned``, these are obviously only unchecked values, but in case it is ``done``, the actual meaning can depend on the application: - maybe the application won't use the ``planned`` state at all, and will only create Arrival after checking them, - maybe the application will inspect the Arrival properties, compare them to reality, update them on the created PhysObj and cancel downstream operations if needed, before calling :meth:`execute`. TODO maybe provide higher level facilities for validation scenarios. """ TYPE = 'wms_arrival' id = Integer(label="Identifier", primary_key=True, autoincrement=False, foreign_key=Operation.use('id').options(ondelete='cascade')) """Primary key.""" goods_type = Many2One(model='Model.Wms.PhysObj.Type') """Expected :class:`PhysObj Type <anyblok_wms_base.core.physobj.Type>`. """ goods_properties = Jsonb(label="Properties of arrived PhysObj") """Expected :class:`Properties <anyblok_wms_base.core.physobj.Properties>`. They are copied over to the newly created :class:`PhysObj <anyblok_wms_base.core.physobj.PhysObj>` as soon as the Arrival is planned, and aren't updated by :meth:`execute`. Matching them with reality is the concern of separate validation processes, and this field can serve for later assessments after the fact. """ goods_code = Text(label="Code to set on arrived PhysObj") """Expected :attr:`PhysObj code <anyblok_wms_base.core.physobj.PhysObj.code>`. Can be ``None`` in case the arrival process issues the code only at the time of actual arrival. """ location = Many2One(model='Model.Wms.PhysObj') """Will be the location of the initial Avatar.""" inputs_number = 0 """This Operation is a purely creative one.""" def specific_repr(self): return ("goods_type={self.goods_type!r}, " "location={self.location!r}").format(self=self) @classmethod def check_create_conditions(cls, state, dt_execution, location=None, **kwargs): """Ensure that ``location`` is indeed a container.""" super(Arrival, cls).check_create_conditions(state, dt_execution, **kwargs) if location is None or not location.is_container(): raise OperationContainerExpected(cls, "location field value {offender}", offender=location) def after_insert(self): PhysObj = self.registry.Wms.PhysObj self_props = self.goods_properties if self_props is None: props = None else: props = PhysObj.Properties.create(**self_props) goods = PhysObj.insert(type=self.goods_type, properties=props, code=self.goods_code) PhysObj.Avatar.insert( obj=goods, location=self.location, reason=self, state='present' if self.state == 'done' else 'future', dt_from=self.dt_execution, ) def execute_planned(self): Avatar = self.registry.Wms.PhysObj.Avatar Avatar.query().filter(Avatar.reason == self).one().update( state='present', dt_from=self.dt_execution)
class Space: id = Integer(primary_key=True) label = String(nullable=False) icon = String() description = Text() type = Selection(selections=[('client', 'Client'), ('space', 'Space')], default='space', nullable=False) order = Integer(nullable=False, default=100) category = Many2One(model="Model.Web.Space.Category", nullable=False, one2many="spaces", foreign_key_options={'ondelete': 'cascade'}) default_menu = Many2One(model='Model.Web.Menu') default_action = Many2One(model='Model.Web.Action') @classmethod def getSpaces(cls, res, params): values = [] value = { 'label': '', 'image': { 'type': 'font-icon', 'value': '' }, } Category = cls.registry.Web.Space.Category for c in Category.query().order_by(Category.order).all(): query = cls.query().filter(cls.category == c).order_by(cls.order) if query.count(): categ = { 'id': str(c.id), 'label': c.label, 'image': { 'type': 'font-icon', 'value': c.icon }, 'values': [], } values.append(categ) for s in query.all(): categ['values'].append({ 'id': str(s.id), 'label': s.label, 'description': s.description, 'type': s.type, 'image': { 'type': 'font-icon', 'value': s.icon }, }) if 'route_params' in params and params['route_params'].get('spaceId'): space = cls.query().get(int(params['route_params']['spaceId'])) value['label'] = space.label value['image']['value'] = space.icon else: value['label'] = values[0]['values'][0]['label'] value['image'] = values[0]['values'][0]['image'] res.append({ 'type': 'UPDATE_ROUTE', 'path': 'space/' + values[0]['values'][0]['id'], }) res.append({ 'type': 'UPDATE_LEFT_MENU', 'value': value, 'values': values, }) def getLeftMenus(self): Menu = self.registry.Web.Menu return Menu.getMenusForSpace(self, 'left') def getRightMenus(self): Menu = self.registry.Web.Menu return Menu.getMenusForSpace(self, 'right')
class Jinja(Mixin.WkHtml2Pdf, Attachment.Template): """Jinja templating""" TYPE = TYPE uuid = UUID(primary_key=True, nullable=False, binary=False, foreign_key=Attachment.Template.use('uuid').options( ondelete='cascade')) jinja_paths = Text(nullable=False) contenttype = Selection(selections={ 'text/html': 'HTML', 'application/pdf': 'PDF', }, default='application/pdf', nullable=False) options = Json(default={}, nullable=False) wkhtml2pdf_configuration = Many2One( model=Declarations.Model.Attachment.WkHtml2Pdf, nullable=True) def check_flush_validity(self): super(Jinja, self).check_flush_validity() if self.contenttype == 'application/pdf': if not self.wkhtml2pdf_configuration: raise TemplateJinjaException( "No WkHtml2Pdf configuration for %r" % self) def update_document(self, document, file_, data): super(Jinja, self).update_document(document, file_, data) document.contenttype = self.contenttype def render(self, data): if self.contenttype == 'text/html': return self.render_html(data) return self.render_pdf(data) def render_html(self, data): jinja_paths = [] if self.jinja_paths: for jinja_path in self.jinja_paths.split(','): jinja_path = format_path(jinja_path.strip()) if not os.path.isdir(jinja_path): raise TemplateJinjaException("%r must be a folder" % jinja_path) jinja_paths.append(jinja_path) jinja_env = SandboxedEnvironment( loader=jinja2.FileSystemLoader(jinja_paths), undefined=jinja2.StrictUndefined, ) parser = self.get_parser() if not hasattr(parser, 'serialize_jinja_options'): raise TemplateJinjaException( ("The parser %r must declare a method " "'serialize_jinja_options' for %r") % (parser, self)) options = self.get_parser().serialize_jinja_options(self.options) return jinja_env.from_string(self.get_template()).render( data=data, str=str, **options).encode('utf-8') def render_pdf(self, data): html_content = self.render_html(data) return self.wkhtml2pdf(html_content)
class Observation(Mixin.WmsSingleInputOperation, Mixin.WmsSingleOutcomeOperation, Mixin.WmsInPlaceOperation, Operation): """Operation to change PhysObj Properties. Besides being commonly associated with some measurement or assessment being done in reality, this Operation is the preferred way to alter the Properties of a physical object (PhysObj), in a traceable, reversible way. For now, only whole Property values are supported, i.e., for :class:`dict`-valued Properties, we can't observe the value of just a subkey. Observations support oblivion in the standard way, by reverting the Properties of the physical object to their prior values. This is consistent with the general rule that oblivion is to be used in cases where the database values themselves are irrelevant (for instance if the Observation was for the wrong physical object). On the other hand, reverting an Observation is semantically more complicated. See :meth:`plan_revert_single` for more details. """ TYPE = 'wms_observation' id = Integer(label="Identifier", primary_key=True, autoincrement=False, foreign_key=Operation.use('id').options(ondelete='cascade')) """Primary key.""" name = Text(nullable=True) """The name of the observation, to identity quickly an observation This field is optional and depends on the developer's needs. """ observed_properties = Jsonb() """Result of the Observation. It is forbidden to fill this field for a planned Observation: this is thought to be contradictory with the idea of actually observing something. In the case of planned Observations, this field should be updated right before execution. TODO: rethink this, wouldn't it make sense actually to record some expected results, so that dependent Operations could be themselves planned ? This doesn't seem to be that useful though, since e.g., Assemblies can check different Properties during their different states. On the other hand, it could make sense in cases where the result is very often the same to prefill it. Another case would be for reversals: prefill the result. """ previous_properties = Jsonb() """Used in particular during oblivion. This records key/value pairs of *direct* properties before execution of the Observation TODO and maybe reversal """ required_properties = Jsonb() """List of Properties that must be present in :attr:`observed_properties` In other words, these are Properties the Observation must update. At execution time, the contents of :attr:`observed_properties` is examined and an error is raised if one of these properties is missing. """ def after_insert(self): inp_av = self.input physobj = inp_av.obj state = self.state if state != 'done' and self.observed_properties is not None: raise ObservationError( self, "Forbidden to create a planned or just started " "Observation together with its results (this " "would mean one knows result in advance).") dt_exec = self.dt_execution inp_av.update(dt_until=dt_exec, state='past') physobj.Avatar.insert( obj=physobj, state='future' if state == 'planned' else 'present', outcome_of=self, location=self.input.location, dt_from=dt_exec, dt_until=None) if self.state == 'done': self.apply_properties() def apply_properties(self): """Save previous properties, then apply :attr:`observed_properties`` The previous *direct* properties of the physical object get saved in :attr:`previous_properties`, then the key/value pairs of :attr:`observed_properties` are applied. In case an observed value is a new one, ie, there wasn't any *direct* key of that name before, it ends up simply to be absent from the :`previous_properties` dict (even if there was an inherited one). This allows for easy restoration of previous values in :meth:`obliviate_single`. """ observed = self.observed_properties if observed is None: raise ObservationError( self, "Can't execute with no observed properties") required = self.required_properties if required: if not set(required).issubset(observed): raise ObservationError( self, "observed_properties {observed!r} is missing " "some of the required {required!r} ", observed=set(observed), required=required) phobj = self.input.obj prev = {} existing = phobj.properties if existing: for k, v in observed.items(): prev_val = existing.get(k, _missing) if prev_val is _missing: continue prev[k] = prev_val self.previous_properties = prev phobj.update_properties(observed) def execute_planned(self): self.apply_properties() dt_exec = self.dt_execution self.input.update(dt_until=dt_exec, state='past') self.outcome.update(dt_from=dt_exec, state='present') def obliviate_single(self): """Restore the Properties as they were before execution. """ phobj = self.input.obj for k in self.observed_properties: old_val = self.previous_properties.get(k, _missing) if old_val is _missing: del phobj.properties[k] else: phobj.properties[k] = old_val super(Observation, self).obliviate_single() def is_reversible(self): """Observations are always reversible. See :meth:`plan_revert_single` for a full discussion of this. """ return True def plan_revert_single(self, dt_execution, follows=()): """Reverting an Observation is a no-op. For the time being, we find it sufficient to consider that Observations are really meant to find some information about the physical object (e.g a weight, a working condition). Therefore, reverting them doesn't make sense, while we don't want to consider them fully irreversible, so that a chain of Operations involving an Operation can still be reversed. The solution to this dilemma for the time being is that reverting an Observation does nothing. For instance, if an Observation follows some other Operation and has itself a follower, the outcome of the reversal of the follower is fed directly to the reversal of the previous operation. We may add more variants (reversal via a prefilled Observation etc.) in the future. """ if not follows: # of course the Observation is not its own reversal, but # this tells reversals of upstream Operations to follow the # Observation return self # An Observation has at most a single follower, to make its # reversal trivial, it's enough to return the reversal of that # single follower return next(iter(follows))