| 1 |
# Burndown plugin |
|---|
| 2 |
# Copyright (C) 2006 Sam Bloomquist <spooninator@hotmail.com> |
|---|
| 3 |
# All rights reserved. |
|---|
| 4 |
# vi: et ts=4 sw=4 |
|---|
| 5 |
# This software may at some point consist of voluntary contributions made by |
|---|
| 6 |
# many individuals. For the exact contribution history, see the revision |
|---|
| 7 |
# history and logs, available at http://projects.edgewall.com/trac/. |
|---|
| 8 |
# |
|---|
| 9 |
# Author: Sam Bloomquist <spooninator@hotmail.com> |
|---|
| 10 |
|
|---|
| 11 |
import time |
|---|
| 12 |
|
|---|
| 13 |
from trac.core import * |
|---|
| 14 |
from trac.env import IEnvironmentSetupParticipant |
|---|
| 15 |
from trac.perm import IPermissionRequestor |
|---|
| 16 |
from trac.web.chrome import INavigationContributor, ITemplateProvider, add_stylesheet |
|---|
| 17 |
from trac.web.main import IRequestHandler |
|---|
| 18 |
from trac.util import escape, Markup, format_date |
|---|
| 19 |
|
|---|
| 20 |
class BurndownComponent(Component): |
|---|
| 21 |
implements(IEnvironmentSetupParticipant, INavigationContributor, |
|---|
| 22 |
IRequestHandler, ITemplateProvider, IPermissionRequestor) |
|---|
| 23 |
|
|---|
| 24 |
#--------------------------------------------------------------------------- |
|---|
| 25 |
# IEnvironmentSetupParticipant methods |
|---|
| 26 |
#--------------------------------------------------------------------------- |
|---|
| 27 |
def environment_created(self): |
|---|
| 28 |
pass |
|---|
| 29 |
|
|---|
| 30 |
def environment_needs_upgrade(self, db): |
|---|
| 31 |
result = False |
|---|
| 32 |
|
|---|
| 33 |
#get a database connection if we don't already have one |
|---|
| 34 |
if not db: |
|---|
| 35 |
db = self.env.get_db_cnx() |
|---|
| 36 |
handle_ta = True |
|---|
| 37 |
else: |
|---|
| 38 |
handle_ta = False |
|---|
| 39 |
|
|---|
| 40 |
# See if the burndown table exists, if not, return True because we need to upgrade the database |
|---|
| 41 |
cursor = db.cursor() |
|---|
| 42 |
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='burndown'") |
|---|
| 43 |
row = cursor.fetchone() |
|---|
| 44 |
if not row: |
|---|
| 45 |
result = True |
|---|
| 46 |
|
|---|
| 47 |
if handle_ta: |
|---|
| 48 |
db.commit() |
|---|
| 49 |
|
|---|
| 50 |
return result |
|---|
| 51 |
|
|---|
| 52 |
def upgrade_environment(self, db): |
|---|
| 53 |
cursor = db.cursor() |
|---|
| 54 |
|
|---|
| 55 |
# Create the burndown table in the database |
|---|
| 56 |
sqlBurndownCreate = "CREATE TABLE burndown (" \ |
|---|
| 57 |
" id integer PRIMARY KEY NOT NULL,"\ |
|---|
| 58 |
" component_name text NOT NULL,"\ |
|---|
| 59 |
" milestone_name text NOT NULL," \ |
|---|
| 60 |
" date text NOT NULL,"\ |
|---|
| 61 |
" hours_remaining integer NOT NULL"\ |
|---|
| 62 |
")" |
|---|
| 63 |
|
|---|
| 64 |
cursor.execute(sqlBurndownCreate) |
|---|
| 65 |
|
|---|
| 66 |
sqlMilestone = [ |
|---|
| 67 |
#-- Add the 'started' column to the milestone table |
|---|
| 68 |
"""CREATE TEMP TABLE milestone_old AS SELECT * FROM milestone;""", |
|---|
| 69 |
"""DROP TABLE milestone;""", |
|---|
| 70 |
"""CREATE TABLE milestone ( |
|---|
| 71 |
name text PRIMARY KEY, |
|---|
| 72 |
due integer, -- Due date/time |
|---|
| 73 |
completed integer, -- Completed date/time |
|---|
| 74 |
started integer, -- Started date/time |
|---|
| 75 |
description text |
|---|
| 76 |
);""", |
|---|
| 77 |
"""INSERT INTO milestone(name,due,completed,started,description) |
|---|
| 78 |
SELECT name,due,completed,0,description FROM milestone_old;""" |
|---|
| 79 |
] |
|---|
| 80 |
for s in sqlMilestone: |
|---|
| 81 |
cursor.execute(s) |
|---|
| 82 |
|
|---|
| 83 |
#--------------------------------------------------------------------------- |
|---|
| 84 |
# INavigationContributor methods |
|---|
| 85 |
#--------------------------------------------------------------------------- |
|---|
| 86 |
def get_active_navigation_item(self, req): |
|---|
| 87 |
return 'burndown' |
|---|
| 88 |
|
|---|
| 89 |
def get_navigation_items(self, req): |
|---|
| 90 |
yield 'mainnav', 'burndown', Markup('<a href="%s">Burndown</a>', self.env.href.burndown()) |
|---|
| 91 |
|
|---|
| 92 |
#--------------------------------------------------------------------------- |
|---|
| 93 |
# IPermissionRequestor methods |
|---|
| 94 |
#--------------------------------------------------------------------------- |
|---|
| 95 |
def get_permission_actions(self): |
|---|
| 96 |
return ["BURNDOWN_VIEW", "BURNDOWN_ADMIN"] |
|---|
| 97 |
|
|---|
| 98 |
#--------------------------------------------------------------------------- |
|---|
| 99 |
# IRequestHandler methods |
|---|
| 100 |
#--------------------------------------------------------------------------- |
|---|
| 101 |
def match_request(self, req): |
|---|
| 102 |
return req.path_info == '/burndown' |
|---|
| 103 |
|
|---|
| 104 |
def process_request(self, req): |
|---|
| 105 |
req.perm.assert_permission('BURNDOWN_VIEW') |
|---|
| 106 |
|
|---|
| 107 |
db = self.env.get_db_cnx() |
|---|
| 108 |
cursor = db.cursor() |
|---|
| 109 |
|
|---|
| 110 |
cursor.execute("SELECT name FROM milestone") |
|---|
| 111 |
milestone_lists = cursor.fetchall() |
|---|
| 112 |
milestones = [] |
|---|
| 113 |
for mile in milestone_lists: |
|---|
| 114 |
milestones.append(mile[0]) |
|---|
| 115 |
|
|---|
| 116 |
cursor.execute("SELECT name FROM component") |
|---|
| 117 |
component_lists = cursor.fetchall() |
|---|
| 118 |
components = [] |
|---|
| 119 |
for comp in component_lists: |
|---|
| 120 |
components.append(comp[0]) |
|---|
| 121 |
|
|---|
| 122 |
selected_milestone = req.args.get('selected_milestone', milestones[0]) |
|---|
| 123 |
selected_component = req.args.get('selected_component', 'All Components') |
|---|
| 124 |
|
|---|
| 125 |
# expose display data to the clearsilver templates |
|---|
| 126 |
req.hdf['milestones'] = milestones |
|---|
| 127 |
req.hdf['components'] = components |
|---|
| 128 |
req.hdf['selected_milestone'] = selected_milestone |
|---|
| 129 |
req.hdf['selected_component'] = selected_component |
|---|
| 130 |
req.hdf['draw_graph'] = False |
|---|
| 131 |
req.hdf['start_complete'] = False |
|---|
| 132 |
|
|---|
| 133 |
if req.perm.has_permission("BURNDOWN_ADMIN"): |
|---|
| 134 |
req.hdf['start_complete'] = True # show the start and complete milestone buttons to admins |
|---|
| 135 |
|
|---|
| 136 |
if req.args.has_key('start'): |
|---|
| 137 |
self.startMilestone(db, selected_milestone) |
|---|
| 138 |
elif req.args.has_key('complete'): |
|---|
| 139 |
self.completeMilestone(db, selected_milestone) |
|---|
| 140 |
else: |
|---|
| 141 |
req.hdf['draw_graph'] = True |
|---|
| 142 |
req.hdf['burndown_data'] = self.getBurndownData(db, selected_milestone, components, selected_component) # this will be a list of (id, hours_remaining) tuples |
|---|
| 143 |
|
|---|
| 144 |
add_stylesheet(req, 'hw/css/burndown.css') |
|---|
| 145 |
return 'burndown.cs', None |
|---|
| 146 |
|
|---|
| 147 |
def getBurndownData(self, db, selected_milestone, components, selected_component): |
|---|
| 148 |
cursor = db.cursor() |
|---|
| 149 |
|
|---|
| 150 |
component_data = {} # this will be a dictionary of lists of tuples -- e.g. component_data = {'componentName':[(id, hours_remaining), (id, hours_remaining), (id, hours_remaining)]} |
|---|
| 151 |
for comp in components: |
|---|
| 152 |
if selected_component == 'All Components' or comp == selected_component: |
|---|
| 153 |
sqlBurndown = "SELECT id, hours_remaining "\ |
|---|
| 154 |
"FROM burndown "\ |
|---|
| 155 |
"WHERE milestone_name = '" + selected_milestone + "' AND component_name = '" + comp + "' "\ |
|---|
| 156 |
"ORDER BY id" |
|---|
| 157 |
|
|---|
| 158 |
cursor.execute(sqlBurndown) |
|---|
| 159 |
component_data[comp] = cursor.fetchall() |
|---|
| 160 |
|
|---|
| 161 |
if component_data[component_data.keys()[0]]: |
|---|
| 162 |
burndown_length = len(component_data[component_data.keys()[0]]) |
|---|
| 163 |
else: |
|---|
| 164 |
burndown_length = 0 |
|---|
| 165 |
burndown_data = [] |
|---|
| 166 |
if selected_component == 'All Components': |
|---|
| 167 |
for day in range (0, burndown_length): |
|---|
| 168 |
sumHours = 0 |
|---|
| 169 |
for comp in components: |
|---|
| 170 |
sumHours += component_data[comp][day][1] |
|---|
| 171 |
|
|---|
| 172 |
burndown_data.append((day+1, sumHours)) |
|---|
| 173 |
|
|---|
| 174 |
else: |
|---|
| 175 |
for day in range (0, len(component_data[selected_component])): |
|---|
| 176 |
burndown_data.append((day+1, component_data[selected_component][day][1])) |
|---|
| 177 |
|
|---|
| 178 |
return burndown_data |
|---|
| 179 |
|
|---|
| 180 |
def startMilestone(self, db, milestone): |
|---|
| 181 |
cursor = db.cursor() |
|---|
| 182 |
cursor.execute("SELECT started FROM milestone WHERE name = '%s'" % milestone) |
|---|
| 183 |
row = cursor.fetchone() |
|---|
| 184 |
if row and row[0] > 0: |
|---|
| 185 |
raise TracError("Milestone '%s' was already started on %s" % (milestone, format_date(int(row[0])))) |
|---|
| 186 |
|
|---|
| 187 |
cursor.execute("UPDATE milestone SET started = %i WHERE name = '%s'" % (int(time.time()), milestone)) |
|---|
| 188 |
|
|---|
| 189 |
db.commit() |
|---|
| 190 |
|
|---|
| 191 |
def completeMilestone(self, db, milestone): |
|---|
| 192 |
cursor = db.cursor() |
|---|
| 193 |
cursor.execute("SELECT completed FROM milestone WHERE name = '%s'" % milestone) |
|---|
| 194 |
row = cursor.fetchone() |
|---|
| 195 |
if row and row[0] > 0: |
|---|
| 196 |
raise TracError("Milestone '%s' was already completed on %s" % (milestone, format_date(int(row[0])))) |
|---|
| 197 |
|
|---|
| 198 |
cursor.execute("UPDATE milestone SET completed = %i WHERE name = '%s'" % (int(time.time()), milestone)) |
|---|
| 199 |
|
|---|
| 200 |
db.commit() |
|---|
| 201 |
|
|---|
| 202 |
#--------------------------------------------------------------------------- |
|---|
| 203 |
# ITemplateProvider methods |
|---|
| 204 |
#--------------------------------------------------------------------------- |
|---|
| 205 |
def get_templates_dirs(self): |
|---|
| 206 |
""" |
|---|
| 207 |
Return the absolute path of the directory containing the provided |
|---|
| 208 |
ClearSilver templates. |
|---|
| 209 |
""" |
|---|
| 210 |
from pkg_resources import resource_filename |
|---|
| 211 |
return [resource_filename(__name__, 'templates')] |
|---|
| 212 |
|
|---|
| 213 |
def get_htdocs_dirs(self): |
|---|
| 214 |
""" |
|---|
| 215 |
Return a list of directories with static resources (such as style |
|---|
| 216 |
sheets, images, etc.) |
|---|
| 217 |
|
|---|
| 218 |
Each item in the list must be a `(prefix, abspath)` tuple. The |
|---|
| 219 |
`prefix` part defines the path in the URL that requests to these |
|---|
| 220 |
resources are prefixed with. |
|---|
| 221 |
|
|---|
| 222 |
The `abspath` is the absolute path to the directory containing the |
|---|
| 223 |
resources on the local file system. |
|---|
| 224 |
""" |
|---|
| 225 |
from pkg_resources import resource_filename |
|---|
| 226 |
return [('hw', resource_filename(__name__, 'htdocs'))] |
|---|