Customer – relaying order items to artisans

The relevant Story for Customer being able to relay Order items to artisans, from the earlier collection of stories is:

  • As a Customer, I need the relevant parts of my Order to be relayed to the appropriate artisans so that they can fulfill their part of my Order.

orders have a significantly more complex life cycle than any of the other objects in hms_sys. Unlike Artisan objects, or perhaps Product objects, they are expected to have a short active lifespan; being created, processed once, then archived or perhaps even deleted. Artisan objects, in contrast, once created, are expected to persist for as long as the Central Office/Artisan relationship lasts. Product objects may or may not persist in an active state for long periods of time, but can also last as long as the Central Office/Artisan relationship of their owning artisans continues. In both of these cases, though the length of their life cycles may vary substantially, they are basically created and persisted (with or without modification) indefinitely.

By contrast, a relatively simple Order, moving through a simple subset of what hms_sys could support, might look like this:

Where:

  • The initial Order (for Product objects P1, P2and P3) is created by the Web Storefront and is handed off to the Artisan Gateway for distribution to and handling by the relevant Artisan users
  • The Artisan Gateway sends Order messages to the Artisan Applications associated with the artisans whose products are in the Order (Artisan #2, in this example, exists, but the Order doesn't contain any of their products):
    • One Order, for products P1 and P3is sent to Artisan #1
    • One Order for Product P2 is sent to Artisan #3
  • Artisan #1 fulfils the part of the order for Product P1 (P1 Fulfilled), which sends an update message for the Order back to the Artisan Gateway, where the fulfillment of that portion is noted and stored
  • A similar cycle occurs (P2 Fulfilled) for Artisan #3, with respect to Product P2 from the original Order
  • The final fulfillment cycle (P3 Fulfilled) is executed by Artisan #1
  • The Order, with all of its fulfillment complete, can be archived, deleted, or handled in whatever other way is needed

Since no concrete Order class was ever created that the Artisan Gateway service would be able to access, that's the first thing that needs to be done. Without knowing precisely how Order data is going to be relayed to the service, but still needing to be able to perform round trip testing of the process later, there's little more that can be done than to define it as a basic class derived from HMSMongoDataObject (like the other data object classes in the co_objects module) and from BaseOrder (from the business_objects module). Additions or changes to it may surface later, but deriving Order from those two classes will provide enough functionality for it to be testable.

After going through all of the analysis effort with the Artisan Application's Order class definition, that feels like a better starting point for the corresponding class in the Central Office code (co_objects), though it will need some modification/conversion in the process. First and foremost, it needs to derive from HMSMongoDataObject instead of JSONFileDataObject—but since both of those, in turn, are derived from BaseDataObject, a fair portion of the new Order class is already implemented with that inheritance change.

There's enough common code between the two Order classes that it would almost certainly be worth spending time moving those common items back down into BaseOrder. Designing, or even implementing concrete classes, then gathering their common functionality into common parent classes is just as valid a design or implementation approach as starting from the foundations and building out, though it happened accidentally in this case.

Beyond that, we'll need a mechanism that will allow the Web Storefront system to create an Order. So far, we don't have any specifications around that process, but that doesn't stop us from creating a class method that will (hopefully) eventually be used in that capacity. For near future testing purposes, it will be set up to accept a BaseCustomer object that's derived as a customer, and a list of Product identifiers, with an eye toward the customer being revised at some point in the future. To start with, all we're concerned with is a method that can be called to create a complete Order with the relevant Product objects attached to it:

def create_order_from_store(
    cls, customer:(BaseCustomer,str,dict), **order_items
):
    """
Creates and returns a new order-instance, whose state is populated 
with data from the     

customer .......... (Type TBD, required) The customer that placed 
                    the order
order_items ....... (dict [oid:quantity], required) The items and 
                    their quantities in the order
"""

It feels reasonably safe to assume that the storefront will be able to pass Product identifiers and their quantities in the Order along as some sort of dict value, and that it won't be keeping track of entire Product objects, at least not in the same structure that hms_sys code uses. Given the list of Product oid values available in the keys() of the order_items, retrieving products to be added to the order instance on creation is simply a matter of filtering all available products down into a collection of the specific items in the Order, while preserving their associated quantities:

    # - Get all the products and quantities identified by the 
    #   incoming oid-values in order_items
    products = {
        product:order_items[str(product.oid)] 
        for product in Product.get()
        if str(product.oid) in order_items.keys()
    ]

The products generated here are dicts, generated by a dictionary comprehension, whose keys are Product objects, and values are the quantities of those products in the Order. Then, we need to acquire the customer:

# TODO: Determine how customer-data is going to be #provided 
# (probably a key/value string, could be a JSON packet 
# that could be converted to a dict), and find or create 
# a customer object if/as needed. In the interim, for 
# testing purposes, accept a BaseCustomer-derived object.
  if not isinstance(customer, BaseCustomer):
      raise NotImplementedError(
          "%s.create_order_from_store doesn't yet accept "
          "customer arguments that aren't BaseCustomer-"
          "derived objects, sorry" % (cls.__name__)
      )

Finally, the new Order instance is created, saved (assuring that its data is persisted), and returned (in case the calling code needs to reference it immediately after it's been created):

# - Create the order-instance, making sure it's tagged 
#   as new and not dirty so that the save process will 
#   call _create
new_order = cls(
    customer, is_dirty=False, is_new=True, *products
)
# - Save it and return it
new_order.save()
return new_order

The Order class will also need a to_message_data method, just like their Product and Artisan counterparts, and with one defined, can use a message transmission process that is basically identical to what was established earlier:

def to_message_data(self) -> (dict,):
    """
Creates and returns a dictionary representation of the instance 
that is safe to be passed to a DaemonMessage instance during or 
after creation as the data of that message.
"""
    return {
        # - Local properties
        'name':self.name,
        'street_address':self.street_address,
        'building_address':self.building_address,
        'city':self.city,
        'region':self.region,
        'postal_code':self.postal_code,
        'country':self.country,
        # - Generate a string:int dict from the UUID:int dict
        'items':{
            str(key):int(self.items[key]) 
            for key in self.items.keys()
        },
        # - Properties from BaseDataObject (through 
        #   HMSMongoDataObject)
        'modified':datetime.strftime(
            self.modified, self.__class__._data_time_string
        ),
        'oid':str(self.oid),
    }

This process implies a new story that will probably be needed mostly for UI development, but that might have some implications in additional design and implementation of the Artisan Applications:

  • As an Artisan, I need to be informed when an Order has been placed that includes one of my Product offerings so that I can fulfill my part of that Order

Since the creation of a new Order by the Web Storefront also needs to relay new Order objects to each Artisan (looking back at the Order flow diagram), and since it seems reasonable to expect that only the store-to-Gateway-service portion of that flow would be calling create_order_from_store, that seems like a reasonable place to implement that messaging at first glance, but in doing so, there would be no access to the service's logging facilities, so any failures in communication between the two systems would potentially be lost. If, instead, the Web Storefront were to issue a create Order message to the Artisan Gateway, the Gateway service could in turn call create_order_from_store with the applicable data, and log events as needed/desired while it executes. For the purposes of illustration, this is the approach that is going to be assumed. In this case, create_order_from_store is complete as it stands, and the Artisan/Order messaging happens as part of the Gateway service's create_order method. The first major chunk of its code looks very much like the other create processes:

def create_order(self, properties:(dict,)) -> None:
    self.info('%s.create_order called' % self.__class__.__name__)
    if type(properties) != dict:
        raise TypeError(
            '%s.create_order expects a dict of Order '
            'properties, but was passed "%s" (%s)' % 
            (
                self.__class__.__name__, properties, 
                type(properties).__name__
            )
        )
    self.debug('properties ... %s:' % (type(properties)))
    self.debug(str(properties))
# - Create the new object...
    new_order = Order.create_order_from_store(properties)
    self.info(
        'New Order %s created successfully' % new_order.oid
    )

Since the create_order_from_store method already saves the new Order, we don't need to save it here—it will already exist in the data store, and can be retrieved by other processes as soon as this point in the code has been reached. In order to proceed, and send the necessary Order messages to the individual artisans who need to be aware of them, we need to sort out which products (and in what quantities) are associated with each Artisan in the system.

Since the Artisan can have a Product, but a Product doesn't keep track of which Artisan they belong to (which might be a good thing to add, in retrospect), the best option we have right now is to load up the Artisan, and search for it for each product. This is not optimal, and definitely worth looking at changing, but it will work for now.

The new_order variable is holding on to an Order object that, if expressed as a dict, would look like this:

{
    'oid':<UUID>,
    'name':<str>,
    # - Shipping-address properties
    'street_address':<str>,
    'building_address':<str> or None,
    'city':<str>,
    'region':<str>,
    'postal_code':<str>,
    'country':<str> or None,
    # - order-items
    'items':{
        <Product object #1>:<int>,
        <Product object #2>:<int>,
        <Product object #3>:<int>,
    },
}

Rendering that down into a dict of Artisan/item:quantity values is simple, if done in a brute-force manner:

    artisan_orders = {}
    # - Get all the artisans
    all_artisans = Artisan.get()
    # - Sort out which artisan is associated with each item 
    #   in the order, and create or add to a list of 
    #   products:quantities for each
    for product in new_order.products:
        try:
            artisan = [
                candidate for candidate in all_artisans
                if product.oid in [
                    p.oid for p in candidate.products
                ]
            ][0]

If an Artisan is found that's associated with the Product, then one of two cases needs to execute: either the artisan already exists as a key in the artisan_orders dict, in which case we just append the item data to the current list of items associated with the artisan, or they haven't had a Product match yet, in which case we create an entry for the artisan, whose value is a list containing the item data in question:

item_data = {
  str(oid):new_order.products[product]
}
if artisan_orders.get(artisan):
   artisan_orders[artisan].append(item_data)
else:
   artisan_orders[artisan] = [item_data]
if artisan_orders.get(artisan):
   artisan_orders[artisan].append(product)
else:
   artisan_orders[artisan] = [product]

Although it shouldn't happen, it's possible that an Order could come in with a Product that has no identifiable artisan to associate with it. The specifics of how that error case should be handled may be dependent on the web store system. Even setting that consideration aside, it should be handled in some fashion that hasn't been defined yet. At a minimum, however, the failure should be logged:

except IndexError:
   self.error(
       '%s.create_order could not find an '
       'artisan-match for the product %s' % 
       (product.oid)
   )
self.debug('All artisan/product associations handled')

Once this sorting has completed, the artisan_orders dict will look something like this, with each key in artisan_orders being an actual Artisan object, with all of the properties and methods of any such instance, with the Product oid and quantities associated:

{
    <Artisan #1>:{
        <str<UUID>>:<int>,
        <str<UUID>>:<int>,
    },
    <Artisan ...>:{
        <str<UUID>>:<int>,
    },
    <Artisan #{whatever}>:{
        <str<UUID>>:<int>,
        <str<UUID>>:<int>,
    },
}
Python dict instances can use almost anything as a key: any immutable built-in type (like str and int values, and even tuple values, but not list or other dict values) can be used as a key in a dict. In addition, instances of user-defined classes, or even those classes themselves, are viable. Instances of built-in classes, or the built-in classes themselves, may not be valid dict keys, though.

With a complete and well-formed artisan_orders, the process of sending Order messages to each Artisan is relatively simple—iterating over each Artisan key, building the message data in the structure that the Artisan Application's Order class expects, creating a DaemonMessage to sign the message, and sending it:

sender = RabbitMQSender()
self.info('Sending order-messages to artisans:')
for artisan in artisan_orders:
# Get the products that this artisan needs to be concerned #with
items = artisan_orders[artisan]
# - Create a message-structure that 
#   artisan_objects.Order.from_message_dict can handle
new_order_data = {
    'target':'order',
    'properties':{
        'name':new_order.name,
        'street_address':new_order.street_address,
                'building_address':new_order.building_address,
                'city':new_order.city,
                'region':new_order.region,
                'postal_code':new_order.postal_code,
                'country':new_order.country,
                'items':items,
                'oid':str(new_order.oid),
            },
        }
        # - Create the signed message
        order_message = DaemonMessage(
            'create', new_order_data, artisan.signing_key
        )

Sending a message to a specific Artisan requires another change: the send_message method of RabbitMQSender was not originally built to send messages to a queue other than the default it was configured with. It makes sense for each Artisan to have their own message queue for several reasons, and in order to use that specific queue, it has to be accepted as a send_message argument. The Gateway side call to send the message reflects that (passing artisan.queue_id as an argument):

# - Send the message to the artisan
sender.send_message(order_message, artisan.queue_id)
self.info(
    '+- Sent order-message with %d products to '
    'Artisan %s' % (len(items), artisan.oid)
)

The related changes in RabbitMQSender.send_message are not complicated: the addition of an optional queue_name argument, and a check to see if it has been provided, falling back to the configured default queue name is all that was needed:

def send_message(self, message:(DaemonMessage), 
        # Added queue_name
        queue_name:(str,None)=None
    ):
    if type(message) != DaemonMessage:
        raise TypeError(
            '%s.send_message expects a DaemonMessage instance '
            'as its message argument, but was passed "%s" (%s)' % 
            (
                self.__class__.__name__, message, 
                type(message).__name__
            )
        )
 # Using the optional queue_name to override the default
    if not queue_name:
        queue_name = self.queue_name
 # - Note that exchange is blank -- we're just using the 
 #   default exchange at this point…
 # - Also note that we're using queue_name instead of the 
 #   original self.queue_name default...
    self.channel.basic_publish(
        exchange='', routing_key=queue_name, 
        body=message.to_message_json()
  )
..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.133.140.153