| 1 | # -*- coding: utf-8 -*- |
|---|
| 2 | # |
|---|
| 3 | # Copyright (C) 2012-2015 Franz Mayer Gefasoft AG |
|---|
| 4 | # All rights reserved. |
|---|
| 5 | # |
|---|
| 6 | # This software is licensed as described in the file COPYING, which |
|---|
| 7 | # you should have received as part of this distribution. |
|---|
| 8 | |
|---|
| 9 | import locale |
|---|
| 10 | import re |
|---|
| 11 | import time |
|---|
| 12 | from pkg_resources import resource_filename |
|---|
| 13 | |
|---|
| 14 | from genshi import HTML |
|---|
| 15 | from genshi.filters import Transformer |
|---|
| 16 | from trac.config import Option, IntOption |
|---|
| 17 | from trac.core import Component, implements |
|---|
| 18 | from trac.db.api import DatabaseManager |
|---|
| 19 | from trac.db.schema import Table, Column |
|---|
| 20 | from trac.perm import IPermissionRequestor |
|---|
| 21 | from trac.ticket.api import ITicketManipulator |
|---|
| 22 | from trac.ticket.model import Ticket |
|---|
| 23 | from trac.util import exception_to_unicode |
|---|
| 24 | from trac.util.html import html |
|---|
| 25 | from trac.util.translation import domain_functions |
|---|
| 26 | from trac.web.api import ITemplateStreamFilter, IRequestFilter |
|---|
| 27 | from trac.web.chrome import ITemplateProvider, add_script |
|---|
| 28 | |
|---|
| 29 | _, tag_, N_, add_domain = domain_functions('ticketbudgeting', '_', |
|---|
| 30 | 'tag_', 'N_', 'add_domain') |
|---|
| 31 | |
|---|
| 32 | BUDGETING_TABLE = Table('budgeting', key=('ticket', 'position'))[ |
|---|
| 33 | Column('ticket', type='int'), |
|---|
| 34 | Column('position', type='int'), |
|---|
| 35 | Column('username'), |
|---|
| 36 | Column('type'), |
|---|
| 37 | Column('estimation', type='int64'), |
|---|
| 38 | Column('cost', type='int64'), |
|---|
| 39 | Column('status', type='int'), |
|---|
| 40 | Column('comment') |
|---|
| 41 | ] |
|---|
| 42 | |
|---|
| 43 | BUDGET_REPORT_ALL_ID = 90 |
|---|
| 44 | |
|---|
| 45 | authorizedToModify = ['TICKET_MODIFY', 'TRAC_ADMIN', |
|---|
| 46 | 'TICKET_BUDGETING_MODIFY'] |
|---|
| 47 | |
|---|
| 48 | _VALUE_NAMES = 'username,type,estimation,cost,status,comment' |
|---|
| 49 | _VALUE_NAMES_LIST = _VALUE_NAMES.split(',') |
|---|
| 50 | |
|---|
| 51 | |
|---|
| 52 | def get_float(value, fld='UNKNOWN FIELD'): |
|---|
| 53 | try: |
|---|
| 54 | if not value: |
|---|
| 55 | ret_val = 0 |
|---|
| 56 | else: |
|---|
| 57 | try: |
|---|
| 58 | ret_val = locale.atof(value) |
|---|
| 59 | except: |
|---|
| 60 | str(value).replace(',', '.') |
|---|
| 61 | ret_val = float(value) |
|---|
| 62 | except Exception, e: |
|---|
| 63 | fld = '%s.%s' % (BUDGETING_TABLE.name, fld) |
|---|
| 64 | raise Exception(fld, e) |
|---|
| 65 | return ret_val |
|---|
| 66 | |
|---|
| 67 | |
|---|
| 68 | class Budget: |
|---|
| 69 | """ Container class for budgeting info""" |
|---|
| 70 | _action = None |
|---|
| 71 | _values = None |
|---|
| 72 | _diff = None |
|---|
| 73 | |
|---|
| 74 | def __init__(self): |
|---|
| 75 | self._values = {} |
|---|
| 76 | |
|---|
| 77 | def set(self, number, value): |
|---|
| 78 | if number is None: |
|---|
| 79 | return |
|---|
| 80 | |
|---|
| 81 | number = int(number) |
|---|
| 82 | if 0 < number < len(_VALUE_NAMES_LIST) + 1: |
|---|
| 83 | fld = _VALUE_NAMES_LIST[number - 1] |
|---|
| 84 | |
|---|
| 85 | if fld == 'status': |
|---|
| 86 | try: |
|---|
| 87 | if value == '': |
|---|
| 88 | self._values[fld] = 0 |
|---|
| 89 | else: |
|---|
| 90 | self._values[fld] = int(value) |
|---|
| 91 | except Exception, e: |
|---|
| 92 | fld = '%s.%s' % (BUDGETING_TABLE.name, fld) |
|---|
| 93 | raise Exception(fld, e) |
|---|
| 94 | elif fld in ('estimation', 'cost'): |
|---|
| 95 | self._values[fld] = get_float(value, fld) |
|---|
| 96 | else: |
|---|
| 97 | self._values[fld] = value |
|---|
| 98 | |
|---|
| 99 | def do_action(self, env, ticket_id, position, def_cost=0): |
|---|
| 100 | if not self._action: |
|---|
| 101 | env.log.warn('no action defined!') |
|---|
| 102 | return |
|---|
| 103 | |
|---|
| 104 | self._diff = {} |
|---|
| 105 | |
|---|
| 106 | if not ticket_id or not position: |
|---|
| 107 | env.log.error('no ticket-id or position available!') |
|---|
| 108 | |
|---|
| 109 | elif self._action == "Insert": |
|---|
| 110 | set_attrs = 'ticket,position' |
|---|
| 111 | set_vals_space = '%s,%s' |
|---|
| 112 | set_vals = [ticket_id, position] |
|---|
| 113 | |
|---|
| 114 | for key, value in self._values.iteritems(): |
|---|
| 115 | if key in ('username', 'type', 'comment'): |
|---|
| 116 | value = value.encode("utf-8") |
|---|
| 117 | elif key == 'cost' and def_cost == -1: |
|---|
| 118 | value = 0 |
|---|
| 119 | |
|---|
| 120 | set_attrs += ",%s" % key |
|---|
| 121 | set_vals_space += ",%s" |
|---|
| 122 | set_vals.append(value) |
|---|
| 123 | |
|---|
| 124 | self._diff[''] = (self._to_str(), '') |
|---|
| 125 | sql = ("INSERT INTO %s (%s) VALUES (%s)" % |
|---|
| 126 | (BUDGETING_TABLE.name, set_attrs, set_vals_space)) |
|---|
| 127 | env.db_transaction(sql, set_vals) |
|---|
| 128 | env.log.debug("Added Budgeting-row at position %s to ticket %s:" |
|---|
| 129 | "\n%s", position, ticket_id, self._to_str()) |
|---|
| 130 | |
|---|
| 131 | elif self._action == "Update": |
|---|
| 132 | |
|---|
| 133 | old_values_sql = ("SELECT %s FROM %s WHERE" |
|---|
| 134 | " ticket=%%s AND position =%%s" |
|---|
| 135 | % (_VALUE_NAMES, BUDGETING_TABLE.name)) |
|---|
| 136 | old_values = env.db_query(old_values_sql, |
|---|
| 137 | (ticket_id, position))[0] |
|---|
| 138 | |
|---|
| 139 | set_attrs = '' |
|---|
| 140 | set_vals = [] |
|---|
| 141 | |
|---|
| 142 | for attrnr in range(len(_VALUE_NAMES_LIST)): |
|---|
| 143 | value = self.get_value(attrnr + 1) |
|---|
| 144 | key = _VALUE_NAMES_LIST[attrnr] |
|---|
| 145 | |
|---|
| 146 | if key == 'cost' and def_cost == -1: |
|---|
| 147 | continue |
|---|
| 148 | elif key in ('username', 'type', 'comment'): |
|---|
| 149 | value = value.encode("utf-8") |
|---|
| 150 | elif key in ('estimation', 'cost', 'status'): |
|---|
| 151 | value = 0 if value == '' or get_float(value) == 0 \ |
|---|
| 152 | else value |
|---|
| 153 | |
|---|
| 154 | if not old_values[attrnr] == value: |
|---|
| 155 | new = '%s: %s' % (key, value) |
|---|
| 156 | old = '%s: %s' % (key, old_values[attrnr]) |
|---|
| 157 | |
|---|
| 158 | if not set_attrs == '': |
|---|
| 159 | set_attrs += ',' |
|---|
| 160 | |
|---|
| 161 | set_attrs += '%s=%%s' % key |
|---|
| 162 | set_vals.append(str(value)) |
|---|
| 163 | |
|---|
| 164 | self._diff[".%s" % key] = (new, old) |
|---|
| 165 | |
|---|
| 166 | if not set_attrs == '': |
|---|
| 167 | sql = ("UPDATE %s SET %s WHERE ticket=%%s AND position=%%s" |
|---|
| 168 | % (BUDGETING_TABLE.name, set_attrs)) |
|---|
| 169 | |
|---|
| 170 | set_vals.append(ticket_id) |
|---|
| 171 | set_vals.append(position) |
|---|
| 172 | env.db_transaction(sql, set_vals) |
|---|
| 173 | env.log.debug("Updated Budgeting-row for ticket %s" |
|---|
| 174 | " at position %s:\n%s", |
|---|
| 175 | ticket_id, position, self._to_str()) |
|---|
| 176 | |
|---|
| 177 | elif self._action == "Delete": |
|---|
| 178 | self._diff[''] = ('', self._to_str()) |
|---|
| 179 | |
|---|
| 180 | sql = ("DELETE FROM %s WHERE ticket=%%s AND position=%%s" |
|---|
| 181 | % BUDGETING_TABLE.name) |
|---|
| 182 | env.db_transaction(sql, (ticket_id, position)) |
|---|
| 183 | |
|---|
| 184 | env.log.debug("Deleted Budgeting-row for ticket %s" |
|---|
| 185 | " at position %s:\n%s", |
|---|
| 186 | ticket_id, position, self._to_str()) |
|---|
| 187 | else: |
|---|
| 188 | env.log.error('no appropriate action found! _action is: %s', |
|---|
| 189 | self._action) |
|---|
| 190 | |
|---|
| 191 | def get_values(self): |
|---|
| 192 | return self._values |
|---|
| 193 | |
|---|
| 194 | def _to_str(self): |
|---|
| 195 | return ("username: %s, type: %s, estimation: %s, cost: %s," |
|---|
| 196 | " state: %s, comment: %s" % |
|---|
| 197 | (self.get_value(1), self.get_value(2), self.get_value(3), |
|---|
| 198 | self.get_value(4), self.get_value(5), self.get_value(6))) |
|---|
| 199 | |
|---|
| 200 | def get_value(self, number): |
|---|
| 201 | if number is None: |
|---|
| 202 | return "" |
|---|
| 203 | |
|---|
| 204 | number = int(number) |
|---|
| 205 | if 0 < number < _VALUE_NAMES_LIST.__len__() + 1: |
|---|
| 206 | fld = _VALUE_NAMES_LIST[number - 1] |
|---|
| 207 | if fld in 'estimation': |
|---|
| 208 | return locale.format('%.2f', self._values[fld]) |
|---|
| 209 | elif fld in 'cost': |
|---|
| 210 | if 'cost' in self._values: |
|---|
| 211 | return locale.format('%.2f', self._values[fld]) |
|---|
| 212 | else: |
|---|
| 213 | return locale.format('%.2f', 0) |
|---|
| 214 | return self._values[fld] |
|---|
| 215 | return "" |
|---|
| 216 | |
|---|
| 217 | def set_action(self, action): |
|---|
| 218 | self._action = action |
|---|
| 219 | |
|---|
| 220 | def get_diff(self): |
|---|
| 221 | return self._diff |
|---|
| 222 | |
|---|
| 223 | |
|---|
| 224 | class TicketBudgetingView(Component): |
|---|
| 225 | implements(ITicketManipulator, ITemplateProvider, ITemplateStreamFilter, |
|---|
| 226 | IRequestFilter) |
|---|
| 227 | |
|---|
| 228 | _CONFIG_SECTION = 'budgeting-plugin' |
|---|
| 229 | |
|---|
| 230 | # these options won't be saved to trac.ini |
|---|
| 231 | _types = Option( |
|---|
| 232 | _CONFIG_SECTION, 'types', |
|---|
| 233 | 'Implementation|Documentation|Specification|Test', |
|---|
| 234 | """Types of work, which could be selected in select-box.""") |
|---|
| 235 | |
|---|
| 236 | Option( |
|---|
| 237 | _CONFIG_SECTION, 'retrieve_users', "permission", |
|---|
| 238 | """indicates whether users should be retrieved from session or |
|---|
| 239 | permission table; possible values: permission, session""") |
|---|
| 240 | |
|---|
| 241 | Option( |
|---|
| 242 | _CONFIG_SECTION, 'exclude_users', |
|---|
| 243 | "'anonymous','authenticated','tracadmin'", |
|---|
| 244 | """list of users, which should be excluded to show in the |
|---|
| 245 | drop-down list; should be usable as SQL-IN list""") |
|---|
| 246 | |
|---|
| 247 | _def_cost = IntOption( |
|---|
| 248 | _CONFIG_SECTION, 'default_cost', 0, |
|---|
| 249 | doc="""Default costs or -1 to disabled entering costs. |
|---|
| 250 | This might useful when costs are entered by third |
|---|
| 251 | party software.""") |
|---|
| 252 | |
|---|
| 253 | _type_list = None |
|---|
| 254 | _name_list = None |
|---|
| 255 | _name_list_str = None |
|---|
| 256 | _budgets = None |
|---|
| 257 | _changed_by_author = None |
|---|
| 258 | |
|---|
| 259 | BUDGET_REPORTS = [ |
|---|
| 260 | (BUDGET_REPORT_ALL_ID, 'report_title_90', 'report_description_90', |
|---|
| 261 | """SELECT t.id, t.summary, |
|---|
| 262 | t.milestone AS __group__, '../milestone/' || |
|---|
| 263 | t.milestone AS __grouplink__, t.owner, t.reporter, |
|---|
| 264 | t.status, t.type, t.priority, t.component, |
|---|
| 265 | COUNT(b.ticket) AS Count, SUM(b.cost) AS Cost, |
|---|
| 266 | SUM(b.estimation) AS Effort, |
|---|
| 267 | %(status)s || '%%' AS "Status", |
|---|
| 268 | (CASE |
|---|
| 269 | WHEN t.status='closed' |
|---|
| 270 | THEN 'color: #777; background: #ddd; border-color: #ccc;' |
|---|
| 271 | WHEN SUM(b.cost) > SUM(b.estimation) |
|---|
| 272 | THEN 'font-weight: bold; background: orange;' |
|---|
| 273 | END) AS __style__ |
|---|
| 274 | FROM ticket t |
|---|
| 275 | LEFT JOIN budgeting b ON b.ticket = t.id |
|---|
| 276 | WHERE t.milestone LIKE |
|---|
| 277 | (CASE $MILESTONE |
|---|
| 278 | WHEN '' THEN '%%' |
|---|
| 279 | ELSE $MILESTONE END) and |
|---|
| 280 | (t.component LIKE (CASE $COMPONENT |
|---|
| 281 | WHEN '' THEN '%%' |
|---|
| 282 | ELSE $COMPONENT END) or t.component is null) and |
|---|
| 283 | (t.owner LIKE (CASE $OWNER |
|---|
| 284 | WHEN '''' THEN $USER |
|---|
| 285 | ELSE $OWNER END) or t.owner is null or |
|---|
| 286 | b.username LIKE (CASE $OWNER |
|---|
| 287 | WHEN '''' THEN $USER |
|---|
| 288 | ELSE $OWNER END) ) |
|---|
| 289 | GROUP BY t.id, t.type, t.priority, t.summary, t.owner, t.reporter, |
|---|
| 290 | t.component, t.status, t.milestone |
|---|
| 291 | HAVING COUNT(b.ticket) > 0 |
|---|
| 292 | ORDER BY t.milestone DESC, t.status, t.id DESC |
|---|
| 293 | """)] |
|---|
| 294 | |
|---|
| 295 | def __init__(self): |
|---|
| 296 | try: |
|---|
| 297 | locale_dir = resource_filename(__name__, 'locale') |
|---|
| 298 | except KeyError: |
|---|
| 299 | pass |
|---|
| 300 | else: |
|---|
| 301 | add_domain(self.env.path, locale_dir) |
|---|
| 302 | |
|---|
| 303 | try: |
|---|
| 304 | self.env.db_query("SELECT ticket FROM %s where ticket is null" % |
|---|
| 305 | BUDGETING_TABLE.name) |
|---|
| 306 | self.log.debug("[TicketBudgetingView.__init__] table '%s' " |
|---|
| 307 | "already exists", BUDGETING_TABLE.name) |
|---|
| 308 | except Exception: |
|---|
| 309 | self.log.debug("[TicketBudgetingView.__init__] table '%s' " |
|---|
| 310 | "does not exists", BUDGETING_TABLE.name) |
|---|
| 311 | try: |
|---|
| 312 | self.create_table() |
|---|
| 313 | self.log.info("[__init__] table '%s' successfully created", |
|---|
| 314 | BUDGETING_TABLE.name) |
|---|
| 315 | self.create_reports() |
|---|
| 316 | self.log.info("[__init__] report '%s' successfully created", |
|---|
| 317 | BUDGET_REPORT_ALL_ID) |
|---|
| 318 | except Exception, e: |
|---|
| 319 | self.log.error("[__init__] ERROR when creating table" |
|---|
| 320 | " or report: %s", e) |
|---|
| 321 | |
|---|
| 322 | def filter_stream(self, req, method, filename, stream, data): |
|---|
| 323 | if filename == 'ticket.html' and data: |
|---|
| 324 | tkt = data['ticket'] |
|---|
| 325 | if tkt and tkt.id: |
|---|
| 326 | self._load_budget(tkt.id) |
|---|
| 327 | else: |
|---|
| 328 | self._budgets = {} |
|---|
| 329 | |
|---|
| 330 | input_html, preview_html = self._get_ticket_html() |
|---|
| 331 | |
|---|
| 332 | modify_allowed = False |
|---|
| 333 | for perm in authorizedToModify: |
|---|
| 334 | modify_allowed = modify_allowed or \ |
|---|
| 335 | perm in req.perm(tkt.resource) |
|---|
| 336 | |
|---|
| 337 | if modify_allowed: |
|---|
| 338 | visibility = ' style="display: none"' |
|---|
| 339 | if self._budgets: |
|---|
| 340 | visibility = '' |
|---|
| 341 | |
|---|
| 342 | fieldset = self._get_budget_fieldset() \ |
|---|
| 343 | % (visibility, input_html) |
|---|
| 344 | stream |= Transformer('.//fieldset [@id="properties"]')\ |
|---|
| 345 | .after(HTML(fieldset)) |
|---|
| 346 | |
|---|
| 347 | # Load default values for Type, Estimation, Cost an State |
|---|
| 348 | # from trac.ini |
|---|
| 349 | def_type = self._get_budget_attr('default_type') |
|---|
| 350 | if not def_type: |
|---|
| 351 | # If the configured default-type is not available, |
|---|
| 352 | # submit -1 ==> first element in type list will be |
|---|
| 353 | # selected |
|---|
| 354 | def_type = '-1' |
|---|
| 355 | def_est = self._get_budget_attr('default_estimation') |
|---|
| 356 | if not def_est: |
|---|
| 357 | def_est = '0.0' |
|---|
| 358 | |
|---|
| 359 | def_state = self._get_budget_attr('default_state') |
|---|
| 360 | if not def_state: |
|---|
| 361 | def_state = '0' |
|---|
| 362 | |
|---|
| 363 | defaults = html.div( |
|---|
| 364 | html.a(self._type_list, id="selectTypes"), |
|---|
| 365 | html.a(self._name_list_str, id="selectNames"), |
|---|
| 366 | html.a(req.authname, id="def_name"), |
|---|
| 367 | html.a(def_type, id="def_type"), |
|---|
| 368 | html.a(def_est, id="def_est"), |
|---|
| 369 | html.a(self._def_cost, id="def_cost"), |
|---|
| 370 | html.a(def_state, id="def_state"), |
|---|
| 371 | style="display: none") |
|---|
| 372 | |
|---|
| 373 | stream |= Transformer('.//fieldset [@id="budget"]')\ |
|---|
| 374 | .append(defaults) |
|---|
| 375 | |
|---|
| 376 | if preview_html: |
|---|
| 377 | fieldset_str = self._get_budget_preview() % preview_html |
|---|
| 378 | stream |= Transformer('//div [@id="content"]' |
|---|
| 379 | '//div [@id="ticket"]')\ |
|---|
| 380 | .after(HTML(fieldset_str)) |
|---|
| 381 | elif filename == 'milestone_view.html': |
|---|
| 382 | by = 'component' |
|---|
| 383 | if 'by' in req.args: |
|---|
| 384 | by = req.args['by'] |
|---|
| 385 | budget_stats, stats_by = self._get_milestone_html(req, by) |
|---|
| 386 | stats_by = u"<fieldset><legend>Budget</legend>" \ |
|---|
| 387 | u"<table>%s</table></fieldset>" % stats_by |
|---|
| 388 | stream |= Transformer('//form[@id="stats"]')\ |
|---|
| 389 | .append(HTML(stats_by)) |
|---|
| 390 | stream |= Transformer('//div[@class="info"]')\ |
|---|
| 391 | .append(HTML(budget_stats)) |
|---|
| 392 | return stream |
|---|
| 393 | |
|---|
| 394 | def _get_budget_attr(self, name): |
|---|
| 395 | return self.config.get('budgeting-plugin', name) |
|---|
| 396 | |
|---|
| 397 | def _get_budget_fieldset(self): |
|---|
| 398 | title = _('in hours') |
|---|
| 399 | fieldset = ('<fieldset id="budget">' |
|---|
| 400 | '<legend>' + _('Budget Estimation') + '</legend>' |
|---|
| 401 | '<div class="inlinebuttons">' |
|---|
| 402 | '<label>' + _('Add a new row') + '</label>' |
|---|
| 403 | '<input type="button" name="addRow" style="margin-left: 5px; border-radius: 1em 1em 1em 1em; font-size: 100%%" onclick="addBudgetRow()" value = "✚"/>' |
|---|
| 404 | '</div>' |
|---|
| 405 | '<span id="hiddenbudgettable"%s>' |
|---|
| 406 | '<table>' |
|---|
| 407 | '<thead id="budgethead">' |
|---|
| 408 | '<tr>' |
|---|
| 409 | '<th>' + _('Person') + '</th>' |
|---|
| 410 | '<th>' + _('Type') + '</th>' |
|---|
| 411 | '<th title="' + title + '">' + _('Estimation') + '</th>' + |
|---|
| 412 | ('<th title="' + title + '">' + _('Cost') + '</th>' if self._def_cost != -1 else '') + |
|---|
| 413 | '<th>' + _('State') + '</th>' |
|---|
| 414 | '<th style="width:300px">' + _('Comment') + '</th>' |
|---|
| 415 | '</tr>' |
|---|
| 416 | '</thead>' |
|---|
| 417 | '<tbody id="budget_container">%s</tbody>' |
|---|
| 418 | '</table>' |
|---|
| 419 | '</span>' |
|---|
| 420 | '</fieldset>') |
|---|
| 421 | self.log.debug('fieldset: %s', fieldset) |
|---|
| 422 | return fieldset |
|---|
| 423 | |
|---|
| 424 | def _get_budget_preview(self): |
|---|
| 425 | fieldset = ( |
|---|
| 426 | '<div id="budgetpreview">' |
|---|
| 427 | '<h2 class="foldable">' + _('Budget Estimation') + '</h2>' |
|---|
| 428 | '<table class="listing">' |
|---|
| 429 | '<thead>' |
|---|
| 430 | '<tr>' |
|---|
| 431 | '<th style="width:90px">' + _('Person') + '</th>' |
|---|
| 432 | '<th style="width:90px">' + _('Type') + '</th>' |
|---|
| 433 | '<th style="width:90px">' + _('Estimation') + '</th>' |
|---|
| 434 | '<th style="width:90px">' + _('Cost') + '</th>' |
|---|
| 435 | '<th style="width:90px">' + _('State') + '</th>' |
|---|
| 436 | '<th style="width:300px">' + _('Comment') + '</th>' |
|---|
| 437 | '</tr>' |
|---|
| 438 | '</thead>' |
|---|
| 439 | '<tbody id="previewContainer">%s' |
|---|
| 440 | '</tbody>' |
|---|
| 441 | '</table>' |
|---|
| 442 | '</div>') |
|---|
| 443 | return fieldset |
|---|
| 444 | |
|---|
| 445 | def pre_process_request(self, req, handler): |
|---|
| 446 | """ overridden from IRequestFilter""" |
|---|
| 447 | return handler |
|---|
| 448 | |
|---|
| 449 | def post_process_request(self, req, template, data, content_type): |
|---|
| 450 | """ overridden from IRequestFilter""" |
|---|
| 451 | if req.path_info.startswith('/newticket') or \ |
|---|
| 452 | req.path_info.startswith('/ticket'): |
|---|
| 453 | add_script(req, 'hw/js/budgeting.js') |
|---|
| 454 | if not data: |
|---|
| 455 | return template, data, content_type |
|---|
| 456 | tkt = data['ticket'] |
|---|
| 457 | |
|---|
| 458 | if tkt and tkt.id and Ticket.id_is_valid(tkt.id): |
|---|
| 459 | # ticket is ready for saving |
|---|
| 460 | if self._changed_by_author: |
|---|
| 461 | self._save_budget(tkt) |
|---|
| 462 | self._budgets = None |
|---|
| 463 | return template, data, content_type |
|---|
| 464 | |
|---|
| 465 | def _get_fields(self, req): |
|---|
| 466 | budget_dict = {} |
|---|
| 467 | # searching budget fields and send them to db |
|---|
| 468 | for arg in req.args: |
|---|
| 469 | field_attrs = arg.split("-") |
|---|
| 470 | if len(field_attrs) >= 2: |
|---|
| 471 | row_no = field_attrs[0] |
|---|
| 472 | if row_no in budget_dict: |
|---|
| 473 | budget_obj = budget_dict[row_no] |
|---|
| 474 | else: |
|---|
| 475 | budget_obj = Budget() |
|---|
| 476 | budget_dict[row_no] = budget_obj |
|---|
| 477 | budget_obj.set(field_attrs[1], req.args.get(arg)) |
|---|
| 478 | |
|---|
| 479 | if len(field_attrs) == 3: |
|---|
| 480 | # New created field, should be insered |
|---|
| 481 | if field_attrs[2] in ('Insert', 'Delete', 'Update'): |
|---|
| 482 | budget_obj.set_action(field_attrs[2]) |
|---|
| 483 | return budget_dict |
|---|
| 484 | |
|---|
| 485 | def _get_milestone_html(self, req, group_by): |
|---|
| 486 | html = '' |
|---|
| 487 | stats_by = '' |
|---|
| 488 | ms = req.args['id'] |
|---|
| 489 | |
|---|
| 490 | for row in self.env.db_query(""" |
|---|
| 491 | SELECT SUM(b.cost), SUM(b.estimation), AVG(b.status) |
|---|
| 492 | FROM budgeting b, ticket t |
|---|
| 493 | WHERE b.ticket=t.id AND t.milestone=%s |
|---|
| 494 | """, (ms,)): |
|---|
| 495 | html = '<dl><dt>' + _('Budget in hours') + ':</dt><dd> </dd>' \ |
|---|
| 496 | '<dt>' + _('Cost') + ': <dd>%.2f</dd></dt>' \ |
|---|
| 497 | '<dt>' + _('Estimation') + ': <dd>%.2f</dd></dt>' \ |
|---|
| 498 | '<dt>' + _('Status') + ': <dd>%.1f%%</dd></dt></dl>' |
|---|
| 499 | html %= row[0], row[1], row[2] |
|---|
| 500 | html = self._get_progress_html(row[0], row[1], row[2]) + html |
|---|
| 501 | |
|---|
| 502 | if not group_by: |
|---|
| 503 | return html, stats_by |
|---|
| 504 | |
|---|
| 505 | for row in self.env.db_query(""" |
|---|
| 506 | SELECT t.%s, SUM(b.cost), SUM(b.estimation), AVG(b.status) |
|---|
| 507 | FROM budgeting b, ticket t |
|---|
| 508 | WHERE b.ticket=t.id AND t.milestone=%%s |
|---|
| 509 | GROUP BY t.%s ORDER BY t.%s |
|---|
| 510 | """ % (group_by, group_by, group_by), (ms,)): |
|---|
| 511 | status_bar = self._get_progress_html(row[1], row[2], row[3], 75) |
|---|
| 512 | link = req.href.query({'milestone': ms, group_by: row[0]}) |
|---|
| 513 | if group_by == 'component': |
|---|
| 514 | link = req.href.report(BUDGET_REPORT_ALL_ID, |
|---|
| 515 | {'MILESTONE': ms, |
|---|
| 516 | 'COMPONENT': row[0], |
|---|
| 517 | 'OWNER': '%'}) |
|---|
| 518 | |
|---|
| 519 | stats_by += '<tr><th scope="row"><a href="%s">' \ |
|---|
| 520 | '%s</a></th>' % (link, row[0]) |
|---|
| 521 | stats_by += '<td>%s</td></tr>' % status_bar |
|---|
| 522 | |
|---|
| 523 | return html, stats_by |
|---|
| 524 | |
|---|
| 525 | def _get_progress_html(self, cost, estimation, status, width=None): |
|---|
| 526 | ratio = 0 |
|---|
| 527 | if estimation > 0 and cost: |
|---|
| 528 | left_bar_value = int(round((cost * 100) / estimation, 0)) |
|---|
| 529 | ratio = left_bar_value |
|---|
| 530 | right_bar_value = int(round(100 - left_bar_value, 0)) |
|---|
| 531 | if right_bar_value + left_bar_value < 100: |
|---|
| 532 | right_bar_value += 1 |
|---|
| 533 | elif left_bar_value > 100: |
|---|
| 534 | left_bar_value = int(100) |
|---|
| 535 | right_bar_value = int(0) |
|---|
| 536 | else: |
|---|
| 537 | left_bar_value = int(0) |
|---|
| 538 | right_bar_value = int(100) |
|---|
| 539 | |
|---|
| 540 | style_cost = "width: " + str(left_bar_value) + "%" |
|---|
| 541 | style_est = "width: " + str(right_bar_value) + "%" |
|---|
| 542 | title = ' title="' + _('Cost') + ' / ' + _('Estimation') + \ |
|---|
| 543 | ': %.1f / %.1f (%.0f %%); ' + _('Status') + ': %.1f%%"' |
|---|
| 544 | title %= cost, estimation, ratio, status |
|---|
| 545 | right_legend = "%.0f %%" % ratio |
|---|
| 546 | |
|---|
| 547 | if int(status) == 100: |
|---|
| 548 | style_cost += ";background:none repeat scroll 0 0 #3300FF;" |
|---|
| 549 | style_est += ";background:none repeat scroll 0 0 #00BB00;" |
|---|
| 550 | elif ratio > 100: |
|---|
| 551 | style_cost += ";background:none repeat scroll 0 0 #BB0000;" |
|---|
| 552 | |
|---|
| 553 | status_bar = '<table class="progress"' |
|---|
| 554 | if width: |
|---|
| 555 | status_bar += ' style="width: ' + str(width) + '%"' |
|---|
| 556 | right_legend = "%.0f / %.0f" % (cost, estimation) |
|---|
| 557 | status_bar += '><tr><td class="closed" style="' + style_cost + '">\ |
|---|
| 558 | <a' + title + '></a> \ |
|---|
| 559 | </td><td style="' + style_est + '" class="open">\ |
|---|
| 560 | <a' + title + '></a> \ |
|---|
| 561 | </td></tr></table><p class="percent"' + title + '>' + \ |
|---|
| 562 | right_legend + '</p>' |
|---|
| 563 | |
|---|
| 564 | return status_bar |
|---|
| 565 | |
|---|
| 566 | def _get_ticket_html(self): |
|---|
| 567 | input_html = '' |
|---|
| 568 | preview_html = '' |
|---|
| 569 | |
|---|
| 570 | if not self._type_list: |
|---|
| 571 | types_str = self.config.get(self._CONFIG_SECTION, 'types') |
|---|
| 572 | self._type_list = re.sub(r'\|', ';', types_str) |
|---|
| 573 | self.log.debug("INIT self._type_list: %s", self._type_list) |
|---|
| 574 | types = self._type_list.split(';') |
|---|
| 575 | |
|---|
| 576 | if not self._name_list: |
|---|
| 577 | self._name_list = self.get_user_list() |
|---|
| 578 | self.log.debug("INIT self._name_list: %s", self._name_list) |
|---|
| 579 | for user in self._name_list: |
|---|
| 580 | if not self._name_list_str: |
|---|
| 581 | self._name_list_str = str(user) |
|---|
| 582 | else: |
|---|
| 583 | self._name_list_str += ';' + str(user) |
|---|
| 584 | |
|---|
| 585 | if self._budgets: |
|---|
| 586 | for pos, budget in self._budgets.iteritems(): |
|---|
| 587 | user_options = '' |
|---|
| 588 | type_options = '' |
|---|
| 589 | values = budget.get_values() |
|---|
| 590 | input_html += '<tr id="row-%s">' % pos |
|---|
| 591 | preview_html += '<tr>' |
|---|
| 592 | el_in_list = False |
|---|
| 593 | |
|---|
| 594 | if self._name_list: |
|---|
| 595 | for opt in self._name_list: |
|---|
| 596 | selected = '' |
|---|
| 597 | if values['username'] == opt: |
|---|
| 598 | selected = ' selected' |
|---|
| 599 | el_in_list = True |
|---|
| 600 | # preview_html += '<td>%s</td>' % opt |
|---|
| 601 | user_options += '<option%s>%s</option>' \ |
|---|
| 602 | % (selected, opt) |
|---|
| 603 | if not el_in_list: |
|---|
| 604 | user_options += '<option selected>%s</option>' \ |
|---|
| 605 | % (values['username']) |
|---|
| 606 | |
|---|
| 607 | el_in_list = False |
|---|
| 608 | for t in types: |
|---|
| 609 | selected = '' |
|---|
| 610 | if values['type'] == t: |
|---|
| 611 | selected = ' selected' |
|---|
| 612 | el_in_list = True |
|---|
| 613 | # preview_html += '<td>%s</td>' % t |
|---|
| 614 | type_options += '<option%s>%s</option>' % (selected, t) |
|---|
| 615 | if not el_in_list: |
|---|
| 616 | type_options += '<option selected>%s</option>' \ |
|---|
| 617 | % (values['type']) |
|---|
| 618 | |
|---|
| 619 | input_html += '<td><select onChange="update(%s,1)" ' \ |
|---|
| 620 | 'name="%s-1" >%s</select></td>' \ |
|---|
| 621 | % (pos, pos, user_options) |
|---|
| 622 | preview_html += '<td>%s</td>' % values['username'] |
|---|
| 623 | input_html += '<td><select onChange="update(%s,2)" ' \ |
|---|
| 624 | 'name="%s-2">%s</select></td>' \ |
|---|
| 625 | % (pos, pos, type_options) |
|---|
| 626 | preview_html += '<td>%s</td>' % values['type'] |
|---|
| 627 | size = 10 |
|---|
| 628 | for col in range(3, 7): |
|---|
| 629 | col_val = budget.get_value(col) |
|---|
| 630 | if col == 6 and col_val: # comment |
|---|
| 631 | col_val = col_val.replace('"', """) |
|---|
| 632 | size = 60 |
|---|
| 633 | elif not col_val: |
|---|
| 634 | if col < 6: |
|---|
| 635 | col_val = '0' |
|---|
| 636 | else: |
|---|
| 637 | col_val = '' |
|---|
| 638 | size = 60 |
|---|
| 639 | |
|---|
| 640 | if col == 4 and self._def_cost == -1: # disable cost |
|---|
| 641 | input_html += '<input type="hidden" name="%s-%s" value="%s" />' % (pos, col, 0) |
|---|
| 642 | else: |
|---|
| 643 | input_html += '<td><input size="%s" onChange="update(%s,%s)" name="%s-%s" value="%s"></td>' % (size, pos, col, pos, col, col_val) |
|---|
| 644 | preview_html += '<td>%s' % col_val |
|---|
| 645 | if col == 5: |
|---|
| 646 | preview_html += ' %' |
|---|
| 647 | preview_html += '</td>' |
|---|
| 648 | input_html += '<td><div class="inlinebuttons"><input type="button" style="border-radius: 1em 1em 1em 1em; font-size: 100%%" name="deleteRow%s" onclick="deleteRow(%s)" value = "✘"/></div></td>' % (pos, pos) |
|---|
| 649 | input_html += '</tr>' |
|---|
| 650 | preview_html += '</tr>' |
|---|
| 651 | return input_html, preview_html |
|---|
| 652 | |
|---|
| 653 | # ITemplateProvider methods |
|---|
| 654 | |
|---|
| 655 | def get_templates_dirs(self): |
|---|
| 656 | return [resource_filename(__name__, 'htdocs')] |
|---|
| 657 | |
|---|
| 658 | def get_htdocs_dirs(self): |
|---|
| 659 | return [('hw', resource_filename(__name__, 'htdocs'))] |
|---|
| 660 | |
|---|
| 661 | def _load_budget(self, ticket_id): |
|---|
| 662 | self._budgets = {} |
|---|
| 663 | if not ticket_id: |
|---|
| 664 | return |
|---|
| 665 | |
|---|
| 666 | for row in self.env.db_query(""" |
|---|
| 667 | SELECT position,username,type,estimation,cost,status,comment |
|---|
| 668 | FROM budgeting WHERE ticket=%s ORDER BY position |
|---|
| 669 | """, (ticket_id,)): |
|---|
| 670 | budget = Budget() |
|---|
| 671 | for i, col in enumerate(row): |
|---|
| 672 | if i > 0: |
|---|
| 673 | budget.set(i, col) |
|---|
| 674 | pos = int(row[0]) |
|---|
| 675 | self._budgets[pos] = budget |
|---|
| 676 | self.log.debug("[_load_budget] loaded budget: %s", |
|---|
| 677 | budget.get_values()) |
|---|
| 678 | |
|---|
| 679 | def _save_budget(self, tkt): |
|---|
| 680 | if self._budgets and tkt and tkt.id: |
|---|
| 681 | user = self._changed_by_author |
|---|
| 682 | self._changed_by_author = None |
|---|
| 683 | for pos, budget in self._budgets.iteritems(): |
|---|
| 684 | budget.do_action(self.env, tkt.id, int(pos), self._def_cost) |
|---|
| 685 | self.log.debug("saved budget of position: %s", pos) |
|---|
| 686 | self._log_changes(tkt, user) |
|---|
| 687 | self._budgets = None |
|---|
| 688 | |
|---|
| 689 | def _log_changes(self, tkt, change_user): |
|---|
| 690 | if not tkt or not tkt.id: |
|---|
| 691 | return |
|---|
| 692 | cur_time = self._get_current_time() |
|---|
| 693 | |
|---|
| 694 | try: |
|---|
| 695 | for pos, budget in self._budgets.iteritems(): |
|---|
| 696 | if budget.get_diff(): |
|---|
| 697 | diff = budget.get_diff() |
|---|
| 698 | for key, (new, old) in diff.iteritems(): |
|---|
| 699 | sql = ("INSERT INTO ticket_change " |
|---|
| 700 | "(ticket,time,author,field,oldvalue,newvalue) " |
|---|
| 701 | "VALUES(%s,%s,%s,%s,%s,%s)") |
|---|
| 702 | self.env.db_transaction(sql, |
|---|
| 703 | (tkt.id, cur_time, change_user, 'budgeting.%s%s' |
|---|
| 704 | % (pos, key), old, new)) |
|---|
| 705 | except Exception, ex: |
|---|
| 706 | self.log.error("Error while logging change: %s", ex) |
|---|
| 707 | |
|---|
| 708 | def _get_current_time(self): |
|---|
| 709 | return (time.time() - 1) * 1000000 |
|---|
| 710 | |
|---|
| 711 | def validate_ticket(self, req, ticket): |
|---|
| 712 | """ overriden from ITicketManipulator """ |
|---|
| 713 | errors = [] |
|---|
| 714 | try: |
|---|
| 715 | self._budgets = self._get_fields(req) |
|---|
| 716 | self._changed_by_author = req.authname or 'anonymous' |
|---|
| 717 | self.log.info("[validate] budget has changed by author: %s", |
|---|
| 718 | self._changed_by_author) |
|---|
| 719 | except Exception, ex: |
|---|
| 720 | self.log.error("Error while validating: %s", ex) |
|---|
| 721 | fld, e = ex |
|---|
| 722 | errors.append([fld, str(e)]) |
|---|
| 723 | |
|---|
| 724 | return errors |
|---|
| 725 | |
|---|
| 726 | def create_table(self): |
|---|
| 727 | """Constructor, see trac/postgres_backend.py:95 (method init_db) |
|---|
| 728 | """ |
|---|
| 729 | conn, dummy_args = DatabaseManager(self.env).get_connector() |
|---|
| 730 | try: |
|---|
| 731 | with self.env.db_transaction as db: |
|---|
| 732 | for stmt in conn.to_sql(BUDGETING_TABLE): |
|---|
| 733 | try: |
|---|
| 734 | if db.schema: |
|---|
| 735 | stmt = re.sub(r'CREATE TABLE ', 'CREATE TABLE "' |
|---|
| 736 | + db.schema + '".', stmt) |
|---|
| 737 | except Exception, e: |
|---|
| 738 | self.log.warn("[INIT table] substituting schema " |
|---|
| 739 | "throws error: %s", e) |
|---|
| 740 | stmt = re.sub(r'(?i)bigint', 'NUMERIC(10,2)', stmt) |
|---|
| 741 | self.log.info("[INIT table] executing sql: %s", stmt) |
|---|
| 742 | db(stmt) |
|---|
| 743 | self.log.info("[INIT table] successfully created " |
|---|
| 744 | "table %s", BUDGETING_TABLE.name) |
|---|
| 745 | except Exception, e: |
|---|
| 746 | self.log.error("[INIT table] Error executing SQL Statement\n" |
|---|
| 747 | "%s", e) |
|---|
| 748 | |
|---|
| 749 | def create_reports(self): |
|---|
| 750 | for report in self.BUDGET_REPORTS: |
|---|
| 751 | sql = """ |
|---|
| 752 | INSERT INTO report (id, author, title, query, description) |
|---|
| 753 | VALUES (%s, NULL, %s, %s, %s) |
|---|
| 754 | """ |
|---|
| 755 | try: |
|---|
| 756 | with self.env.db_transaction as db: |
|---|
| 757 | query = report[3] \ |
|---|
| 758 | % {'status': db.cast('AVG(b.status)', 'int')} |
|---|
| 759 | db(sql, (report[0], _(report[1]), query, _(report[2]))) |
|---|
| 760 | except Exception, e: |
|---|
| 761 | self.log.error("[INIT reports] Error executing SQL " |
|---|
| 762 | "Statement%s", |
|---|
| 763 | exception_to_unicode(e, traceback=True)) |
|---|
| 764 | else: |
|---|
| 765 | self.log.info("[INIT reports] successfully created report " |
|---|
| 766 | "with id %s", report[0]) |
|---|
| 767 | |
|---|
| 768 | def get_col_list(self, ignore_cols=None): |
|---|
| 769 | """ return col list as string; usable for selecting all cols |
|---|
| 770 | from budgeting table """ |
|---|
| 771 | col_list = "" |
|---|
| 772 | i = 0 |
|---|
| 773 | for col in BUDGETING_TABLE.columns: |
|---|
| 774 | try: |
|---|
| 775 | if ignore_cols and ignore_cols.index(col.name) > -1: |
|---|
| 776 | continue |
|---|
| 777 | except: |
|---|
| 778 | pass |
|---|
| 779 | |
|---|
| 780 | if i > 0: |
|---|
| 781 | col_list += "," |
|---|
| 782 | col_list += col.name |
|---|
| 783 | i += 1 |
|---|
| 784 | return col_list |
|---|
| 785 | |
|---|
| 786 | def get_user_list(self): |
|---|
| 787 | sql_result = [] |
|---|
| 788 | |
|---|
| 789 | sql = ("SELECT DISTINCT sid FROM session WHERE authenticated > 0" |
|---|
| 790 | " ORDER BY sid") |
|---|
| 791 | |
|---|
| 792 | if self.config.get(self._CONFIG_SECTION, 'retrieve_users') == \ |
|---|
| 793 | 'permission': |
|---|
| 794 | sql = "SELECT DISTINCT username FROM permission" |
|---|
| 795 | if self.config.get(self._CONFIG_SECTION, 'exclude_users'): |
|---|
| 796 | excl_user = self.config.get(self._CONFIG_SECTION, |
|---|
| 797 | 'exclude_users') |
|---|
| 798 | sql = "%s WHERE username NOT IN (%s)" % (sql, excl_user) |
|---|
| 799 | sql += " ORDER BY username" |
|---|
| 800 | for row in self.env.db_query(sql): |
|---|
| 801 | sql_result.append(row[0]) |
|---|
| 802 | return sql_result |
|---|
| 803 | |
|---|
| 804 | |
|---|
| 805 | class TicketBudgetingPermission(Component): |
|---|
| 806 | """Publicise permission TICKET_BUDGETING_MODIFY """ |
|---|
| 807 | implements(IPermissionRequestor) |
|---|
| 808 | |
|---|
| 809 | definedPermissions = "TICKET_BUDGETING_MODIFY" |
|---|
| 810 | |
|---|
| 811 | # IPermissionRequestor methods |
|---|
| 812 | |
|---|
| 813 | def get_permission_actions(self): |
|---|
| 814 | yield self.definedPermissions |
|---|