예제 #1
0
            class Person:

                id = Integer(primary_key=True)
                address_1 = Many2One(model=Model.Address)
                address_2 = Many2One(model=Model.Address)
예제 #2
0
 class Test2:
     id = Integer(primary_key=True)
     test = Many2One(model=Model.Test, one2many='test2')
예제 #3
0
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)
예제 #4
0
            class TestM2O:

                id = Integer(primary_key=True)
                test = Many2One(model=Model.Test, nullable=True,
                                foreign_key_options={'ondelete': 'cascade'})
예제 #5
0
class Location:
    """A stock location.

    TODO add location types to encode behavioral properties (internal, EDI,
    stuff like size ?)
    """
    id = Integer(label="Identifier", primary_key=True)
    code = String(label="Identifying code")  # TODO index
    label = String(label="Label")
    parent = Many2One(label="Parent location", model='Model.Wms.Location')

    def __str__(self):
        return ("(id={self.id}, code={self.code!r}, "
                "label={self.label!r})".format(self=self))

    def __repr__(self):
        return "Wms.Location" + str(self)

    def quantity(self, goods_type, additional_states=None, at_datetime=None):
        """Return the full quantity in location for the given type.

        :param additional_states:
            Optionally, states of the Goods Avatar to take into account
            in addition to the ``present`` state.

            Hence, for ``additional_states=['past']``, we have the
            Goods Avatars that were already there and still are,
            as well as those that aren't there any more,
            and similarly for 'future'.
        :param at_datetime: take only into account Goods Avatar whose date
                            and time contains the specified value.

                            Mandatory if ``additional_states`` is specified.

        TODO: make recursive (not fully decided about the forest structure
        of locations)

        TODO: provide filtering according to Goods properties (should become
        special PostgreSQL JSON clauses)

        TODO PERF: for timestamp ranges, use GiST indexes and the @> operator.
        See the comprehensive answer to `that question
        <https://dba.stackexchange.com/questions/39589>`_ for an entry point.
        Let's get a DB with serious volume and datetimes first.
        """
        Goods = self.registry.Wms.Goods
        Avatar = Goods.Avatar
        query, use_count = self.base_quantity_query()
        query = query.filter(Goods.type == goods_type, Avatar.location == self)

        if additional_states is None:
            query = query.filter(Avatar.state == 'present')
        else:
            states = ('present', ) + tuple(additional_states)
            query = query.filter(Avatar.state.in_(states))
            if at_datetime is None:
                # TODO precise exc or define infinites and apply them
                raise ValueError(
                    "Querying quantities with additional states {!r} requires "
                    "to specify the 'at_datetime' kwarg".format(
                        additional_states))

        if at_datetime is not None:
            query = query.filter(
                Avatar.dt_from <= at_datetime,
                or_(Avatar.dt_until.is_(None), Avatar.dt_until > at_datetime))
        if use_count:
            return query.count()

        res = query.one()[0]
        return 0 if res is None else res

    @classmethod
    def base_quantity_query(cls):
        """Return base join query, without any filtering, and eval indication.

        :return: query, ``True`` if ``count()`` is to be used. Otherwise,
                 the query is assumed to produce exactly one row, with the
                 wished quantity result (possibly ``None`` for 0)
        """
        Avatar = cls.registry.Wms.Goods.Avatar
        return Avatar.query().join(Avatar.goods), True
예제 #6
0
            class Person:

                name = String(primary_key=True)
                address = Many2One(model=Model.Address, index=True)
예제 #7
0
            class MTest:

                test = Many2One(model=Model.Test)
예제 #8
0
class Field(Mixin.IOCSVFieldMixin):

    exporter = Many2One(model=IO.Exporter,
                        nullable=False,
                        one2many='fields_to_export')
    mode = Selection(selections='get_selection', nullable=False, default='any')
    mapping = String()

    @classmethod
    def get_selection(cls):
        return {
            'any': '',
            'external_id': 'External ID',
        }

    def _get_fields_description(self, name, entry):
        Model = self.get_model(entry.__registry_name__)
        fields_description = Model.fields_description(fields=[name])
        if name not in fields_description:
            raise CSVExporterException("unknow field %r in exporter field %r" %
                                       (name, self.name))

        return fields_description[name]

    def _value2str(self, exporter, name, entry, external_id):
        fields_description = self._get_fields_description(name, entry)
        if fields_description['primary_key'] and external_id:
            return self.anyblok.IO.Exporter.get_key_mapping(entry)

        ctype = fields_description['type']
        model = fields_description['model']
        return exporter.value2str(getattr(entry, name),
                                  ctype,
                                  external_id=external_id,
                                  model=model)

    def _rc_get_sub_entry(self, name, entry):
        fields_description = self._get_fields_description(name, entry)
        if fields_description['type'] in ('Many2One', 'One2One'):
            return getattr(entry, name)

        elif fields_description['model']:
            model = fields_description['model']
            Model = self.anyblok.get(model)
            pks = Model.get_primary_keys()
            if len(pks) == 1:
                pks = {pks[0]: getattr(entry, name)}
            else:
                raise CSVExporterException("Not implemented yet")

            return Model.from_primary_keys(**pks)

        else:
            raise CSVExporterException(
                "the field %r of %r is not in (Many2One, One2One) "
                "or has not a foreign key")

    def value2str(self, exporter, entry):
        def _rc_get_value(names, entry):
            if not names:
                return ''
            elif len(names) == 1:
                external_id = False if self.mode == 'any' else True
                return self._value2str(exporter, names[0], entry, external_id)
            else:
                return _rc_get_value(names[1:],
                                     self._rc_get_sub_entry(names[0], entry))

        return _rc_get_value(self.name.split('.'), entry)

    def format_header(self):
        if self.mode == 'any':
            return self.name
        else:
            return self.name + '/EXTERNAL_ID'
예제 #9
0
    class Person:
        __db_schema__ = 'test_db_m2o_schema'

        name = String(primary_key=True)
        address = Many2One(model=Model.Address)
예제 #10
0
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')
예제 #11
0
    def build_model(self, modelname, bases, properties):
        tmp_bases = [
            x for x in bases if x is not self.registry.declarativebase
        ]

        res = super(ContextualModelFactory,
                    self).build_model(modelname, tmp_bases, properties)

        related_models = res.define_contextual_models()

        models = {}
        transformation_models = {}
        lnfs = self.registry.loaded_namespaces_first_step
        properties['loaded_contextual_fields'] = set()
        for fieldname in properties['loaded_fields']:
            field = lnfs[properties["__registry_name__"]][fieldname]
            if isinstance(field, Contextual):
                properties['loaded_contextual_fields'].add(fieldname)
                registry_name = (f'{properties["__registry_name__"]}.'
                                 f'{field.identity.capitalize()}')
                if field.identity not in models:
                    transformation_models[field.identity] = {}
                    tablename = (
                        f"{properties['__tablename__']}_{field.identity}")

                    lnfs[registry_name] = {
                        '__depends__': set(),
                        '__db_schema__': properties['__db_schema__'],
                        '__tablename__': tablename,
                    }

                    models[field.identity] = {
                        '__db_schema__': properties['__db_schema__'],
                        '__depends__': set(),
                        '__model_factory__':
                        ModelFactory(registry=self.registry),
                        '__registry_name__': registry_name,
                        '__tablename__': tablename,
                        'add_in_table_args': [],  # Add unicity
                        'hybrid_property_columns': [],
                        'loaded_columns': [],
                        'loaded_fields': {},
                    }

                    self.registry.call_plugins(
                        'initialisation_tranformation_properties',
                        models[field.identity],
                        transformation_models[field.identity])

                    self.declare_field_for(
                        'id',
                        Integer(primary_key=True),
                        models[field.identity],
                        transformation_models[field.identity],
                    )

                    self.declare_field_for(
                        'relate',
                        Many2One(model=properties['__registry_name__'],
                                 nullable=False,
                                 foreign_key_options={'ondelete': 'cascade'}),
                        models[field.identity],
                        transformation_models[field.identity],
                    )

                    self.declare_field_for(
                        field.identity,
                        Many2One(
                            model=related_models[field.identity]['model'],
                            nullable=False,
                        ),
                        models[field.identity],
                        transformation_models[field.identity],
                    )

                self.declare_field_for(
                    fieldname,
                    field.field,
                    models[field.identity],
                    transformation_models[field.identity],
                )

        for name, model_properties in models.items():
            bases_ = TypeList(Model, self.registry,
                              model_properties['__registry_name__'],
                              transformation_models[name])

            mixins = related_models[name].get('mixins', [])
            if not isinstance(mixins, list):
                mixins = [mixins]

            bases_.extend(mixins)
            bases_.extend([x for x in self.registry.loaded_cores['SqlBase']])
            bases_.append(self.registry.declarativebase)
            bases_.extend([x for x in self.registry.loaded_cores['Base']])
            bases_.append(self.registry.registry_base)
            Model.insert_in_bases(self.registry,
                                  model_properties['__registry_name__'],
                                  bases_, transformation_models[name],
                                  model_properties)
            relate_modelname = f'{modelname}{name.capitalize()}'
            relate = type(relate_modelname, tuple(bases_), model_properties)
            properties[name.capitalize()] = relate
            self.registry.loaded_namespaces[
                model_properties['__registry_name__']] = relate

            primaryjoin = [
                f"{relate_modelname}.{x} == {modelname}.{x[len('relate_'):]}"
                for x in relate.loaded_columns if x.startswith('relate_')
            ]
            properties[f"__{name}"] = relationship(
                relate,
                primaryjoin='and_(' + ', '.join(primaryjoin) + ')',
                lazy='dynamic',
                overlaps='__anyblok_field_relate')

        return super(ContextualModelFactory,
                     self).build_model(modelname, bases, properties)
예제 #12
0
class WmsInventoryOperation:
    """Add Wms.Inventory support on low-level Inventory Operations."""

    inventory = Many2One(model='Model.Wms.Inventory')
예제 #13
0
 class T1:
     id = Integer(primary_key=True)
     code = String()
     val = Integer()
     parent = Many2One(model='Model.T1')
예제 #14
0
 class T1:
     id = Integer(primary_key=True)
     code = String()
     val = Integer()
     rs_id = Integer(foreign_key=Model.Rs.use('id'))
     rs = Many2One(model=Model.Rs, column_names='rs_id')
예제 #15
0
            class Test:

                parent = Many2One(model='Model.Test', one2many='children')
예제 #16
0
    class Person:

        name = String(primary_key=True, db_column_name="y1")
        address = Many2One(model=Model.Address)
예제 #17
0
            class Test(Mixin.MTest):

                parent = Many2One(model='Model.Test', one2many='children')
예제 #18
0
    class Person:

        name = String(primary_key=True, db_column_name="y1")
        address_id = Integer(db_column_name="y2",
                             foreign_key=Model.Address.use('id'))
        address = Many2One(model=Model.Address)
예제 #19
0
            class Test2:

                id = Integer(primary_key=True)
                test = Many2One(model=Model.Test, column_names=(
                    'other_test_id', 'other_test_id2'))
예제 #20
0
    class Person:

        name = String(primary_key=True)
        address = Many2One(model=Model.Address,
                           column_names="address")
예제 #21
0
            class TestM2O:

                id = Integer(primary_key=True)
                test = Many2One(model=Model.Test, nullable=True)
예제 #22
0
    class Person:

        name = String(primary_key=True)
        address = Many2One()
예제 #23
0
            class Person:

                name = String(primary_key=True)
                address_1 = Many2One(model=Model.Address)
                address_2 = Many2One(model=Model.Address)
예제 #24
0
            class Test2:

                seq = Sequence(primary_key=True)
                test = Many2One(model=Model.Test)
예제 #25
0
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)
예제 #26
0
    class Person:

        name = String(primary_key=True)
        address = Many2One(model=Model.Address,
                           remote_columns="id", column_names="id_of_address",
                           one2many="persons", nullable=False)
예제 #27
0
 class Test2:
     id = Integer(primary_key=True)
     test_id = Integer(foreign_key=Model.Test.use('id'))
     test = Many2One(model=Model.Test, one2many='test2')
예제 #28
0
            class Test:

                id = Integer(primary_key=True)
                parent = Many2One(model='Model.Test', one2many='children')
예제 #29
0
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()
예제 #30
0
 class Test2:
     id = Integer(primary_key=True)
     name = String()
     test = Many2One(model=Model.Test, one2many="test2")