diff --git a/organize/README.txt b/organize/README.txt index 065ba85..bbf5ebd 100644 --- a/organize/README.txt +++ b/organize/README.txt @@ -64,110 +64,3 @@ Service Management (See cyberapps.tumsm for comprehensive description and tests.) - -Work -==== - -Work items are stored in a tracking storage; in order to conveniently access -the work items we have to provide an adapter to the tracking storage. - - >>> from cybertools.tracking.btree import TrackingStorage - >>> from cybertools.organize.interfaces import IWorkItems - >>> from cybertools.organize.work import WorkItem, WorkItems - >>> component.provideAdapter(WorkItems) - -The individual work item (a track) is carrying a state attribute that is -governed by a special states definition. We have to register this states -definition as a utility. - - >>> from cybertools.organize.work import workItemStates - >>> component.provideUtility(workItemStates(), name='organize.workItemStates') - -We are now ready to set up the tracking storage. - - >>> tracks = TrackingStorage(trackFactory=WorkItem) - >>> workItems = component.getAdapter(tracks, IWorkItems) - -The work management only deals with the IDs or names of tasks and persons, -so we do not have to set up real objects. - - >>> wi01 = workItems.add('001', 'john') - >>> wi01 - - -Properties that have not been set explicitly have a default of None; properties -not specified in the IWorkItem interface will lead to an AttributeError. - - >>> wi01.description is None - True - >>> wi01.something - Traceback (most recent call last): - ... - AttributeError: something - -Certain (not all) properties may be set after creation. - - >>> wi01.setInitData(planStart=1229955772, planDuration=600, party='annie') - >>> wi01 - - -It's possible to change a value after it has been set as long as the work -item is in state 'new'. - - >>> wi01.setInitData(planEffort=700) - >>> wi01.planEffort - 700 - - >>> wi01.setInitData(party='jim') - >>> wi01.userName - 'jim' - -Change work item state ----------------------- - -Now Jim accepts the work item, i.e. he wants to work on it. Now the party -that the work item is assigned to may not be changed any more. - - >>> wi01.assign() - >>> wi01.state - 'assigned' - >>> wi01.setInitData(party='annie') - Traceback (most recent call last): - ... - ValueError: Attribute 'party' already set to 'jim'. - -Jim now really starts to work. The start time is usually set automatically -but may also be specified explicitly. - - >>> wi01.startWork(start=1229958000) - >>> wi01 - - -Stopping work -------------- - -After five minutes of work Jim decides to stop working; but he will -continue work later, so he executes a ``continue`` transition that will -set up a copy of the work item. - -He also specifies a new plan start and duration for the new work item. -Plan end and plan effort are given explicitly as None values so that they -won't be taken from the old work item but recalculated. - -Note that the work item has already been set to assigned as Jim has -committed himself to continue working on it by selecting the ``continue`` -transition. - - >>> wi02 = wi01.stopWork('continue', end=1229958300, planStart=1229960000, - ... planDuration=400, planEnd=None, planEffort=None) - >>> wi02 - diff --git a/organize/interfaces.py b/organize/interfaces.py index a4cdc0b..689a045 100644 --- a/organize/interfaces.py +++ b/organize/interfaces.py @@ -426,6 +426,7 @@ class IWorkItem(ITrack): done by exactly one party (usually a person). """ + # index attributes task = Attribute('The task this work item belongs to, identified by ' 'its name or ID.') run = Attribute('Used for recurring tasks: identifies the run ' @@ -433,17 +434,9 @@ class IWorkItem(ITrack): party = Attribute('Whoever does the work, usually a person, identified ' 'by its name or ID.') state = Attribute('The current state the work item is in.') + # standard attributes title = Attribute('A short text characterizing the work item.') description = Attribute('A note about what has to be done, and why...') - # optional plan fields; duration (and effort) may be derived from start and end - # all date/time fields are timeStamp values, all duration and effort - # fields are in seconds - planStart = Attribute('When the work should start.') - planEnd = Attribute('When the work should be finished.') - planDuration = Attribute('How long it may take to finish the work.') - planEffort = Attribute('How much effort (time units) it might take ' - 'to finish the work.') - # real stuff start = Attribute('When the work was started.') end = Attribute('When the work was finished.') duration = Attribute('How long it took to finish the work.') @@ -452,32 +445,14 @@ class IWorkItem(ITrack): # work item handling creator = Attribute('The party that has set up the work item.') created = Attribute('The timeStamp of the initial creation of the work item.') - assigned = Attribute('The timeStamp of the assignment of the work item.') - predecessor = Attribute('Optional: a work item this work item was created from.') - successor = Attribute('Optional: a work item that was created from this one ' - 'to continue the work.') newTask = Attribute('Optional: a new task that has been created based ' 'on this work item.') - def setInitData(**kw): + def setData(**kw): """ Set initial work item data (if allowed by the current state); values not given will be derived if appropriate. """ - def assign(party): - """ Assign the work item to the party given. - """ - - def startWork(**kw): - """ Start working on the work item; properties may be given - as keyword arguments. - """ - - def stopWork(transition='finish', **kw): - """ Finish working on the work item; properties may be given - as keyword arguments. - """ - def doAction(action, **kw): """ Execute an action. diff --git a/organize/tests.py b/organize/tests.py index 11cceb1..64cdde4 100755 --- a/organize/tests.py +++ b/organize/tests.py @@ -24,6 +24,7 @@ def test_suite(): return unittest.TestSuite(( unittest.makeSuite(TestParty), DocFileSuite('README.txt', optionflags=flags), + DocFileSuite('work.txt', optionflags=flags), )) if __name__ == '__main__': diff --git a/organize/work.py b/organize/work.py index 5218b4e..6f51dbf 100644 --- a/organize/work.py +++ b/organize/work.py @@ -41,22 +41,37 @@ _not_found = object() @implementer(IStatesDefinition) def workItemStates(): return StatesDefinition('workItemStates', - State('new', 'new', ('assign', 'cancel',), color='red'), - State('assigned', 'assigned', ('start', 'finish', 'cancel', 'transfer'), + State('new', 'new', + ('plan', 'accept', 'start', 'stop', 'finish', 'modify', 'delegate'), + color='red'), + State('planned', 'planned', + ('plan', 'accept', 'start', 'stop', 'finish', 'cancel', 'modify'), + color='red'), + State('accepted', 'accepted', + ('plan', 'accept', 'start', 'stop', 'finish', 'cancel', 'modify'), color='yellow'), - State('running', 'running', ('finish', 'continue', 'cancel', 'transfer'), + State('running', 'running', + ('stop', 'finish', 'cancel', 'modify'), color='orange'), - State('finished', 'finished', ('cancel',), color='green'), - State('continued', 'continued', ('finish', 'cancel'), color='blue'), - State('transferred', 'transferred', ('finish', 'cancel'), - color='lightblue'), - State('cancelled', 'cancelled', (), color='grey'), - Transition('assign', 'assign', 'assigned'), + State('stopped', 'stopped', + ('plan', 'accept', 'start', 'stop', 'finish', 'cancel', 'modify'), + color='orange'), + State('finished', 'finished', + ('plan', 'accept', 'start', 'stop', 'modify', 'close'), + color='green'), + State('cancelled', 'cancelled', + ('plan', 'accept', 'start', 'stop', 'modify', 'close'), + color='grey'), + State('closed', 'closed', (), color='lightblue'), + Transition('plan', 'plan', 'planned'), + Transition('accept', 'accept', 'accepted'), Transition('start', 'start', 'running'), + Transition('stop', 'stop', 'stopped'), Transition('finish', 'finish', 'finished'), - Transition('continue', 'continue', 'continued'), - Transition('transfer', 'transfer', 'transferred'), Transition('cancel', 'cancel', 'cancelled'), + Transition('modify', 'modify', 'new'), + Transition('delegate', 'delegate', 'planned'), + Transition('close', 'close', 'closed'), initialState='new') @@ -72,10 +87,8 @@ class WorkItem(Stateful, Track): index_attributes = metadata_attributes typeName = 'WorkItem' - initAttributes = set(['party', 'title', 'description', 'predecessor', - 'planStart', 'planEnd', 'planDuration', 'planEffort']) - - closeAttributes = set(['end', 'duration', 'effort', 'comment']) + initAttributes = set(['party', 'title', 'description', 'start', 'end', + 'duration', 'effort']) def __init__(self, taskId, runId, userName, data): super(WorkItem, self).__init__(taskId, runId, userName, data) @@ -83,11 +96,6 @@ class WorkItem(Stateful, Track): self.data['creator'] = userName self.data['created'] = self.timeStamp - def __getattr__(self, attr): - if attr not in IWorkItem: - raise AttributeError(attr) - return self.data.get(attr) - def getStatesDefinition(self): return component.getUtility(IStatesDefinition, name=self.statesDefinition) @@ -99,124 +107,85 @@ class WorkItem(Stateful, Track): def title(self): return self.data.get('title') or self.description - def setInitData(self, **kw): - indexChanged = False - updatePlanData = False - for k in kw: - if k not in self.initAttributes: - raise ValueError("Illegal initial attribute: '%s'." % k) - self.checkOverwrite(kw) + @property + def duration(self): + value = self.data.get('duration') + if value is None: + start, end = (self.data.get('start'), self.data.get('end')) + if not None in (start, end): + value = end - start + return value + + @property + def effort(self): + value = self.data.get('effort') + if value is None: + return self.duration + + def __getattr__(self, attr): + if attr not in IWorkItem: + raise AttributeError(attr) + return self.data.get(attr) + + def doAction(self, action, **kw): + if action in self.specialActions: + return self.specialActions[action](self, **kw) + if action not in [t.name for t in self.getAvailableTransitions()]: + raise ValueError("Action '%s' not allowed in state '%s'" % + (action, self.state)) + if self.state == 'new': + self.setData(**kw) + self.doTransition(action) + self.reindex('state') + return self + new = self.createNew(action, **kw) + new.doTransition(action) + new.reindex('state') + return new + + def modify(self, **kw): + print '*** modifying' + if self.state == 'new': + self.setData(**kw) + return self + + def delegate(self, **kw): + print '*** delegating' + + def close(self, **kw): + print '*** closing' + + specialActions = dict(modify=modify, delegate=delegate, close=close) + + def setData(self, **kw): + if self.state != 'new': + raise ValueError("Attributes may only be changed in state 'new'.") party = kw.pop('party', None) if party is not None: self.userName = party - indexChanged = True + self.reindex('userName') + start = kw.get('start') + if start is not None: + self.timeStamp = start + self.reindex('timeStamp') data = self.data for k, v in kw.items(): data[k] = v - if k.startswith('plan'): - updatePlanData = True - if self.planStart is not None and self.planStart != self.timeStamp: - self.timeStamp = self.planStart - indexChanged = True - if updatePlanData and self.planStart: - data['planEnd'], data['planDuration'], data['planEffort'] = \ - recalcTimes(self.planStart, self.planEnd, - self.planDuration, self.planEffort) - if indexChanged: - self.reindex() - def assign(self, party=None): - self.doTransition('assign') - self.data['assigned'] = getTimeStamp() - if party is not None: - self.userName = party - self.reindex() - - def startWork(self, **kw): - self.checkOverwrite(kw) - self.doTransition('start') - start = self.data['start'] = kw.pop('start', None) or getTimeStamp() - self.timeStamp = start - self.reindex() - - def stopWork(self, transition='finish', **kw): - self.doTransition(transition) - data = self.data - for k in self.closeAttributes: - v = kw.pop(k, None) + def createNew(self, action, **kw): + newData = {} + for k in self.initAttributes: + v = kw.get(k, _not_found) + if v is _not_found: + v = self.data.get(k) if v is not None: - data[k] = v - if self.start: - data['end'], data['duration'], data['effort'] = \ - recalcTimes(self.start, self.end, self.duration, self.effort) - self.timeStamp = self.end or getTimeStamp() - self.reindex() - if transition in ('continue', 'transfer'): - if transition == 'continue' and kw.get('party') is not None: - raise ValueError("Setting 'party' is not allowed when continuing.") - newData = {} - for k in self.initAttributes: - v = kw.pop(k, _not_found) - if v is _not_found: - v = data.get(k) - if v is not None: - newData[k] = v - if transition == 'transfer' and 'party' not in newData: - raise ValueError("Property 'party' must be set when transferring.") - workItems = IWorkItems(getParent(self)) - new = workItems.add(self.taskId, self.userName, self.runId, **newData) - if transition == 'continue': - new.assign() - #new.data['predecessor'] = getName(self) - new.data['predecessor'] = self.__name__ - #self.data['successor'] = getName(new) - self.data['successor'] = new.__name__ - return new + newData[k] = v + workItems = IWorkItems(getParent(self)) + new = workItems.add(self.taskId, self.userName, self.runId, **newData) + return new - # actions - - def doAction(self, action, **kw): - # TODO: check if action is allowed - m = getattr(self, 'action_' + action) - m(**kw) - - def action_start(self, **kw): - if self.state == 'new': - self.assign(kw.pop('party', None)) - self.startWork(**kw) - - def action_finish(self, **kw): - for k in ('description', 'title'): - if k in kw: - self.data[k] = kw.pop(k) - if self.state == 'new': - self.assign(kw.pop('party', None)) - if self.state == 'assigned': - self.startWork(start=kw.pop('start', None)) - self.stopWork(**kw) - - # auxiliary methods - - def reindex(self): - getParent(self).updateTrack(self, {}) # force reindex - - def checkOverwrite(self, kw): - if self.state == 'new': - return - for k, v in kw.items(): - old = getattr(self, k, None) - if old is not None and old != v: - raise ValueError("Attribute '%s' already set to '%s'." % (k, old)) - - -def recalcTimes(start, end, duration, effort): - if duration is None and start and end: - duration = end - start - if end is None and start and duration: - end = start + duration - if effort is None and duration: - effort = duration - return end, duration, effort + def reindex(self, idx=None): + getParent(self).indexTrack(None, self, idx) class WorkItems(object): @@ -245,7 +214,9 @@ class WorkItems(object): return self.context.query(**criteria) def add(self, task, party, run=0, **kw): + if not run: + run = self.context.startRun() trackId = self.context.saveUserTrack(task, run, party, {}) track = self[trackId] - track.setInitData(**kw) + track.setData(**kw) return track diff --git a/organize/work.txt b/organize/work.txt new file mode 100644 index 0000000..d2169ef --- /dev/null +++ b/organize/work.txt @@ -0,0 +1,135 @@ +========================================================== +Organizations: Persons, Institutions, Addresses, Work, ... +========================================================== + + ($Id$) + + >>> from zope import component + + +Basic Work Item Lifecycle +========================= + +Work items are stored in a tracking storage; in order to conveniently access +the work items we have to provide an adapter to the tracking storage. + + >>> from cybertools.tracking.btree import TrackingStorage + >>> from cybertools.organize.interfaces import IWorkItems + >>> from cybertools.organize.work import WorkItem, WorkItems + >>> component.provideAdapter(WorkItems) + +The individual work item (a track) is carrying a state attribute that is +governed by a special states definition. We have to register this states +definition as a utility. + + >>> from cybertools.organize.work import workItemStates + >>> component.provideUtility(workItemStates(), name='organize.workItemStates') + +We are now ready to set up the tracking storage. + + >>> tracks = TrackingStorage(trackFactory=WorkItem) + >>> workItems = component.getAdapter(tracks, IWorkItems) + +The work management only deals with the IDs or names of tasks and persons, +so we do not have to set up real objects. + + >>> wi01 = workItems.add('001', 'john') + >>> wi01 + + +Properties that have not been set explicitly have a default of None; properties +not specified in the IWorkItem interface will lead to an AttributeError. + + >>> wi01.description is None + True + >>> wi01.something + Traceback (most recent call last): + ... + AttributeError: something + +Properties may be set as long as the work item is in status "new". + + >>> wi01.setData(start=1229955772, party='annie') + >>> wi01 + + +The duration value is calculated automatically when start and end are set; +the effort is taken from the duration if not set explicitly. + + >>> (wi01.end, wi01.duration, wi01.effort) + (None, None, None) + >>> wi01.setData(end=1229956372) + >>> (wi01.end, wi01.duration, wi01.effort) + (1229956372, 600, 600) + + >>> wi01.setData(duration=700) + >>> wi01.effort + 700 + + >>> w = wi01.doAction('plan', party='jim') + >>> wi01.userName + 'jim' + +Change work item state +---------------------- + +Now Jim accepts the work item, i.e. he wants to work on it. + + >>> wi02 = wi01.doAction('accept') + >>> wi01.state + 'planned' + >>> wi02 + + +It is not possible to change a value of a work item that is not in state "new". + + >>> wi01.setData(party='annie') + Traceback (most recent call last): + ... + ValueError: Attributes may only be changed in state 'new'. + +Jim now really starts to work. The start time is usually set automatically +but may also be specified explicitly. + + >>> wi03 = wi02.doAction('start', start=1229958000) + >>> wi03 + + +Stopping and finishing work +--------------------------- + +After five minutes of work Jim decides to stop working; but he will +continue work later, so he executes a ``stop`` action. + + >>> wi04 = wi03.doAction('stop', end=1229958300) + >>> wi04 + + +Closing work +------------ + +Let's now check how many work items have been generated. + + >>> len(list(workItems)) + 4 + + +Delegation of Work Items +======================== + + +Modification of Work Items +========================== + + +Queries +======= +