root/tracslimtimerplugin/0.10/tracslimtimer/ticket_change.py

Revision 2203, 14.9 kB (checked in by tst, 2 years ago)

TracSlimTimerPlugin:

Publishing version 0.1.0 minus docs to trac hacks

Line 
1 #
2 # Synchronises tickets in trac with tasks in SlimTimer
3 #
4
5 import re
6 import os
7 from string import lower
8
9 from trac.core import *
10 from trac.ticket.api import ITicketChangeListener
11 from trac.ticket.model import Ticket
12
13 import slimtimer as ST
14 from users import Users
15 from trac_ticket import TracTicket
16
17 #
18 # Ahh, I don't know Python. Surely there's a safer way of doing parseInt than
19 # this.
20 #
21 def getint(str):
22     try:
23         rv = int(str)
24     except ValueError:
25         rv = -1
26     return rv
27
28 def getfloat(str):
29     try:
30         rv = float(str)
31     except ValueError:
32         rv = -1
33     return rv
34
35 #
36 # The ticket change listener
37 #
38 class TracSlimTimerTicketChangeListener(Component):
39
40     implements(ITicketChangeListener)
41
42     #
43     # ITicketChangeListener methods
44     #
45
46     def ticket_created(self, ticket):
47         """Called when a ticket is created."""
48         pass
49
50     def ticket_changed(self, ticket, comment, author, old_values):
51         """Called when a ticket is modified.
52         
53         `old_values` is a dictionary containing the previous values of the
54         fields that have changed.
55         """
56        
57         if not ticket.values.get('status'):
58             return
59
60         new_status     = ticket['status']
61         status_changed = old_values.get('status') != None
62
63         if status_changed:
64             if new_status == 'assigned':
65                 self.log.debug('Ticket assigned: %s' %
66                                ticket.values.get('summary'))
67                 self._do_ticket_assigned(ticket, old_values, author)
68
69             elif new_status == 'reopened':
70                 self.log.debug('Ticket re-opened: %s' %
71                                ticket.values.get('summary'))
72                 self._do_ticket_reopened(ticket, old_values, author)
73
74             elif new_status == 'closed':
75                 self.log.debug('Ticket closed: %s' %
76                                ticket.values.get('summary'))
77                 self._do_ticket_closed(ticket, old_values, author)
78
79         elif new_status == 'assigned' or new_status=='reopened':
80             self._do_ticket_updated(ticket, old_values, author)
81
82     def ticket_deleted(self, ticket):
83         """Called when a ticket is deleted."""
84         if ticket.values.get('status','') in ('assigned', 'reopened'):
85             self.log.debug('Ticket deleted: %s' % ticket.values.get('summary'))
86             self._do_ticket_deleted(ticket)
87
88     #
89     # Implementation helpers
90     #
91
92     def _do_ticket_assigned(self, ticket, old_values, author):
93         """
94         A ticket has been accepted. Create and update task as necessary.
95         """
96
97         st_task = self._get_task(ticket, create = True,
98                                  old_values = old_values)
99         if not st_task:
100             return
101
102         st_task.complete = False
103
104         self.log.debug("Creating task: %s" % st_task.name)
105         self._sync_task(st_task, ticket, old_values, author)
106
107     def _do_ticket_reopened(self, ticket, old_values, author):
108         """
109         A ticket has been re-opened. Update the task or create it if necessary.
110         """
111
112         st_task = self._get_task(ticket, create = True,
113                                  old_values = old_values)
114         if not st_task:
115             return
116
117         st_task.complete = False
118
119         self.log.debug("Re-opening task: %s" % st_task.name)
120         self._sync_task(st_task, ticket, old_values, author)
121
122     def _do_ticket_closed(self, ticket, old_values, author):
123         """
124         A ticket has been closed. Mark the task as complete.
125         """
126
127         st_task = self._get_task(ticket, create = False,
128                                  old_values = old_values)
129         if not st_task:
130             return
131
132         st_task.complete = True
133
134         self.log.debug("Completing existing task: %s" % st_task.name)
135         self._sync_task(st_task, ticket, old_values, author)
136
137     def _do_ticket_updated(self, ticket, old_values, author):
138         """
139         Some change has been made to the ticket. Update the task if necessary.
140         """
141
142         #
143         # First check if anything of note has actually changed. This is coupled
144         # somewhat with _update_task.
145         #
146         for key in ('summary', 'milestone', 'keywords', 'cc', 'slimtimer_id'):
147             if key in old_values.keys():
148                 break
149         else:
150             #
151             # Nothing of relevance changed. Skip update.
152             #
153             self.log.debug("Nothing of relevance changed. Skipping update.")
154             return
155
156         st_task = self._get_task(ticket, create = True,
157                                  old_values = old_values)
158         if not st_task:
159             return
160
161         self.log.debug("Updating task: %s" % st_task.name)
162         self._sync_task(st_task, ticket, old_values, author)
163
164     def _do_ticket_deleted(self, ticket):
165         """
166         The ticket has been deleted. Complete the task.
167         """
168
169         st_task = self._get_task(ticket, create = False)
170                                  
171         if not st_task:
172             return
173
174         st_task.complete = True
175
176         self.log.debug("Completing deleted task: %s" % st_task.name)
177         try:
178             st_task.update()
179         except Exception,e:
180             self.log.error("Could not update task: %s (%s)" %
181                            (st_task.name, e))
182
183     def _get_session(self, ticket):
184         """
185         Get the SlimTimer session
186         """
187
188         #
189         # Get the task name for using in error messages
190         #
191         task_name = self._get_st_task_name(ticket)
192
193         #
194         # Get the trac username
195         #
196         trac_user = ticket.values.get('owner','')
197         if not trac_user:
198             self.log.warn(
199                     "Tried to update ticket (\"%s\") but it has no owner." %
200                     task_name)
201             return None
202
203         #
204         # Get the trac to ST user mapping
205         #
206         users = self._get_user_mapping()
207         if not users:
208             self.log.error(
209                     "Couldn't get users listing for updating ticket: %s" %
210                     task_name)
211             return None
212
213         st_user = users.get_st_user(trac_user)
214         if not st_user:
215             self.log.warn("This ticket (\"%s\") is owned by a user (\"%s\")"\
216                           " who is not listed as a SlimTimer user." %
217                           (task_name, trac_user))
218             return None
219
220         username = st_user.get('st_user','')
221         password = st_user.get('st_pass','')
222         api_key  = self.config.get('slimtimer', 'api_key')
223
224         if not username or not api_key:
225             self.log.warn("Missing username or API key for SlimTimer login."\
226                 " Task (\"%s\") will not be updated" % task_name)
227             return None
228
229         try:
230             st = ST.SlimTimerSession(username, password, api_key)
231         except:
232             self.log.error("Could not log in to SlimTimer with username %s,"\
233                            " password %s character(s) in length, and API "\
234                            "key %s." % (username, len(password), api_key))
235             return None
236
237         return st
238
239     def _get_user_mapping(self):
240         """
241         Get the object that maps trac users to ST users
242         """
243         config_file = self._get_user_config_file()
244         return Users(config_file)
245
246     def _get_user_config_file(self):
247         """
248         Get the filename of the user configuration file
249         """
250         return os.path.join(self.env.path, 'conf', 'users.xml')
251
252     def _get_task(self, ticket, create = False, old_values = {}):
253         """
254         Get the ST task using the ticket's ST ID or name.
255         """
256
257         st_task = None
258
259         #
260         # Get session
261         #
262         st = self._get_session(ticket)
263         if not st:
264             return None
265
266         #
267         # Lookup by ID first
268         #
269         task_id = ticket.values.get('slimtimer_id', 0)
270         if task_id:
271             st_task = st.get_task_by_id(task_id)
272
273         if st_task:
274             return st_task
275
276         #
277         # Lookup by name next
278         #
279         task_name = self._get_st_task_name(ticket, old_values)
280         st_task = st.get_task_by_name(task_name)
281
282         if st_task:
283             return st_task
284
285         #
286         # Nope, we have to make a new task
287         #
288         if create:
289             st_task = ST.SlimTimerTask(st, task_name)
290             self.log.debug("Creating new task: %s" % task_name)
291
292         return st_task
293
294     def _get_st_task_name(self, ticket, old_values = {}):
295         """
296         Gets the task name for a ticket. If old_values is provided and has
297         a value for summary, it will be used instead.
298         """
299         if (old_values.get('summary')):
300             summary = old_values['summary']
301         else:
302             summary = ticket.values.get('summary', '(No summary)')
303
304         return "#%s: %s" % (ticket.id, summary)
305
306     def _sync_task(self, st_task, ticket, old_values, author):
307         """
308         Update the SlimTimer task and the trac ticket.
309         """
310         self._update_task(st_task, ticket, old_values)
311         self._update_ticket(ticket, st_task, author)
312
313     def _update_task(self, st_task, ticket, old_values):
314         """
315         Update the SlimTiemr task with information from the trac ticket.
316         """
317
318         #
319         # Update name
320         #
321         # Sometimes the old name will be set and we need to update it (so
322         # definitely DON'T pass in old_values here)
323         #
324         st_task.name = self._get_st_task_name(ticket)
325
326         #
327         # Tags
328         #
329         # We want to preserve the tags already defined on this task and only
330         # add and remove the tags that have changed in trac. This way the user
331         # can add their own tags in SlimTimer and updating via trac won't
332         # interfere with them.
333         #
334         new_milestone = ticket.values.get('milestone', '')
335         new_keywords  = ticket.values.get('keywords', '')
336         new_tags = ','.join(filter(lambda x: x, (new_milestone, new_keywords)))
337
338         old_milestone = old_values.get('milestone', '')
339         old_keywords  = old_values.get('keywords', '')
340         old_tags = ','.join(filter(lambda x: x, (old_milestone, old_keywords)))
341
342         added, removed = self._diff_tags(old_tags, new_tags)
343         st_task.tags = self._apply_diff(st_task.tags, added, removed)
344
345         #
346         # Coworkers and reporters
347         #
348         if st_task.id < 1:
349             st_task.coworkers = self._get_updated_coworkers_list(ticket,
350                                                 old_values, st_task.coworkers)
351
352         try:
353             st_task.update()
354         except Exception,e:
355             self.log.error("Could not update task: %s (%s)" % (task_name, e))
356
357     def _parse_tags(self, tags_text):
358         """
359         Split a comma separated list of tags
360         """
361         pat = r'"[^"]*"|[^," \t][^,"]+[^," \t]'
362         return re.findall(pat, tags_text)
363
364     def _diff_tags(self, old_tags, new_tags):
365         """
366         Compare (case-insensitive) two lists of tags and find the differences
367         """
368
369         #
370         # Sorry about this mess. I don't know Python at all. I want to preserve
371         # the case of the input in the output whilst also doing a case
372         # insensitive comparison.
373         #
374         old_list = self._parse_tags(old_tags)
375         new_list = self._parse_tags(new_tags)
376
377         old_lower = map((lambda x: lower(x)), old_list)
378         new_lower = map((lambda x: lower(x)), new_list)
379
380         added   = filter((lambda x: lower(x) not in old_lower), new_list)
381         removed = filter((lambda x: lower(x) not in new_lower), old_list)
382
383         return added, removed
384
385     def _apply_diff(self, target, add, remove):
386         """
387         Apply additions and removals to a list
388         """
389
390         #
391         # As with _diff_tags we want to preserve the case of the input in the
392         # output whilst also doing a case insensitive comparison. There are
393         # surely many better ways to do this.
394         #
395         remove_lower = map((lambda x: lower(x)), remove)
396         target_lower = map((lambda x: lower(x)), target)
397
398         return filter((lambda x: lower(x) not in remove_lower), target) + \
399                filter((lambda x: lower(x) not in target_lower), add)
400
401     def _get_updated_coworkers_list(self, ticket, old_values, current_list):
402         """
403         Do some magic to create a list of coworkers
404         """
405
406         #
407         # XXX All of this is probably unnecessary as we now only update
408         # coworkers when we create the ticket
409         #
410
411         #
412         # As with the tags, do a diff between the current CC and the past one
413         #
414         new_cc = ticket.values.get('cc', '')
415         old_cc = old_values.get('cc', '')
416         added, removed = self._diff_tags(old_cc, new_cc)
417         result = self._apply_diff(current_list, added, removed)
418
419         #
420         # Also make sure the reporter and default CC list are in there
421         #
422         result_lower = map((lambda x: lower(x)), result)
423
424         additions = []
425
426         # Add default CCs unless they're the owner
427         users = self._get_user_mapping()
428         owner = users.get_st_user(ticket.values.get('owner',''))
429         owner_email = ''
430         if owner: owner_email = owner.get('st_user','')
431         if users:
432             additions += \
433                 filter((lambda x: x != owner_email), users.get_cc_emails())
434
435         result += filter((lambda x: lower(x) not in result_lower), additions)
436
437         return result
438
439     def _update_ticket(self, ticket, st_task, author):
440         """
441         Update the trac ticket based on the SlimTimer task.
442         """
443
444         #
445         # Check if anything changed
446         #
447         id_changed = ticket.values.has_key('slimtimer_id') and \
448                      (getint(ticket['slimtimer_id']) < 1 or \
449                      getint(ticket['slimtimer_id']) != st_task.id)
450
451         hours_changed = ticket.values.has_key('totalhours') and \
452                         getfloat(ticket['totalhours']) != st_task.hours
453
454         if not id_changed and not hours_changed:
455             return
456
457         #
458         # Get some common information for storing the changes
459         #
460         db = self.env.get_db_cnx()
461         cl = ticket.get_changelog()
462
463         if cl:
464             most_recent_change = cl[-1];
465             change_time = most_recent_change[0]
466         else:
467             change_time = ticket.time_created
468
469         raw_ticket = TracTicket(db, ticket.id)
470
471         #
472         # Apply ID changes
473         #
474         if id_changed:
475             prev_st_id = ticket.values.get('slimtimer_id')
476             new_st_id  = st_task.id
477             raw_ticket.set_st_id(prev_st_id, new_st_id, author, change_time)
478
479         #
480         # Apply hours changes
481         #
482         if hours_changed:
483             prev_totalhours = ticket.values.get('totalhours')
484             new_totalhours  = st_task.hours
485             raw_ticket.set_hours(prev_totalhours, new_totalhours, author,
486                                  change_time)
487             #
488             # Update the ticket object too in case another ticket change
489             # handler is called after us
490             #
491             ticket['totalhours'] = str(st_task.hours)
492             ticket['hours'] = '0'
Note: See TracBrowser for help on using the browser.