| 1 | # -*- coding: utf-8 -*- |
|---|
| 2 | # |
|---|
| 3 | # Copyright (c) 2007-2012 Colin Guthrie <trac@colin.guthr.ie> |
|---|
| 4 | # Copyright (c) 2011-2016 Ryan J Ollos <ryan.j.ollos@gmail.com> |
|---|
| 5 | # All rights reserved. |
|---|
| 6 | # |
|---|
| 7 | # This software is licensed as described in the file COPYING, which |
|---|
| 8 | # you should have received as part of this distribution. |
|---|
| 9 | |
|---|
| 10 | from datetime import datetime |
|---|
| 11 | from time import time |
|---|
| 12 | |
|---|
| 13 | from trac.ticket.notification import TicketNotifyEmail |
|---|
| 14 | from trac.ticket import Ticket |
|---|
| 15 | from trac.util.datefmt import format_date, format_time, pretty_timedelta, \ |
|---|
| 16 | to_datetime |
|---|
| 17 | try: |
|---|
| 18 | from trachours.hours import TracHoursPlugin |
|---|
| 19 | except ImportError: |
|---|
| 20 | pass |
|---|
| 21 | |
|---|
| 22 | |
|---|
| 23 | class WorkLogManager: |
|---|
| 24 | env = None |
|---|
| 25 | config = None |
|---|
| 26 | authname = None |
|---|
| 27 | explanation = None |
|---|
| 28 | now = None |
|---|
| 29 | |
|---|
| 30 | def __init__(self, env, config, authname='anonymous'): |
|---|
| 31 | self.env = env |
|---|
| 32 | self.config = config |
|---|
| 33 | self.authname = authname |
|---|
| 34 | self.explanation = "" |
|---|
| 35 | self.now = int(time()) - 1 |
|---|
| 36 | |
|---|
| 37 | def get_explanation(self): |
|---|
| 38 | return self.explanation |
|---|
| 39 | |
|---|
| 40 | def can_work_on(self, ticket): |
|---|
| 41 | # Need to check several things. |
|---|
| 42 | # 1. Is some other user working on this ticket? |
|---|
| 43 | # 2. a) Is the autostopstart setting true? or |
|---|
| 44 | # b) Is the user working on a ticket already? |
|---|
| 45 | # 3. a) Is the autoreassignaccept setting true? or |
|---|
| 46 | # b) Is the ticket assigned to the user? |
|---|
| 47 | |
|---|
| 48 | # 0. Are you logged in? |
|---|
| 49 | if self.authname == 'anonymous': |
|---|
| 50 | self.explanation = 'You need to be logged in to work on tickets.' |
|---|
| 51 | return False |
|---|
| 52 | |
|---|
| 53 | # 1. Other user working on it? |
|---|
| 54 | who, since = self.who_is_working_on(ticket) |
|---|
| 55 | if who: |
|---|
| 56 | if who != self.authname: |
|---|
| 57 | self.explanation = 'Another user (%s) has been working on ticket #%s since %s' % (who, ticket, since) |
|---|
| 58 | else: |
|---|
| 59 | self.explanation = 'You are already working on ticket #%s' % (ticket,) |
|---|
| 60 | return False |
|---|
| 61 | |
|---|
| 62 | # 2. a) Is the autostopstart setting true? or |
|---|
| 63 | # b) Is the user working on a ticket already? |
|---|
| 64 | if not self.config.getbool('worklog', 'autostopstart'): |
|---|
| 65 | active = self.get_active_task() |
|---|
| 66 | if active: |
|---|
| 67 | self.explanation = 'You cannot work on ticket #%s as you are currently working on ticket #%s. You have to chill out.' % (ticket, active['ticket']) |
|---|
| 68 | return False |
|---|
| 69 | |
|---|
| 70 | # 3. a) Is the autoreassignaccept setting true? or |
|---|
| 71 | # b) Is the ticket assigned to the user? |
|---|
| 72 | if not self.config.getbool('worklog', 'autoreassignaccept'): |
|---|
| 73 | tckt = Ticket(self.env, ticket) |
|---|
| 74 | if self.authname != tckt['owner']: |
|---|
| 75 | self.explanation = 'You cannot work on ticket #%s as you are not the owner. You should speak to %s.' % (ticket, tckt['owner']) |
|---|
| 76 | return False |
|---|
| 77 | |
|---|
| 78 | # If we get here then we know we can start work :) |
|---|
| 79 | return True |
|---|
| 80 | |
|---|
| 81 | def save_ticket(self, tkt, msg): |
|---|
| 82 | now_dt = to_datetime(self.now) |
|---|
| 83 | tkt.save_changes(self.authname, msg, now_dt) |
|---|
| 84 | |
|---|
| 85 | tn = TicketNotifyEmail(self.env) |
|---|
| 86 | tn.notify(tkt, newticket=0, modtime=now_dt) |
|---|
| 87 | # We fudge time as it has to be unique |
|---|
| 88 | self.now += 1 |
|---|
| 89 | |
|---|
| 90 | def start_work(self, ticket): |
|---|
| 91 | |
|---|
| 92 | if not self.can_work_on(ticket): |
|---|
| 93 | return False |
|---|
| 94 | |
|---|
| 95 | # We could just horse all the fields of the ticket to the right values |
|---|
| 96 | # bit it seems more correct to follow the in-build state-machine for |
|---|
| 97 | # ticket modification. |
|---|
| 98 | |
|---|
| 99 | # If the ticket is closed, we need to reopen it. |
|---|
| 100 | tckt = Ticket(self.env, ticket) |
|---|
| 101 | |
|---|
| 102 | if 'closed' == tckt['status']: |
|---|
| 103 | tckt['status'] = 'reopened' |
|---|
| 104 | tckt['resolution'] = '' |
|---|
| 105 | self.save_ticket(tckt, 'Automatically reopening in order to start work.') |
|---|
| 106 | |
|---|
| 107 | # Reinitialise for next test |
|---|
| 108 | tckt = Ticket(self.env, ticket) |
|---|
| 109 | |
|---|
| 110 | if self.authname != tckt['owner']: |
|---|
| 111 | tckt['owner'] = self.authname |
|---|
| 112 | if 'new' == tckt['status']: |
|---|
| 113 | tckt['status'] = 'accepted' |
|---|
| 114 | else: |
|---|
| 115 | tckt['status'] = 'new' |
|---|
| 116 | self.save_ticket(tckt, 'Automatically reassigning in order to start work.') |
|---|
| 117 | |
|---|
| 118 | # Reinitialise for next test |
|---|
| 119 | tckt = Ticket(self.env, ticket) |
|---|
| 120 | |
|---|
| 121 | if 'accepted' != tckt['status']: |
|---|
| 122 | tckt['status'] = 'accepted' |
|---|
| 123 | self.save_ticket(tckt, 'Automatically accepting in order to start work.') |
|---|
| 124 | |
|---|
| 125 | # There is a chance the user may be working on another ticket at the moment |
|---|
| 126 | # depending on config options |
|---|
| 127 | if self.config.getbool('worklog', 'autostopstart'): |
|---|
| 128 | # Don't care if this fails, as with these arguments the only failure |
|---|
| 129 | # point is if there is no active task... which is the desired scenario :) |
|---|
| 130 | self.stop_work(comment='Stopping work on this ticket to start work on #%s.' % ticket) |
|---|
| 131 | self.explanation = '' |
|---|
| 132 | |
|---|
| 133 | self.env.db_transaction(""" |
|---|
| 134 | INSERT INTO work_log (worker, ticket, lastchange, starttime, endtime) |
|---|
| 135 | VALUES (%s, %s, %s, %s, %s) |
|---|
| 136 | """, (self.authname, ticket, self.now, self.now, 0)) |
|---|
| 137 | |
|---|
| 138 | return True |
|---|
| 139 | |
|---|
| 140 | def stop_work(self, stoptime=None, comment=''): |
|---|
| 141 | active = self.get_active_task() |
|---|
| 142 | if not active: |
|---|
| 143 | self.explanation = 'You cannot stop working as you appear to be a complete slacker already!' |
|---|
| 144 | return False |
|---|
| 145 | |
|---|
| 146 | if stoptime: |
|---|
| 147 | if stoptime <= active['starttime']: |
|---|
| 148 | self.explanation = 'You cannot set your stop time to that value as it is before the start time!' |
|---|
| 149 | return False |
|---|
| 150 | elif stoptime >= self.now: |
|---|
| 151 | self.explanation = 'You cannot set your stop time to that value as it is in the future!' |
|---|
| 152 | return False |
|---|
| 153 | else: |
|---|
| 154 | stoptime = self.now - 1 |
|---|
| 155 | |
|---|
| 156 | stoptime = float(stoptime) |
|---|
| 157 | |
|---|
| 158 | self.env.db_transaction(""" |
|---|
| 159 | UPDATE work_log SET endtime=%s, lastchange=%s, comment=%s |
|---|
| 160 | WHERE worker=%s AND lastchange=%s AND endtime=0 |
|---|
| 161 | """, (stoptime, stoptime, comment, self.authname, |
|---|
| 162 | active['lastchange'])) |
|---|
| 163 | |
|---|
| 164 | plugtne = self.config.getbool('worklog', 'timingandestimation') and self.config.get('ticket-custom', 'hours') |
|---|
| 165 | plughrs = self.config.getbool('worklog', 'trachoursplugin') and self.config.get('ticket-custom', 'totalhours') |
|---|
| 166 | |
|---|
| 167 | message = '' |
|---|
| 168 | hours = '0.0' |
|---|
| 169 | seconds = 0 |
|---|
| 170 | ticket = Ticket(self.env, active['ticket']) |
|---|
| 171 | |
|---|
| 172 | # Leave a comment if the user has configured this or if they have entered |
|---|
| 173 | # a work log comment. |
|---|
| 174 | if plugtne or plughrs: |
|---|
| 175 | round_delta = float(self.config.getint('worklog', 'roundup') or 1) |
|---|
| 176 | |
|---|
| 177 | # Get the delta in minutes |
|---|
| 178 | seconds = float(int(stoptime) - int(active['starttime'])) |
|---|
| 179 | delta = seconds / 60.0 |
|---|
| 180 | |
|---|
| 181 | # Round up if needed |
|---|
| 182 | delta = int(round((delta / round_delta) + float(0.5))) * int(round_delta) |
|---|
| 183 | |
|---|
| 184 | # This hideous hack is here because I don't yet know how to do variable-DP rounding in python - sorry! |
|---|
| 185 | # It's meant to round to 2 DP, so please replace it if you know how. Many thanks, MK. |
|---|
| 186 | hours = str(float(int(100 * float(delta) / 60) / 100.0)) |
|---|
| 187 | |
|---|
| 188 | if plughrs: |
|---|
| 189 | hours_message = "%s hours recorded by worklog plugin" % hours |
|---|
| 190 | if comment: |
|---|
| 191 | hours_message = comment + " (%s)" % hours_message |
|---|
| 192 | TracHoursPlugin(self.env) \ |
|---|
| 193 | .add_ticket_hours(ticket.id, self.authname, seconds, |
|---|
| 194 | comments=hours_message) |
|---|
| 195 | |
|---|
| 196 | if self.config.getbool('worklog', 'comment'): |
|---|
| 197 | started = datetime.fromtimestamp(active['starttime']) |
|---|
| 198 | finished = datetime.fromtimestamp(stoptime) |
|---|
| 199 | message = '%s worked on this ticket for %s between %s %s and %s %s.' % \ |
|---|
| 200 | (self.authname, |
|---|
| 201 | hours if plughrs else pretty_timedelta(started, finished), # use decimal output to prevent plughrs from parsing the comment |
|---|
| 202 | format_date(active['starttime']), format_time(active['starttime']), |
|---|
| 203 | format_date(stoptime), format_time(stoptime)) |
|---|
| 204 | if comment: |
|---|
| 205 | message += "\n[[BR]]\n" + comment |
|---|
| 206 | |
|---|
| 207 | if plugtne: |
|---|
| 208 | if not message: |
|---|
| 209 | message = 'Hours recorded automatically by the worklog plugin.' |
|---|
| 210 | |
|---|
| 211 | if plugtne: |
|---|
| 212 | ticket['hours'] = hours |
|---|
| 213 | self.save_ticket(ticket, message) |
|---|
| 214 | message = '' |
|---|
| 215 | |
|---|
| 216 | if message: |
|---|
| 217 | self.save_ticket(ticket, message) |
|---|
| 218 | |
|---|
| 219 | return True |
|---|
| 220 | |
|---|
| 221 | def who_is_working_on(self, ticket): |
|---|
| 222 | for who, since in self.env.db_query(""" |
|---|
| 223 | SELECT worker,starttime FROM work_log |
|---|
| 224 | WHERE ticket=%s AND endtime=0 |
|---|
| 225 | """, (ticket,)): |
|---|
| 226 | return who, float(since) |
|---|
| 227 | else: |
|---|
| 228 | return None, None |
|---|
| 229 | |
|---|
| 230 | def who_last_worked_on(self, ticket): |
|---|
| 231 | return "Not implemented" |
|---|
| 232 | |
|---|
| 233 | def get_latest_task(self): |
|---|
| 234 | if self.authname == 'anonymous': |
|---|
| 235 | return None |
|---|
| 236 | |
|---|
| 237 | lastchange = None |
|---|
| 238 | for lastchange, in self.env.db_query(""" |
|---|
| 239 | SELECT MAX(lastchange) FROM work_log WHERE worker=%s |
|---|
| 240 | """, (self.authname,)): |
|---|
| 241 | break |
|---|
| 242 | else: |
|---|
| 243 | return None |
|---|
| 244 | |
|---|
| 245 | task = {} |
|---|
| 246 | for row in self.env.db_query(""" |
|---|
| 247 | SELECT wl.worker, wl.ticket, t.summary, wl.lastchange, |
|---|
| 248 | wl.starttime, wl.endtime, wl.comment |
|---|
| 249 | FROM work_log wl |
|---|
| 250 | LEFT JOIN ticket t ON wl.ticket=t.id |
|---|
| 251 | WHERE wl.worker=%s AND wl.lastchange=%s |
|---|
| 252 | """, (self.authname, lastchange)): |
|---|
| 253 | task['user'] = row[0] |
|---|
| 254 | task['ticket'] = row[1] |
|---|
| 255 | task['summary'] = row[2] |
|---|
| 256 | task['lastchange'] = float(row[3]) |
|---|
| 257 | task['starttime'] = float(row[4]) |
|---|
| 258 | task['endtime'] = float(row[5]) |
|---|
| 259 | task['comment'] = row[6] or '' |
|---|
| 260 | return task |
|---|
| 261 | |
|---|
| 262 | def get_active_task(self): |
|---|
| 263 | task = self.get_latest_task() |
|---|
| 264 | if not task: |
|---|
| 265 | return None |
|---|
| 266 | if not task.has_key('endtime'): |
|---|
| 267 | return None |
|---|
| 268 | |
|---|
| 269 | if task['endtime'] > 0: |
|---|
| 270 | return None |
|---|
| 271 | |
|---|
| 272 | return task |
|---|
| 273 | |
|---|
| 274 | def get_work_log(self, mode='all'): |
|---|
| 275 | args = () |
|---|
| 276 | if mode == 'user': |
|---|
| 277 | sql = """ |
|---|
| 278 | SELECT wl.worker, s.value, wl.starttime, wl.endtime, |
|---|
| 279 | wl.ticket, t.summary, t.status, wl.comment |
|---|
| 280 | FROM work_log wl |
|---|
| 281 | INNER JOIN ticket t ON wl.ticket=t.id |
|---|
| 282 | LEFT JOIN session_attribute s |
|---|
| 283 | ON wl.worker=s.sid AND s.name='name' |
|---|
| 284 | WHERE wl.worker=%s |
|---|
| 285 | ORDER BY wl.lastchange DESC |
|---|
| 286 | """ |
|---|
| 287 | args = (self.authname,) |
|---|
| 288 | elif mode == 'summary': |
|---|
| 289 | sql = """ |
|---|
| 290 | SELECT wl.worker, s.value, wl.starttime, wl.endtime, |
|---|
| 291 | wl.ticket, t.summary, t.status, wl.comment |
|---|
| 292 | FROM (SELECT worker,MAX(lastchange) AS lastchange |
|---|
| 293 | FROM work_log GROUP BY worker) wlt |
|---|
| 294 | INNER JOIN work_log wl ON wlt.worker=wl.worker |
|---|
| 295 | AND wlt.lastchange=wl.lastchange |
|---|
| 296 | INNER JOIN ticket t ON wl.ticket=t.id |
|---|
| 297 | LEFT JOIN session_attribute s |
|---|
| 298 | ON wl.worker=s.sid AND s.name='name' |
|---|
| 299 | ORDER BY wl.lastchange DESC, wl.worker |
|---|
| 300 | """ |
|---|
| 301 | else: |
|---|
| 302 | sql = """ |
|---|
| 303 | SELECT wl.worker, s.value, wl.starttime, wl.endtime, |
|---|
| 304 | wl.ticket, t.summary, t.status, wl.comment |
|---|
| 305 | FROM work_log wl |
|---|
| 306 | INNER JOIN ticket t ON wl.ticket=t.id |
|---|
| 307 | LEFT JOIN session_attribute s |
|---|
| 308 | ON wl.worker=s.sid AND s.name='name' |
|---|
| 309 | ORDER BY wl.lastchange DESC, wl.worker |
|---|
| 310 | """ |
|---|
| 311 | |
|---|
| 312 | rv = [] |
|---|
| 313 | for (user, name, starttime, endtime, ticket, summary, status, |
|---|
| 314 | comment) in self.env.db_query(sql, args): |
|---|
| 315 | starttime = float(starttime) |
|---|
| 316 | endtime = float(endtime) |
|---|
| 317 | |
|---|
| 318 | started = datetime.fromtimestamp(starttime) |
|---|
| 319 | |
|---|
| 320 | dispname = user |
|---|
| 321 | if name: |
|---|
| 322 | dispname = '%s (%s)' % (name, user) |
|---|
| 323 | |
|---|
| 324 | if not endtime == 0: |
|---|
| 325 | finished = datetime.fromtimestamp(endtime) |
|---|
| 326 | delta = 'Worked for %s (between %s %s and %s %s)' % \ |
|---|
| 327 | (pretty_timedelta(started, finished), |
|---|
| 328 | format_date(starttime), format_time(starttime), |
|---|
| 329 | format_date(endtime), format_time(endtime)) |
|---|
| 330 | else: |
|---|
| 331 | delta = 'Started %s ago (%s %s)' % \ |
|---|
| 332 | (pretty_timedelta(started), |
|---|
| 333 | format_date(starttime), format_time(starttime)) |
|---|
| 334 | |
|---|
| 335 | rv.append({'user': user, |
|---|
| 336 | 'name': name, |
|---|
| 337 | 'dispname': dispname, |
|---|
| 338 | 'starttime': int(starttime), |
|---|
| 339 | 'endtime': int(endtime), |
|---|
| 340 | 'delta': delta, |
|---|
| 341 | 'ticket': ticket, |
|---|
| 342 | 'summary': summary, |
|---|
| 343 | 'status': status, |
|---|
| 344 | 'comment': comment}) |
|---|
| 345 | return rv |
|---|