2

I built a small django based web application that makes use of basic workflow/state machine concept for some of the operations. Nothing big or fancy so I never initially thought of using a workflow library for it; now though, when I am in need for some additional functionality, I am thinking about getting a library that would fit my needs instead of advancing what I have so far.

Before the explanation gets too boring, I will explain in brief what my needs are and how I have implemented them this far.

Although I do use django, most of data interactions are done via Django Rest Framework - most of data is retrieved and submitted by JavaScript on client's end, I thought bake web forms on the server side.

This is, in brief (and slightly simplified for readability), how it currently works for me.

current implementation sequence diagram

My workflow needs are:

  1. Introduce default fields for when the model is created (configuration driven, rather than default value in Model Field)
  2. Introduce additional actions (say, 'mark as done' would save an entity with a status field set to a 'done' value)
  3. Control which model entity fields are updated when an action is performed (say, on 'create' the 'solution' field will be hidden and on 'mark as done' the solution field will be the only one on the form to show for edit)
  4. Control if the user is able to perform specific actions based on their role and/or based on current state of the entity (entities with 'done' status will not have 'mark as done' in available actions)
  5. Prepopulate interaction forms with data (say, when a user is performing 'allocate user to project' on a project page, the 'project' field will be pre-populated and hidden on the form).

Short question: My home baked implementation is becoming obsolete as my needs and codebase grow; is there a solution that will nicely merry with Django Rest Framework and an existing form builder?

Below are some additional boring details to help better understand how my stuff currently works - I think this is going to give the question proper context and lead to a better answer.

Workflow configuration

My workflow configuration is stored in the following format:

#        0               1                      2               3                   4                 5
#('<action_code>'  '<Transition Name>', [<statuses from>], <status to>, [<fields to display>], <Permission getter>)
#Permission getter will return True of False based on the action availability based on user context
wf_Milestone = (
    ('edit', 'Edit', [1, 2, 3, 4], None, '*', 'get_perm_change_status'),
    ('update_scope', 'Update Scope', [1, 2], None, ['scope'], 'get_perm_change_status'),
    ('mark_closed', 'Mark as Closed', [1, 2], 4, [], 'get_perm_change_status'),
    ('mark_cancelled', 'Mark as Cancelled', [1, 2], 3, [], 'get_perm_change_status'),
    ('delete', 'Delete', [1, 2, 3, 4], 5, [], 'get_perm_change_status'),
    ('change_scope_and_delete', 'Update Scope and Delete', [1, 2, 3, 4], 5, ['scope'], 'get_perm_change_status'),
)

Basically, it stores, the code of the action and it's name (to show on buttons and stuff), (int) of status id the action is valid for, (int) of target status (will not change if None) and model's method to validate user's ability to perform this action.

Retrieving available actions

When model data is retrieved, it will as well return available_actions attribute to the client. It's done in two stages: first, available actions are converted to a class and then model's attribute is filled with model id and other details that are required.

def available_actions(self, *args, **kwargs):
    actions = list()
    for action in self.wf_get_actions(*args, **kwargs):
        if action['fields'] == '*' or action['fields']:
            form_builder_url = '/form_builder/%s/%s/%s/' % (self.__class__.__name__, action['code'], self.id)
        else:
            form_builder_url = None
        url = '/REST/WF/%s/%s/%s/' % (self.__class__.__name__, self.id, action['code'])
        actions.append(
            {
                'code': action['code'],
                'name': action['name'],
                'form_builder_url': form_builder_url,
                'wf_post_url': url,
                'fields': action['fields']}
        )
    return actions

When a model instance is returned to the client, it will look like this:

[
    {
        "id": 29,
        "author": {
            "first_name": "Sasha",
            "last_name": "Bolotnov",
        },
        "created_date": "Nov 12, 2015 @ 03:11",
        "due_date": null,
        "project": null,
        "status": {
            "id": 1,
            "is_active": true,
            "name": "New",
            "order": 10
        },
        "available_actions": [
            {
                "form_builder_url": "/form_builder/ActionItem/eidt/29/",
                "fields": "*",
                "code": "eidt",
                "name": "Edit",
                "wf_post_url": "/REST/WF/ActionItem/29/eidt/"
            },
            {
                "form_builder_url": "/form_builder/ActionItem/close/29/",
                "fields": [
                    "resolution"
                ],
                "code": "close",
                "name": "Close",
                "wf_post_url": "/REST/WF/ActionItem/29/close/"
            },
            {
                "form_builder_url": "/form_builder/ActionItem/mark_done/29/",
                "fields": [
                    "resolution"
                ],
                "code": "mark_done",
                "name": "Mark as Done",
                "wf_post_url": "/REST/WF/ActionItem/29/mark_done/"
            },
            {
                "form_builder_url": "/form_builder/ActionItem/assign/29/",
                "fields": [
                    "assignee"
                ],
                "code": "assign",
                "name": "Assign",
                "wf_post_url": "/REST/WF/ActionItem/29/assign/"
            },
            {
                "form_builder_url": "/form_builder/ActionItem/delete/29/",
                "fields": [
                    "resolution"
                ],
                "code": "delete",
                "name": "Delete",
                "wf_post_url": "/REST/WF/ActionItem/29/delete/"
            }
        ],
        "chat_id": "ActionItem-29"
    }
]

Every available action has a form_builder_url, this URL is used to get the form user needs to fill out to perform the action.

Form Builder

Form builder builds the forms. It makes sure there are only the right fields to show and the rest of the fields are hidden and all. It's fairly simple how it works.

I use crispy forms to actually build them:

class ActionItemShortForm(forms.ModelForm):
    assignee = forms.ModelChoiceField(queryset=User.objects.filter(userprofile__allow_login=True).order_by('first_name'))

    class Meta:
        model = ActionItem
        fields = (
            'portfolio',
            'project',
            'priority',
            'due_date',
            'assignee',
            'name',
            'description',
        )

    def __init__(self, *args, **kwargs):
        super(ActionItemShortForm, self).__init__(*args, **kwargs)
        if kwargs.get('initial', {}).get('portfolio'):
            self.fields['project'].queryset = Project.objects.filter(portfolio__id=kwargs.get('initial').get('portfolio'))
        self.helper = FormHelper()
        self.helper.form_tag = True
        self.helper.layout = Layout()

and so when a form is requested, it uses the same workflow mechanism to figure out the right fields to display or hide and all:

def get_form(request, action, id=None):

    form = None

    if action == 'create':
        form = ActionItemShortForm(initial=dict(request.GET.iteritems()))
        for param, value in request.GET.iteritems():
            if form.fields.get(param, None):
                form.fields[param].widget = forms.HiddenInput()

    elif action and id:
        _instance = ActionItem.objects.get(id=id)
        wf_definition = WFTransition.get_by_code_from_schema(_instance.get_wf_def(), action)
        if wf_definition:
            form = ActionItemFullForm(instance=_instance)
            if wf_definition.fields == [] or (wf_definition.fields and wf_definition.fields != '*'):
                for field in form.fields:
                    if field not in wf_definition.fields:
                        form.fields[field].widget = forms.HiddenInput()

    return render_to_response('forms/formbase.html', {'form': form}, template.RequestContext(request))

Posting forms to initiate the workflow

When user fills out a form and submits it, the data goes to wf_post_url into workflow processor that does the actual work on managing the entity data, setting it's new status and other things, when required:

@api_view(http_method_names=['POST'])
def wf_processor(request, action, id):
    try:
        _instance = ActionItem.objects.get(id=id)
        _wf_def = ActionItem.wf_get_action_by_code(_instance, action)
        if not _wf_def:
            raise Exception("Supplied WF Code %s does not match any of existing WF codes in configuration" % action)
        if _wf_def.fields == [] and len(_wf_def.fields) == 0:
            _instance.status = DicActionItemStatus.objects.get(id=_wf_def.trans_to)
            _instance.save()
        else:
            _serializer = ActionItemBaseSerializer(data=request.data, instance=_instance)
            if _serializer.is_valid():
                if _wf_def.trans_to:
                    _serializer.validated_data[WF_ACTION_ITEM_STATUS_FIELD] = DicActionItemStatus.objects.get(id=_wf_def.trans_to)
                _serializer.save()
            else:
                return Response(_serializer.errors, status=HTTP_400_BAD_REQUEST)
        msg_serializer = MessageSerializer(Message(title="OK", body="Operation completed successfully!"))
        return Response(msg_serializer.data)
    except Exception, e:
        msg_serializer = MessageSerializer(Message(title="Error", body=e.message))
        return Response(msg_serializer.data, status=HTTP_500_INTERNAL_SERVER_ERROR)

The above covers pretty much the complete cycle here. It does work, although I hate the code and see a lot of room for refactoring (I am not a professional programmer and this is my probably largest project with python so I am learning as I progress) and optimization, but there are a couple of things that cause issues now:

Issue 1: I want to be able to pass context into the workflow

Let's say I am looking at an object project with a child object demand and want to create another object allocation. Since I know that I will be creating allocation against a particular demand, I am able to save this allocation properly, but since I know the project, I want this field to be pre-populated and hidden on the form. In other words, I want to be able to define which additional context goes into the form builder and further down. Right now, with my current implementation, if I wanted to do this, it would be a lot of custom coding that is hand to maintain.

Issue 2: My current approach is probably very wrong

I think that my current approach is wrong:

  • it's not encapsulated into a single tier, it has connections with too many areas: models, forms, REST
  • lots of code, overall - most of entities have their own implementation of form builder (there are little things here and there that make it almost impossible to inhering from a single implementation)
  • my codebase does not look very expandable and maintainable - solely for this reason, I would probably get this of what I have and replace it by something else, even if it doesn't solve my first problem.

Thank you very much for reading this far!

abolotnov
  • 4,282
  • 9
  • 56
  • 88
  • Have you tried to evaluate projects listed here http://stackoverflow.com/questions/6795328/workflow-frameworks-for-django/25717038#25717038 ? – kmmbvnr Aug 25 '16 at 02:18

0 Answers0