def _load(workflow, name): """ (workflow:etree_doc, name:str) -> Workflow """ transitions = [] states = [] domain = strip_none(workflow.get("domain")) # !+domain(mr, jul-2011) drop? only used as state/transition title default wuids = set() # unique IDs in this XML workflow file auditable = as_bool(strip_none(workflow.get("auditable")) or "false") versionable = as_bool(strip_none(workflow.get("versionable")) or "false") if versionable: assert auditable, "Workflow [%s] is versionable but not auditable" % ( name) note = strip_none(workflow.get("note")) # initial_state, must be "" assert workflow.get("initial_state") == "", "Workflow [%s] initial_state " \ "attribute must be empty string, not [%s]" % ( name, workflow.get("initial_state")) initial_state = None ZCML_PROCESSED = bool(name in ZCML_WORKFLOWS_PROCESSED) if not ZCML_PROCESSED: ZCML_WORKFLOWS_PROCESSED.add(name) ZCML_LINES.append(ZCML_INDENT) ZCML_LINES.append(ZCML_INDENT) ZCML_LINES.append("%s<!-- %s -->" % (ZCML_INDENT, name)) def validate_id(id, tag): """Ensure that ID values are unique within the same XML doc scope. """ m = 'Invalid <%s> id="%s" in workflow [%s]' % (tag, id, name) assert id is not None, "%s -- id may not be None" % (m) assert ID_RE.match(id), '%s -- only letters, numbers, "_" allowed' % (m) assert id not in wuids, "%s -- id not unique in workflow document" % (m) wuids.add(id) def get_like_state(state_id): if state_id is None: return for state in states: if state.id == state_id: return state assert False, 'Invalid value: like_state="%s"' % (state_id) def check_add_permission(permissions, like_permissions, assignment, p, r): for perm in [(GRANT, p, r), (DENY, p, r)]: assert perm not in permissions, "Workflow [%s] state [%s] " \ "conflicting state permission: (%s, %s, %s)" % ( name, state_id, assignment, p, r) if perm in like_permissions: like_permissions.remove(perm) permissions.append((assignment, p, r)) def assert_valid_attr_names(e, allowed_attr_names): for key in e.keys(): assert key in allowed_attr_names, \ "Workflow [%s]: unknown attribute %s in %s" % ( name, key, etree.tostring(e)) # top-level child ordering grouping, allowed_child_ordering = 0, ("grant", "state", "transition") for child in workflow.iterchildren(): if not isinstance(child.tag, basestring): # ignore comments continue while child.tag != allowed_child_ordering[grouping]: grouping += 1 assert grouping < 3, "Workflow [%s] element <%s> %s not allowed " \ "here -- element order must respect: %s" % ( name, child.tag, child.items(), allowed_child_ordering) # global grants for p in workflow.iterchildren("grant"): pid = strip_none(p.get("permission")) role = strip_none(p.get("role")) #+!assertRegisteredPermission(permission) ZCML_LINES.append( '%s<grant permission="%s" role="%s" />' % (ZCML_INDENT, pid, role)) # states for s in workflow.iterchildren("state"): assert_valid_attr_names(s, STATE_ATTRS) # @id state_id = strip_none(s.get("id")) assert state_id, "Workflow State must define @id" validate_id(state_id, "state") # actions actions = [] # version if strip_none(s.get("version")) is not None: make_version = as_bool(strip_none(s.get("version"))) if make_version is None: raise ValueError("Invalid state value " '[version="%s"]' % s.get("version")) if make_version: actions.append(ACTIONS_MODULE.create_version) # state-id-inferred action - if "actions" module defines an action for # this state (associated via a naming convention), then use it. # !+ tmp, until actions are user-exposed as part of <state> action_name = "_%s_%s" % (name, state_id) if hasattr(ACTIONS_MODULE, action_name): actions.append(getattr(ACTIONS_MODULE, action_name)) # @like_state, permissions permissions = [] # [ tuple(bool:int, permission:str, role:str) ] # state.@like_state : to reduce repetition and enhance maintainibility # of workflow XML files, a state may specify a @like_state attribute to # inherit all permissions defined by the specified like_state; further # permissions specific to this state may be added, but as these may # also override inherited permissions we streamline those out so that # downstream execution (a permission should be granted or denied only # once per transition to a state). like_permissions = [] like_state = get_like_state(strip_none(s.get("like_state"))) if like_state: like_permissions.extend(like_state.permissions) # (within same state) a deny is *always* executed after a *grant* for i, assign in enumerate(["grant", "deny"]): for p in s.iterchildren(assign): permission = strip_none(p.get("permission")) role = strip_none(p.get("role")) #+!assertRegisteredPermission(permission) check_add_permission(permissions, like_permissions, ASSIGNMENTS[i], permission, role) if like_state: # splice any remaining like_permissions at beginning of permissions permissions[0:0] = like_permissions # notifications notifications = [] # [ notification.Notification ] for n in s.iterchildren("notification"): notifications.append( Notification( strip_none(n.get("condition")), # python resolvable strip_none(n.get("subject")), # template source, i18n strip_none(n.get("from")), # template source strip_none(n.get("to")), # template source strip_none(n.get("body")), # template source, i18n strip_none(n.get("note")), # documentational note ) ) # states states.append( State(state_id, Message(s.get("title", domain)), strip_none(s.get("note")), actions, permissions, notifications, as_bool(strip_none(s.get("permissions_from_parent")) or "false"), as_bool(strip_none(s.get("obsolete")) or "false") ) ) STATE_IDS = [ s.id for s in states ] # transitions for t in workflow.iterchildren("transition"): assert_valid_attr_names(t, TRANS_ATTRS) for key in TRANS_ATTRS_REQUIREDS: if key == "source" and t.get(key) == "": # initial_state, an explicit empty string continue elif strip_none(t.get(key)) is None: raise SyntaxError('No required "%s" attribute in %s' % ( key, etree.tostring(t))) # sources, empty string -> initial_state sources = t.get("source").split() or [initial_state] assert len(sources) == len(set(sources)), \ "Transition contains duplicate sources [%s]" % (sources) for source in sources: if source is not initial_state: assert source in STATE_IDS, \ "Unknown transition source state [%s]" % (source) # destination must be a valid state destination = t.get("destination") assert destination in STATE_IDS, \ "Unknown transition destination state [%s]" % (destination) # update ZCML for dedicated permission for (XML multi-source) transition tid = "%s.%s" % ( ".".join([ source or "" for source in sources ]), destination) pid = "bungeni.%s.wf.%s" % (name, tid) if not ZCML_PROCESSED: if is_zcml_permissionable(t): zcml_transition_permission(pid, t.get("title"), t.get("roles", "bungeni.Clerk").split()) kw = {} # optionals -- only set on kw IFF explicitly defined for i in TRANS_ATTRS_OPTIONALS: val = t.get(i) if not val: # we let setting of defaults be handled upstream continue kw[i] = val # data up-typing # # trigger if "trigger" in kw: kw["trigger"] = trigger_value_map[kw["trigger"]] # permission - one-to-one per transition, may only be {pid} or None if is_zcml_permissionable(t): kw["permission"] = pid else: assert kw.get("permission") is None, "Not allowed to set a " \ "permission on (creation) transition: %s" % (tid) # python resolvables if "condition" in kw: kw["condition"] = capi.get_workflow_condition(kw["condition"]) # numeric if "order" in kw: kw["order"] = float(kw["order"]) # ValueError if not numeric # bool if "require_confirmation" in kw: try: kw["require_confirmation"] = as_bool(kw["require_confirmation"]) assert kw["require_confirmation"] is not None except: raise ValueError("Invalid transition value " '[require_confirmation="%s"]' % ( t.get("require_confirmation"))) # multiple-source transitions are really multiple "transition paths" for source in sources: args = (Message(t.get("title"), domain), source, destination) transitions.append(Transition(*args, **kw)) log.debug("[%s] adding transition [%s-%s] [%s]" % ( name, source or "", destination, kw)) return Workflow(name, states, transitions, auditable, versionable, note)
def _load(workflow, name): """ (workflow:etree_doc, name:str) -> Workflow """ # !+ @title, @description transitions = [] states = [] domain = strip_none(workflow.get("domain")) # !+domain(mr, jul-2011) needed? wuids = set() # unique IDs in this XML workflow file note = strip_none(workflow.get("note")) allowed_tags = (strip_none(workflow.get("tags")) or "").split() # initial_state, in XML this must be "" assert workflow.get("initial_state") == "", "Workflow [%s] initial_state " \ "attribute must be empty string, not [%s]" % ( name, workflow.get("initial_state")) initial_state = None ZCML_PROCESSED = bool(name in ZCML_WORKFLOWS_PROCESSED) if not ZCML_PROCESSED: ZCML_WORKFLOWS_PROCESSED.add(name) ZCML_LINES.append(ZCML_INDENT) ZCML_LINES.append(ZCML_INDENT) ZCML_LINES.append("%s<!-- %s -->" % (ZCML_INDENT, name)) def validate_id(id, tag): """Ensure that ID values are unique within the same XML doc scope. """ m = 'Invalid <%s> id="%s" in workflow [%s]' % (tag, id, name) assert id is not None, "%s -- id may not be None" % (m) assert ID_RE.match( id), '%s -- only letters, numbers, "_" allowed' % (m) assert id not in wuids, "%s -- id not unique in workflow document" % ( m) wuids.add(id) def get_like_state(state_id): if state_id is None: return for state in states: if state.id == state_id: return state raise ValueError('Invalid value: like_state="%s"' % (state_id)) def check_add_permission(permissions, like_permissions, assignment, p, r): for perm in [(GRANT, p, r), (DENY, p, r)]: assert perm not in permissions, "Workflow [%s] state [%s] " \ "duplicated or conflicting state permission: (%s, %s, %s)" % ( name, state_id, assignment, p, r) if perm in like_permissions: like_permissions.remove(perm) permissions.append((assignment, p, r)) def assert_valid_attr_names(elem, allowed_attr_names): for key in elem.keys(): assert key in allowed_attr_names, \ "Workflow [%s]: unknown attribute %s in %s" % ( name, key, etree.tostring(elem)) # top-level child ordering grouping, allowed_child_ordering = 0, ("feature", "grant", "state", "transition") for child in workflow.iterchildren(): if not isinstance(child.tag, basestring): # ignore comments continue while child.tag != allowed_child_ordering[grouping]: grouping += 1 assert grouping < 4, "Workflow [%s] element <%s> %s not allowed " \ "here -- element order must respect: %s" % ( name, child.tag, child.items(), allowed_child_ordering) # features features = [] for f in workflow.iterchildren("feature"): assert_valid_attr_names(f, FEATURE_ATTRS) # @name feature_name = strip_none(f.get("name")) assert feature_name, "Workflow Feature must define @name" # !+ archetype/feature inter-dep; should be part of feature descriptor feature_enabled = as_bool(strip_none(f.get("enabled")) or "true") if feature_enabled and feature_name == "version": assert "audit" in [ fe.name for fe in features if fe.enabled ], \ "Workflow [%s] has version but no audit feature" % (name) features.append( Feature(feature_name, enabled=feature_enabled, note=strip_none(f.get("note")))) # global grants _permission_role_mixes = {} for p in workflow.iterchildren("grant"): pid = strip_none(p.get("permission")) role = strip_none(p.get("role")) # for each global permission, build list of roles it is set to _permission_role_mixes.setdefault(pid, []).append(role) #+!assertRegisteredPermission(permission) assert pid and role, "Global grant must specify valid permission/role" ZCML_LINES.append('%s<grant permission="%s" role="%s" />' % (ZCML_INDENT, pid, role)) for perm, roles in _permission_role_mixes.items(): # assert roles mix limitations for state permissions assert_distinct_permission_scopes(perm, roles, name, "global grants") # states for s in workflow.iterchildren("state"): assert_valid_attr_names(s, STATE_ATTRS) # @id state_id = strip_none(s.get("id")) assert state_id, "Workflow State must define @id" validate_id(state_id, "state") # actions actions = [] # version (prior to any custom actions) if strip_none(s.get("version")) is not None: make_version = as_bool(strip_none(s.get("version"))) if make_version is None: raise ValueError("Invalid state value " '[version="%s"]' % s.get("version")) if make_version: actions.append(ACTIONS_MODULE.create_version) # state-id-inferred action - if "actions" module defines an action for # this state (associated via a naming convention), then use it. # !+ tmp, until actions are user-exposed as part of <state> action_name = "_%s_%s" % (name, state_id) if hasattr(ACTIONS_MODULE, action_name): actions.append(getattr(ACTIONS_MODULE, action_name)) # publish (after any custom actions) if strip_none(s.get("publish")) is not None: do_pub = as_bool(strip_none(s.get("publish"))) if do_pub is None: raise ValueError("Invalid state value " '[publish="%s"]' % s.get("publish")) if do_pub: actions.append(ACTIONS_MODULE.publish_to_xml) # @tags tags = (strip_none(s.get("tags")) or "").split() # @like_state, permissions permissions = [] # [ tuple(bool:int, permission:str, role:str) ] # state.@like_state : to reduce repetition and enhance maintainibility # of workflow XML files, a state may specify a @like_state attribute to # inherit all permissions defined by the specified like_state; further # permissions specific to this state may be added, but as these may # also override inherited permissions we streamline those out so that # downstream execution (a permission should be granted or denied only # once per transition to a state). like_permissions = [] like_state = get_like_state(strip_none(s.get("like_state"))) if like_state: like_permissions.extend(like_state.permissions) # (within same state) a deny is *always* executed after a *grant* for i, assign in enumerate(["grant", "deny"]): for p in s.iterchildren(assign): permission = strip_none(p.get("permission")) role = strip_none(p.get("role")) #+!assertRegisteredPermission(permission) check_add_permission(permissions, like_permissions, ASSIGNMENTS[i], permission, role) if like_state: # splice any remaining like_permissions at beginning of permissions permissions[0:0] = like_permissions # notifications notifications = [] # [ notification.Notification ] for n in s.iterchildren("notification"): notifications.append( Notification( strip_none(n.get("condition")), # python resolvable strip_none(n.get("subject")), # template source, i18n strip_none(n.get("from")), # template source strip_none(n.get("to")), # template source strip_none(n.get("body")), # template source, i18n strip_none(n.get("note")), # documentational note )) # states states.append( State( state_id, Message(strip_none(s.get("title")), domain), strip_none(s.get("note")), actions, permissions, notifications, tags, as_bool( strip_none(s.get("permissions_from_parent")) or "false"), as_bool(strip_none(s.get("obsolete")) or "false"))) STATE_IDS = [s.id for s in states] # transitions for t in workflow.iterchildren("transition"): assert_valid_attr_names(t, TRANS_ATTRS) for key in TRANS_ATTRS_REQUIREDS: if key == "source" and t.get(key) == "": # initial_state, an explicit empty string continue elif strip_none(t.get(key)) is None: raise SyntaxError('No required "%s" attribute in %s' % (key, etree.tostring(t))) # title title = strip_none(t.get("title")) # sources, empty string -> initial_state sources = t.get("source").split() or [initial_state] assert len(sources) == len(set(sources)), \ "Transition contains duplicate sources [%s]" % (sources) for source in sources: if source is not initial_state: assert source in STATE_IDS, \ "Unknown transition source state [%s]" % (source) # destination must be a valid state destination = t.get("destination") assert destination in STATE_IDS, \ "Unknown transition destination state [%s]" % (destination) # optionals -- only set on kw IFF explicitly defined kw = {} for i in TRANS_ATTRS_OPTIONALS: val = strip_none(t.get(i)) if not val: # we let setting of defaults be handled upstream continue kw[i] = val # data up-typing # # trigger if "trigger" in kw: kw["trigger"] = trigger_value_map[kw["trigger"]] # roles -> permission - one-to-one per transition roles = kw.pop("roles", None) # space separated str if not is_zcml_permissionable(t): assert not roles, "Workflow [%s] - non-permissionable transition " \ "does not allow @roles [%s]." % (name, roles) kw["permission"] = None # None -> CheckerPublic # !+CAN_EDIT_AS_DEFAULT_TRANSITION_PERMISSION(mr, oct-2011) this feature # is functional (uncomment following elif clause) but as yet not enabled. # # Advantage would be that it would be easier to keep transitions # permissions in sync with object permissions (set in state) as the # majority of transition require exactly this as privilege; for the # occassional transition needing a different privilege, the current # transition.@roles mechanism may be used to make this explicit. # # Need to consider implications further; the zope_principal_role_map db # table, that caches contextual roles for principals, should probably # first be reworked to be db-less (as for zope_role_permission_map). # #elif not roles: # # then as fallback transition permission use can modify object # kw["permission"] = "bungeni.%s.Edit" % (name) # fallback permission else: # Dedicated permission for XML multi-source transition. # Given that, irrespective of how sources are grouped into # multi-source XML <transition> elements, there may be only *one* # path from any given *source* to any given *destination* state, # it suffices to use only the first source element + the destination # to guarantee a unique identifier for an XML transition element. # # Note: the "-" char is not allowed within a permission id # (so we use "." also here). # tid = "%s.%s" % (sources[0] or "", destination) pid = "bungeni.%s.wf.%s" % (name, tid) if not ZCML_PROCESSED: # use "bungeni.Clerk" as default list of roles roles = (roles or "bungeni.Clerk").split() zcml_transition_permission(pid, title, roles) # remember list of roles from xml kw["_roles"] = roles kw["permission"] = pid # python resolvables if "condition" in kw: kw["condition"] = capi.get_workflow_condition(kw["condition"]) # numeric if "order" in kw: kw["order"] = float(kw["order"]) # ValueError if not numeric # bool if "require_confirmation" in kw: try: kw["require_confirmation"] = as_bool( kw["require_confirmation"]) assert kw["require_confirmation"] is not None except: raise ValueError("Invalid transition value " '[require_confirmation="%s"]' % (t.get("require_confirmation"))) # multiple-source transitions are really multiple "transition paths" for source in sources: args = (Message(title, domain), source, destination) transitions.append(Transition(*args, **kw)) log.debug("[%s] adding transition [%s-%s] [%s]" % (name, source or "", destination, kw)) return Workflow(name, features, allowed_tags, states, transitions, note)