Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / desktop / WPF

WPF and IronPython Business Rules - Part 2

5.00/5 (8 votes)
9 Jun 2015CPOL16 min read 25.1K   474  
A full WPF sample application demonstrating IronPython business rules

Introduction

Code for Part 2 - Don't forget to Unblock before extracting

In Part 1, we introduced the concept of "scriptable business rules" using IronPython. That project consisted primarily of background information and some unit tests that showed the concepts in action.

Here in Part 2, we're going to get more practical and demonstrate a full implementation. Our sample application is a fictitious boat sales company. Here's the UI for the app:

Image 1

Disclaimers

The purpose of this sample is to show how to use the Aim.Scripting framework to integrate dynamic business rules into any application. For this article, I've chosen WPF/MVVM as the vehicle to demonstrate the concepts. Where appropriate, I've included information about Caliburn.Micro MVVM, but keep in mind this is not a tutorial for that framework, nor for WPF in general. So, keep the following in mind:

  • I'm not a WPF, MVVM, or Caliburn.Micro guru. I have a good grasp of many of the concepts, but some things may not be implemented according to absolute best practices for said technologies.
  • Most of the styling, and some of the custom controls for the app come from a DLL called Aim.Wpf. Feel free to use it as you wish - it's pretty solid. I'm especially fond of my PathButton and FlowPanel custom controls :). However, don't expect it to remain constant across releases for this article series - it's a work in progress.
  • The repository storage mechanism used for this sample is not really suitable for a real application. You would most likely want to use a database as a backing store, but I wanted to reduce the number of dependencies for the project, so I opted for a simple binary file repository rather than SQL or NoSQL.
  • The Aim.Xxx DLLs used in this project are not yet hosted in an open-source repository such as GitHub. I'm working on it, but haven't gotten around to it yet.
  • Many of the "implementations" that we use for samples are mostly placeholders with some comments about what a real implementation might look like. EmailingProvider, IntegrationProvider, etc. - you shouldn't expect to start a real-time connection to your corporate SAP implementation!

Refresher

At the foundation of business rules are two high-level concepts that any programmer understands: Events and Commands.

Events

Events are "auto-wired" bits of Python code that respond to user interaction or system-level activity. They require the following pieces:

  • A script-enabled event handler defined in your .NET code.
  • An IronPython module (which is usually just another data model within your system, implementing the IScriptDefinition interface) with a Type Key string to identify the types to listen to.
  • Some IronPython code in that same module with a signature to match the event.
    • Business rules event functions always have the signature onEventName(sender, e), where on (lowercase) is the prefix, EventName is the name of the .NET event, sender is the object triggering the event, and e is the EventArgs-derived argument expected by the event delegate contract.

Quick Reminder - Script-Enabled Event Handlers

To "script-enable" an event handler, include the Aim.Scripting DLL in your project. You don't need to import any namespaces, because the EventArgs extension methods are defined in the System namespace.

Override script-enabled handler (defined in a class that derives from the class that defines the event):

C#
/// <summary>
/// Overridden to become a script-connected event handler.
/// </summary>
/// <param name="e"></param>
protected override void OnPropertyChanged( PropertyChangedEventArgs e )
{
    e.FireWithScriptListeners( base.OnPropertyChanged, this );
}

Virtual script-enabled handler (defined in the same class as the event):

C#
/// <summary>
/// Script-connected event handler for SomeEvent event.
/// </summary>
/// <param name="e"></param>
protected virtual void OnSomeEvent( EventArgs e )
{
    e.FireWithScriptListeners( () => SomeEvent, this );
}

Commands

Commands are quite a bit more involved and are composed of user interaction, Python functions, stored records, return values, and display contexts. Commands require the following pieces:

  • An IronPython module with command functions that will be triggered by the user. This is the same data model type that is used to define events (BizRuleDataModel in this implementation). The only difference is the way we write the Python code.
    • While events have a specific signature that is understood by the scripting runtime, commands do not. A command function can take any number or type of arguments, which will be supplied by the user.
  • A storable command data model (BizRuleCmdDataModel in this implementation) with meta-information such as the command name, display context, active dates, etc.
  • A method to determine the active display context, aggregate together the applicable commands, and display them to the user.
  • An optional return value.

We'll be covering events first in this article. We'll also cover commands, but future articles will go into more depth on that subject, because command implementations are more complex.

Breaking Changes Since Part 1

I've added the ICommandDefinition interface to the Aim.Scripting library to represent a command. Previously, extracting active commands from a business rule module was somewhat kludgy, and required a lot of boilerplate code in the UI and composition root. I opted to formalize these commands under an interface, allowing the script factory to handle some of the work of finding active commands for a module.

Since the concept of commands has been better formalized in the Aim.Scripting library, it means that the IScriptDefinition interface needs a way to query for these commands. Consequently, it got slightly more complex to implement, but is still pretty simple.

Here are the listings for IScriptDefinition and ICommandDefinition:

C#
using System;
using System.Collections.Generic;

namespace Aim.Scripting
{
    /// <summary>
    /// The interface that defines an object that contains scripts. Some of
    /// the functions in the scripts will be commands (user-initiated), others
    /// will be events (system-initiated). Event methods have a specific
    /// signature that expects a sender ("sender") and event args ("e"). Command
    /// methods can accept any number or type of parameter.
    /// </summary>
    public interface IScriptDefinition
    {
        /// <summary>
        /// Gets the "module name" of this script definition. This is
        /// usually just the stored name or id in the data store where
        /// this script definition is stored.
        /// </summary>
        /// <returns></returns>
        string GetModuleName();

        /// <summary>
        /// Gets the unique type id so that the script engine or handler
        /// knows whether to connect or not.
        /// </summary>
        /// <returns></returns>
        string GetTypeKey();

        /// <summary>
        /// Gets the text of the script (the "program").
        /// </summary>
        /// <returns></returns>
        string GetScripts();

        /// <summary>
        /// Gets a list of executable commands.
        /// </summary>
        /// <returns></returns>
        IEnumerable<ICommandDefinition> GetCommands();

        /// <summary>
        /// Gets whether the definition is currently active. You should have a way
        /// to mark definitions as inactive. The best way to do this is with a date
        /// range, but you can also use a simple Boolean flag.
        /// </summary>
        /// <returns></returns>
        bool IsActive();

        /// <summary>
        /// Gets a sequence number so that we can order events and command lists.
        /// </summary>
        /// <returns></returns>
        int GetRunSequence();
    }

    /// <summary>
    /// Script definitions can contain defined commands. These are a list
    /// of objects that map to individual cmdXXX function within the scripts
    /// of the definition.
    /// </summary>
    public interface ICommandDefinition
    {
        /// <summary>
        /// Gets the full function declaration, e.g. cmdDoThis(intX, strTest).
        /// </summary>
        /// <returns></returns>
        string GetFunction();

        /// <summary>
        /// Gets the human-readable command name that has been assigned.
        /// </summary>
        /// <returns></returns>
        string GetCommandName();

        /// <summary>
        /// Gets the execution context that has been assigned. Commands can show
        /// up in various places; this provides the ability to control where
        /// and when the command is available.
        /// </summary>
        /// <returns></returns>
        ExecutionContext GetExecutionContext();

        /// <summary>
        /// Gets whether the command is currently active. You should have a way
        /// to mark commands as inactive. The best way to do this is with a date
        /// range, but you can also use a simple Boolean flag also.
        /// </summary>
        /// <returns></returns>
        bool IsActive();
    }
}

In our sample project, these two interfaces are implemented as BizRuleDataModel and BizRuleCmdDataModel, respectively. You can look at the code for those to see how simple the interface implementation really is - every single implementation method of both interfaces is a single line of code, essentially forwarding a value already defined as a stored property of the class.

Our First Dynamic Event Handler

Open up the Read Me - Events business rule (click the Fx button to the upper right - this will open the list of business rules modules. Double-click a line in the grid to open that record).

Image 2

Uncomment the onInform(...) handler that is defined, then save the business rule.

Image 3

Now, navigate to the product list (sailboat icon), and open an existing or create a new product record. Press the Save button. After you save, check out the status bar at the bottom of the screen. It should have printed a message that was supplied by the business rule event handler.

Image 4

So how did this happen?

  1. We created a BizRuleDataModel with a Type Key that tells it to listen to events of ProductDataModel instances.
  2. We added an onInform(sender, e) Python method which will listen to Inform events on ProductDataModel.
  3. We edited a ProductDataModel instance, and the repository raised ProductDataModel's Inform event. In this case, the event args was passed with the Info property set to "Saving".

A More Useful Event Handler

Let's do something more real-world. Promotional codes are often used to give customers discounts based on the text of the code and maybe other information like the type of the product.

Handling promotional codes can be a pain unless you have a lot of built-in types to handle it. You need to:

  • Recognize the code
  • Have a time span for which the code is valid
  • Determine when to apply the code (usually upon saving the record)
  • Determine if the product meets the criteria for promotion
  • Determine if the customer meets the criteria - perhaps they have a past due account and are not eligible for promotional discounts
  • Do the math to determine the discount
    • This can be further complicated if the customer has a standard discount

To look at the code for our July Promotion, open the business rule module with that name and navigate to the Scripts tab.

Image 5

Got it? Ok, once you've looked that over (you don't need to change anything), create a new SaleDataModel (click the money icon in the main toolbar to show the Sale list). Set the SaleNumber, Customer, and PromoCode properties on the Main tab. Set the PromoCode to JULY.

Now, navigate to the Items tab of the sale and add a few items. For each item you add, select a product from the Product combobox.

Now, keeping your eye on the DiscountPct column of the sale items list, press the Save button. You should see the number change from a 0 to a 10. (It may change to more than 10 if you happen to have selected a customer with a standard discount that is more than 10%). You should also see a status message about a successful application of the promotional code.

Image 6

One other thing you should notice about the July Promotion business rule module: If you look at the ActiveFrom and ActiveThru properties, you'll see that it will only be active from 2015-06-01 thru 2015-08-01. Go ahead and set the ActiveThru property of July Promotion to something like 2015-06-02, and save the business rule. Then, create another sale using a Customer without a standard discount. You'll see that the DiscountPct property remains at 0.

It's recommended that you always supply an active date range for your modules. It should be composed of two nullable DateTime properties. BizRuleDataModel defines it like this:

C#
/// <summary>
/// The active start date for the module. Can be null.
/// </summary>
public DateTime? ActiveFrom
{
    get
    {
        return Get( m_ActiveFrom );
    }
    set
    {
        Set( ref m_ActiveFrom, value, "ActiveFrom" );
    }
}

/// <summary>
/// The active end date for the module. Can be null.
/// </summary>
public DateTime? ActiveThru
{
    get
    {
        return Get( m_ActiveThru );
    }
    set
    {
        Set( ref m_ActiveThru, value, "ActiveThru" );
    }
}

/// <summary>
/// Is the business rule module currently active?
/// </summary>
public bool IsActive
{
    get
    {
        var now = DateTime.Now;
        if( ActiveFrom.HasValue && ActiveThru.HasValue )
            return ActiveFrom.Value < now && now <= ActiveThru.Value;

        if( ActiveFrom.HasValue )
            return ActiveFrom.Value < now;

        if( ActiveThru.HasValue )
            return now <= ActiveThru.Value;

        return true;
    }
}

Some Other Events

Here are a couple other events that demonstrate other concepts.

A Fire-Once Startup Event

Our sample also includes a scripted handler in the Startup Events module that fires once, upon the AppBootstrapper raising the Ready event.

Image 7

Image 8

These types of fire-once startup events can be used for logging program usage, setting global settings, etc.

Sending an Email

We've also defined a rule that says to send an email to the big boss (and write a system log message) if a sale exceeds $100,000. Keep in mind that our EmailingProvider implementation is just a placeholder.

Image 9

There are other events defined in other modules. All of the modules have good notes at the beginning of the Python code in the Scripts tab. Please take the time to read through all of them for ideas, how-tos, and best practices.

Moving On to Commands

We're going to move on now to cover commands. We'll first look at the modules and output from the commands that are already defined, then go into the how-to. Commands require a lot more context than events, so we need to show how we got things working.

Image 10

Open the product list (sailboat icon). To show the Commands Panel, click the list-gear icon in the far upper right of the main toolbar. Commands are context-sensitive, based on the following:

  • The Type Key of the associated business rule module.
  • The Execution Context of the command. ExecutionContext is an enumeration defined in Aim.Scripting, with the following members:
    • Any - show the module commands based only on the Type Key. These commands will show when a list of items is active, or when a single item is active (such as an edit view).
    • Single - show the module commands based on the Type Key and whether a single record is active, such as is the case in an edit view.
    • List - show the module commands based on the Type Key and whether multiple records are active, such as is the case in a list view.
  • The Type Key for business rule modules is not required for commands. In the case of a blank Type Key, the command will show based solely on the ExecutionContext for that command definition.

With the Commands Panel still open, open the sale list (money icon). Notice that the Commands Panel now shows several group boxes with Run buttons inside.

Image 11

Where did those come from? To get an idea, navigate to the business rule module list (Fx icon). Open the module called Read Me - Commands, and follow the directions in the Scripts tab.

Image 12

Once you've done that, navigate back to the products list (sailboat icon). You should see something like the following:

Image 13

Go ahead and click the Run button for our new command. You should see a status bar message that reflects the state of the check box control above the Run button.

Command Results

Executing a command can return a result. This result can be anything. A simple string message, a DataTable, an IEnumerable<T> list...

We need some place to show those results, and that's where the Command Results panel comes in. Navigate back to the sale list. Once there, press the Run button under the command called Month to Date.

Image 14

You should see a new tab at the bottom showing the results of executing the command. In this case, we returned an IEnumerable<SaleDataModel>.

To see how we got here, open the Sales Reports business rules module. First look at the Scripts tab, and read the comments and the code in there.

Image 15

Next, look at the Commands tab to see how the commands are defined. Notice that each one has its Context property set to List - that's how we got the commands to show up when the active context is a list of sale records.

Image 16

Defining Commands

There are two parts to defining a command:

  • The command function. This is the Python code that will run when the command is executed.
  • The stored command, which references this function, but also adds a lot of metadata such as the human-readable command name, execution context, active from-thru dates, etc. See the previous image or the Sales Reports business rules module for examples.

Defining command functions isn't really that different from definining event handler functions - except that we have a lot more freedom. There are a few things to be aware of, however, when defining a command function in your Python code:

  • The command function name must begin with cmd (lowercase). For example, cmdMyFirstCommand().
    • There is actually a good reason for this. When defining the stored commands, we show the available command functions in a combobox. Since a business rule module could have any number of functions, some of which are event handlers, some of which are helper functions, etc., we need a way to differentiate those functions that will be attached to stored commands so that we don't clutter up the combobox selection list with unwanted functions - we especially don't want someone calling an event handler by invoking a command!
  • The command function can take any number or type of arguments. Since Python is a dynamically-typed language, Aim.Scripting uses some naming convention magic to attempt setting the expected type (at dynamic function construction time) and converting the provided value (at dynamic function execution time). The recognized parameter name prefixes are int, long, num, bool, date, dateTime, time, str, and obj. I'll leave it as an exercise for the reader to divine what the prefixes mean :).
    • This naming convention also has some beneficial side effects. If you have a sharp eye, you may have noticed that some of the controls in the Commands Panel render as checkboxes, some as text boxes, some as date pickers, etc. We use the parameter type inference along with a wonderful WPF concept called "template selectors" to achieve this.
    • If the parameter prefix is recognized, the remainder of the parameter name will be split along uppercase lines to become the "human-readable" parameter name. For example, numQtyInMeters will get a type of double and a display name of Qty In Meters.

Defining the stored command is really simple. Once you have a business rule module with some cmdMyCommand() type functions, you can navigate to the Commands tab and enter a stored command that associates with the command function.

Image 17

Notice that the combobox under Function will show all the functions from the module that begin with cmd.

The Command _target Variable

When you execute a command, the Aim.Scripting runtime sets a special variable called _target that is accessible from the running function.

This _target variable contains the context in which the command was executed - generally a list of objects in the List context, and a single object in the Single context. For example, here is an "integration" command that uses the _target variable as the list of objects that will be "integrated". This is defined in the Sales Integration business rule module:

"""
    Simulated integration strategies. See the AppRuleHost.cs
    C# class for more details.
    
    In particular, the host.integrate() method shows one way
    that you might wire up a Task<T>, send it off to do its
    thing, then report back to the UI with status updates.
    
    While it doesn't have a whole lot to do with scripted
    business rules as such, the varying ways that things must
    be integrated makes a good case for using business rules
    to handle some of the variability points.
    
    Note the use of the automatic _target variable here.
    Because we set the Type Key of this module to "Sale", when
    we run this command from a context of the list of sales,
    that list will be what the _target variable is set to.

"""

##
## Send sales information off to our Materials Requirements
## Planning software. Because our MRP system has such a simple
## API (hahHAHahAHhAHAhahhahaHah), we just coded up a quick
## PhonyIntegrator class to do the job.
##
def cmdSendToMrp():
    ##
    ## Call our phony local provider, which spins up a Task<T>
    ## and sends it off to do the work, reporting back on the
    ## status line for each item integrated. If any items fail,
    ## the host.integrate method will show an alert box with
    ## information about the number of successes and failures.
    ## See AppRuleHost.cs for more information.
    ##
    host.integrate('Phony', _target)

Command Arguments

We've already touched on how Aim.Scripting uses naming conventions to enable type inference, but you can do more with command arguments. For example, you can provide default values for the parameters using a special comment above the command function. Here is the Python code for the Sales Reports business rule module. Pay special attention to the two special comments above the cmdForYearMonth(...) function:

"""
    Sales list context reports. The output of these will
    show up in a new Command Results window at the bottom
    of the screen.
    
    Here, we're simply pulling in the repository and using
    that as our data source. More likely you would bring
    in System.Data and maybe run a custom stored procedure
    that's specific to a certain tenant, for example.
    
    Note how we can reference specific record ids in our
    code. That would be a huge no-no in your compiled,
    baked-in code, but for a business rule it's exactly
    what we would expect to do. We can change it in just
    a few seconds if the need arises.
    
    This module shows the concept of "parameter defaults".
    If you look at the cmdForYearMonth(...) function, you'll
    see that it is decorated with two special comments.
    These comments can be used to set parameter defaults
    when the PyFunction is constructed (and prior to its
    actual runtime execution). Notice that for the "value"
    part of the comment, we are calling an actual function
    that's defined in this module!
    
    There are a couple rules about using a function as the
    default parameter value:
    1. The function must be defined in this module, or it
       must be included via host.include(...)
    2. The function must take no arguments, that is it
       must be in the form of myFunction(), with nothing
       between the parentheses. Think of the signature for
       the function as a Func<T>, that is a method that
       takes no arguments and returns a value.
    3. The function for the default value is run in a C#
       try/catch block. If the try fails, the parameter value
       will not be set.

"""

import clr
clr.AddReference('System.Data')
clr.AddReference('BizRules.Core')
clr.AddReference('BizRules.DataModels')
from System import DateTime
from System.Data import DataTable
from BizRules import RepositoryProvider
from BizRules.DataModels import SaleDataModel

prevYear = None
prevMonth = None

def allSales():
    return RepositoryProvider.Current.GetRepository[SaleDataModel]().GetAll()

##
## Function for param value default
##
def getYear():
    ##
    ## If they have already run it, return the value they used before
    ##
    if prevYear:
        return prevYear
    return DateTime.Now.Year

##
## Function for param value default
##
def getMonth():
    ##
    ## If they have already run it, return the value they used before
    ##
    if prevMonth:
        return prevMonth
    return DateTime.Now.Month
    

def cmdMonthToDate():
    now = DateTime.Now
    return cmdForYearMonth(now.Year, now.Month)

##
## This is how we set default parameters for a command.
## Notice that we can even call a function to get the
## default value.
##
# <param name="intYear" value="getYear()" />
# <param name="intMonth" value="getMonth()" />
def cmdForYearMonth(intYear, intMonth):
    global prevYear
    global prevMonth
    prevYear = intYear
    prevMonth = intMonth
    sales = allSales()
    mSales = []
    for sale in sales:
        dos = sale.DateOfSale
        if dos.Year == intYear and dos.Month == intMonth:
            mSales.append(sale)
    return mSales

##
## Note how we are referring to a specific record id here.
## Not a big deal at all in business rules scripts.
##
def cmdAllWidgetsInc():
    sales = allSales()
    mSales = []
    for sale in sales:
        if sale.CustomerId == '36':
            mSales.append(sale)
    return mSales

The format of the special comment is:

XML
# <param name="parameterName" value="parameterValue" />

The command parameter comment isn't too intelligent yet - for example, it doesn't associate itself with a specific command function, so if you have two command functions in the same module that have the same parameter name, you'll get the results of the first comment. This is still a work in progress - I plan to make it more robust. In the meantime, if you just use different parameter names inside your command function declarations, all should be well.

By the way, you may have wondered "why not just use Python's default argument values?" We could have done it that way, but (as far as I know), you cannot call another Python function to get the default value. The way we define it, since we bypass the normal syntax for Python default arguments, we can implement it however we want - in our case, we've added the ability to make the default get its value by calling another function.

Well, This is Getting Pretty Long

There is much more to cover. We have barely touched on AppRuleHost.cs, for example - our implementation of the DefaultExecuter class. Read through the code and comments in AppRuleHost for ideas about how you can make your business rules much simpler by defining a host.do_something(...) method, so you can write parameterized boilerplate code in C# rather than IronPython.

What to Look For in the Code

If you want to know how the Commands Panel responds to the context and refreshes itself with the list of available and relevant commands, look at the following classes:

  • BizRules.Client.Wpf.ViewModels.CommandModuleListViewModel
  • BizRules.Client.Wpf.ViewModels.CommandModuleViewModel
  • BizRules.Client.Wpf.ViewModels.CommandViewModel
  • Pay particular attention to the code in the Handle(CommandTargetActivatedMessage message) method defined in CommandModuleListViewModel. which is an implementation of the Caliburn.Micro.IHandle<T> interface - one of my favorite interfaces.
  • Notice how these three VMs combine to form a hierarchy that shows in the Commands Panel view: Modules -> Module -> Commands -> Command -> Parameters -> Parameter.

If you want to know more about extending Aim.Scripting.DefaultExecuter, study the BizRules.Client.Wpf.AppRuleHost class. This class has lots of comments about:

  • Creating your own host methods
  • Writing to the UI on the UI thread
  • Spinning up Task<T> and letting it go off and do its thing, while reporting back in real time

If you need to know more about composition root, and where things are set up for scripting integration, look at the following classes:

  • BizRules.Client.Wpf.AppBootstrapper
    • Pay particular attention to the overridden Configure() method
  • BizRules.Client.Wpf.AppScriptProvider
  • BizRules.Client.Wpf.ViewModels.ShellViewModel
    • Look in the shell view model for lots more IHandle<T> implementations

See if you can figure out what's going on in Security Events, Admin Customer Reports, and Login Swap business rule modules. I'll leave it as an exercise for the reader.

History

  • 2015-06-09: First release

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)