File: reducer.py

package info (click to toggle)
taskcoach 1.4.1-4
  • links: PTS, VCS
  • area: main
  • in suites: jessie, jessie-kfreebsd
  • size: 32,496 kB
  • ctags: 17,810
  • sloc: python: 72,170; makefile: 254; ansic: 120; xml: 29; sh: 16
file content (269 lines) | stat: -rw-r--r-- 13,220 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
'''
Task Coach - Your friendly task manager
Copyright (C) 2004-2014 Task Coach developers <developers@taskcoach.org>

Task Coach is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.

Task Coach is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
GNU General Public License for more details.

You should have received a copy of the GNU General Public License
along with this program.  If not, see <http://www.gnu.org/licenses/>.
'''

from taskcoachlib import patterns
from taskcoachlib.domain import date, task
from wx.lib.pubsub import pub
import composite
import effortlist
import effort


class EffortAggregator(patterns.SetDecorator, 
                       effortlist.EffortUICommandNamesMixin):
    ''' This class observes an TaskList and aggregates the individual effort
        records to CompositeEfforts, e.g. per day or per week. Whenever a 
        CompositeEffort becomes empty, for example because effort is deleted,
        it sends an 'empty' event so that the aggregator can remove the
        (now empty) CompositeEffort from itself. '''
        
    def __init__(self, *args, **kwargs):
        self.__composites = {}
        self.__trackedComposites = set()
        aggregation = kwargs.pop('aggregation')
        assert aggregation in ('day', 'week', 'month')
        aggregation = aggregation.capitalize()
        self.__start_of_period = getattr(date.DateTime, 'startOf%s' % aggregation)
        self.__end_of_period = getattr(date.DateTime, 'endOf%s' % aggregation)
        super(EffortAggregator, self).__init__(*args, **kwargs)
        pub.subscribe(self.onCompositeEmpty, 
                      composite.CompositeEffort.compositeEmptyEventType())
        pub.subscribe(self.onTaskEffortChanged, 
                      task.Task.effortsChangedEventType())
        patterns.Publisher().registerObserver(self.onChildAddedToTask,
            eventType=task.Task.addChildEventType())
        patterns.Publisher().registerObserver(self.onChildRemovedFromTask,
            eventType=task.Task.removeChildEventType())
        patterns.Publisher().registerObserver(self.onTaskRemoved, 
                                              self.observable().removeItemEventType(),
                                              eventSource=self.observable())
        pub.subscribe(self.onEffortStartChanged, 
                      effort.Effort.startChangedEventType())
        pub.subscribe(self.onRevenueChanged,
                      task.Task.hourlyFeeChangedEventType())

    def detach(self):
        super(EffortAggregator, self).detach()
        patterns.Publisher().removeObserver(self.onChildAddedToTask)
        patterns.Publisher().removeObserver(self.onChildRemovedFromTask)
        patterns.Publisher().removeObserver(self.onTaskRemoved)

    def extend(self, efforts):  # pylint: disable=W0221
        for effort in efforts:
            effort.task().addEffort(effort)

    def removeItems(self, efforts):  # pylint: disable=W0221
        for effort in efforts:
            effort.task().removeEffort(effort)
            
    @patterns.eventSource
    def extendSelf(self, tasks, event=None):
        ''' extendSelf is called when an item is added to the observed
            list. The default behavior of extendSelf is to add the item
            to the observing list (i.e. this list) unchanged. We override 
            the default behavior to first get the efforts from the task
            and then group the efforts by time period. '''
        new_composites = []
        for task in tasks:  # pylint: disable=W0621
            new_composites.extend(self.__create_composites(task, task.efforts()))
        self.__extend_self_with_composites(new_composites, event=event)
        
    @patterns.eventSource
    def __extend_self_with_composites(self, new_composites, event=None):
        ''' Add composites to the aggregator. '''
        super(EffortAggregator, self).extendSelf(new_composites, event=event)
        for new_composite in new_composites:
            if new_composite.isBeingTracked():
                self.__trackedComposites.add(new_composite)
                pub.sendMessage(effort.Effort.trackingChangedEventType(),
                                newValue=True, sender=new_composite)

    @patterns.eventSource
    def removeItemsFromSelf(self, tasks, event=None):
        ''' removeItemsFromSelf is called when an item is removed from the 
            observed list. The default behavior of removeItemsFromSelf is to 
            remove the item from the observing list (i.e. this list)
            unchanged. We override the default behavior to remove the 
            tasks' efforts from the CompositeEfforts they are part of. '''
        composites_to_remove = []
        for task in tasks:  # pylint: disable=W0621
            composites_to_remove.extend(self.__composites_to_remove(task))
        self.__remove_composites_from_self(composites_to_remove, event=event)
         
    @patterns.eventSource
    def __remove_composites_from_self(self, composites_to_remove, event=None):
        ''' Remove composites from the aggregator. '''
        self.__trackedComposites.difference_update(set(composites_to_remove))
        super(EffortAggregator, self).removeItemsFromSelf(composites_to_remove, 
                                                          event=event)
        
    def onTaskRemoved(self, event):
        ''' Whenever tasks are removed, find the composites that 
            (did) contain effort of those tasks and update them. '''
        affected_composites = self.__get_composites_for_tasks(event.values())
        for affected_composite in affected_composites:
            affected_composite._invalidateCache()
            affected_composite.notifyObserversOfDurationOrEmpty()
            
    def onTaskEffortChanged(self, newValue, sender):
        if sender not in self.observable():
            return
        new_composites = []
        newValue, oldValue = newValue
        efforts_added = [effort for effort in newValue if effort not in oldValue]
        efforts_removed = [effort for effort in oldValue if effort not in newValue]
        new_composites.extend(self.__create_composites(sender, efforts_added))
        self.__extend_self_with_composites(new_composites)
        for affected_composite in self.__get_composites_for_efforts(efforts_added + efforts_removed):
            is_tracked = affected_composite.isBeingTracked()
            was_tracked = affected_composite in self.__trackedComposites
            if is_tracked and not was_tracked:
                self.__trackedComposites.add(affected_composite)
            elif not is_tracked and was_tracked:
                self.__trackedComposites.remove(affected_composite)
            affected_composite.onTimeSpentChanged(newValue, sender)
        
    def onChildAddedToTask(self, event):
        new_composites = []
        for task in event.sources():  # pylint: disable=W0621
            if task in self.observable():
                child = event.value(task)
                new_composites.extend(self.__create_composites(task,
                    child.efforts(recursive=True)))
        self.__extend_self_with_composites(new_composites)
        
    def onChildRemovedFromTask(self, event):
        affected_composites = self.__get_composites_for_tasks(event.sources() | set(event.values()))
        for affected_composite in affected_composites:
            affected_composite._invalidateCache()
            affected_composite.notifyObserversOfDurationOrEmpty()

    def onCompositeEmpty(self, sender):
        # pylint: disable=W0621
        if sender not in self:
            return
        key = self.__key_for_composite(sender)
        if key in self.__composites:
            # A composite may already have been removed, e.g. when a
            # parent and child task have effort in the same period
            del self.__composites[key]
        self.__remove_composites_from_self([sender])
        
    def onEffortStartChanged(self, newValue, sender):  # pylint: disable=W0613
        new_composites = []
        key = self.__key_for_effort(sender)
        task = sender.task()  # pylint: disable=W0621
        if (task in self.observable()) and (key not in self.__composites):
            new_composites.extend(self.__create_composites(task, [sender]))
        self.__extend_self_with_composites(new_composites)
        for affected_composite in self.__get_composites_for_efforts([sender]):
            is_tracked = affected_composite.isBeingTracked()
            was_tracked = affected_composite in self.__trackedComposites
            if is_tracked and not was_tracked:
                self.__trackedComposites.add(affected_composite)
            elif not is_tracked and was_tracked:
                self.__trackedComposites.remove(affected_composite)
            affected_composite.onTimeSpentChanged(newValue, sender)
            
    def onRevenueChanged(self, newValue, sender):
        for affected_composite in self.__get_composites_for_tasks([sender]):
            affected_composite.onRevenueChanged(newValue, sender)
            
    def __get_composites_for_tasks(self, tasks):
        tasks = set(tasks)
        return [each_composite for each_composite in self \
                if each_composite.task() in tasks or \
                (each_composite.task().__class__.__name__ == 'Total' and \
                 tasks & each_composite.tasks())]
        
    def __get_composites_for_efforts(self, efforts):
        efforts = set(efforts)
        return [each_composite for each_composite in self \
                if set(each_composite._getEfforts()) & efforts]
        
    def __create_composites(self, task, efforts):  # pylint: disable=W0621
        new_composites = []
        for effort in efforts:
            new_composites.extend(self.__create_composites_for_task(effort, task))
            new_composites.extend(self.__create_composite_for_period(effort))
        return new_composites

    def __create_composites_for_task(self, an_effort, task):  # pylint: disable=W0621
        new_composites = []
        for each_task in [task] + task.ancestors():
            key = self.__key_for_effort(an_effort, each_task)
            if key in self.__composites:
                self.__composites[key].addEffort(an_effort)
                continue
            new_composite = composite.CompositeEffort(*key)  # pylint: disable=W0142
            new_composite.addEffort(an_effort)
            self.__composites[key] = new_composite
            new_composites.append(new_composite)
        return new_composites
    
    def __create_composite_for_period(self, an_effort):
        key = self.__key_for_period(an_effort)
        if key in self.__composites:
            self.__composites[key].addEffort(an_effort)
            return []
        new_composite_per_period = composite.CompositeEffortPerPeriod(key[0], 
                                          key[1], self.observable(), an_effort)
        self.__composites[key] = new_composite_per_period
        return [new_composite_per_period]

    def __composites_to_remove(self, task):  # pylint: disable=W0621
        efforts = task.efforts()
        task_and_ancestors = [task] + task.ancestors()
        composites_to_remove = []
        for effort in efforts:
            for task in task_and_ancestors:
                composites_to_remove.extend(self.__composite_to_remove(effort, task))
        return composites_to_remove
        
    def __composite_to_remove(self, an_effort, task):  # pylint: disable=W0613,W0621
        key = self.__key_for_effort(an_effort, task)
        # A composite may already have been removed, e.g. when a
        # parent and child task have effort in the same period
        return [self.__composites.pop(key)] if key in self.__composites else []

    def maxDateTime(self):
        stop_times = [effort.getStop() for composite_effort in self for effort
                      in composite_effort if effort.getStop() is not None]
        return max(stop_times) if stop_times else None

    @staticmethod
    def __key_for_composite(composite_effort):
        if composite_effort.task().__class__.__name__ == 'Total':
            return (composite_effort.getStart(), composite_effort.getStop())
        else:
            return (composite_effort.task(), composite_effort.getStart(), 
                    composite_effort.getStop())
    
    def __key_for_effort(self, effort, task=None):  # pylint: disable=W0621
        task = task or effort.task()
        effort_start = effort.getStart()
        return (task, self.__start_of_period(effort_start), 
                      self.__end_of_period(effort_start))
        
    def __key_for_period(self, effort):
        key = self.__key_for_effort(effort)
        return key[1], key[2]
    
    @classmethod
    def sortEventType(class_):
        return 'this event type is not used'  # pragma: no cover