tracjsganttplugin/0000755000175500017550000000000012747464260014367 5ustar debacledebacletracjsganttplugin/0.11/0000755000175500017550000000000012417257713014743 5ustar debacledebacletracjsganttplugin/0.11/.gitignore0000644000175500017550000000006012317534363016725 0ustar debacledebacle*~ build dist Trac_jsGantt.egg-info *.out *.pyc tracjsganttplugin/0.11/tracjsgantt/0000755000175500017550000000000012417257713017267 5ustar debacledebacletracjsganttplugin/0.11/tracjsgantt/__init__.py0000644000175500017550000000002312300753567021372 0ustar debacledebacleimport tracjsgantt tracjsganttplugin/0.11/tracjsgantt/test/0000755000175500017550000000000012317534363020244 5ustar debacledebacletracjsganttplugin/0.11/tracjsgantt/test/test_resource_leveling_1_ASAP.ctl0000644000175500017550000000012012317534363026540 0ustar debacledebacle2007-01-01 00:00:00 2007-01-01 06:00:00 2007-01-01 06:00:00 2007-01-02 07:00:00 tracjsganttplugin/0.11/tracjsgantt/test/test_resource_leveling_0_ASAP.ctl0000644000175500017550000000012012317534363026537 0ustar debacledebacle2007-01-01 00:00:00 2007-01-01 06:00:00 2007-01-01 00:00:00 2007-01-02 01:00:00 tracjsganttplugin/0.11/tracjsgantt/test.py0000644000175500017550000000772312364006341020617 0ustar debacledebacle# -*- coding: utf-8 -*- # # Copyright (C) 2010-2014 Chris Nelson # All rights reserved. # # This software is licensed as described in the file COPYING, which # you should have received as part of this distribution. import sys import os import tempfile import shutil import unittest import pprint import filecmp import copy from trac.web.api import Request from trac.env import Environment from trac.core import TracError from tracpm import * class TracPMTestCase(unittest.TestCase): index = [0] def _setup(self, configuration = None): configuration = configuration or \ '[TracPM]\nfields.estimate = estimatedhours\n' + \ 'date_format = %Y-%m-%d\n' + \ '[components]\ntracpm.* = enabled\n' instancedir = os.path.join(tempfile.gettempdir(), 'test-PM%d' % self.index[0]) self.index[0] += 1 if os.path.exists(instancedir): shutil.rmtree(instancedir, False) env = Environment(instancedir, create=True) open(os.path.join(os.path.join(instancedir, 'conf'), 'trac.ini'), 'a').write('\n' + configuration + '\n') return Environment(instancedir) def _get_data(self, env, options, tickets): pm = TracPM(env) pm.recomputeSchedule(options, tickets) result = '' for t in tickets: result += format_date(t['_calc_start'][0],'%Y-%m-%d %H:%M:%S') + '\n' result += format_date(t['_calc_finish'][0],'%Y-%m-%d %H:%M:%S') + '\n' return result def _do_test(self, env, options, tickets, testfun, testname): from os.path import join, dirname testdir = join(dirname(dirname(dirname(testfolder))), 'test') outfilename = join(testdir, testname + '.out') ctlfilename = join(testdir, testname + '.ctl') open(outfilename, 'w').write(testfun(env, options, tickets)) return filecmp.cmp(outfilename, ctlfilename) def _do_test_diffs(self, env, options, tickets, testfun, testname): self._do_test(env, options, tickets, testfun, testname) from os.path import join, dirname testdir = join(dirname(dirname(dirname(testfolder))), 'test') import sys from difflib import Differ d = Differ() def readall(ext): return open(join(testdir, testname + ext), 'rb').readlines() result = d.compare(readall('.ctl'), readall('.out')) lines = [ line for line in result if line[0] != ' '] self.assertEquals(0, len(lines)) def test_resource_leveling_0_ASAP(self): env = self._setup() options = {'doResourceLeveling': '0', 'hoursPerDay': 8, 'useActuals': False, 'schedule': 'asap', 'force': True, 'start': '2007-01-01'} tickets = [] ticket = {'id': 1, 'estimatedhours': 6, 'children': [], 'priority': None, 'type': None, 'owner': 'Monty', 'status': 'new'} tickets.append(copy.copy(ticket)) ticket['id'] = 2 ticket['estimatedhours'] = 9 tickets.append(copy.copy(ticket)) self._do_test_diffs(env, options, tickets, self._get_data, 'test_resource_leveling_0_ASAP') def test_resource_leveling_1_ASAP(self): env = self._setup() options = {'doResourceLeveling': '1', 'hoursPerDay': 8, 'useActuals': False, 'schedule': 'asap', 'force': True, 'start': '2007-01-01'} tickets = [] ticket = {'id': 1, 'estimatedhours': 6, 'children': [], 'priority': None, 'type': None, 'owner': 'Monty', 'status': 'new'} tickets.append(copy.copy(ticket)) ticket['id'] = 2 ticket['estimatedhours'] = 9 tickets.append(copy.copy(ticket)) self._do_test_diffs(env, options, tickets, self._get_data, 'test_resource_leveling_1_ASAP') def suite(): return unittest.makeSuite(TracPMTestCase, 'test') if __name__ == '__main__': testfolder = __file__ unittest.main(defaultTest='suite') tracjsganttplugin/0.11/tracjsgantt/tracpm.py0000644000175500017550000037267512417257713021153 0ustar debacledebacle# -*- coding: utf-8 -*- # # Copyright (C) 2010-2014 Chris Nelson # All rights reserved. # # This software is licensed as described in the file COPYING, which # you should have received as part of this distribution. import re import time import math import copy from datetime import timedelta, datetime from trac.ticket import ITicketChangeListener, Ticket from trac.ticket.query import Query from trac.util.datefmt import format_date, localtz, to_datetime try: from trac.util.datefmt import to_utimestamp except ImportError: from trac.util.datefmt import to_timestamp as to_utimestamp from trac.config import IntOption, Option, ExtensionOption from trac.core import implements, Component, TracError, Interface, ExtensionPoint from trac.env import IEnvironmentSetupParticipant from trac.db import DatabaseManager from pmapi import IResourceCalendar, ITaskScheduler, ITaskSorter import db_default # TracPM masks implementation details of how various plugins implement # dates and ticket relationships and business rules about what the # default estimate for a ticket is, etc. # # TracPM.query() augments TicketQuery so that you can find all the # descendants of a ticket (root=id) or all the tickets required for a # specified ticket (goal=id). After querying, TracPM post-processes # the query results to augment the tickets in memory with normalized # meta-data about their relationships. After that processing, a # ticket may have the properties listed below. (Which properties are # present depends on what is configured in the [TracPM] section of # trac.ini.) # # parent - accessed with TracPM.parent(t) # children - accessed with TracPM.children(t) # # successors - accessed with TracPM.successors(t) # predecessors - accessed with TracPM.predecessors(t) # # start - accessed with TracPM.start(t) # finish - accessed with TracPM.finish(t) # # FIXME - do we need access methods for estimate and worked? # # # Some notes on terminology # # goal # # There is potential confusion about what a "milestone" is. In # Project Management terms, a milestone is a due date for a # deliverable. In Trac, a milestone is more like a project: a set of # tickets and an optional date when those tickets are due. To avoid # confusion (or excessive use of adjectives leading to variables like # pmMilestone and tracMiletone, etc.) TracPM uses the term "goal." # # A goal may be an instance of a user-specific ticket type (e.g., # "goal", "inchpebble", "milestone") or a pseudo-ticket representing a # Trac milestone. # # A Trac milestone may be complete or incomplete. The goal # pseudo-ticket for a Trac milestone has a status of "closed" if the # milestone has a completed date or a configurable open status if not # yet completed. # # The tickets required for a goal are scheduled if the goal has a status # in a configurable list of active goal statuses (or if the list is # empty). class TracPM(Component): implements(IEnvironmentSetupParticipant) cfgSection = 'TracPM' # IEnvironmentSetupParticipant methods def environment_created(self): self.log.info('Creating environment for TracPM.') self.found_db_version = 0 self.upgrade_environment(self.env.get_db_cnx()) def environment_needs_upgrade(self, db): cursor = db.cursor() cursor.execute('SELECT value FROM system WHERE name=%s', (db_default.name, )) value = cursor.fetchone() try: self.found_db_version = int(value[0]) if self.found_db_version < db_default.version: self.log.info('TracPM environment out of date.') return True except: self.log.info('TracPM environement missing.') self.found_db_version = 0 return True # Environment version is current return False def upgrade_environment(self, db): self.log.info('Upgrading environment for TracPM.') db_manager, _ = DatabaseManager(self.env)._get_connector() cursor = db.cursor() # Add or update TracPM version in system table if not self.found_db_version: cursor.execute("INSERT INTO system (name, value) VALUES (%s, %s)", (db_default.name, db_default.version)) else: cursor.execute("UPDATE system SET value=%s WHERE name=%s", (db_default.version, db_default.name)) # Create tables for table in db_default.tables: for sql in db_manager.to_sql(table): cursor.execute(sql) # Configurable data sources fields = None sources = None relations = None # How dates are stored in the database dbDateFormat = None # What ticket type to treat like milestones milestoneType = None # Configurable estimate pad, default estimate, hours per estimate estPad = None dftEst = None hpe = None # Format of parent field (e.g., with or without leading '#') parent_format = None Option(cfgSection, 'hours_per_estimate', '1', """Hours represented by each unit of estimated work""") Option(cfgSection, 'default_estimate', '4.0', """Default work for an unestimated task, same units as estimate""") Option(cfgSection, 'estimate_pad', '0.0', "How much work may be remaining when a task goes over estimate,"+ " same units as estimate""") Option(cfgSection, 'fields.percent', None, """Ticket field to use as the data source for the percent complete column.""") Option(cfgSection, 'fields.estimate', None, """Ticket field to use as the data source for estimated work""") Option(cfgSection, 'fields.worked', None, """Ticket field to use as the data source for completed work""") Option(cfgSection, 'fields.start', None, """Ticket field to use as the data source for start date""") Option(cfgSection, 'fields.finish', None, """Ticket field to use as the data source for finish date""" ) Option(cfgSection, 'fields.pred', None, """Ticket field to use as the data source for predecessor list""") Option(cfgSection, 'fields.succ', None, """Ticket field to use as the data source for successor list""") Option(cfgSection, 'fields.parent', None, """Ticket field to use as the data source for the parent""") Option(cfgSection, 'parent_format', '%s', """Format of ticket IDs in parent field (Only for fields.parent. Should be blank when configuring parent-child as a relation.)""") Option(cfgSection, 'milestone_type', '*deprecated*', """Ticket type for milestone-like tickets (Deprecated; use goal_ticket_type.)""") Option(cfgSection, 'goal_ticket_type', 'milestone', """Ticket type for milestone-like tickets""") Option(cfgSection, 'incomplete_milestone_goal_status', 'active', """Status to give goal-type tickets representing incomplete Trac milestones""") Option(cfgSection, 'active_goal_statuses', 'active', """List of statuses for goal-type tickets that are active""") Option(cfgSection, 'useActuals', '0', """Use actual start, finish date for tickets""") scheduler = ExtensionOption(cfgSection, 'scheduler', ITaskScheduler, 'ResourceScheduler') def __init__(self): self.env.log.info('Initializing TracPM') # PM-specific data can be retrieved from custom fields or # external relations. self.sources lists all configured data # sources and provides a key to self.fields or self.relations # to drive the actual db query. self.sources = {} # All the data sources that may come from custom fields. fields = ('percent', 'estimate', 'worked', 'start', 'finish', 'pred', 'succ', 'parent') # All the data sources that may come from external relations. # # Each item lists the fields (above) that are affected by this # relation. From configuration we get another three elements: # table, src, dst. relations = {} relations['pred-succ'] = [ 'pred', 'succ' ] relations['parent-child'] = [ 'parent', 'children' ] # Process field configuration self.fields = {} for field in fields: value = self.config.get(self.cfgSection, 'fields.%s' % field) # As of 0.11.6, there appears to be a bug in Option() so # that a default of None isn't used and we get an empty # string instead. # # Remember the source for this data if value != '' and value != None: # FUTURE: If we really wanted to, we could validate value # against the list of Trac's custom fields. self.sources[field] = field self.fields[field] = value # Process relation configuration # # Find out how to query ticket relationships # Each item is "table,src,dst" so that we can do # SELECT dst FROM table where src = id # or # SELECT dst FROM table WHERE src IN origins # to find the related tickets. self.relations = {} for r in relations: value = self.config.getlist(self.cfgSection, 'relation.%s' % r) # See note above about defaults. # Remember the source for this data if value != [] and value != None: # Validate field and relation configuation if len(value) != 3: self.env.log.error('Relation %s is misconfigured. ' 'Should have three fields: ' 'table,src,dst; found "%s".' % (r, value)) else: for f in relations[r]: if f in self.fields: self.env.log.error('Cannot configure %s as a field ' 'and from relation %s' % (f, r)) self.sources[f] = r self.relations[r] = relations[r] + value # Tickets of this type will be treated as goals self.goalTicketType = self.config.get(self.cfgSection, 'milestone_type') if self.goalTicketType == '*deprecated*': self.goalTicketType = self.config.get(self.cfgSection, 'goal_ticket_type') else: self.env.log.info('The milestone_type setting is deprecated.' ' Use goal_ticket_type.') # Goal-type tickets with these statuses will be considered active. # (An empty list bypasses the test for active.) self.activeGoalStatuses = self.config.getlist(self.cfgSection, 'active_goal_statuses') # An open goal-type pseudo-ticket representing a Trac # milestone has this status. (Closed milestones will have # status of "closed".) self.incompleteMilestoneStatus = \ self.config.get(self.cfgSection, 'incomplete_milestone_goal_status') # Hours per estimate unit. # # If estimate is in hours, this is 1. # # If estimate is in days, this is may be 8 or 24, depending on # your needs and the setting of hoursPerDay self.hpe = float(self.config.get(self.cfgSection, 'hours_per_estimate')) # Default work in an unestimated task self.dftEst = float(self.config.get(self.cfgSection, 'default_estimate')) # How much to pad an estimate when a task has run over self.estPad = float(self.config.get(self.cfgSection, 'estimate_pad')) # Parent format option self.parent_format = self.config.get(self.cfgSection,'parent_format') # This is the format of start and finish in the Trac database self.dbDateFormat = str(self.config.get(self.cfgSection, 'date_format')) # Use actual start, finish time for tickets self.useActuals = int(self.config.get(self.cfgSection, 'useActuals')) # Return True if all of the listed PM data items ('pred', # 'parent', etc.) have sources configured, False otherwise def isCfg(self, sources): if type(sources) == type([]): for s in sources: if s not in self.sources: return False else: return sources in self.sources return True def isField(self, field): return self.isCfg(field) and self.sources[field] in self.fields def isRelation(self, field): return self.isCfg(field) and self.sources[field] in self.relations # Return True if ticket has a non-empty value for field, False # otherwise. def isSet(self, ticket, field): if self.isCfg(field) \ and len(ticket[self.fields[field]]) != 0: return True else: return False # FIXME - Many of these should be marked as more private. Perhaps # an leading underscore? def parseDbDate(self, dateString): if not dateString: d = None else: d = datetime(*time.strptime(dateString, self.dbDateFormat)[0:7]) d = d.replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=localtz) return d # Parse the start field and return a datetime # Return None if the field is not configured or empty. def parseStart(self, ticket): return self.parseTaskDate(ticket, 'start') # Parse the finish field and return a datetime # Return None if the field is not configured or empty. def parseFinish(self, ticket): return self.parseTaskDate(ticket, 'finish') def parseTaskDate(self, ticket, field): if self.isSet(ticket, field): try: taskDate = self.parseDbDate(ticket[self.fields[field]]) except: raise TracError('Ticket %s has an invalid %s value, "%s".' \ ' It should match the format "%s".' % (ticket['id'], self.fields[field], ticket[self.fields[field]], self.dbDateFormat)) else: taskDate = None return taskDate # Is d at the start of the day # # It gets messy to do this explicitly inline, especially because # some date math seems to have rounding issues that result in # non-zero microseconds. def isStartOfDay(self, d): # Subtract d from midnight that day delta = d - d.replace(hour=0, minute=0, second=0, microsecond=0) # If within 5 seconds of midnight, it's midnight. # # Note that seconds and microseconds are always positive # http://docs.python.org/library/datetime.html#timedelta-objects return delta.seconds < 5 # Get the value for a PM field from a ticket def _fieldValue(self, ticket, field): # If the field isn't configured, return None if not self.isCfg(field): return None # If the value comes from a custom field, resolve the field # name through sources and use it to index the ticket. elif self.isField(field): return ticket[self.fields[self.sources[field]]] # If the value comes from a relation, we use the internal # name directly. else: return ticket[field] # Return the integer ID of the parent ticket # None if no parent or parent is not configured def parent(self, ticket): value = self._fieldValue(ticket, 'parent') if value == None: return value elif value == []: return None else: return value[0] # Return list of integer IDs of children. # None if parent is not configured. def children(self, ticket): return ticket['children'] # Return an unordered list of integer IDs of the roots (tickets # without parents) in ticketsByID. def roots(self, ticketsByID): if self.isCfg('parent'): roots = [] # Find the roots of the trees for tid in ticketsByID: pid = self.parent(ticketsByID[tid]) if not pid or pid not in ticketsByID: roots.append(tid) # If there is no parent field, all tickets are roots. else: roots = ticketsByID.keys() return roots # Return a list of integer ticket IDs for immediate precedessors # for ticket or an empty list if there are none. def predecessors(self, ticket): value = self._fieldValue(ticket, 'pred') if value == None: return [] else: return value # Return a list of integer ticket IDs for immediate successors for # ticket or an empty list if there are none. def successors(self, ticket): value = self._fieldValue(ticket, 'succ') if value == None: return [] else: return value # Return start for ticket as a Python datetime object def start(self, ticket): if ticket.get('_calc_start'): return ticket['_calc_start'][0] elif ticket.get('_sched_start'): return to_datetime(ticket['_sched_start']) else: return None # Return finish for ticket as a Python datetime object def finish(self, ticket): if ticket.get('_calc_finish'): return ticket['_calc_finish'][0] elif ticket.get('_sched_finish'): return to_datetime(ticket['_sched_finish']) else: return None # Return a set of custom fields that PM needs to work. The # caller can add this to the list of fields in a query so that # when the tickets are passed back to PM the necessary data is # there. def queryFields(self): # Start with Trac core fields related to PM/scheduling fields = set([ 'owner', 'status', 'milestone', 'priority', 'type' ]) # Add configured custom fields for dependendencies, etc. for field in self.fields: fields.add(self.fields[field]) return fields # Return True if ticket is a milestone, False otherwise. def isMilestone(self, ticket): return ticket['type'] == self.goalTicketType # Return True if ticket is a Trac milestone, False otherwise. def isTracMilestone(self, ticket): return self.isMilestone(ticket) and ticket['id'] < 0 # Return total hours of work in ticket as a floating point number def workHours(self, ticket): if self.isCfg('estimate') and ticket.get(self.fields['estimate']): est = float(ticket[self.fields['estimate']]) else: est = None if self.isCfg('worked') and ticket.get(self.fields['worked']): work = float(ticket[self.fields['worked']]) else: work = None # Milestones have no work. if ticket['type'] == self.goalTicketType: est = 0.0 # Closed tickets took as long as they took elif ticket['status'] == 'closed' and work: est = work # If the task is over its estimate, assume it will take # pad more time elif work > est: est = work + self.estPad # If unestimated, use the default elif not est or est == 0: est = self.dftEst # Otherwise, use the estimate parsed above. # Scale by hours per estimate hours = est * self.hpe return hours # Return percent complete. # # If estimate and worked are configured and estimate is not 0, # returns 'est/work' as a string. # # If estimate or work are not configured or estimate is 0, returns # percent complete as an integer. def percentComplete(self, ticket): # Closed tickets are 100% complete if ticket['status'] == 'closed': percent = 100 # Compute percent complete if given estimate and worked elif self.isCfg(['estimate', 'worked']): # Try to compute the percent complete, default to 0 estimate = self.workHours(ticket) / self.hpe if (estimate == 0): percent = 0 else: worked = ticket[self.fields['worked']] if worked == '': percent = 0 else: worked = float(worked) percent = '%s/%s' % (worked, estimate) # Use percent if provided elif self.isCfg('percent'): try: percent = int(ticket[self.fields['percent']]) except: percent = 0 # If no estimate and worked (above) and no percent, it's 0 else: percent = 0 return percent # Find all the tasks connected (directly or indirectly) to the # specified origins via dependencies (parent-child or pred-succ). # # We expand a border between tasks we have explored and those yet # to explore until there are no more to explore. This is a # variation on a graph coloring algorithm which colors explored # nodes black, border nodes grey, and nodes to explore white. # Such labeling doesn't fit well with the Trac database structure. # We could name our sets white, grey, and black, but that's much # less clear than naming them for what they contain. # # @param origins list of integer ticket IDs to fan out from # @param depth how how many times to traverse links (-1=all, default) # @return list of ticket ID strings for tickets reachable from # origins (including origins) def _reachable(self, origins, depth=-1): # Helper to find the immediate neighbors of a set of nodes. # @param nodes a set of nodes to find neighbors of def neighbors(nodes): n = set() nodes = list(nodes) # Get parents of nodes if self.isCfg('parent'): n |= set(self._followLink(nodes, 'parent', self.parent_format, 1)) # Get children of nodes if self.isRelation('parent'): n |= set(self._followLink(nodes, 'children', '%s', 1)) # Get immediate predecessors of nodes if self.isCfg('pred'): n |= set(self._followLink(nodes, 'pred', '%s', 1)) # Get immedicate successors of nodes if self.isCfg('succ'): n |= set(self._followLink(nodes, 'succ', '%s', 1)) return n # We haven't explored anything yet. explored = set() # We'll explore out from the initial set toExplore = set(origins) # While we have more exploring to do while toExplore != set() and depth != 0: depth -= 1 # Our border is what we're about to explore border = toExplore # Find neighbors of the border nodes toExplore = neighbors(border) # Some of the neighbors of the border are already in the # explored set. Remove them. toExplore -= explored # Now we've explored the border explored |= border return list(explored) # Expand the list of tickets in origins to include those # related through field. # # Recurses following link until # * no new items are found, or # * field has been followed depth times # # @param origins a list of ticket IDs as strings # @param field the field to follow # @param format the format of ticket IDs in field # @param depth how many steps to follow the link (default -1, no limit) # # @return a list of integer ticket IDs of tickets up to depth # steps from origins via field def _followLink(self, origins, field, format, depth = -1): if len(origins) == 0 or depth == 0: return [] node_list = [format % tid for tid in origins] with self.env.db_query as db: cursor = db.cursor() # Query from external table if self.isRelation(field): relation = self.relations[self.sources[field]] # Forward query if field == relation[0]: (f1, f2, tbl, src, dst) = relation # Reverse query elif field == relation[1]: (f1, f2, tbl, dst, src) = relation else: raise TracError('Relation configuration error for %s' % field) # Build up enough instances of %s to represent all the # nodes. The DB API will replace them with items from # node_list, properly quoted for the DB back-end. # # In 0.12, we could do # # ','.join([db.quote(node) for node in node_list]) # # but 0.11 doesn't have db.quote() inClause = "IN (%s)" % ','.join(('%s',) * len(node_list)) cursor.execute("SELECT %s FROM %s WHERE %s " % \ (dst, tbl, src) + \ inClause, node_list) # Query from custom field elif self.isField(field): fieldName = self.fields[self.sources[field]] # See explantion in relation handling, above. inClause = "IN (%s)" % ','.join(('%s',) * len(node_list)) cursor.execute("SELECT t.id " "FROM ticket AS t " "LEFT OUTER JOIN ticket_custom AS p ON " " (t.id=p.ticket AND p.name=%s) " "WHERE p.value " + inClause, [fieldName] + node_list) # We really can't get here because the callers test for # isCfg() but it's nice form to have an else. else: raise TracError('Cannot expand %s; ' 'Not configured as a field or relation.' % field) # Get tickets IDs of related tickets as strings nodes = ['%s' % row[0] for row in cursor] # Filter out ticket IDs we already know about nodes = [tid for tid in nodes if tid not in origins] return nodes + self._followLink(nodes, field, format, depth - 1) # Returns (possibily empty) set of ID strings of tickets # meeting PM constraints. # FIXME - dumb name def preQuery(self, options, req = None): ids = set() this_ticket = None if req: matches = re.match('/ticket/(\d+)', req.path_info) if matches: this_ticket = matches.group(1) if options.get('scheduled'): with self.env.db_query as db: cursor = db.cursor() cursor.execute("SELECT ticket FROM schedule") for row in cursor: tid = row[0] ids.add(str(tid)) if options.get('root'): if not self.isCfg('parent'): self.env.log.info('Cannot get tickets under root ' + 'without "parent" field configured') else: nodes = set() if options['root'] == 'self': if this_ticket: nodes.add(this_ticket) else: # FIXME - display an error here that self needs a ticket pass else: nodes |= set(options['root'].split('|')) ids |= nodes ids |= set(self._followLink(nodes, 'parent', self.parent_format)) if options.get('goal'): if not self.isCfg('succ'): self.env.log.info('Cannot get tickets for goal ' + 'without "succ" field configured') else: nodes = set() if options['goal'] == 'self': if this_ticket: nodes.add(this_ticket) else: # FIXME - display an error here that self needs a ticket pass else: nodes |= set(options['goal'].split('|')) # Loop getting all the predecessors of the tickets # gathered so far then the children of the tickets # gathered so far until the list doesn't change with a # new query. nodes2 = set() while nodes != nodes2: # Get all the predecessors nodes2 = nodes | set(self._followLink(nodes, 'succ', '%s')) # Get the children, if parent configured if self.isCfg('parent'): nodes = nodes2 | set(self._followLink(nodes2, 'parent', self.parent_format)) else: nodes = nodes2 ids |= nodes return ids # Create a pseudoticket for a Trac milestone with all the fields # needed for PM work. def _pseudoTicket(self, tid, summary, description, milestone): ticket = {} ticket['id'] = tid ticket['summary'] = summary ticket['description'] = description ticket['milestone'] = milestone # Milestones are always shown ticket['level'] = 0 # A milestone has no owner ticket['owner'] = '' ticket['type'] = self.goalTicketType ticket['status'] = '' if self.isCfg('estimate'): ticket[self.fields['estimate']] = 0 if self.isCfg('worked'): ticket[self.fields['worked']] = 0 # There is no percent complete for a pseudoticket if self.isCfg('percent'): ticket[self.fields['percent']] = 0 # A milestone has no children or parent if self.isField('parent'): ticket[self.fields['parent']] = [ ] else: ticket['parent'] = [ ] ticket['children'] = [] # Place holder. ticket['link'] = '' # A milestone has no priority ticket['priority'] = 'n/a' return ticket # Add tasks for milestones related to the tickets def _add_milestones(self, options, tickets): if options.get('milestone'): milestones = options['milestone'].split('|') else: milestones = [] for t in tickets: if 'milestone' in t and \ t['milestone'] != '' and \ t['milestone'] not in milestones: milestones.append(t['milestone']) # Need a unique ID for each task. if len(milestones) > 0: tid = 0 # Get the milestones and their due dates with self.env.db_query as db: cursor = db.cursor() # See explanation in _followLink() inClause = "IN (%s)" % ','.join(('%s',) * len(milestones)) cursor.execute("SELECT name, due, completed FROM milestone " + "WHERE name " + inClause, milestones) for row in cursor: msName, msDueDate, msCompletedDate = row tid = tid - 1 milestoneTicket = self._pseudoTicket(tid, msName, 'Milestone %s' % msName, msName) # If the completed date is set (non-0), the milestone is closed" if msCompletedDate: milestoneTicket['status'] = 'closed' # Otherwise, use the configured open status else: milestoneTicket['status'] = self.incompleteMilestoneStatus # If there's no due date, let the scheduler set it. if self.isCfg('finish'): ts = msDueDate if ts: # The scheduled start and finish, from the database milestoneTicket['_sched_start'] = ts milestoneTicket['_sched_finish'] = ts milestoneTicket[self.fields['finish']] = \ format_date(ts, self.dbDateFormat) else: milestoneTicket[self.fields['finish']] = '' # jsGantt ignores start for a milestone but we use it # for scheduling. if self.isCfg('start'): milestoneTicket[self.fields['start']] = \ milestoneTicket[self.fields['finish']] elif self.isCfg('start'): milestoneTicket[self.fields['start']] = '' # Any ticket with this as a milestone and no # successors has the milestone as a successor if self.isCfg(['pred', 'succ']): pred = [] for t in tickets: if t['milestone'] == msName and \ self.successors(t) == []: if self.isField('succ'): t[self.fields[self.sources['succ']]] = \ [ tid ] else: t['succ'] = [ tid ] pred.append(t['id']) if self.isField('pred'): milestoneTicket[self.fields[self.sources['pred']]] = \ pred else: milestoneTicket['pred'] = pred # A Trac milestone has no successors if self.isField('succ'): milestoneTicket[self.fields[self.sources['succ']]] = [] elif self.isRelation('succ'): milestoneTicket['succ'] = [] tickets.append(milestoneTicket) # Get ticket dates from the database. # # On exit: # * Tickets with a precomputed schedule have _sched_start and # _sched_finish populated from the database. # # * Active tickets (in a state other than "new") have # _actual_start set to the time of the first transition out of # "new" # # * Closed tickets (those in the "closed" state) have # _actual_finish set from the last transition into "closed". def getTicketDates(self, tickets): with self.env.db_query as db: cursor = db.cursor() # Table indexed by ticket ID for faster updates ticketsByID = {} for t in tickets: ticketsByID[t['id']] = t # All the tickets we care about. ids = ticketsByID.keys() # Get dates from precomputed schedule, if any. inClause = "IN (%s)" % ','.join(('%s',) * len(ids)) cursor.execute("SELECT ticket, start, finish" + " FROM schedule WHERE ticket " + inClause, ids) for row in cursor: tid, start, finish = row ticketsByID[tid]['_sched_start'] = start ticketsByID[tid]['_sched_finish'] = finish # Get actual start for active tickets. (Don't do this for # milestones.) taskIDs = [t['id'] for t in tickets if not self.isMilestone(t)] if len(taskIDs) > 0: inClause = "IN (%s)" % ','.join(('%s',) * len(taskIDs)) cursor.execute("SELECT id, x.time AS begunTime" + " FROM ticket" + " INNER JOIN ticket_change AS x" + " ON (x.ticket = ticket.id AND x.field = %s)" + " WHERE x.time = (SELECT MIN(time)" + " FROM ticket_change AS y" + " WHERE y.ticket = ticket.id " + " AND y.field = %s" + " GROUP BY y.ticket)" + " AND x.oldvalue = %s " + " AND ticket.id " + inClause, ['status', 'status', 'new'] + taskIDs) for row in cursor: (tid, begunTime) = row ticketsByID[tid]['_actual_start'] = begunTime # Get actual finish for closed tickets. # FIXME - omit milestone as above? closedIDs = [t['id'] for t in tickets if t['status'] == 'closed'] if len(closedIDs) > 0: inClause = "IN (%s)" % ','.join(('%s',) * len(closedIDs)) cursor.execute("SELECT id, x.time AS closedTime" + " FROM ticket" + " INNER JOIN ticket_change AS x" + " ON (x.ticket = ticket.id AND x.field = %s)" + " WHERE x.time = (SELECT MAX(time)" + " FROM ticket_change AS y" + " WHERE y.ticket = ticket.id " + " AND y.field = %s" + " GROUP BY y.ticket)" + " AND x.newvalue = %s " + " AND ticket.id " + inClause, ['status', 'status', 'closed'] + closedIDs) for row in cursor: (tid, closedTime) = row ticketsByID[tid]['_actual_finish'] = closedTime # Process the tickets to normalize formats, etc. to simplify # access functions. # # Also queries PM values that come from external relations. # # A 'children' field is added to each ticket. If a 'parent' field # is configured for PM, then 'children' is the (possibly empty) # list of children. if there is no 'parent' field, then # 'children' is set to None. # # Schedule information comes from the TracPM private table schedule. # # Any "dangling references" are cleaned up. That is, if A is a # parent of B but A is not in tickets then the returned set shows # B with no parent. Similarly for predecessors and successors. # # Milestones for the tickets are added as pseudo-tickets. def postQuery(self, options, tickets): # Handle custom fields. # Clean up custom fields which might be null ('--') vs. blank ('') for t in tickets: nullable = [ 'pred', 'succ', 'start', 'finish', 'parent', 'worked', 'estimate', 'percent' ] for field in nullable: if self.isField(field): fieldName = self.fields[self.sources[field]] if fieldName not in t: raise TracError('%s is not a custom ticket field' % fieldName) if t[fieldName] == '--': t[fieldName] = '' # Get all the IDs we care about ids = [t['id'] for t in tickets] # Normalize parent field values. All parent values must be # done before building child lists, below. if self.isField('parent'): for t in tickets: # ChildTicketsPlugin puts '#' at the start of the # parent field. Strip it for simplicity. fieldName = self.fields[self.sources['parent']] parent = t[fieldName] if len(parent) > 0 and parent[0] == '#': t[fieldName] = parent[1:] # An empty parent field string, default empty list (no parent) if t[fieldName] == '': t[fieldName] = [] # If the parent isn't in the list we're processing, # pretend there is no parent. elif int(t[fieldName]) not in ids: t[fieldName] = [] # Otherwise, convert the string to an integer and put # it in a list. # # NOTE: Subtickets plugin allows multiple parents. # The parent field is then in the form "123, 234". In # that case, the int() call will raise an exception # and the overall query will crash. To fail # gracefully, we'd have to use a try around the # parsing (which would add overhead on every call that # almost never provided any benefit because there is # usually no exception to catch), use a regular # expression to look for non-digit characters (again, # expensive) or split the string (which works for # Subtickets but may not handle another plugin which # uses a different separator). I choose to let the # exception be unhandled. else: t[fieldName] = [ int(t[fieldName]) ] # Build child lists for t in tickets: if not self.isCfg('parent'): t['children'] = [] # NOTE: This can't build dangling references becuase it is # built from the parent field which is set to None, above, # if the parent isn't in the set we're processing. elif self.isField('parent'): fieldName = self.fields[self.sources['parent']] t['children'] = [c['id'] for c in tickets \ if t['id'] in c[fieldName]] # Clean up successor, predecessor lists for t in tickets: lists = [ 'pred', 'succ' ] for field in lists: if self.isField(field): fieldName = self.fields[self.sources[field]] if t[fieldName] == '': t[fieldName] = [] else: # Get all the related tickets t[fieldName] = \ [int(s.strip()) \ for s in t[fieldName].split(',')] # Prune the list to tickets we care about t[fieldName] = [tid for tid in t[fieldName] \ if tid in ids] # Fill in relations with self.env.db_query as db: cursor = db.cursor() # For each configured relation ... for r in self.relations: # Get the elements of the relationship ... (f1, f2, tbl, src, dst) = self.relations[r] # ... query all relations with the desired IDs on either end ... # See explanation in _followLink() inClause = "IN (%s)" % ','.join(('%s',) * len(ids)) # Use AND, not OR, here so we only get links between the # tickets were care about and don't create dangling # references. cursor.execute("SELECT %s, %s FROM %s " % (src, dst, tbl) + \ "WHERE %s " % src + inClause + \ " AND %s " % dst + inClause, ids + ids) # ... quickly build a local cache of the forward and # reverse links (where both ends are in the list we care # about) ... fwd = {} rev = {} for row in cursor: # FIXME - this masks src, dst field names above. (src, dst) = row if dst in fwd: fwd[dst].append(src) else: fwd[dst] = [ src ] if src in rev: rev[src].append(dst) else: rev[src] = [ dst ] # ... and put the links in the tickets. for t in tickets: if t['id'] in fwd: t[f1] = fwd[t['id']] else: t[f1] = [] if t['id'] in rev: t[f2] = rev[t['id']] else: t[f2] = [] # Get precomputed schedule, close dates, etc. self.getTicketDates(tickets) # Add pseudo-tickets for Trac milestones self._add_milestones(options, tickets) # Something like ticket.query.Query() with a slightly different # interface. # # @param options hash of query options (e.g., id, milestone, owner) # @param fields set of names of fields that the caller needs # (e.g., 'status') # @param ticket ticket to use for "root=this", "goal=this". # # @return a list of ticket results, each item is a hash of ticket # fields including those named in fields and those required for PM # def query(self, options, fields, req=None): query_args = {} # Copy query args from caller (e.g., q_a['owner'] = 'monty|phred') for key in options.keys(): # FIXME - This test is a kludge. Need a way to exclude # those handled by preQuery() in a data-driven way. if key not in [ 'goal', 'root', 'start', 'finish', 'useActuals', 'scheduled' ]: query_args[str(key)] = options[key] # Expand (or set) list of IDs to include those specified by PM # query meta-options (e.g., root) pm_ids = self.preQuery(options, req) if len(pm_ids) != 0: if 'id' in query_args: query_args['id'] += '|' + '|'.join(pm_ids) else: query_args['id'] = '|'.join(pm_ids) # Default to getting all tickets if 'max' not in query_args: query_args['max'] = 0 # Tell the query what columns to return query_args['col'] = "|".join(self.queryFields() | fields) # Construct the querystring. query_string = '&'.join(['%s=%s' % (str(f), unicode(v)) for (f, v) in query_args.iteritems()]) # Get the Query object. query = Query.from_string(self.env, query_string) # Get all tickets tickets = query.execute(req) # Post process to add more PM stuff self.postQuery(options, tickets) return tickets # tickets is an unordered list of tickets as returned by TracPM.query(). # # TracPM.query() preloads schedule data from the database, if present. # # If options['force'] is True, the precomputed schedule values are # ignored and every ticket is rescheduled. # # If options['force'] is False or missing, the precomputed # schedule values are preserved and tickets without precomputed # schedule values are scheduled around those times. def computeSchedule(self, options, tickets): # Convert list to dictionary, making copies so schedule can # mess with the tickets. ticketsByID = {} for t in tickets: ticketsByID[t['id']] = {} for field in t: ticketsByID[t['id']][field] = copy.copy(t[field]) # Normalize useActuals from a wiki macro '1' vs. '0' (or # absent) to a Boolean if options.get('useActuals'): if options['useActuals'] == '1': options['useActuals'] = True else: options['useActuals'] = False elif self.useActuals == 1: options['useActuals'] = True else: options['useActuals'] = False # Schedule the tickets self.scheduler.scheduleTasks(options, ticketsByID) # Copy back the schedule results for t in tickets: for field in [ '_calc_start', '_calc_finish']: if field in ticketsByID[t['id']]: t[field] = ticketsByID[t['id']][field] # Recompute schedule # # Compute schedule, like computeSchedule(), but on return each # ticket has a "_rescheduled" field if its schedule changed. def recomputeSchedule(self, options, tickets): # Call computeSchedule self.computeSchedule(options, tickets) # Test each returned ticket to see if the start or finish changed for t in tickets: dbStart = t.get('_sched_start') dbFinish = t.get('_sched_finish') if not dbStart: rescheduled = True elif dbStart != to_utimestamp(self.start(t)): rescheduled = True elif not dbFinish: rescheduled = True elif dbFinish != to_utimestamp(self.finish(t)): rescheduled = True else: rescheduled = False t['_rescheduled'] = rescheduled # Augment tickets by propagating dependencies from parents to # children # # We don't copy every dependency to every child because several # children may form a sequence and thus be affected by the # dependency indirectly. The first child in the sequence gets the # parent's predecessors and the last child gets the parent's # successors. Then because of the children's dependence on each # other, the parent dependencies affect all the children in the # sequence. # FIXME - I don't like this name. It really just propagates # dependencies but that's the name of the private helper defined # inside this function. What can I call the helper? def augmentTickets(self, ticketsByID): # Indexed by ticket ID, lists ticket's descendants desc = {} # Build descendant look up recursively. def buildDesc(tid): # A ticket is in its own "family" tree. desc[tid] = [ tid ] # For each child, add its subtree. for cid in self.children(ticketsByID[tid]): desc[tid] += buildDesc(cid) return desc[tid] # Propagate dependencies from parent to descendants (first # children, then recurse). # def propagateDependencies(pid): parent = ticketsByID[pid] # Process predecessors and successors for fieldFunc in [ self.predecessors, self.successors ]: # Set functions to add dependency and its reverse # between two tickets. fwd = fieldFunc if fwd == self.predecessors: rev = self.successors else: rev = self.predecessors # For each child, if any for cid in self.children(parent): # If the child is in the list we're # working on if cid in ticketsByID: # Does child depend on any "cousins" # (other descendants)? child = ticketsByID[cid] cousins = [did for did in fieldFunc(child) \ if did in desc[pid]] # If not, this is the end of the # line and we have to copy the # parent's dependencies down. if cousins == []: # For each related ticket, if any for tid in fwd(parent): # If the other ticket is in the list we're # working on if tid in ticketsByID: # And not already linked if tid not in fwd(ticketsByID[cid]): # Add parent's dependency to this # child fwd(ticketsByID[cid]).append(tid) rev(ticketsByID[tid]).append(cid) # Recurse to lower-level descendants propagateDependencies(cid) roots = self.roots(ticketsByID) if self.isCfg('parent'): # Build the descendant tree for each root (and its descendants) for tid in roots: buildDesc(tid) # For each ticket for tid in ticketsByID: ticket = ticketsByID[tid] # If it is the root of a tree if tid in roots: # Propagate depedencies down to its children # (which recurses to update other descendants) propagateDependencies(tid) # ======================================================================== # Really simple calendar # class SimpleCalendar(Component): implements(IResourceCalendar) def __init__(self): self.env.log.debug('Creating a simple calendar') """Nothing""" # FIXME - we'd like this to honor hoursPerDay def hoursAvailable(self, date, resource = None): # No hours on weekends if date.weekday() > 4: hours = 0 # 8 hours on week days else: hours = 8.0 return hours # ------------------------------------------------------------------------ # Some common behaviors for task sorters. class BaseSorter: # When sorting on an enum like priority or severity, we need to # sort on 1, 2, 3, not 'critical', 'blocker', 'major', etc. def _buildEnumMap(self, field): classMap = {} with self.env.db_query as db: cursor = db.cursor() cursor.execute("SELECT name," + db.cast('value', 'int') + " FROM enum WHERE type=%s", (field,)) for name, value in cursor: classMap[name] = value return classMap # Priorities are continuous, 0..n, so half the length is average # value Search for the priority string that has that value. def averageEnum(self, enumMap): n = len(enumMap) / 2 # Search for the priority string that has that value avgValue = None for value in enumMap: if enumMap[value] == n: avgValue = value # If we didn't find one (unlikely), just use the first. if avgValue == None: avgValue = enumMap.keys()[0] return avgValue # Compare two tasks by a single field. def compareOneField(self, field, t1, t2): p1 = t1[field] p2 = t2[field] # Better priority (lower number) earlier if p1 < p2: result = -1 elif p1 > p2: result = 1 else: result = 0 return result # ------------------------------------------------------------------------ class SimpleSorter(BaseSorter, Component): implements(ITaskSorter) prioMap = None def __init__(self): self.prioMap = self._buildEnumMap('priority') # Make sure all tickets hav a valid priority that we can map to # sortable integer. def prepareTasks(self, ticketsByID): # Use average priority for tickets with bad priority avgPriority = self.averageEnum(self.prioMap) # Process all the tickets for tid in ticketsByID: ticket = ticketsByID[tid] if self.prioMap.get(ticket['priority']) == None: ticket['priority'] = avgPriority # Compare two tickets by their priority value. def compareTasks(self, t1, t2): return self.compareOneField('priority', t1, t2) # ------------------------------------------------------------------------ # Sort tasks within a "project". That is, using some grouping type # ticket using Subtickets or ChildTickets plugin to create a tree of # projects made of deliverables which have tasks which have subtasks, # etc. and being able to adjust leaf-node task priority by changing # the project priority and having it carry through the tree. class ProjectSorter(BaseSorter, Component): implements(ITaskSorter) pm = None prioMap = None def __init__(self): self.prioMap = self._buildEnumMap('priority') # FIXME - would I be better off having the PM pass itself in # when creating the sorter? self.pm = TracPM(self.env) # Make sure all tickets hav a valid priority that we can map to # sortable integer and compute effective priority of children # based on parent priority. def prepareTasks(self, ticketsByID): # Make sure every ticket has a valid priority. # Use average priority for tickets with bad priority avgPriority = self.averageEnum(self.prioMap) # Process all the tickets for tid in ticketsByID: ticket = ticketsByID[tid] if self.prioMap.get(ticket['priority']) == None: ticket['priority'] = avgPriority def setEffectivePriority(tid, parentPriority): ticket = ticketsByID[tid] effectivePriority = parentPriority + \ [ self.prioMap[ticket['priority']] ] ticket['effectivePriority'] = copy.copy(effectivePriority) for cid in self.pm.children(ticket): setEffectivePriority(cid, effectivePriority) # Build up "effective priority" by prepending parent priority # to task priority. for pid in self.pm.roots(ticketsByID): setEffectivePriority(pid, []) # Compare two tickets by their priority value. def compareTasks(self, t1, t2): return self.compareOneField('effectivePriority', t1, t2) # ------------------------------------------------------------------------ # Handles dates, duration (estimate) dependencies, and resource # leveling but not priorities when leveling resources. # # Assumes a 5-day work week (Monday-Friday) and options['hoursPerDay'] # for every resource. # # A Note About Working Hours # # The naive scheduling algorithm in the plugin assumes all resources # work the same number of hours per day. That limit can be configured # (hoursPerDay) but defaults to 8.0. While is is likely that these # hours are something like 8am to 4pm (or 8am to 5pm, minus an hour # lunch), daily scheduling isn't concerned with which hours are # worked, only how many are worked each day. To simplify range # checking throughout the scheduler, calculations are done as if the # work day starts at midnight (hour==0) and continues for the # configured number of hours per day (e.g., 00:00..08:00). # # Differs from CalendarScheduler only in keeping track of when # resources are available. class ResourceScheduler(Component): implements(ITaskScheduler) pm = None calendar = None sorter = None # The ResourceScheduler uses the Bridge design pattern to separate # the overall scheduling process from the implementation of # prioritizing tasks, via an ITaskSorter implementation and # determining resource availability, via an IResourceCalendar # implementation. # # We identify all the enabled implementations via ExtensionPoint() # then use _mixIn() to pick one (if more than one is enabled). # Find any enabled sorters and calendars. We'll pick one each in __init__ sorters = ExtensionPoint(ITaskSorter) calendars = ExtensionPoint(IResourceCalendar) # Pick one of N enabled implementations of interface or fall back # to default if none are found. # interface - The name of the interface (e.g., 'ITaskSorter') # expt - The extension point to process # default - default implementation to use def _mixIn(self, interface, extpt, default = None): # Count the enabled implementations i = 0 for e in extpt: i += 1 # If none if i == 0: # Use default, if set if default: self.env.log.info(('No %s implementations enabled. ' + 'Using default, %s') % (interface, default)) e = default(self.env) # Otherwise, we can't go on. else: raise TracError('No %s implementations enabled.' % interface) # If more than one, log the one we picked. elif i > 1: self.env.log.info(('Found %s enabled %s implementations. ' + 'Using %s.') % (i, interface, e)) # Return the chosen (or default) implementation. return e def __init__(self): # Instantiate the PM component self.pm = TracPM(self.env) self.calendar = self._mixIn('IResourceCalendar', self.calendars, SimpleCalendar) self.sorter = self._mixIn('ITaskSorter', self.sorters, SimpleSorter) self.logEnabled = self.config.get('TracPM', 'logScheduling', '0') # Log scheduling progress. def _logSch(self, msg): if self.logEnabled == '1': self.env.log.info('sch>' + msg) # ITaskScheduler method # Uses options hoursPerDay and schedule (alap or asap). def scheduleTasks(self, options, ticketsByID): # The earliest (latest) time a resource is available for the # next task in an ALAP (ASAP) schedule. Indexed by # owner/user. Elements are a datetime. # # Need to clear these every time we schedule. self.limits = {} self.taskStack = [] # Return a time delta hours (positive or negative) from # fromDate, accounting for working hours and weekends. def _calendarOffset(ticket, hours, fromDate): if hours < 0: sign = -1 else: sign = 1 delta = timedelta(hours=0) while hours != 0: f = fromDate + delta # Get total hours available for resource on that date available = self.calendar.hoursAvailable(f, ticket['owner']) # Clip available based on time of day on target date # (hours before a finish or after a start) # # Convert 4:30 into 4.5, 16:15 into 16.25, etc. h = f.hour + f.minute / 60. + f.second / 3600. # See how many hours are available before or after the # threshold on this day if sign == -1: if h < available: available = h else: if options['hoursPerDay']-h < available: available = options['hoursPerDay']-h # If we can finish the task this day if available >= math.fabs(hours): # See how many hours are available for other tasks this day available += -1 * sign * hours # If there are no more hours this day, make sure # that the delta ends up at the end (start or # finish) of the day if available == 0: if sign == -1: delta += timedelta(hours=-h) else: delta += timedelta(hours=options['hoursPerDay']-h) # If there is time left after this, just update # the delta within this day else: # Add the remaining time to the delta (sign is # implicit in hours) delta += timedelta(hours=hours) # No hours left when we're done. hours = 0 # If we can't finish the task this day else: # We do available hours of work this day ... hours -= sign * available # ... And move to another day to do more. if sign == -1: # Account for the time worked this date # (That is, get to start of the day) delta += timedelta(hours = -h) # Back up to end of previous day delta += timedelta(hours = -(24 - options['hoursPerDay'])) else: # Account for the time work this date # (That is move to the end of today) delta += timedelta(hours = options['hoursPerDay'] - h) # Move ahead to the start of the next day delta += timedelta(hours = 24 - options['hoursPerDay']) return delta # Return True if d1 is better than d2 # Each is a tuple in the form [date, source] or None where # source is a numeric precedence (the lower the better). def _betterDate(d1, d2): # If both are None, neither is better if d1 == None and d2 == None: better = False # If d1 is None, d2 is better elif d1 == None: better = True # If d2 is None, d1 has to be better elif d2 == None: better = True # Otherwise, the lower number wins elif d1[1] < d2[1]: better = True # >= is not better. else: better = False return better # Schedule a task # # @param t task to schedule (hash of attributes) # @param ancestorLimit function to return from value based on # t's ancestors # @param dependentLimit function to return to value based on # t's dependencies # @param fromField 'start' or 'finish' # @param toField 'finish' or 'start' # @param compareLimit function to compare computed from to # resource limit # @param wrapDay function to wrap from at beginning or end of # work day # # FIXME - I think that there may be times when a task has an # explicit date but the contraints (e.g., from resource # leveling) make it start earlier/later. We should log a # warning when that hapens. def _schedule_task(t, ancestorLimit, dependentLimit, fromField, toField, compareLimits, wrapDay): # If we found a loop, tell the user and give up. if t['id'] in self.taskStack: # We want to show the whole loop so add the current ID # to the list self.taskStack.append(t['id']) # Not much we can do at this point so show the user # the data error raise TracError('Ticket %s is part of a loop: %s' % (t['id'], '->'.join([str(t) for t in self.taskStack]))) self.taskStack.append(t['id']) # Are we scheduling forward or backward? Compare now to # an hour from how to figure it out. d1 = datetime.now() d2 = d1 + timedelta(hours=1) dir = compareLimits(d1, d2) # Start/finish date origin predecence constant values SF_LIMIT = 0 SF_ACTUAL = 1 SF_SCHEDULE = 2 SF_TASK = 3 SF_DEPENDENCIES = 4 SF_PROJECT = 5 SF_DEFAULT = 6 # If we haven't scheduled this yet, do it now. if t.get('_calc_' + fromField) == None: self._logSch('Scheduling %s' % t['id']) # Use actual dates, if requested. if t.get('_actual_' + fromField) and options.get('useActuals'): taskFrom = [ to_datetime(t['_actual_' + fromField]), SF_ACTUAL ] self._logSch('Using actual %s:%s' % (fromField, taskFrom[0])) # If there is a precomputed date in the database, # use it unless we're forcing a schedule calculation. elif t.get('_sched_' + fromField) and not options.get('force'): taskFrom = [ to_datetime(t['_sched_' + fromField]), SF_SCHEDULE ] self._logSch('Using db %s: %s' % (fromField, taskFrom[0])) # If there is a user-supplied date set, use it elif self.pm.isSet(t, fromField): # Don't adjust for work week; use the explicit date. taskFrom = self.pm.parseTaskDate(t, fromField) taskFrom = [taskFrom, SF_TASK] self._logSch('Using explicit %s: %s' % (fromField, taskFrom[0])) # Otherwise, compute from date from dependencies. else: taskFrom = dependentLimit(t, ancestorLimit(t)) # The date derived from dependencies is *not* a # fixed (user-specified) date. if taskFrom != None: self._logSch('Got %s from dependencies: %s' % (fromField, taskFrom[0])) taskFrom[1] = SF_DEPENDENCIES # If dependencies don't give a date, use date from # project. Default to today if none given. else: # Get user-supplied date for schedule. taskFrom = self.pm.parseDbDate(options.get(fromField)) # If none, use midnight today if taskFrom == None: taskFrom = datetime.today().replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=localtz) self._logSch('Defaulting %s: %s' % (fromField, taskFrom)) taskFrom = [taskFrom, SF_DEFAULT] else: self._logSch('Using project %s: %s' % (fromField, taskFrom)) taskFrom = [taskFrom, SF_PROJECT] # Check resource availability. # # There are three cases when we don't need to level # resource usage: # * a milestone doesn't require any work # * a closed ticket doesn't require any more work # * a ticket with children is just a grouping # artifact with no work in it if options.get('doResourceLeveling') == '1' and \ t['type'] != self.pm.goalTicketType and \ not self.pm.children(t) and \ t['status'] != 'closed': self._logSch('Checking limit for %s' % t['owner']) limit = self.limits.get(t['owner']) if limit and compareLimits(limit, taskFrom[0]) == -1: self._logSch('from was %s, setting from %s limit %s' % (taskFrom, t['owner'], limit)) taskFrom = [limit, SF_LIMIT] # Adjust for end of day. (That is, a finish at the # beginning of a day is really at the end of the # previous day.) taskFrom = wrapDay(taskFrom) # Set the field t['_calc_' + fromField] = taskFrom self._logSch('%s scheduled %s is %s' % (t['id'], fromField, taskFrom)) # While the first few clauses below duplicate the first # few clauses for the fromField, the later ones differ in a # subtle way that makes it hard to extract this as a # function. Specifically, when the toField is set in the # task. if t.get('_calc_' + toField) == None: # Use actual dates, if requested. if t.get('_actual_' + toField) and options.get('useActuals'): taskTo = [ to_datetime(t['_actual_' + toField]), SF_ACTUAL ] self._logSch('Using actual %s: %s' % (toField, taskTo[0])) # If there is a precomputed date in the database, # use it unless we're forcing a schedule calculation. elif t.get('_sched_' + toField) and not options.get('force'): taskTo = [ to_datetime(t['_sched_' + toField]), SF_SCHEDULE ] self._logSch('Using db %s: %s' % (toField, taskTo[0])) # If there is a user-supplied date set, use it elif self.pm.isSet(t, toField): taskTo = self.pm.parseTaskDate(t, toField) taskTo = [taskTo, SF_TASK] self._logSch('Using explicit %s: %s' % (toField, taskTo[0])) # Otherwise, the to date is based on the from date and # the work to be done. else: hours = self.pm.workHours(t) taskTo = t['_calc_' + fromField][0] + \ _calendarOffset(t, dir * hours, t['_calc_' + fromField][0]) taskTo = [taskTo, t['_calc_' + fromField][1]] self._logSch('Computed %s from %s, work: %s' % (toField, fromField, taskTo[0])) t['_calc_' + toField] = taskTo # Adjust dates based on precedence if _betterDate(t['_calc_' + toField], t['_calc_' + fromField]): self._logSch('Explicit %s, calculated %s; updating %s' % (toField, fromField, fromField)) self._logSch('%s was %s' % (fromField, t['_calc_' + fromField])) hours = self.pm.workHours(t) t['_calc_' + fromField][0] = t['_calc_' + toField][0] + \ _calendarOffset(t, (-1/dir) * hours, t['_calc_' + toField][0]) self._logSch('%s now %s' % (fromField, t['_calc_' + fromField])) elif _betterDate(t['_calc_' + fromField], t['_calc_' + toField]): self._logSch('Explicit %s, calculated %s; updating %s' % (fromField, toField, toField)) self._logSch('%s was %s' % (toField, t['_calc_' + toField])) hours = self.pm.workHours(t) t['_calc_' + toField][0] = t['_calc_' + fromField][0] + \ _calendarOffset(t, dir * hours, t['_calc_' + fromField][0]) self._logSch('%s now %s' % (toField, t['_calc_' + toField])) # Remember the limit for open tickets # # See note about checking resource availability, above, # to see why we exclude some tickets. if t['type'] != self.pm.goalTicketType and \ not self.pm.children(t) and \ t['status'] != 'closed': limit = self.limits.get(t['owner']) self._logSch("%s's limit was %s" % (t['owner'], limit)) if not limit or \ compareLimits(limit, t['_calc_' + toField][0]) == 1: self._logSch("Updating %s's limit to %s" % (t['owner'], t['_calc_' + toField][0])) self.limits[t['owner']] = t['_calc_' + toField][0] self.taskStack.pop() return t['_calc_' + toField] # Schedule a task As Late As Possible # # Return a tuple like [start, explicit] where # start is the start of the task as a date object # # explicit is True if start was parsed from a user # specified value and False if it was inferred as # today def _schedule_task_alap(t): # Find the finish of the closest ancestor with one set (if any) def _ancestor_finish(t): finish = None # If there are parent and finish fields if self.pm.isCfg(['finish', 'parent']): pid = self.pm.parent(t) # If this ticket has a parent, process it if pid: if pid in ticketsByID: parent = ticketsByID[pid] _schedule_task_alap(parent) if _betterDate(ticketsByID[pid]['_calc_finish'], finish): finish = ticketsByID[pid]['_calc_finish'] else: self.env.log.info(('Ticket %s has parent %s ' + 'but %s is not in the chart. ' + 'Ancestor deadlines ignored.') % (t['id'], pid, pid)) self._logSch('ancestor finish for %s is %s' % (t['id'], finish)) return copy.copy(finish) # Find the earliest start of any successor # t is a ticket (list of ticket fields) # start is a tuple ([date, explicit]) def _earliest_successor(t, start): for tid in self.pm.successors(t): if tid in ticketsByID: s = _schedule_task_alap(ticketsByID[tid]) if _betterDate(s, start) and \ start == None or \ (s and start and s[0] < start[0]): start = s else: self.env.log.info(('Ticket %s has successor %s ' + 'but %s is not in the chart. ' + 'Dependency deadlines ignored.') % (t['id'], tid, tid)) self._logSch('earliest successor for %s is %s' % (t['id'], start)) return copy.copy(start) def _compare_alap_limits(a, b): delta = a - b # Technically we should include microseconds but we # round those all off everywhere. seconds = 24 * 60 * 60 * delta.days + delta.seconds if (seconds < 0): retval = -1 elif (seconds > 0): retval = 1 else: retval = 0 self._logSch("comparing %s and %s gives %s" % (a, b, retval)) return retval def _wrap_alap_day(f): if self.pm.isStartOfDay(f[0]): # If this is a fixed finish, change start of day # to end of day by adding hours per day. if f[1]: f[0] += timedelta(hours=options['hoursPerDay']); # If this is a derived finish, fixup for work week. else: # Move to beginning of next day so fixup below # will handle work week. f[0] += timedelta(days=1) # Move back one hour from start of day to make # sure finish is on a work day. f[0] += _calendarOffset(t, -1, f[0]) # Move forward one hour to the end of the day f[0] += timedelta(hours=1) self._logSch('Adjusted finish of %s to end of day, %s' % (t['id'], f)) return f return _schedule_task(t, _ancestor_finish, _earliest_successor, 'finish', 'start', _compare_alap_limits, _wrap_alap_day) # Schedule a task As Soon As Possible # # See _schedule_task_alap() for description of argument and return. def _schedule_task_asap(t): # Find the start of the closest ancestor with one set (if any) def _ancestor_start(t): start = None # If there are parent and start fields if self.pm.isCfg(['start', 'parent']): pid = self.pm.parent(t) # If this ticket has a parent, process it if pid: if pid in ticketsByID: parent = ticketsByID[pid] _schedule_task_asap(parent) if _betterDate(ticketsByID[pid]['_calc_start'], start): start = ticketsByID[pid]['_calc_start'] else: self.env.log.info(('Ticket %s has parent %s ' + 'but %s is not in the chart. ' + 'Ancestor deadlines ignored.') % (t['id'], pid, pid)) return copy.copy(start) # Find the latest finish of any predecessor # t is a ticket (list of ticket fields) # start is a tuple ([date, explicit]) def _latest_predecessor(t, finish): for tid in self.pm.predecessors(t): if tid in ticketsByID: f = _schedule_task_asap(ticketsByID[tid]) if _betterDate(f, finish) and \ finish == None or \ (f and finish and f[0] > finish[0]): finish = f else: self.env.log.info(('Ticket %s has predecessor %s ' + 'but %s is not in the chart. ' + 'Dependency deadlines ignored.') % (t['id'], tid, tid)) return copy.copy(finish) def _compare_asap_limits(a, b): delta = a - b # Technically we should include microseconds but we # round those all off everywhere. seconds = 24 * 60 * 60 * delta.days + delta.seconds if (seconds < 0): retval = 1 elif (seconds > 0): retval = -1 else: retval = 0 self._logSch("comparing %s and %s gives %s" % (a, b, retval)) return retval def _wrap_asap_day(s): if self.pm.isStartOfDay(s[0] - timedelta(hours = options['hoursPerDay'])): # Move ahead to the start of the next day s[0] += timedelta(hours=24-options['hoursPerDay']) # Adjust for work days as needed s[0] += _calendarOffset(t, 1, s[0]) s[0] += timedelta(hours=-1) self._logSch('Adjusted start of %s to end of day, %s' % (t['id'], s)) return s return _schedule_task(t, _ancestor_start, _latest_predecessor, 'start', 'finish', _compare_asap_limits, _wrap_asap_day) # Augment tickets in a scheduler-specific way to make # scheduling easier # # If a parent task has a dependency, copy it to its children. def _augmentTickets(ticketsByID): # Propagate dependencies. self.pm.augmentTickets(ticketsByID) # For each ticket to schedule for tid in ticketsByID: ticket = ticketsByID[tid] # Count predecessors and successors in tickets being # scheduled. if self.pm.isCfg('pred'): pred = self.pm.predecessors(ticket) pred = [p for p in pred if p in ticketsByID] ticket['npred'] = len(pred) else: ticket['npred'] = 0 if self.pm.isCfg('succ'): succ = self.pm.successors(ticket) succ = [s for s in succ if s in ticketsByID] ticket['nsucc'] = len(succ) else: ticket['nsucc'] = 0 # This function implements a simple serial-SGS (Solution # Generation Scheme) as suggested by Briand and Bezanger in # "An any-order SGS for project scheduling with scarce # resources and precedence constraints": # # A serial-SGS consists of n interations: In each iteration, # an activity is selected according to its priority and # inserted inside a partial schedule at the earliest # ... Only an eligible activity can be selected at each # iteration. An activity is eligible if all its # predecessors have already been scheduled. The priorities # are determined according to [a] rule [outside the SGS]. # # Our ASAP schedule follows their description. ALAP reverses # it (scheduling the last task, then any task with all of its # successors already scheduled, etc. # # One difference from their algorithm description is they step # through time, chosing tasks that might be done then. We # step through tasks and let time fall where it may. # # scheduleFunction - schedule one task # eligibleField - when ticket[eligibleField] is 0, the ticket # is eligible # nextIndex - index of best ticket in elibigle list # dependentFunction - Get list of dependents to update # eligibleField in def serialSGS(scheduleFunction, eligibleField, nextIndex, dependentFunction): unscheduled = ticketsByID.keys() # FIXME - Sometimes, eligible includes a group which has # children which have predecessors or successors. Do I # need to propagate dependencies up, too? This seems to # work but I guess needs more testing. eligible = [ticketsByID[tid] for tid in unscheduled \ if ticketsByID[tid][eligibleField] == 0] details = True while unscheduled and eligible: # FIXME - Maybe sort after adding some. I may not need # to sort every loop.) eligible.sort(self.sorter.compareTasks) if details: self.env.log.debug('Eligible tickets:%s' % [t['id'] for t in eligible]) # Schedule the best eligible task ticket = eligible.pop(nextIndex) tid = ticket['id'] if tid in unscheduled: unscheduled.remove(tid) if details: self.env.log.debug(' scheduling:%s' % tid) self.env.log.debug(' unscheduled:%s' % unscheduled) else: self.env.log.debug('Could not remove %s from unscheduled list' % tid) self.env.log.debug(' unscheduled:%s' % unscheduled) self.env.log.debug(' ticket:%s' % ticket) self.env.log.debug(' eligible:%s' % eligible) raise TracError('Could not remove %s from unscheduled list' % tid) scheduleFunction(ticket) # Decrement number of unscheduled successors for each # predecessor (or vice versa). Any ticket that ends # up with no unscheduled dependents is now eligible to # schedule. for tid in dependentFunction(ticket): if tid in ticketsByID: other = ticketsByID[tid] other[eligibleField] -= 1 if other[eligibleField] == 0: eligible.append(other) if not eligible and len(unscheduled): details = True self.env.log.error('Not all tickets scheduled') self.env.log.error('%s remain ineligible. Scheduling.' % unscheduled) eligible = [ticketsByID[tid] for tid in unscheduled] # Make sure we don't add them again for ticket in eligible: ticket[eligibleField] = 0 # Main schedule processing # Add data to tickets to facilitate scheduling.Propagate _augmentTickets(ticketsByID) # Make sure sorting (compareTasks, below) works. self.sorter.prepareTasks(ticketsByID) # If schedule option is present and 'asap', do that. # Otherwise, fall through to default to ALAP. if options.get('schedule') == 'asap': # Schedule ASAP. # Eligible tasks are those with npred==0. # The best eligible task is first (0) after sorting. # Update successors after scheduling a task serialSGS(_schedule_task_asap, 'npred', 0, self.pm.successors) elif options.get('schedule') == 'alap': # Schedule ALAP. # Eligible tasks are those with nsucc==0. # The best eligible task is last (-1) after sorting. # Update predecessors after scheduling a task serialSGS(_schedule_task_alap, 'nsucc', -1, self.pm.predecessors) else: # Don't schedule. But some milestones may not have due dates. # Set them from the latest ticket in the milestone. # Get IDs of milestone pseudotickets. milestoneIDs = [tid for tid in ticketsByID.keys() \ if self.pm.isTracMilestone(ticketsByID[tid]) \ and not self.pm.finish(ticketsByID[tid])] # Get IDs of all other tickets. ticketIDs = [tid for tid in ticketsByID.keys() \ if tid not in milestoneIDs] # Process each milestone, setting the start and finish # from the latest ticket in the milestone. for mid in milestoneIDs: ms = ticketsByID[mid] # Find the latest ticket in the milestone msDue = None for tid in ticketIDs: t = ticketsByID[tid] if t['milestone'] == ms['milestone'] \ and (not msDue or msDue < self.pm.finish(t)): msDue = self.pm.finish(t) # A milestone has no duration (start == finish) ms['_calc_start'] = [msDue, True] ms['_calc_finish'] = [msDue, True] # FIXME - need to react to milestone changes, too (for dates). 0.11.6 # doesn't have a milestone change listener. I belive a later version # does. class TicketRescheduler(Component): implements(ITicketChangeListener) pm = None scheduleFields = None options = {} def __init__(self): self.pm = TracPM(self.env) self.scheduleFields = [] # Built-in fields that can affect scheduling self.scheduleFields = ['owner', 'priority', 'milestone'] # Configurable fields from plugins that can affect scheduling for f in ['estimate', 'start', 'finish', 'pred', 'succ', 'parent']: # If the user configured this field if f in self.pm.fields: # Get the name of the configured field self.scheduleFields.append(self.pm.fields[f]) # FIXME - Make this configurable self.scheduleFields.append('parents') self.scheduleFields.append('blocking') self.scheduleFields.append('blockedby') self.options['schedule'] = \ self.config.get('TracPM', 'option.schedule', 'asap') self.options['hoursPerDay'] = \ float(self.config.get('TracPM', 'option.hoursPerDay', '6.0')) self.options['doResourceLeveling'] = \ self.config.get('TracPM', 'option.doResourceLeveling', '1') # When recomputing the schedule, we have to ignore the # database and compute all start/finish times so we can then # compare to the database and store changes. self.options['force'] = True # Do any of the fields that changed affect scheduling? For # example, component would not but owner, and dependencies would. # Some of the ones that affect scheduling are built-in (e.g., # owner) some are not (e.g., blockedby). # # @param ticket ticket object as passed to ticket change listener # @param old_values old ticket values as passed to change listener # # @return True if changes in old_values affect schedule # # FIXME - Should this be in the TracPM class? def _affectsSchedule(self, ticket, old_values): # If any of the changed values are schedule related for f in old_values: if f in self.scheduleFields: return True # If the status changed, check for closing or reopening # tickets and making goals active or inactive. if 'status' in old_values: # If the ticket changed status in or out of closed, reschedule if (old_values['status'] == 'closed' \ or ticket['status'] == 'closed'): return True # If the ticket is a goal and changes status in or out of # active, reschedule if ticket['type'] == self.pm.goalTicketType: wasActive = old_values['status'] in self.pm.activeGoalStatuses isActive = ticket['status'] in self.pm.activeGoalStatuses if (wasActive and not isActive) \ or (isActive and not wasActive): return True return False # Find tickets related to this one (before and after changes). # # Find IDs of all tickets affected by changing this ticket's # schedule: # Same owner # Any successors, predecessors # Any ancestors, descendants # @param ticket ticket object passed to change listener # @param old_values list of old values passed to change listener # # @return a list of ticket ID strings def _findAffected(self, ticket, old_values): # Helper to find owners of tickets # @param ids set of tickets ID strings # @return set of owner strings def ownersOf(ids): with self.env.db_query as db: inClause = "IN (%s)" % ','.join(('%s',) * len(ids)) cursor = db.cursor() cursor.execute(("SELECT DISTINCT owner FROM ticket " "WHERE id " + inClause), list(ids)) owners = [row[0] for row in cursor] return set(owners) # Helper to find open tickets by owners # @param owners set of owner strings from tickets # @return a set of ticket ID strings for tickets owned by owners def openByOwner(owners): # FIXME - this may fix a bug I don't have any more. if len(owners) == 0: return set() with self.env.db_query as db: inClause = "IN (%s)" % ','.join(('%s',) * len(owners)) cursor = db.cursor() cursor.execute(("SELECT id FROM ticket " "WHERE status!=%s AND owner " + inClause), ['closed'] + list(owners)) ids = ['%s' % row[0] for row in cursor] return set(ids) # Find all the tickets affected by the old values. For # example if processing ticket A which used to be a # prerequisite for B but isn't any longer, B will be in # old_values['blocking'] and may be able to start earlier now. # # @param old_values as passed to TicketChangeListener # # @return set of ticket ID strings # # FIXME - need better tests here; this assumes Master Tickets # and Subtickets plugins. def affectedByOld(old_values): affected = set() if 'parents' in old_values.keys() \ and old_values['parents'] \ and len(old_values['parents']) != 0: affected.add(str(old_values['parents'])) if 'blockedby' in old_values.keys() \ and old_values['blockedby'] \ and len(old_values['blockedby']) != 0: affected |= \ set([x.strip() for x in old_values['blockedby'].split(',')]) if 'blocking' in old_values.keys() \ and old_values['blocking'] \ and len(old_values['blocking']) != 0: affected |= \ set([x.strip() for x in old_values['blocking'].split(',')]) # If owner changed, get tickets by old owner. (New owner is # handled by more()) if 'owner' in old_values.keys(): owners = set() owners.add(old_values['owner']) affected |= openByOwner(owners) return affected # Used by more(), below, to optimize queries. (I'd like a # better scope but Python doesn't do closures the way I # expected.) self.knownOwners = set() # Helper to expand set of tickets by one generation # # FIXME - passing 1 as depth to _reachable() misses some # closed but reachable tickets. In my test data, I don't get # 150, a child of 149. Other children of 149 show up. # # @param ids set of ticket ID strings to find more tickets from # @return set of IDs for tickets related to ids def more(ids): n = set(self.pm._reachable(list(ids))) # Get owners for these tickets newOwners = ownersOf(n) # Remove owners we already know newOwners -= self.knownOwners # If we don't already have all these owners, process the new ones if len(newOwners) != 0: # Add new owners to known owners self.knownOwners |= newOwners # Get tickets for the new owners x = openByOwner(newOwners) # Remove known tickets x -= ids # Add new tickets to set n |= x return n # The set of tickets that may be affected by this change. affected = set() # The changed ticket is affected affected.add(str(ticket.id)) # Tickets referenced in old values affected |= affectedByOld(old_values) # Find all tickets reachable from the list we've built so far # # NOTE: The last loop finds all the tickets in explored that # are adjacent to the border and pruning gives an empty set. # You'd think I could stop one iteration earlier but I don't # know how. explored = set() toExplore = affected # FIXME - elsewhere I use "toExplore != set()". Which is more # efficient? Or clearer? while len(toExplore) != 0: border = toExplore toExplore = more(border) toExplore -= explored explored |= border return list(explored) # Clean up links to removed tickets def _repairGraph(self, tickets): ids = [t['id'] for t in tickets] # Fairly often tickets is empty after pruning but setting up # the link field names is cheap and traversing an empty list # is basically free. # # FIXME - this is a really gross and fragile way to do it but # it'll due for now. linkFieldNames = {} for linkField in [ 'parent', 'pred', 'succ']: if not self.pm.isCfg(linkField): linkFieldNames[linkField] = None elif self.pm.isField(linkField): linkFieldNames[linkField] = \ self.pm.fields[self.pm.sources[linkField]] else: linkFieldNames[linkField] = linkField for t in tickets: # Remove link to parent pid = self.pm.parent(t) if pid and pid not in ids: t[linkFieldNames['parent']] = None # Remove links to children # (Include only children still in the set) t['children'] = [cid for cid in t['children'] if cid in ids] # Predecessors and successors for linkField in ['pred', 'succ']: t[linkFieldNames[linkField]] = \ [tid for tid in t[linkFieldNames[linkField]] if tid in ids] # Remove closed tickets. # # @param tickets a list of tickets # # @return list of items removed from tickes def _pruneClosed(self, tickets): closedTickets = [t for t in tickets if t['status'] == 'closed'] openTickets = [t for t in tickets if t['status'] != 'closed'] self._repairGraph(openTickets) del tickets[:] tickets.extend(openTickets) return closedTickets # Remove tickets that are not required for any goal with one of # the configured active statuses. # # 1. mark all tickets inactive # 2. for each active goal # 2.1 follow predecessor links to mark tickets active # 3. Delete all inactive tickets (and fix up dependencies) # # @param tickets a list of tickets # # @return list of items removed from tickets def _pruneInactive(self, tickets): # If no configured active statuses, don't prune. if not self.pm.activeGoalStatuses: return [] # Make a lookup for faster processing ticketsByID = {} for t in tickets: ticketsByID[t['id']] = t # Copy dependencies from parents to children self.pm.augmentTickets(ticketsByID) # Mark all tickets inactive. for t in tickets: t['_active'] = False # Helper to traverse dependencies recursively def markActive(ticket): # If this ticket hasn't been processed yet if not ticket['_active']: # Mark it active ticket['_active'] = True # And propagate to predecessors for pid in self.pm.predecessors(ticket): markActive(ticketsByID[pid]) # All predecessors of active goals are active for t in tickets: # FIXME - encapsulate this test in "self.pm.activeGoal()"? if t['type'] == self.pm.goalTicketType \ and t['status'] in self.pm.activeGoalStatuses: markActive(t) # Separate active and inactive tickets activeTickets = [t for t in tickets if t['_active']] inactiveTickets = [t for t in tickets if not t['_active']] # Fix dangling links self._repairGraph(activeTickets) # Empty the list and add active tickets back to it del tickets[:] tickets.extend(activeTickets) # Return the ones we removed return inactiveTickets # Query ticket table (and linked custom fields) for fields needed # to reschedule # # @param ids list of ticket IDs to get data for # # @return list of hashes for tickets def queryTickets(self, ids): options = {} options['max'] = 0 options['id'] = "|".join(ids) return self.pm.query(options, set()) ## # Update in-memory relationships because other plugins' ticket # change listeners may not have run yet so the database may be # stale. # # @param tickets list of tickets to examine # @param ticket ticket that changed # @param old_values previous values for fields in ticket which changed # # Note: ticket and old_values are passed to ITicketChangeListener # methods. # # FIXME - this is very specific to Subtickets and MasterTickets # and accesses fields directly instead of using accessor # functions. Let's make it work *then* make it pretty. # # MasterTickets and Subtickets both maintain relationships in # private tables but maintain custom fields which summarize or # preview the relationships when viewing tickets. Most of TracPM # abstracts access to the private tables but since this is called # from a ticket change listener, we have only the custom fields to # work with. def spliceGraph(self, tickets, ticket, old_values): # See if any of the changed values are for custom relationship # fields. relationshipChanged = False for f in old_values: # FIXME - these are the custom field names for Subtickets # and MasterTickets. Should be configurable or flexible # somehow. if f in [ 'parents', 'blockedby', 'blocking' ]: relationshipChanged = True # If a relationship changed, process it. if relationshipChanged: # Relationships can be configured to be access via fields # or via a relation. This bit -- copied from # _pruneInactive -- builds a lookup encapsulating that # configuration and simplifying the update logic which # follows. # # 'parent', 'pred', and 'succ' are the names of the # relationships. This code determines which field holds # the data for that relationship. linkFieldNames = {} for linkField in [ 'parent', 'pred', 'succ']: if not self.pm.isCfg(linkField): linkFieldNames[linkField] = None elif self.pm.isField(linkField): linkFieldNames[linkField] = \ self.pm.fields[self.pm.sources[linkField]] else: linkFieldNames[linkField] = linkField # Table indexed by ticket ID for faster updates ticketsByID = {} for t in tickets: ticketsByID[t['id']] = t # Fix up parent # # Note: the 'children' field is *always* internal to # TracPM. It does not correspond to a custom field; no # known plugin for parent/child relationships has a custom # field for children. if linkFieldNames['parent'] in old_values: # Remove ticket from children of old parent parent = ticketsByID[old_values[[linkFieldsName['parent']]]] parent['children'] = \ [cid for cid in parent['children'] if cid != ticket.id] # Add ticket to children of new parent parent = ticketsByID[ticket[linkFieldsNames['parent']]] if ticket.id not in parent['children']: parent['children'].append(ticket.id) # The custom fields which allow previewing predecessors # and successors. # # FIXME - these are the custom field names for # MasterTickets. Should be configurable or flexible # somehow. previewFields = {} previewFields['pred'] = 'blockedby' previewFields['succ'] = 'blocking' # Fix up predecessors, successors for linkField in ['pred', 'succ']: # We need to figure out the forward and reverse # relationships below. fwd = linkField if fwd == 'pred': rev = 'succ' else: rev = 'pred' fwdField = linkFieldNames[fwd] revField = linkFieldNames[rev] # If the custom field summarizing this relationship # changed, we need to splice the graph. # # This code depends on the structure of the # relationship data in the graph (which TracPM # controls) and *not* on the name of any fields so it # should be correct regardless of what plugins are # being used. if previewFields[fwd] in old_values: # Get the set of old and new dependants if len(old_values[previewFields[fwd]]) == 0: oldDependants = set() else: ids = old_values[previewFields[fwd]].split(',') oldDependants = set([int(tid) for tid in ids]) if len(ticket[previewFields[fwd]]) == 0: newDependants = set() else: ids = ticket[previewFields[fwd]].split(',') newDependants = set([int(tid) for tid in ids]) # Set difference tells us what is in old and not # new and vice versa. removed = oldDependants - newDependants added = newDependants - oldDependants # Remove from both ends, if needed for tid in removed: ticketsByID[ticket.id][fwdField] = \ [did for did in ticketsByID[ticket.id][fwdField] if did != tid] ticketsByID[tid][revField] = \ [did for did in ticketsByID[tid][revField] if did != ticket.id] # Link on both ends, if needed for tid in added: if tid not in ticketsByID[ticket.id][fwdField]: ticketsByID[ticket.id][fwdField].append(tid) ticketsByID[tid][revField].append(ticket.id) # Update schedule and schedule_change tables in the database # # * Remove idle tickets from the schedule and put them in # schedule_change with NULL new start and finish (if open) or # final start, finish (if closed). # * Insert tickets which are not in schedule into it and put them # in schedule_change with a NULL old start and finish # * Update tickets which are in schedule and put their old and new # start aand finish in schedule_change. def _updateScheduleDB(self, idle, tickets, profile): with self.env.db_transaction as db: cursor = db.cursor() # All the history records need the same timestamp. dbTime = datetime.now().replace(tzinfo=localtz) if len(idle) != 0: start = datetime.now() # Remove idle tickets from schedule inClause = 'IN (%s)' % ','.join(('%s',) * len(idle)) cursor.execute('DELETE FROM schedule WHERE ticket ' + \ inClause, [t['id'] for t in idle]) # And note idling in schedule history values = [] for t in idle: value = (t['id'], to_utimestamp(dbTime), to_utimestamp(self.pm.start(t)), to_utimestamp(self.pm.finish(t))) if t['status'] == 'closed': value += (to_utimestamp(self.pm.start(t)), to_utimestamp(self.pm.finish(t))) else: value += (None, None) values.append(value) # Update schedule history cursor.executemany('INSERT INTO schedule_change' + \ ' (ticket, time,' + \ ' oldstart, oldfinish,' ' newstart, newfinish)' + \ ' VALUES (%s,%s,%s,%s,%s,%s)', values) end = datetime.now() profile.append([ 'idling', len(idle), end - start ]) ids = [t['id'] for t in tickets] if len(tickets) != 0: # Some "rescheduled" tickets had their schedule created # for the first time, some had it changed. We have to be # able to choose between UPDATE (for those that changed) # and INSERT (for the others). # # First, find which are already there. # (Query and save old start, finish values at the same time.) inClause = 'IN (%s)' % ','.join(('%s',) * len(ids)) cursor.execute('SELECT ticket, start, finish' + \ ' FROM schedule WHERE ticket ' + \ inClause, ids) toUpdate = set() historyValues = {} for row in cursor: tid = row[0] oldStart = row[1] oldFinish = row[2] historyValues[tid] = [ oldStart, oldFinish ] toUpdate.add(tid) # Second, update the tickets that are there. # (Build before/after values as we go to update history next.) start = datetime.now() values = [] for t in tickets: if t['id'] in toUpdate: value = () # Index history by ticket ID and time value += (t['id'], to_utimestamp(dbTime)) # Old start and finish value += (historyValues[t['id']][0], historyValues[t['id']][1]) # New start and finish value += (to_utimestamp(self.pm.start(t)), to_utimestamp(self.pm.finish(t))) values.append(value) cursor.execute('UPDATE schedule' ' SET start=%s, finish=%s' ' WHERE ticket=%s', (to_utimestamp(self.pm.start(t)), to_utimestamp(self.pm.finish(t)), t['id'])) # Third, insert the history for the updated tickets. if len(toUpdate) != 0: cursor.executemany('INSERT INTO schedule_change' + \ ' (ticket, time,' + \ ' oldstart, oldfinish,' + \ ' newstart, newfinish)' + \ ' VALUES (%s,%s,%s,%s,%s,%s)', values) end = datetime.now() profile.append([ 'updating', len(toUpdate), end - start ]) # Fourth, insert tickets that aren't already in the schedule toInsert = set(ids) - toUpdate start = datetime.now() if len(toInsert) != 0: values = [] for t in tickets: if t['id'] in toInsert: value = (t['id'], to_utimestamp(self.pm.start(t)), to_utimestamp(self.pm.finish(t))) values.append(value) cursor.executemany('INSERT INTO schedule' + \ ' (ticket, start, finish)' + \ ' VALUES (%s,%s,%s)', values) # Finally, add history records to schedule_change # for newly scheduled tickets. values = [] for t in tickets: if t['id'] in toInsert: # Old start and finish are null value = (t['id'], to_utimestamp(dbTime), to_utimestamp(self.pm.start(t)), to_utimestamp(self.pm.finish(t))) values.append(value) cursor.executemany('INSERT INTO schedule_change' + \ ' (ticket, time,' + \ ' newstart, newfinish)' + \ ' VALUES (%s,%s,%s,%s)', values) end = datetime.now() profile.append([ 'inserting', len(toInsert), end - start ]) # Reschedule based on a ticket changing. # # Arguments as for TicketChangeListener. # # No return. The calculated start and finish dates in the ticket # database may be updated. def rescheduleTickets(self, ticket, old_values): # If active statuses configured if not self.pm.activeGoalStatuses: self.env.log.info('Background ticket rescheduler requires' + ' goal ticket type and active goal statuses' + ' to be configured.') return # Each step (e.g., finding, querying, pruning) has an entry # Each entry is [ step, ticketcount, time ] profile = [] with self.env.db_query as db: cursor = db.cursor() # Get IDs of active goals start = datetime.now() inClause = 'IN (%s)' % \ ','.join(('%s',) * len(self.pm.activeGoalStatuses)) cursor.execute('SELECT id FROM ticket' + \ ' WHERE type = %s' + \ ' AND status ' + inClause, [self.pm.goalTicketType] + self.pm.activeGoalStatuses) activeGoals = ['%s' % row[0] for row in cursor] end = datetime.now() profile.append([ 'getting active goals', len(activeGoals), end - start ]) # Get IDs of tickets required for those goals # # NOTE: This includes closed tickets which are predecessors of # work still to be done. start = datetime.now() nowActive = self.pm.preQuery({'goal': '|'.join(activeGoals)}) end = datetime.now() profile.append([ 'getting active tickets', len(nowActive), end - start ]) # Get IDs of tickets that were active before this change # # NOTE: In the steady state, there should be no closed tickets # in the schedule. Why schedule work that is already complete? start = datetime.now() cursor.execute('SELECT ticket FROM schedule') wasActive = set(['%s' % row[0] for row in cursor]) end = datetime.now() profile.append([ 'getting scheduled tickets', len(wasActive), end - start ]) # There are four possibilities for the state of the changed # ticket relative to the sets of formerly or currently active # tickets: # # 1. It wasn't and isn't active. # The schedule doesn't change. # 2. It wasn't active (including didn't exist) and is now. # Active tickets need to be rescheduled to fit it in. # 3. It was active and isn't now. # Active tickets needs to be rescheduled to fill in the gap. # 4. It was and continues to be active. # The schedule needs to be adjusted for the change in # schedling attributes (dependency, estimated duration, # etc.) # # So, if it was or is active, we have to reschedule. That # requires getting the ticket details (dependencies, # estimates, etc.) for the scheduler. start = datetime.now() tid = str(ticket.id) if tid in wasActive: self.env.log.debug('%s was active' % tid) else: self.env.log.debug('%s was not active' % tid) if tid in nowActive: self.env.log.debug('%s is active' % tid) else: self.env.log.debug('%s is not active' % tid) if tid in nowActive or tid in wasActive: start = datetime.now() # Get ticket details of all tickets to process details = self.queryTickets(wasActive | nowActive) # Get the active tickets tickets = [t for t in details if str(t['id']) in nowActive] self.env.log.debug('There are %d active tickets' % len(tickets)) # Prune to those that aren't closed self._pruneClosed(tickets) self.env.log.debug('There are %d active, open tickets' % len(tickets)) # Update nowActive based on pruning nowActive = set([str(t['id']) for t in tickets]) # Find idle tickets idleIDs = wasActive - nowActive self.env.log.debug('%d tickets were idled' % len(idleIDs)) idle = [t for t in details if str(t['id']) in idleIDs] end = datetime.now() profile.append([ 'getting ticket details', len(details), end - start ]) else: idle = [] tickets = [] # Reschedule only if there are active tickets if len(tickets) != 0: # Compute schedule with configured options self.env.log.info('Recomputing schedule with options:%s' % self.options) start = datetime.now() self.pm.recomputeSchedule(self.options, tickets) end = datetime.now() profile.append(['rescheduling', len(tickets), end - start ]) # Reduce list to only those tickets that were rescheduled. tickets = [t for t in tickets if t.get('_rescheduled')] self.env.log.info('%s tickets rescheduled' % len(tickets)) # Remove milestone pseudo-tickets because we don't save # their data. The "id" of those tickets is not guaranteed # to be the same between two runs so the key to the # schedule tables would be meaningless. tickets = [t for t in tickets if not self.pm.isTracMilestone(t)] # Update the database for any rescheduled or idled tickets self._updateScheduleDB(idle, tickets, profile) for step in profile: self.env.log.info('%s %s tickets took %s' % (step[0], step[1], step[2])) # ITicketChangeListener methods # # The change listener methods get called after all changes have # been saved to the database. def ticket_created(self, ticket): self.env.log.info('Ticket %s created.' % ticket.id) self.rescheduleTickets(ticket, {}) def ticket_changed(self, ticket, comment, author, old_values): if self._affectsSchedule(ticket, old_values): self.env.log.info('Changes to %s affect schedule. Rescheduling.' % ticket.id) self.rescheduleTickets(ticket, old_values) def ticket_deleted(self, ticket): self.env.log.info('Ticket %s deleted.' % ticket.id) self.rescheduleTickets(ticket, {}) tracjsganttplugin/0.11/tracjsgantt/tracjsgantt.py0000644000175500017550000010175712364006341022166 0ustar debacledebacle# -*- coding: utf-8 -*- # # Copyright (C) 2010-2014 Chris Nelson # All rights reserved. # # This software is licensed as described in the file COPYING, which # you should have received as part of this distribution. import re import time from datetime import timedelta, datetime from operator import itemgetter, attrgetter from trac.util.datefmt import localtz try: from trac.util.datefmt import to_utimestamp except ImportError: from trac.util.datefmt import to_timestamp as to_utimestamp from trac.util.text import to_unicode from trac.util.html import Markup from trac.wiki.macros import WikiMacroBase from trac.web.chrome import Chrome import copy from trac.ticket.query import Query from trac.config import IntOption, Option from trac.core import implements, Component, TracError from trac.web.api import IRequestFilter from trac.web.chrome import ITemplateProvider, add_script, add_stylesheet from pkg_resources import resource_filename from trac.wiki.api import parse_args from tracpm import TracPM try: from trac.util.text import javascript_quote except ImportError: # Fallback for Trac<0.11.3 - verbatim copy from Trac 1.0 _js_quote = {'\\': '\\\\', '"': '\\"', '\b': '\\b', '\f': '\\f', '\n': '\\n', '\r': '\\r', '\t': '\\t', "'": "\\'"} for i in range(0x20) + [ord(c) for c in '&<>']: _js_quote.setdefault(chr(i), '\\u%04x' % i) _js_quote_re = re.compile(r'[\x00-\x1f\\"\b\f\n\r\t\'&<>]') def javascript_quote(text): """Quote strings for inclusion in javascript""" if not text: return '' def replace(match): return _js_quote[match.group(0)] return _js_quote_re.sub(replace, text) # ======================================================================== class TracJSGanttSupport(Component): implements(IRequestFilter, ITemplateProvider) Option('trac-jsgantt', 'option.format', 'day', """Initial format of Gantt chart""") Option('trac-jsgantt', 'option.formats', 'day|week|month|quarter', """Formats to show for Gantt chart""") IntOption('trac-jsgantt', 'option.sample', 0, """Show sample Gantt""") IntOption('trac-jsgantt', 'option.res', 1, """Show resource column""") IntOption('trac-jsgantt', 'option.dur', 1, """Show duration column""") IntOption('trac-jsgantt', 'option.comp', 1, """Show percent complete column""") Option('trac-jsgantt', 'option.caption', 'Resource', """Caption to follow task in Gantt""") IntOption('trac-jsgantt', 'option.startDate', 1, """Show start date column""") IntOption('trac-jsgantt', 'option.endDate', 1, """Show finish date column""") Option('trac-jsgantt', 'option.dateDisplay', 'mm/dd/yyyy', """Format to display dates""") IntOption('trac-jsgantt', 'option.openLevel', 999, """How many levels of task hierarchy to show open""") IntOption('trac-jsgantt', 'option.expandClosedTickets', 1, """Show children of closed tasks in the task hierarchy""") Option('trac-jsgantt', 'option.colorBy', 'priority', """Field to use to color tasks""") IntOption('trac-jsgantt', 'option.lwidth', None, """Width (in pixels) of left table""") IntOption('trac-jsgantt', 'option.showdep', 1, """Show dependencies in Gantt""") IntOption('trac-jsgantt', 'option.userMap', 1, """Map user IDs to user names""") IntOption('trac-jsgantt', 'option.omitMilestones', 0, """Omit milestones""") Option('trac-jsgantt', 'option.schedule', 'alap', """Schedule algorithm: alap or asap""") IntOption('trac-jsgantt', 'option.doResourceLeveling', 0, """Resource level (1) or not (0)""") # This seems to be the first floating point option. Option('trac-jsgantt', 'option.hoursPerDay', '8.0', """Hours worked per day""") Option('trac-jsgantt', 'option.display', None, """Display filter for tickets in the form 'field1:value1|field2:value2' or 'field:value1|value2'; displays tickets where field1==value1, etc.""") Option('trac-jsgantt', 'option.order', 'wbs', """Fields to sort tasks by before display. May include tickets fields (including custom fields) or 'wbs'.""") Option('trac-jsgantt', 'option.scrollTo', None, """Date to scroll chart to (yyyy-mm--dd or 'today')""") Option('trac-jsGantt', 'option.linkStyle', 'standard', """Style for ticket links; jsgantt (new window) or standard browser behavior like ticket links.""") # ITemplateProvider methods def get_htdocs_dirs(self): return [('tracjsgantt', resource_filename(__name__, 'htdocs'))] def get_templates_dirs(self): return [] # IRequestFilter methods def pre_process_request(self, req, handler): # I think we should look for a TracJSGantt on the page and set # a flag for the post_process_request handler if found return handler def post_process_request(self, req, template, data, content_type): add_script(req, 'tracjsgantt/jsgantt.js') add_stylesheet(req, 'tracjsgantt/jsgantt.css') add_stylesheet(req, 'tracjsgantt/tracjsgantt.css') return template, data, content_type class TracJSGanttChart(WikiMacroBase): """ Displays a Gantt chart for the specified tickets. The chart display can be controlled with a number of macro arguments: ||'''Argument'''||'''Description'''||'''Default'''|| || `formats`||What to display in the format control. A pipe-separated list of `minute`, `hour`, `day`, `week`, `month`, and `quarter` (though `minute` may not be very useful). ||'day|week|month|quarter'|| || `format`||Initial display format, one of those listed in `formats` || First format || || `sample`||Display sample tasks (1) or not (0) || 0 || || `res`||Show resource column (1) or not (0) || 1 || || `dur`||Show duration colunn (1) or not (0) || 1 || || `comp`||Show percent complete column (1) or not (0) || 1 || || `caption`||Caption to place to right of tasks: None, Caption, Resource, Duration, %Complete || Resource || || `startDate`||Show start date column (1) or not (0) || 1 || || `endDate`||Show end date column (1) or not (0) || 1 || || `dateDisplay`||Date display format: 'mm/dd/yyyy', 'dd/mm/yyyy', or 'yyyy-mm-dd' || 'mm/dd/yyyy' || || `openLevel`||Number of levels of tasks to show. 1 = only top level task. || 999 || || `colorBy`||Field to use to choose task colors. Each unique value of the field will have a different color task. Other likely useful values are owner and milestone but any field can be used. || priority || || `root`||When using something like Subtickets plugin to maintain a tree of tickets and subtickets, you may create a Gantt showing a ticket and all of its descendants with `root=`. The macro uses the configured `parent` field to find all descendant tasks and build an `id=` argument for Trac's native query handler.[[br]][[br]]Multiple roots may be provided like `root=1|12|32`.[[br]][[br]]When used in a ticket description or comment, `root=self` will display the current ticket's descendants.||None|| || `goal`||When using something like MasterTickets plugin to maintain ticket dependencies, you may create a Gantt showing a ticket and all of its predecessors with `goal=`. The macro uses the configured `succ` field to find all predecessor tasks and build an `id=` argument for Trac's native query handler.[[br]][[br]]Multiple goals may be provided like `goal=1|12|32`.[[br]][[br]]When used in a ticket description or comment, `goal=self` will display the current ticket's predecessors.||None|| || `lwidth`||The width, in pixels, of the table of task names, etc. on the left of the Gantt. || || || `showdep`||Show dependencies (1) or not (0)||1|| || `userMap`||Map user !IDs to full names (1) or not (0).||1|| || `omitMilestones`||Show milestones for displayed tickets (0) or only those specified by `milestone=` (1)||0|| || `schedule`||Schedule tasks based on dependenies and estimates. Either as soon as possible (asap) or as late as possible (alap)||alap|| ||`doResourceLeveling`||Resolve resource conflicts (1) or not (0) when scheduling tickets.||0|| ||`display`||Filter for limiting display of tickets. `owner:fred` shows only tickets owned by fred. `status:closed` shows only closed tickets.||None|| ||`order`||Order of fields used to sort tickets before display. `order=milestone` sorts by milestone. May include ticket fields, including custom fields, or "wbs" (work breakdown structure).||wbs|| Site-wide defaults for macro arguments may be set in the `trac-jsgantt` section of `trac.ini`. `option.` overrides the built-in default for `` from the table above. All other macro arguments are treated as TracQuery specification (e.g., milestone=ms1|ms2) to control which tickets are displayed. """ pm = None options = {} # The date part of these formats has to be in sync. Including # hour and minute in the pyDateFormat makes the plugin easier to # debug at times because that's how the date shows up in page # source. # # jsDateFormat is the date format that the JavaScript expects # dates in. It can be one of 'mm/dd/yyyy', 'dd/mm/yyyy', or # 'yyyy-mm-dd'. pyDateFormat is a strptime() format that matches # jsDateFormat. As long as they are in sync, there's no real # reason to change them. jsDateFormat = 'yyyy-mm-dd' pyDateFormat = '%Y-%m-%d %H:%M' # User map (login -> realname) is loaded on demand, once. # Initialization to None means it is not yet initialized. user_map = None def __init__(self): # Instantiate the PM component self.pm = TracPM(self.env) self.GanttID = 'g' # All the macro's options with default values. # Anything else passed to the macro is a TracQuery field. options = ('format', 'formats', 'sample', 'res', 'dur', 'comp', 'caption', 'startDate', 'endDate', 'dateDisplay', 'openLevel', 'expandClosedTickets', 'colorBy', 'lwidth', 'showdep', 'userMap', 'omitMilestones', 'schedule', 'hoursPerDay', 'doResourceLeveling', 'display', 'order', 'scrollTo', 'linkStyle') for opt in options: self.options[opt] = self.config.get('trac-jsgantt', 'option.%s' % opt) def _begin_gantt(self, options): if options['format']: defaultFormat = options['format'] else: defaultFormat = options['formats'].split('|')[0] showdep = options['showdep'] text = '' text += '
\n' text += '\n' return chart def _gantt_options(self, options): opt = '' if (options['linkStyle']): linkStyle = options['linkStyle'] else: linkStyle = 'standard' opt += self.GanttID+'.setLinkStyle("%s")\n' % linkStyle opt += self.GanttID+'.setShowRes(%s);\n' % options['res'] opt += self.GanttID+'.setShowDur(%s);\n' % options['dur'] opt += self.GanttID+'.setShowComp(%s);\n' % options['comp'] if (options['scrollTo']): opt += self.GanttID+'.setScrollDate("%s");\n' % options['scrollTo'] w = options['lwidth'] if w: opt += self.GanttID+'.setLeftWidth(%s);\n' % w opt += self.GanttID+'.setCaptionType("%s");\n' % \ javascript_quote(options['caption']) opt += self.GanttID+'.setShowStartDate(%s);\n' % options['startDate'] opt += self.GanttID+'.setShowEndDate(%s);\n' % options['endDate'] opt += self.GanttID+'.setDateInputFormat("%s");\n' % \ javascript_quote(self.jsDateFormat) opt += self.GanttID+'.setDateDisplayFormat("%s");\n' % \ javascript_quote(options['dateDisplay']) opt += self.GanttID+'.setFormatArr(%s);\n' % ','.join( '"%s"' % javascript_quote(f) for f in options['formats'].split('|')) opt += self.GanttID+'.setPopupFeatures("location=1,scrollbars=1");\n' return opt # TODO - use ticket-classN styles instead of colors? def _add_sample_tasks(self): task= '' tasks = self.GanttID+'.setDateInputFormat("mm/dd/yyyy");\n' # ID Name Start End Display Link MS Res Pct Gr Par Open Dep Cap tasks += self.GanttID+'.AddTaskItem(new JSGantt.TaskItem('+self.GanttID+',1, "Define Chart API", "", "", "#ff0000", "http://help.com", 0, "Brian", 0, 1, 0, 1));\n' tasks += self.GanttID+'.AddTaskItem(new JSGantt.TaskItem('+self.GanttID+',11, "Chart Object", "2/20/2011", "2/20/2011", "#ff00ff", "http://www.yahoo.com", 1, "Shlomy", 100, 0, 1, 1));\n' tasks += self.GanttID+'.AddTaskItem(new JSGantt.TaskItem('+self.GanttID+',12, "Task Objects", "", "", "#00ff00", "", 0, "Shlomy", 40, 1, 1, 1));\n' tasks += self.GanttID+'.AddTaskItem(new JSGantt.TaskItem('+self.GanttID+',121, "Constructor Proc", "2/21/2011", "3/9/2011", "#00ffff", "http://www.yahoo.com", 0, "Brian T.", 60, 0, 12, 1));\n' tasks += self.GanttID+'.AddTaskItem(new JSGantt.TaskItem('+self.GanttID+',122, "Task Variables", "3/6/2011", "3/11/2011", "#ff0000", "http://help.com", 0, "", 60, 0, 12, 1,121));\n' tasks += self.GanttID+'.AddTaskItem(new JSGantt.TaskItem('+self.GanttID+',123, "Task Functions", "3/9/2011", "3/29/2011", "#ff0000", "http://help.com", 0, "Anyone", 60, 0, 12, 1, 0, "This is another caption"));\n' tasks += self.GanttID+'.AddTaskItem(new JSGantt.TaskItem('+self.GanttID+',2, "Create HTML Shell", "3/24/2011", "3/25/2011", "#ffff00", "http://help.com", 0, "Brian", 20, 0, 0, 1,122));\n' tasks += self.GanttID+'.AddTaskItem(new JSGantt.TaskItem('+self.GanttID+',3, "Code Javascript", "", "", "#ff0000", "http://help.com", 0, "Brian", 0, 1, 0, 1));\n' tasks += self.GanttID+'.AddTaskItem(new JSGantt.TaskItem('+self.GanttID+',31, "Define Variables", "2/25/2011", "3/17/2011", "#ff00ff", "http://help.com", 0, "Brian", 30, 0, 3, 1, 0,"Caption 1"));\n' tasks += self.GanttID+'.AddTaskItem(new JSGantt.TaskItem('+self.GanttID+',32, "Calculate Chart Size", "3/15/2011", "3/24/2011", "#00ff00", "http://help.com", 0, "Shlomy", 40, 0, 3, 1));\n' tasks += self.GanttID+'.AddTaskItem(new JSGantt.TaskItem('+self.GanttID+',33, "Draw Taks Items", "", "", "#00ff00", "http://help.com", 0, "Someone", 40, 1, 3, 1));\n' tasks += self.GanttID+'.AddTaskItem(new JSGantt.TaskItem('+self.GanttID+',332, "Task Label Table", "3/6/2011", "3/11/2011", "#0000ff", "http://help.com", 0, "Brian", 60, 0, 33, 1));\n' tasks += self.GanttID+'.AddTaskItem(new JSGantt.TaskItem('+self.GanttID+',333, "Task Scrolling Grid", "3/9/2011", "3/20/2011", "#0000ff", "http://help.com", 0, "Brian", 60, 0, 33, 1));\n' tasks += self.GanttID+'.AddTaskItem(new JSGantt.TaskItem('+self.GanttID+',34, "Draw Task Bars", "", "", "#990000", "http://help.com", 0, "Anybody", 60, 1, 3, 1));\n' tasks += self.GanttID+'.AddTaskItem(new JSGantt.TaskItem('+self.GanttID+',341, "Loop each Task", "3/26/2011", "4/11/2011", "#ff0000", "http://help.com", 0, "Brian", 60, 0, 34, 1, "332,333"));\n' tasks += self.GanttID+'.AddTaskItem(new JSGantt.TaskItem('+self.GanttID+',342, "Calculate Start/Stop", "4/12/2011", "5/18/2011", "#ff6666", "http://help.com", 0, "Brian", 60, 0, 34, 1));\n' tasks += self.GanttID+'.AddTaskItem(new JSGantt.TaskItem('+self.GanttID+',343, "Draw Task Div", "5/13/2011", "5/17/2011", "#ff0000", "http://help.com", 0, "Brian", 60, 0, 34, 1));\n' tasks += self.GanttID+'.AddTaskItem(new JSGantt.TaskItem('+self.GanttID+',344, "Draw Completion Div", "5/17/2011", "6/04/2011", "#ff0000", "http://help.com", 0, "Brian", 60, 0, 34, 1));\n' tasks += self.GanttID+'.AddTaskItem(new JSGantt.TaskItem('+self.GanttID+',35, "Make Updates", "10/17/2011","12/04/2011","#f600f6", "http://help.com", 0, "Brian", 30, 0, 3, 1));\n' return tasks # Get the required columns for the tickets which match the # criteria in options. def _query_tickets(self, options): query_options = {} for key in options.keys(): if not key in self.options: query_options[key] = options[key] # The fields always needed by the Gantt fields = set([ 'description', 'owner', 'type', 'status', 'summary', 'milestone', 'priority']) # Make sure the coloring field is included if 'colorBy' in options: fields.add(str(options['colorBy'])) rawtickets = self.pm.query(query_options, fields, self.req) # Do permissions check on tickets tickets = [t for t in rawtickets if 'TICKET_VIEW' in self.req.perm('ticket', t['id'])] return tickets def _compare_tickets(self, t1, t2): # If t2 depends on t1, t2 is first if t1['id'] in self.pm.successors(t2): return 1 # If t1 depends on t2, t1 is first elif t2['id'] in self.pm.successors(t1): return -1 # If t1 ends first, it's first elif self.pm.finish(t1) < self.pm.finish(t2): return -1 # If t2 ends first, it's first elif self.pm.finish(t1) > self.pm.finish(t2): return 1 # End dates are same. If t1 starts later, it's later elif self.pm.start(t1) > self.pm.start(t2): return 1 # Otherwise, preserve order (assume t1 is before t2 when called) else: return 0 # Compute WBS for sorting and figure out the tickets' levels for # controlling how many levels are open. # # WBS is a list like [ 2, 4, 1] (the first child of the fourth # child of the second top-level element). def _compute_wbs(self): # Set the ticket's level and wbs then recurse to children. def _setLevel(tid, wbs, level): # Update this node self.ticketsByID[tid]['level'] = level self.ticketsByID[tid]['wbs'] = copy.copy(wbs) # Recurse to children childIDs = self.pm.children(self.ticketsByID[tid]) if childIDs: childTickets = [self.ticketsByID[cid] for cid in childIDs] childTickets.sort(self._compare_tickets) childIDs = [ct['id'] for ct in childTickets] # Add another level wbs.append(1) for c in childIDs: wbs = _setLevel(c, wbs, level+1) # Remove the level we added wbs.pop() # Increment last element of wbs wbs[len(wbs)-1] += 1 return wbs # Set WBS and level on all top level tickets (and recurse) If # a ticket's parent is not in the viewed tickets, consider it # top-level wbs = [ 1 ] roots = self.pm.roots(self.ticketsByID) for t in self.tickets: if t['id'] in roots: wbs = _setLevel(t['id'], wbs, 1) def _task_display(self, t, options): def _buildMap(field): self.classMap = {} i = 0 for t in self.tickets: if t[field] not in self.classMap: i = i + 1 self.classMap[t[field]] = i def _buildEnumMap(field): self.classMap = {} db = self.env.get_db_cnx() cursor = db.cursor() cursor.execute("SELECT name," + db.cast('value', 'int') + " FROM enum WHERE type=%s", (field,)) for name, value in cursor: self.classMap[name] = value display = None colorBy = options['colorBy'] # Build the map the first time we need it if self.classMap == None: # Enums (TODO: what others should I list?) if options['colorBy'] in ['priority', 'severity']: _buildEnumMap(colorBy) else: _buildMap(colorBy) # Set display based on class map if t[colorBy] in self.classMap: display = 'class=ticket-class%d' % self.classMap[t[colorBy]] # Add closed status for strike through if t['status'] == 'closed': if display == None: display = 'class=ticket-closed' else: display += ' ticket-closed' if display == None: display = '#ff7f3f' return display # Format a ticket into JavaScript source to display the # task. ticket is expected to have: # children - child ticket IDs or None # description - ticket description. # id - ticket ID, an integer # level - levels from root (0) # link - What to link to # owner - Used as resource name. # percent - integer percent complete, 0..100 (or "act/est") # priority - used to color the task # calc_finish - end date (ignored if children is not None) # self.fields[parent] - parent ticket ID # self.fields[pred] - predecessor ticket IDs # calc_start - start date (ignored if children is not None) # status - string displayed in tool tip ; FIXME - not displayed yet # summary - ticket summary # type - string displayed in tool tip FIXME - not displayed yet def _format_ticket(self, ticket, options): # Translate owner to full name def _owner(ticket): if self.pm.isMilestone(ticket): owner_name = '' else: owner_name = ticket['owner'] if options['userMap']: # Build the map the first time we use it if self.user_map is None: self.user_map = {} for username, name, email in self.env.get_known_users(): self.user_map[username] = name # Map the user name if self.user_map.get(owner_name): owner_name = self.user_map[owner_name] return owner_name task = '' # pID, pName if self.pm.isMilestone(ticket): if ticket['id'] > 0: # Put ID number on inchpebbles name = 'MS:%s (#%s)' % (ticket['summary'], ticket['id']) else: # Don't show bogus ID of milestone pseudo tickets. name = 'MS:%s' % ticket['summary'] else: name = "#%d:%s (%s %s)" % \ (ticket['id'], ticket['summary'], ticket['status'], ticket['type']) task += 't = new JSGantt.TaskItem(%s,%d,"%s",' % \ (self.GanttID, ticket['id'], javascript_quote(name)) # pStart, pEnd task += '"%s",' % self.pm.start(ticket).strftime(self.pyDateFormat) task += '"%s",' % self.pm.finish(ticket).strftime(self.pyDateFormat) # pDisplay task += '"%s",' % javascript_quote(self._task_display(ticket, options)) # pLink task += '"%s",' % javascript_quote(ticket['link']) # pMile if self.pm.isMilestone(ticket): task += '1,' else: task += '0,' # pRes (owner) task += '"%s",' % javascript_quote(_owner(ticket)) # pComp (percent complete); integer 0..100 task += '"%s",' % self.pm.percentComplete(ticket) # pGroup (has children) if self.pm.children(ticket): task += '%s,' % 1 else: task += '%s,' % 0 # pParent (parent task ID) # If there's no parent, don't link to it if self.pm.parent(ticket) == None: task += '%s,' % 0 else: task += '%s,' % self.pm.parent(ticket) # open if int(ticket['level']) < int(options['openLevel']) and \ ((options['expandClosedTickets'] != 0) or \ (ticket['status'] != 'closed')): openGroup = 1 else: openGroup = 0 task += '%d,' % openGroup # predecessors pred = [str(s) for s in self.pm.predecessors(ticket)] if len(pred): task += '"%s",' % javascript_quote(','.join(pred)) else: task += '"%s",' % javascript_quote(','.join('')) # caption # FIXME - if caption isn't set to caption, use "" because the # description could be quite long and take a long time to make # safe and display. task += '"%s (%s %s)"' % (javascript_quote(ticket['description']), javascript_quote(ticket['status']), javascript_quote(ticket['type'])) task += ');\n' task += self.GanttID+'.AddTaskItem(t);\n' return task def _filter_tickets(self, options, tickets): # Build the list of display filters from the configured value if not options.get('display') or options['display'] == '': displayFilter = {} else: # The general form is # 'display=field:value|field:value...'. Split on pipe to # get each part displayList = options['display'].split('|') # Process each part into the display filter displayFilter = {} field = None for f in displayList: parts = f.split(':') # Just one part, it's a value for the previous field if len(parts) == 1: if field == None: raise TracError(('display option error in "%s".' + ' Should be "display=f1:v1|f2:v2"' + ' or "display=f:v1|v2".') % options['display']) else: value = parts[0] else: field = parts[0] value = parts[1] if field in displayFilter: displayFilter[field].append(value) else: displayFilter[field] = [ value ] # If present and 1, true, otherwise false. if options.get('omitMilestones') \ and int(options['omitMilestones']) == 1: omitMilestones = True else: omitMilestones = False # Filter the tickets filteredTickets = [] for ticket in tickets: # Default to showing every ticket fieldDisplay = True if omitMilestones and \ self.pm.isTracMilestone(ticket): fieldDisplay = False else: # Process each element and disable display if all # filters fail to match. ((or) disjunction) for f in displayFilter: display = True for v in displayFilter[f]: if ticket[f] == v: display = True break display = False fieldDisplay = fieldDisplay & display if fieldDisplay: filteredTickets.append(ticket) return filteredTickets # Sort tickets by options['order']. For example, # order=milestone|wbs sorts by wbs within milestone. # # http://wiki.python.org/moin/HowTo/Sorting (at # #Sort_Stability_and_Complex_Sorts) notes that Python list # sorting is stable so you can sort by increasing priority of keys # (tertiary, then secondary, then primary) to get a multi-key # sort. # # FIXME - this sorts enums by text, not value. def _sortTickets(self, tickets, options): # Force milestones to the end def msSorter(t1, t2): # If t1 is a not milestone and t2 is, t1 comes first if not self.pm.isMilestone(t1) and self.pm.isMilestone(t2): result = -1 elif self.pm.isMilestone(t1) and not self.pm.isMilestone(t2): result = 1 else: result = 0 return result # Get all the sort fields sortFields = options['order'].split('|') # If sorting by milestone, force milestone type tickets to the # end before any other sort. The stability of the other sorts # will keep them at the end of the milestone group (unless # overridden by other fields listed in `order`). if 'milestone' in sortFields: tickets.sort(msSorter) # Reverse sort fields so lowest priority is first sortFields.reverse() # Do the sort by each field for field in sortFields: tickets.sort(key=itemgetter(field)) return tickets def _add_tasks(self, options): if options.get('sample') and int(options['sample']) != 0: tasks = self._add_sample_tasks() else: tasks = '' self.tickets = self._query_tickets(options) # Faster lookups for WBS and scheduling. self.ticketsByID = {} for t in self.tickets: self.ticketsByID[t['id']] = t # Schedule the tasks self.pm.computeSchedule(options, self.tickets) # Sort tickets by date for computing WBS self.tickets.sort(self._compare_tickets) # Compute the WBS self._compute_wbs() # Set the link for clicking through the Gantt chart for t in self.tickets: if t['id'] > 0: t['link'] = self.req.href.ticket(t['id']) else: t['link'] = self.req.href.milestone(t['summary']) # Filter tickets based on options (omitMilestones, display, etc.) displayTickets = self._filter_tickets(options, self.tickets) # Sort the remaining tickets for display (based on order option). displayTickets = self._sortTickets(displayTickets, options) for ticket in displayTickets: tasks += self._format_ticket(ticket, options) return tasks def _parse_options(self, content): _, options = parse_args(content, strict=False) for opt in self.options.keys(): if opt in options: # FIXME - test for success, log on failure if isinstance(self.options[opt], (int, long)): options[opt] = int(options[opt]) else: options[opt] = self.options[opt] # FIXME - test for success, log on failure options['hoursPerDay'] = float(options['hoursPerDay']) # Make sure we get all the tickets. (For complex Gantts, # there can be a lot of tickets, easily more than the default # max.) if 'max' not in options: options['max'] = 999 return options def expand_macro(self, formatter, name, content): self.req = formatter.req # Each invocation needs to build its own map. self.classMap = None options = self._parse_options(content) # Surely we can't create two charts in one microsecond. self.GanttID = 'g_'+str(to_utimestamp(datetime.now(localtz))) chart = '' tasks = self._add_tasks(options) if len(tasks) == 0: chart += 'No tasks selected.' else: chart += self._begin_gantt(options) chart += self._gantt_options(options) chart += tasks chart += self._end_gantt(options) return chart tracjsganttplugin/0.11/tracjsgantt/pmapi.py0000644000175500017550000000337512364006341020745 0ustar debacledebacle# -*- coding: utf-8 -*- # # Copyright (C) 2010-2014 Chris Nelson # All rights reserved. # # This software is licensed as described in the file COPYING, which # you should have received as part of this distribution. from trac.core import Interface class ITaskSorter(Interface): # Process task list to precompute keys or otherwise make # compareTasks() more efficient. def prepareTasks(self, ticketsByID): """Called to prepare tasks for sorting.""" # Provide a compare function for sorting tasks. # May be used as cmp argument for sorted(), and list.sort(). # Returns -1 if t1 < t2, 0 if they are equal, 1 if t1 > t2. def compareTasks(self, t1, t2): """Called to compare two tasks""" class IResourceCalendar(Interface): # Return the number of hours available for the resource on the # specified date. # FIXME - return None if no information available? # FIXME - should we just pass the ticket we want to work on? It # has resource (owner), estimate, etc. which might be useful to a # calendar. # FIXME - should this be pm_hoursAvailable or something so other # plugins can implement it without potential conflict? def hoursAvailable(self, date, resource = None): """Called to see how many hours are available on date""" class ITaskScheduler(Interface): # Schedule each the ticket in tickets with consideration for # dependencies, estimated work, hours per day, etc. # # Assumes tickets is a list returned by TracPM.query(). # # On exit, each ticket has a start and finish that can be accessed # with TracPM.start() and finish(). No other changes are made. def scheduleTasks(self, options, tickets): """Called to schedule tasks""" tracjsganttplugin/0.11/tracjsgantt/htdocs/0000755000175500017550000000000012323565611020546 5ustar debacledebacletracjsganttplugin/0.11/tracjsgantt/htdocs/jsgantt.js0000644000175500017550000027677112323565611022602 0ustar debacledebacle/* Copyright (c) 2009, Shlomy Gantz BlueBrick Inc. All rights reserved. * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: * * Redistributions of source code must retain the above copyright * notice, this list of conditions and the following disclaimer. * * Redistributions in binary form must reproduce the above copyright * notice, this list of conditions and the following disclaimer in the * documentation and/or other materials provided with the distribution. * * Neither the name of Shlomy Gantz or BlueBrick Inc. nor the * names of its contributors may be used to endorse or promote products * derived from this software without specific prior written permission. * * THIS SOFTWARE IS PROVIDED BY SHLOMY GANTZ/BLUEBRICK INC. ''AS IS'' AND ANY * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE * DISCLAIMED. IN NO EVENT SHALL SHLOMY GANTZ/BLUEBRICK INC. BE LIABLE FOR ANY * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ /** * JSGantt component is a UI control that displays gantt charts based by using CSS and HTML * @module jsgantt * @title JSGantt */ var JSGantt; if (!JSGantt) JSGantt = {}; var vTimeout = 0; var vBenchTime = new Date().getTime(); /* Adapted from * http://www.toao.net/32-my-htmlspecialchars-function-for-javascript */ function htmlspecialchars(str) { if (typeof(str) == "string") { str = str.replace(/&/g, "&"); /* must do & first */ str = str.replace(/"/g, """); str = str.replace(/'/g, "'"); str = str.replace(//g, ">"); } return str; } /* Adapted from http://www.javascripter.net/faq/ctrl_alt.htm */ function mouseDown(e) { var ctrlPressed=0; var altPressed=0; var shiftPressed=0; var button=""; if (parseInt(navigator.appVersion)>3) { var evt = e ? e:window.event; // Adapted from http://unixpapa.com/js/mouse.html if (evt.which == null) { /* IE case */ button = (evt.button < 2) ? "LEFT" : ((evt.button == 4) ? "MIDDLE" : "RIGHT"); } else { /* All others */ button = (evt.which < 2) ? "LEFT" : ((evt.which == 2) ? "MIDDLE" : "RIGHT"); } if (document.layers && navigator.appName=="Netscape" && parseInt(navigator.appVersion)==4) { // NETSCAPE 4 CODE var mString =(e.modifiers+32).toString(2).substring(3,6); shiftPressed=(mString.charAt(0)=="1"); ctrlPressed =(mString.charAt(1)=="1"); altPressed =(mString.charAt(2)=="1"); } else { // NEWER BROWSERS [CROSS-PLATFORM] shiftPressed=evt.shiftKey; altPressed =evt.altKey; ctrlPressed =evt.ctrlKey; } JSGantt.mouse = { "button": button, "shift" : shiftPressed, "alt" : altPressed, "ctrl" : ctrlPressed } } return true; } /** * Creates a task (one row) in gantt object * @class TaskItem * @namespace JSGantt * @constructor * @for JSGantt * @param g - Gantt that this task is being added to * @param pID {Number} Task unique numeric ID * @param pName {String} Task Name * @param pStart {Date} Task start date/time (not required for pGroup=1 ) * @param pEnd {Date} Task end date/time, you can set the end time to 12:00 to indicate half-day (not required for pGroup=1 ) * @param pDisplay {String} How to display the task. "class=classname" or "style=style string" or a color ("#rgb" or a color name) * @param pLink {String} Task URL, clicking on the task will redirect to this url. Leave empty if you do not with the Task also serve as a link * @param pMile {Boolean} Determines whether task is a milestone (1=Yes,0=No) * @param pRes {String} Resource to perform the task * @param pComp {String} Percent complete (Number between 0 and 100) or "act/est" * @param pGroup {Boolean} * @param pParent {Number} ID of the parent task * @param pOpen {Boolean} * @param pDepend {String} Comma seperated list of IDs this task depends on * @param pCaption {String} Caption to be used instead of default caption (Resource). * note : you should use setCaption("Caption") in order to display the caption * @return void */ JSGantt.TaskItem = function(g, pID, pName, pStart, pEnd, pDisplay, pLink, pMile, pRes, pComp, pGroup, pParent, pOpen, pDepend, pCaption) { /** * The name of the attribute. * @property vID * @type String * @default pID * @private */ var vID = pID; /** * @property vName * @type String * @default pName * @private */ var vName = pName; /** * @property vStart * @type Datetime * @default new Date() * @private */ var vStart = new Date(); /** * @property vEnd * @type Datetime * @default new Date() * @private */ var vEnd = new Date(); /** * @property vColor * @type String * @default pColor * @private */ var vColor; var vClass; var vStyle; /* Parse display value */ if (pDisplay.indexOf('=') == -1) { vColor = pDisplay; vClass = ''; vStyle = ''; } else { vColor = ''; pDisplay = pDisplay.split('=') switch (pDisplay[0]) { case 'class': vStyle = ''; vClass = pDisplay[1]; break; case 'style': vStyle = pDisplay[1]; vClass = ''; break; } } /** * @property vLink * @type String * @default pLink * @private */ var vLink = pLink; /** * @property vMile * @type Boolean * @default pMile * @private */ var vMile = pMile; /** * @property vRes * @type String * @default pRes * @private */ var vRes = pRes; /** * @property vComp * @type Number * @default pComp * @private */ var vComp; var vEst; var vAct; if (typeof pComp === 'number') { vComp = pComp; vEst = null; vAct = null; } else if (pComp.indexOf('/') == -1) { vComp = parseFloat(pComp); vEst = null; vAct = null; } else { // Parse on /, do math, and assign pComp = pComp.split('/'); vAct = parseFloat(pComp[0]); vEst = parseFloat(pComp[1]) if (vAct == NaN || vEst == NaN) { vComp = 0; } else if (vEst == 0) { vComp = 0 } else { vComp = Math.round(100 * (vAct / vEst)); } } /** * @property vGroup * @type Boolean * @default pGroup * @private */ var vGroup = pGroup; /** * @property vParent * @type Number * @default pParent * @private */ var vParent = pParent; /** * @property vOpen * @type Boolean * @default pOpen * @private */ var vOpen = pOpen; /** * @property vDepend * @type String * @default pDepend * @private */ var vDepend = pDepend; /** * @property vCaption * @type String * @default pCaption * @private */ var vCaption = pCaption; /** * @property vDuration * @type Number * @default '' * @private */ var vDuration = ''; /** * @property vLevel * @type Number * @default 0 * @private */ var vLevel = 0; /** * @property vNumKid * @type Number * @default 0 * @private */ var vNumKid = 0; /** * @property vVisible * @type Boolean * @default 0 * @private */ var vVisible = 1; var x1, y1, x2, y2; if (vGroup != 1) { vStart = JSGantt.parseDateStr(pStart,g.getDateInputFormat()); vEnd = JSGantt.parseDateStr(pEnd,g.getDateInputFormat()); } /** * Returns task ID * @method getID * @return {Number} */ this.getID = function(){ return vID }; /** * Returns task name * @method getName * @return {String} */ this.getName = function(){ return htmlspecialchars(vName) }; /** * Returns task start date * @method getStart * @return {Datetime} */ this.getStart = function(){ return vStart}; /** * Returns task end date * @method getEnd * @return {Datetime} */ this.getEnd = function(){ return vEnd }; /** * Returns task bar color (i.e. 00FF00) * @method getColor * @return {String} */ this.getColor = function(){ return vColor}; /** * Returns task bar style * @method getStyle * @return {String} */ this.getStyle = function(){ return vStyle}; /** * Returns task bar class * @method getClass * @return {String} */ this.getClass = function(){ return vClass}; /** * Returns task URL (i.e. http://www.jsgantt.com) * @method getLink * @return {String} */ this.getLink = function(){ return vLink }; /** * Returns whether task is a milestone (1=Yes,0=No) * @method getMile * @return {Boolean} */ this.getMile = function(){ return vMile }; /** * Returns task dependencies as list of values (i.e. 123,122) * @method getDepend * @return {String} */ this.getDepend = function(){ if(vDepend) return vDepend; else return null }; /** * Returns task caption (if it exists) * @method getCaption * @return {String} */ this.getCaption = function(){ if(vCaption) return vCaption; else return ''; }; /** * Returns task resource name as string * @method getResource * @return {String} */ this.getResource = function(){ if(vRes) return vRes; else return ' '; }; /** * Returns task estimate as a numeric value * @method getEstVal * @return {Number} */ this.getEstVal = function(){ if(vEst != null) return vEst; else return 100; }; /** * Returns task work as numeric value * @method getWorkVal * @return {Number} */ this.getWorkVal = function(){ if(vAct != null) return vAct; else return vComp; }; /** * Returns task completion percent as numeric value * @method getCompVal * @return {Number} */ this.getCompVal = function(){ if(vComp) return vComp; else return 0; }; /** * Returns task completion percent as formatted string (##%) * @method getCompStr * @return {String} */ this.getCompStr = function(){ if(vComp) return vComp+'%'; else return ''; }; /** * Returns task duration as a fortmatted string based on the current selected format * @method getDuration * @param vFormat {String} selected format (minute,hour,day,week,month) * @return {String} */ this.getDuration = function(vFormat){ if (vMile) vDuration = '-'; else if (vFormat=='hour') { tmpPer = Math.ceil((this.getEnd() - this.getStart()) / ( 60 * 60 * 1000) ); if(tmpPer == 1) vDuration = '1 Hour'; else vDuration = tmpPer + ' Hours'; } else if (vFormat=='minute') { tmpPer = Math.ceil((this.getEnd() - this.getStart()) / ( 60 * 1000) ); if(tmpPer == 1) vDuration = '1 Minute'; else vDuration = tmpPer + ' Minutes'; } else { //if(vFormat == 'day') { tmpPer = Math.ceil((this.getEnd() - this.getStart()) / (24 * 60 * 60 * 1000) + 1); if(tmpPer == 1) vDuration = '1 Day'; else vDuration = tmpPer + ' Days'; } //else if(vFormat == 'week') { // tmpPer = ((this.getEnd() - this.getStart()) / (24 * 60 * 60 * 1000) + 1)/7; // if(tmpPer == 1) vDuration = '1 Week'; // else vDuration = tmpPer + ' Weeks'; //} //else if(vFormat == 'month') { // tmpPer = ((this.getEnd() - this.getStart()) / (24 * 60 * 60 * 1000) + 1)/30; // if(tmpPer == 1) vDuration = '1 Month'; // else vDuration = tmpPer + ' Months'; //} //else if(vFormat == 'quater') { // tmpPer = ((this.getEnd() - this.getStart()) / (24 * 60 * 60 * 1000) + 1)/120; // if(tmpPer == 1) vDuration = '1 Qtr'; // else vDuration = tmpPer + ' Qtrs'; //} return( vDuration ) }; /** * Returns task parent ID * @method getParent * @return {Number} */ this.getParent = function(){ return vParent }; /** * Returns whether task is a group (1=Yes,0=No) * @method getGroup * @return {Number} */ this.getGroup = function(){ return vGroup }; /** * Returns whether task is open (1=Yes,0=No) * @method getOpen * @return {Boolean} */ this.getOpen = function(){ return vOpen }; /** * Returns task tree level (0,1,2,3...) * @method getLevel * @return {Boolean} */ this.getLevel = function(){ return vLevel }; /** * Returns the number of child tasks * @method getNumKids * @return {Number} */ this.getNumKids = function(){ return vNumKid }; /** * Returns the X position of the left side of the task bar on the graph (right side) * @method getStartX * @return {Number} */ this.getStartX = function(){ return x1 }; /** * Returns the Y position of the top of the task bar on the graph (right side) * @method getStartY * @return {Number} */ this.getStartY = function(){ return y1 }; /** * Returns the X position of the right of the task bar on the graph (right side) * @method getEndX * @return {Int} */ this.getEndX = function(){ return x2 }; /** * Returns the Y position of the bottom of the task bar on the graph (right side) * @method getEndY * @return {Number} */ this.getEndY = function(){ return y2 }; /** * Returns whether task is visible (1=Yes,0=No) * @method getVisible * @return {Boolean} */ this.getVisible = function(){ return vVisible }; /** * Set task dependencies * @method setDepend * @param pDepend {String} A comma delimited list of task IDs the current task depends on. * @return {void} */ this.setDepend = function(pDepend){ vDepend = pDepend;}; /** * Set task start date/time * @method setStart * @param pStart {Datetime} * @return {void} */ this.setStart = function(pStart){ vStart = pStart;}; /** * Set task end date/time * @method setEnd * @param pEnd {Datetime} * @return {void} */ this.setEnd = function(pEnd) { vEnd = pEnd; }; /** * Set task tree level (0,1,2,3...) * @method setLevel * @param pLevel {Number} * @return {void} */ this.setLevel = function(pLevel){ vLevel = pLevel;}; /** * Set Number of children for the task * @method setNumKid * @param pNumKid {Number} * @return {void} */ this.setNumKid = function(pNumKid){ vNumKid = pNumKid;}; /** * Set task work * @method setWorkVal * @param pWorkVal {Number} * @return {void} */ this.setWorkVal = function(pWorkVal){ vAct = pWorkVal; }; /** * Set task estimate * @method setEstVal * @param pEstVal {Number} * @return {void} */ this.setEstVal = function(pEstVal){ vEst = pEstVal; }; /** * Set task completion percentage * @method setCompVal * @param pCompVal {Number} * @return {void} */ this.setCompVal = function(pCompVal){ vComp = pCompVal;}; /** * Set a task bar starting position (left) * @method setStartX * @param pX {Number} * @return {void} */ this.setStartX = function(pX) {x1 = pX; }; /** * Set a task bar starting position (top) * @method setStartY * @param pY {Number} * @return {String} */ this.setStartY = function(pY) {y1 = pY; }; /** * Set a task bar starting position (right) * @method setEndX * @param pX {Number} * @return {String} */ this.setEndX = function(pX) {x2 = pX; }; /** * Set a task bar starting position (bottom) * @method setEndY * @param pY {Number} * @return {String} */ this.setEndY = function(pY) {y2 = pY; }; /** * Set task open/closed * @method setOpen * @param pOpen {Boolean} * @return {void} */ this.setOpen = function(pOpen) {vOpen = pOpen; }; /** * Set task visibility * @method setVisible * @param pVisible {Boolean} * @return {void} */ this.setVisible = function(pVisible) {vVisible = pVisible; }; }; /** * Creates the Gantt chart. for example:

var g = new JSGantt.GanttChart('g',document.getElementById('GanttChartDIV'), 'day');

var g = new JSGantt.GanttChart( - assign the gantt chart to a javascript variable called 'g' 'g' - the name of the variable that was just assigned (will be used later so that gantt object can reference itself) document.getElementById('GanttChartDIV') - reference to the DIV that will hold the gantt chart 'day' - default format will be by day * * @class GanttChart * @param pGanttVar {String} the name of the gantt chart variable * @param pDiv {String} reference to the DIV that will hold the gantt chart * @param pFormat {String} default format (minute,hour,day,week,month,quarter) * @return void */ /* FIXME - should be able to set Today color (or in CSS?) */ JSGantt.GanttChart = function(pGanttVar, pDiv, pFormat, pShowDep) { /** * The name of the gantt chart variable * @property vGanttVar * @type String * @default pGanttVar * @private */ var vGanttVar = pGanttVar; /** * The name of the gantt chart DIV * @property vDiv * @type String * @default pDiv * @private */ var vDiv = pDiv; /** * Selected format (minute,hour,day,week,month) * @property vFormat * @type String * @default pFormat * @private */ var vFormat = pFormat; /** * Whether or not to draw dependency lines * @property vShowDep * @type Character * @default pShowDep * @private */ var vShowDep = pShowDep; /** * Show resource column * @property vShowRes * @type Number * @default 1 * @private */ var vShowRes = 1; /** * Ticket link style * @property vLinkStyle * @type String * @default "jsgantt" * @private */ var vLinkStyle = "jsgantt"; /** * Show duration column * @property vShowDur * @type Number * @default 1 * @private */ var vShowDur = 1; /** * Show percent complete column * @property vShowComp * @type Number * @default 1 * @private */ var vShowComp = 1; /** * Show start date column * @property vShowStartDate * @type Number * @default 1 * @private */ var vShowStartDate = 1; /** * Show end date column * @property vShowEndDate * @type Number * @default 1 * @private */ var vShowEndDate = 1; /** * Date input format * @property vDateInputFormat * @type String * @default "mm/dd/yyyy" * @private */var vDateInputFormat = "mm/dd/yyyy"; /** * Date display format * @property vDateDisplayFormat * @type String * @default "mm/dd/yy" * @private */var vDateDisplayFormat = "mm/dd/yy"; /** * Initial scroll position of chart * @property vScrollDateStr * @type String * @default null * @private */var vScrollDateStr = null; /** * Popup-window features (width, height, scroll bars, etc.) * @property vPopupFeatures * @type String * @default "height=200,width=300" * @private */var vPopupFeatures = "height=200,width=300"; /** * Width of the left table (task, duration, etc.) * @property vLeftWidth * @type integer * @default Room for +/-, name, resource, duration, %, start, and end dates * @private */var vLeftWidth = 15 + 220 + 70 + 70 + 70 + 70 + 70; var vNumUnits = 0; var vCaptionType; var vDepId = 1; var vTaskList = new Array(); var vFormatArr = new Array("day","week","month","quarter"); var vQuarterArr = new Array(1,1,1,2,2,2,3,3,3,4,4,4); var vMonthDaysArr = new Array(31,28,31,30,31,30,31,31,30,31,30,31); var vMonthArr = new Array("January","February","March","April","May","June","July","August","September","October","November","December"); /** * Set current display format (minute/hour/day/week/month/quarter) * Only the first 4 arguments are used, for example: * * g.setFormatArr("day","week","month"); * * will show 3 formatting options (day/week/month) at the bottom right of the gantt chart * @method setFormatArr * @return {void} */ this.setFormatArr = function() { vFormatArr = new Array(); for(var i = 0; i < arguments.length; i++) {vFormatArr[i] = arguments[i];} if(vFormatArr.length>4){vFormatArr.length=4;} }; /** * Set link style to old (jsGantt) or standard * @param pShow {String} "jsgantt"|"standard" * @method setLinkStyle * @return {void} */ this.setLinkStyle = function(pStyle) { vLinkStyle = pStyle; }; /** * Show/Hide resource column * @param pShow {Number} 1=Show,0=Hide * @method setShowRes * @return {void} */ this.setShowRes = function(pShow) { vShowRes = pShow; }; /** * Show/Hide duration column * @param pShow {Number} 1=Show,0=Hide * @method setShowDur * @return {void} */ this.setShowDur = function(pShow) { vShowDur = pShow; }; /** * Whether or not to show depedency lines * @param pShowDep{Character} 1=show,0=Hide * @method setShowDep * #return {void} */this.setShowDep = function(pShowDep){vShowDep = pShowDep}; /** * Show/Hide completed column * @param pShow {Number} 1=Show,0=Hide * @method setShowComp * @return {void} */ this.setShowComp = function(pShow) { vShowComp = pShow; }; /** * Show/Hide start date column * @param pShow {Number} 1=Show,0=Hide * @method setShowStartDate * @return {void} */ this.setShowStartDate = function(pShow) { vShowStartDate = pShow; }; /** * Show/Hide end date column * @param pShow {Number} 1=Show,0=Hide * @method setShowEndDate * @return {void} */ this.setShowEndDate = function(pShow) { vShowEndDate = pShow; }; /** * Overall date input format * @param pShow {String} (mm/dd/yyyy,dd/mm/yyyy,yyyy-mm-dd) * @method setDateInputFormat * @return {void} */ this.setDateInputFormat = function(pShow) { vDateInputFormat = pShow; }; /** * Overall date display format * @param pShow {String} (mm/dd/yyyy,dd/mm/yyyy,yyyy-mm-dd) * @method setDateDisplayFormat * @return {void} */ this.setDateDisplayFormat = function(pShow) { vDateDisplayFormat = pShow; }; /** * Initial scroll position of chart * @param pDate {String} yyyy-mm-dd * @method setScrollDate * @return {void} */ this.setScrollDate = function(pDateStr) { vScrollDateStr = pDateStr; }; /** * Popup window features * @param pPopupFeatures {String} * @method setPopupFeatures * @return {void} */ this.setPopupFeatures = function(pPopupFeatures) { vPopupFeatures = pPopupFeatures; }; /** * Left table width * @param pLeftWidth {integer} * @method setLeftWidth * @return {void} */ this.setLeftWidth = function(pLeftWidth) { vLeftWidth = pLeftWidth; }; /** * Set gantt caption * @param pType {String}

Caption-Displays a custom caption set in TaskItem
Resource-Displays task resource
Duration-Displays task duration
Complete-Displays task percent complete

* @method setCaptionType * @return {void} */ this.setCaptionType = function(pType) { vCaptionType = pType }; /** * Set current display format and redraw gantt chart (minute/hour/day/week/month/quarter) * @param pFormat {String} (mm/dd/yyyy,dd/mm/yyyy,yyyy-mm-dd) * @method setFormat * @return {void} */ this.setFormat = function(pFormat){ vFormat = pFormat; this.Draw(); }; /** * Returns whether resource column is shown * @method getShowRes * @return {Boolean} */ this.getShowRes = function(){ return vShowRes }; /** * Returns whether duration column is shown * @method getShowDur * @return {Boolean} */ this.getShowDur = function(){ return vShowDur }; /** * Returns whether percent complete column is shown * @method getShowComp * @return {Boolean} */ this.getShowComp = function(){ return vShowComp }; /** * Returns whether start date column is shown * @method getShowStartDate * @return {Boolean} */ this.getShowStartDate = function(){ return vShowStartDate }; /** * Returns whether end date column is shown * @method getShowEndDate * @return {Boolean} */ this.getShowEndDate = function(){ return vShowEndDate }; /** * Returns date input format * @method getDateInputFormat * @return {String} */ this.getDateInputFormat = function() { return vDateInputFormat }; /** * Returns current display format * @method getDateDisplayFormat * @return {String} */ this.getDateDisplayFormat = function() { return vDateDisplayFormat }; /** * Returns popup feature string * @method getPopupFeatures * @return {String} */ this.getPopupFeatures = function() { return vPopupFeatures }; /** * Returns current gantt caption type * @method getCaptionType * @return {String} */ this.getCaptionType = function() { return vCaptionType }; /** * Calculates X/Y coordinates of a task and sets the Start and End properties of the TaskItem * @method CalcTaskXY * @return {Void} */ this.CalcTaskXY = function () { var vList = this.getList(); var vTaskDiv; var vParDiv; var vLeft, vTop, vHeight, vWidth; for(i = 0; i < vList.length; i++) { vID = vList[i].getID(); vTaskDiv = document.getElementById("taskbar_"+vID); vBarDiv = document.getElementById(pGanttVar+"_bardiv_"+vID); vParDiv = document.getElementById(pGanttVar+"_childgrid_"+vID); if(vBarDiv) { vList[i].setStartX( vBarDiv.offsetLeft ); vList[i].setStartY( vParDiv.offsetTop+vBarDiv.offsetTop+6 ); vList[i].setEndX( vBarDiv.offsetLeft + vBarDiv.offsetWidth ); vList[i].setEndY( vParDiv.offsetTop+vBarDiv.offsetTop+6 ); }; }; }; /** * Adds a TaskItem to the Gantt object task list array * @method AddTaskItem * @return {Void} */ this.AddTaskItem = function(value) { vTaskList.push(value); }; /** * Returns task list Array * @method getList * @return {Array} */ this.getList = function() { return vTaskList }; /** * Returns Gantt ID * @method getID * @return string */ this.getID = function() { return vGanttVar }; /** * Clears dependency lines between tasks * @method clearDependencies * @return {Void} */ this.clearDependencies = function() { var parent = document.getElementById('rightside_'+pGanttVar); var depLine; var vMaxId = vDepId; for ( i=1; i task 2 start) * @method drawDependency * @return {Void} */ this.drawDependency =function(x1,y1,x2,y2) { if(x1 + 10 < x2) { this.sLine(x1,y1,x1+4,y1); this.sLine(x1+4,y1,x1+4,y2); this.sLine(x1+4,y2,x2,y2); this.dLine(x2,y2,x2-3,y2-3); this.dLine(x2,y2,x2-3,y2+3); this.dLine(x2-1,y2,x2-3,y2-2); this.dLine(x2-1,y2,x2-3,y2+2); } else { this.sLine(x1,y1,x1+4,y1); this.sLine(x1+4,y1,x1+4,y2-10); this.sLine(x1+4,y2-10,x2-8,y2-10); this.sLine(x2-8,y2-10,x2-8,y2); this.sLine(x2-8,y2,x2,y2); this.dLine(x2,y2,x2-3,y2-3); this.dLine(x2,y2,x2-3,y2+3); this.dLine(x2-1,y2,x2-3,y2-2); this.dLine(x2-1,y2,x2-3,y2+2); } }; /** * Draw all task dependencies * @method DrawDependencies * @return {Void} */ this.DrawDependencies = function () { if (vShowDep=="1"){ //First recalculate the x,y this.CalcTaskXY(); this.clearDependencies(); var vList = this.getList(); for(var i = 0; i < vList.length; i++) { vDepend = vList[i].getDepend(); if(vDepend) { var vDependStr = vDepend + ''; var vDepList = vDependStr.split(','); var n = vDepList.length; for(var k=0;k 0) { // Process all tasks preset parent date and completion % JSGantt.processRows(vTaskList, 0, -1, 1, 1); // get overall min/max dates plus padding vMinDate = JSGantt.getMinDate(vTaskList, vFormat); vMaxDate = JSGantt.getMaxDate(vTaskList, vFormat); // Parse "scroll to" date, if any if (vScrollDateStr) { if (vScrollDateStr == 'today') { // Use today vScrollDate = new Date(); } else { // Parse date string to date vScrollDate = JSGantt.parseDateStr(vScrollDateStr, this.getDateInputFormat()); } } // Calculate chart width variables. vColWidth can be altered manually to change each column width // May be smart to make this a parameter of GanttChart or set it based on existing pWidth parameter if(vFormat == 'day') { vColWidth = 18; vColUnit = 1; } else if(vFormat == 'week') { vColWidth = 37; vColUnit = 7; } else if(vFormat == 'month') { vColWidth = 37; vColUnit = 30; } else if(vFormat == 'quarter') { vColWidth = 60; vColUnit = 90; } else if(vFormat=='hour') { vColWidth = 18; vColUnit = 1; } else if(vFormat=='minute') { vColWidth = 18; vColUnit = 1; } vNumDays = (Date.parse(vMaxDate) - Date.parse(vMinDate)) / ( 24 * 60 * 60 * 1000); vNumUnits = vNumDays / vColUnit; vChartWidth = vNumUnits * vColWidth + 1; vDayWidth = (vColWidth / vColUnit) + (1/vColUnit); vMainTable = '' if (vShowRes == 1) { vNameWidth -= vStatusWidth; vRadioSpan += 1; } if (vShowDur == 1) { vNameWidth -= vStatusWidth; vRadioSpan += 1; } if (vShowComp == 1) { vNameWidth -= vStatusWidth; vRadioSpan += 1; } if (vShowStartDate == 1) { vNameWidth -= vStatusWidth; vRadioSpan += 1; } if (vShowEndDate == 1) { vNameWidth -= vStatusWidth; vRadioSpan += 1; } if (parseInt(navigator.appVersion)>3) { document.onmousedown = mouseDown; if (navigator.appName=="Netscape") { document.captureEvents(Event.MOUSEDOWN); } } // DRAW the Left-side of the chart (names, resources, comp%) vLeftTable = '
' + '' + ' ' + ' '; vLeftTable += '' + ' ' + ' ' ; if(vShowRes ==1) vLeftTable += ' ' ; if(vShowDur ==1) vLeftTable += ' ' ; if(vShowComp==1) vLeftTable += ' ' ; if(vShowStartDate==1) vLeftTable += ' ' ; if(vShowEndDate==1) vLeftTable += ' ' ; vLeftTable += ''; for(i = 0; i < vTaskList.length; i++) { if( vTaskList[i].getGroup()) { vBGColor = "f3f3f3"; vRowType = "group"; } else { vBGColor = "ffffff"; vRowType = "row"; } vID = vTaskList[i].getID(); if(vTaskList[i].getVisible() == 0) vLeftTable += '' ; else vLeftTable += '' ; vLeftTable += ' ' + ' ' ; if(vShowRes ==1) vLeftTable += ' ' ; if(vShowDur ==1) vLeftTable += ' ' ; if(vShowComp==1) vLeftTable += ' ' ; if(vShowStartDate==1) vLeftTable += ' ' ; if(vShowEndDate==1) vLeftTable += ' ' ; vLeftTable += ''; } // DRAW the date format selector at bottom left. Another potential GanttChart parameter to hide/show this selector vLeftTable += '' + '
'; vLeftTable += 'Format:'; if (vFormatArr.join().indexOf("minute")!=-1) { if (vFormat=='minute') vLeftTable += 'Minute'; else vLeftTable += 'Minute'; } if (vFormatArr.join().indexOf("hour")!=-1) { if (vFormat=='hour') vLeftTable += 'Hour'; else vLeftTable += 'Hour'; } if (vFormatArr.join().indexOf("day")!=-1) { if (vFormat=='day') vLeftTable += 'Day'; else vLeftTable += 'Day'; } if (vFormatArr.join().indexOf("week")!=-1) { if (vFormat=='week') vLeftTable += 'Week'; else vLeftTable += 'Week'; } if (vFormatArr.join().indexOf("month")!=-1) { if (vFormat=='month') vLeftTable += 'Month'; else vLeftTable += 'Month'; } if (vFormatArr.join().indexOf("quarter")!=-1) { if (vFormat=='quarter') vLeftTable += 'Qtr.'; else vLeftTable += 'Qtr.'; } // vLeftTable += ' .'; vLeftTable += '
ResourceDuration% Comp.Start DateEnd Date
 '; for(j=1; j ' ; else vLeftTable += '+ ' ; } else { vLeftTable += '   '; } vLeftTable += ' ' + vTaskList[i].getName() + '' + '' + vTaskList[i].getResource() + '' + vTaskList[i].getDuration(vFormat) + '' + vTaskList[i].getCompStr() + '' + JSGantt.formatDateStr( vTaskList[i].getStart(), vDateDisplayFormat) + '' + JSGantt.formatDateStr( vTaskList[i].getEnd(), vDateDisplayFormat) + '
  Powered by jsGantt      '; vLeftTable += '
'; vMainTable += vLeftTable; // Draw the Chart Rows vRightID = 'rightside_'+pGanttVar; vRightTable = '
' + ''; if (JSGantt.isIE()) { // IE; vRightTable += ''; } else { vRightTable += ''; } vTmpDate.setFullYear(vMinDate.getFullYear(), vMinDate.getMonth(), vMinDate.getDate()); vTmpDate.setHours(0); vTmpDate.setMinutes(0); // Major Date Header while(Date.parse(vTmpDate) <= Date.parse(vMaxDate)) { vStr = vTmpDate.getFullYear() + ''; vStr = vStr.substring(2,4); if(vFormat == 'minute') { vRightTable += ''; vTmpDate.setHours(vTmpDate.getHours()+1); } if(vFormat == 'hour') { vRightTable += ''; vTmpDate.setDate(vTmpDate.getDate()+1); } if(vFormat == 'day') { vRightTable += ''; vTmpDate.setDate(vTmpDate.getDate()+7); } else if(vFormat == 'month') { vRightTable += ''; vTmpDate.setDate(vTmpDate.getDate() + 1); while(vTmpDate.getDate() > 1) { vTmpDate.setDate(vTmpDate.getDate() + 1); } } else if(vFormat == 'quarter') { vRightTable += ''; vTmpDate.setDate(vTmpDate.getDate() + 81); while(vTmpDate.getDate() > 1) { vTmpDate.setDate(vTmpDate.getDate() + 1); } } } vRightTable += ''; // Minor Date header and Cell Rows vTmpDate.setFullYear(vMinDate.getFullYear(), vMinDate.getMonth(), vMinDate.getDate()); vNxtDate.setFullYear(vMinDate.getFullYear(), vMinDate.getMonth(), vMinDate.getDate()); vNumCols = 0; while(Date.parse(vTmpDate) <= Date.parse(vMaxDate)) { if (vFormat == 'minute') { if( vTmpDate.getMinutes() ==0 ) vWeekdayColor = "ccccff"; else vWeekdayColor = "ffffff"; vDateRowStr += ''; vItemRowStr += ''; vTmpDate.setMinutes(vTmpDate.getMinutes() + 1); } else if (vFormat == 'hour') { if( vTmpDate.getHours() ==0 ) vWeekdayColor = "ccccff"; else vWeekdayColor = "ffffff"; vDateRowStr += ''; vItemRowStr += ''; vTmpDate.setHours(vTmpDate.getHours() + 1); } else if(vFormat == 'day' ) { if( JSGantt.formatDateStr(vCurrDate,'mm/dd/yyyy') == JSGantt.formatDateStr(vTmpDate,'mm/dd/yyyy')) { vWeekdayColor = "ccccff"; vWeekendColor = "9999ff"; vWeekdayGColor = "bbbbff"; vWeekendGColor = "8888ff"; } else { vWeekdayColor = "ffffff"; vWeekendColor = "cfcfcf"; vWeekdayGColor = "f3f3f3"; vWeekendGColor = "c3c3c3"; } if (vScrollDate && JSGantt.formatDateStr(vScrollDate,'mm/dd/yyyy') == JSGantt.formatDateStr(vTmpDate,'mm/dd/yyyy')) { vScrollID = 'id="'+pGanttVar+'-scrollTo"'; } else { vScrollID = ''; } if(vTmpDate.getDay() % 6 == 0) { vDateRowStr += ''; vItemRowStr += ''; } else { vDateRowStr += ''; if( JSGantt.formatDateStr(vCurrDate,'mm/dd/yyyy') == JSGantt.formatDateStr(vTmpDate,'mm/dd/yyyy')) vItemRowStr += ''; else vItemRowStr += ''; } vTmpDate.setDate(vTmpDate.getDate() + 1); } else if(vFormat == 'week') { vNxtDate.setDate(vNxtDate.getDate() + 7); if( vCurrDate >= vTmpDate && vCurrDate < vNxtDate ) vWeekdayColor = "ccccff"; else vWeekdayColor = "ffffff"; if (vScrollDate && vTmpDate <= vScrollDate && vScrollDate < vNxtDate ) { vScrollID = 'id="'+pGanttVar+'-scrollTo"'; } else { vScrollID = ''; } if(vNxtDate <= vMaxDate) { vDateRowStr += ''; if( vCurrDate >= vTmpDate && vCurrDate < vNxtDate ) vItemRowStr += ''; else vItemRowStr += ''; } else { vDateRowStr += ''; if( vCurrDate >= vTmpDate && vCurrDate < vNxtDate ) vItemRowStr += ''; else vItemRowStr += ''; } vTmpDate.setDate(vTmpDate.getDate() + 7); } else if(vFormat == 'month') { vNxtDate.setFullYear(vTmpDate.getFullYear(), vTmpDate.getMonth(), vMonthDaysArr[vTmpDate.getMonth()]); if( vCurrDate >= vTmpDate && vCurrDate < vNxtDate ) vWeekdayColor = "ccccff"; else vWeekdayColor = "ffffff"; if (vScrollDate && vTmpDate <= vScrollDate && vScrollDate < vNxtDate ) { vScrollID = 'id="'+pGanttVar+'-scrollTo"'; } else { vScrollID = ''; } if(vNxtDate <= vMaxDate) { vDateRowStr += ''; if( vCurrDate >= vTmpDate && vCurrDate < vNxtDate ) vItemRowStr += ''; else vItemRowStr += ''; } else { vDateRowStr += ''; if( vCurrDate >= vTmpDate && vCurrDate < vNxtDate ) vItemRowStr += ''; else vItemRowStr += ''; } vTmpDate.setDate(vTmpDate.getDate() + 1); while(vTmpDate.getDate() > 1) { vTmpDate.setDate(vTmpDate.getDate() + 1); } } else if(vFormat == 'quarter') { vNxtDate.setDate(vNxtDate.getDate() + 122); if( vTmpDate.getMonth()==0 || vTmpDate.getMonth()==1 || vTmpDate.getMonth()==2 ) vNxtDate.setFullYear(vTmpDate.getFullYear(), 2, 31); else if( vTmpDate.getMonth()==3 || vTmpDate.getMonth()==4 || vTmpDate.getMonth()==5 ) vNxtDate.setFullYear(vTmpDate.getFullYear(), 5, 30); else if( vTmpDate.getMonth()==6 || vTmpDate.getMonth()==7 || vTmpDate.getMonth()==8 ) vNxtDate.setFullYear(vTmpDate.getFullYear(), 8, 30); else if( vTmpDate.getMonth()==9 || vTmpDate.getMonth()==10 || vTmpDate.getMonth()==11 ) vNxtDate.setFullYear(vTmpDate.getFullYear(), 11, 31); if( vCurrDate >= vTmpDate && vCurrDate < vNxtDate ) vWeekdayColor = "ccccff"; else vWeekdayColor = "ffffff"; if (vScrollDate && vTmpDate <= vScrollDate && vScrollDate < vNxtDate ) { vScrollID = 'id="'+pGanttVar+'-scrollTo"'; } else { vScrollID = ''; } if(vNxtDate <= vMaxDate) { vDateRowStr += ''; if( vCurrDate >= vTmpDate && vCurrDate < vNxtDate ) vItemRowStr += ''; else vItemRowStr += ''; } else { vDateRowStr += ''; if( vCurrDate >= vTmpDate && vCurrDate < vNxtDate ) vItemRowStr += ''; else vItemRowStr += ''; } vTmpDate.setDate(vTmpDate.getDate() + 81); while(vTmpDate.getDate() > 1) { vTmpDate.setDate(vTmpDate.getDate() + 1); } } } vRightTable += vDateRowStr + ''; vRightTable += '
' ; vRightTable += JSGantt.formatDateStr(vTmpDate, vDateDisplayFormat) + ' ' + vTmpDate.getHours() + ':00 -' + vTmpDate.getHours() + ':59 ' ; vRightTable += JSGantt.formatDateStr(vTmpDate, vDateDisplayFormat) + ''; var vTmpMonth = vTmpDate.getMonth(); vTmpDate.setDate(vTmpDate.getDate()+6); if (vTmpMonth == vTmpDate.getMonth()) vRightTable += vMonthArr[vTmpDate.getMonth()].substr(0,3) + " "+ vTmpDate.getFullYear(); else vRightTable += vMonthArr[vTmpMonth].substr(0,3) +"-"+vMonthArr[vTmpDate.getMonth()].substr(0,3) + " "+ vTmpDate.getFullYear(); vTmpDate.setDate(vTmpDate.getDate()+1); } else if(vFormat == 'week') { vRightTable += '`'+ vStr + '`'+ vStr + '`'+ vStr + '
' + vTmpDate.getMinutes() + '
  
' + vTmpDate.getHours() + '
  
' + vTmpDate.getDate() + '
 
' + vTmpDate.getDate() + '
  
  
' + (vTmpDate.getMonth()+1) + '/' + vTmpDate.getDate() + '
  
  
' + (vTmpDate.getMonth()+1) + '/' + vTmpDate.getDate() + '
  
  
' + vMonthArr[vTmpDate.getMonth()].substr(0,3) + '
  
  
' + vMonthArr[vTmpDate.getMonth()].substr(0,3) + '
  
  
Qtr. ' + vQuarterArr[vTmpDate.getMonth()] + '
  
  
Qtr. ' + vQuarterArr[vTmpDate.getMonth()] + '
  
  
'; // Draw each row for(i = 0; i < vTaskList.length; i++) { vTmpDate.setFullYear(vMinDate.getFullYear(), vMinDate.getMonth(), vMinDate.getDate()); vTaskStart = vTaskList[i].getStart(); vTaskEnd = vTaskList[i].getEnd(); vNumCols = 0; vID = vTaskList[i].getID(); // vNumUnits = Math.ceil((vTaskList[i].getEnd() - vTaskList[i].getStart()) / (24 * 60 * 60 * 1000)) + 1; vNumUnits = (vTaskList[i].getEnd() - vTaskList[i].getStart()) / (24 * 60 * 60 * 1000) + 1; if (vFormat=='hour') { vNumUnits = (vTaskList[i].getEnd() - vTaskList[i].getStart()) / ( 60 * 1000) + 1; } else if (vFormat=='minute') { vNumUnits = (vTaskList[i].getEnd() - vTaskList[i].getStart()) / ( 60 * 1000) + 1; } if(vTaskList[i].getVisible() == 0) vRightTable += ''; } vMainTable += vRightTable + '
'; vDiv.innerHTML = vMainTable; // Sane defaults for most screens these days. In the worst // case, it will scroll var width = "1000px"; if (typeof window.innerWidth != 'undefined') { // 95% of overall window width width = window.innerWidth * 0.95 + 'px'; } else { width = document.documentElement.clientWidth * 0.95 + 'px'; } vDiv.style.width = width; // Scroll to configured date, if any. var vScrollToEl = document.getElementById(pGanttVar+'-scrollTo'); if (vScrollToEl != null) { var vChartDiv = document.getElementById(vRightID); // Scroll div to put scroll-to date on left vChartDiv.scrollLeft = vScrollToEl.offsetLeft; } } }; //this.draw /** * Mouseover behaviour for gantt row * @method mouseOver * @return {Void} */ this.mouseOver = function( pObj, pID, pPos, pType ) { if( pPos == 'right' ) vID = this.getID()+'_child_' + pID; else vID = 'childrow_' + pID; pObj.bgColor = "#ffffaa"; vRowObj = JSGantt.findObj(vID); if (vRowObj) vRowObj.bgColor = "#ffffaa"; }; /** * Mouseout behaviour for gantt row * @method mouseOut * @return {Void} */ this.mouseOut = function( pObj, pID, pPos, pType ) { if( pPos == 'right' ) vID = this.getID()+'_child_' + pID; else vID = 'childrow_' + pID; pObj.bgColor = "#ffffff"; vRowObj = JSGantt.findObj(vID); if (vRowObj) { if( pType == "group") { pObj.bgColor = "#f3f3f3"; vRowObj.bgColor = "#f3f3f3"; } else { pObj.bgColor = "#ffffff"; vRowObj.bgColor = "#ffffff"; } } }; }; //GanttChart /** * @class */ /** * Checks whether browser is IE * * @method isIE */ JSGantt.isIE = function () { if(typeof document.all != 'undefined') {return true;} else {return false;} }; /** * Recursively process task tree ... set min, max dates of parent tasks * and identfy task level. * * @method processRows * @param pList {Array} - Array of all TaskItem Objects in the chart * @param pID {Number} - task ID of the parent to process * @param pRow {Number} - Row in chart * @param pLevel {Number} - Current tree level * @param pOpen {Boolean} * @return void */ JSGantt.processRows = function(pList, pID, pRow, pLevel, pOpen) { var vMinDate = new Date(); var vMaxDate = new Date(); var vMinSet = 0; var vMaxSet = 0; var vList = pList; var vLevel = pLevel; var i = 0; var vNumKid = 0; var vEstSum = 0; var vWorkSum = 0; var vVisible = pOpen; for (i = 0; i < pList.length; i++) { if (pList[i].getParent() == pID) { vVisible = pOpen; pList[i].setVisible(vVisible); if (vVisible == 1 && pList[i].getOpen() == 0) { vVisible = 0; } pList[i].setLevel(vLevel); vNumKid++; if (pList[i].getGroup() == 1) { JSGantt.processRows(vList, pList[i].getID(), i, vLevel+1, vVisible); } if (vMinSet == 0 || pList[i].getStart() < vMinDate) { vMinDate = pList[i].getStart(); vMinSet = 1; } if (vMaxSet == 0 || pList[i].getEnd() > vMaxDate) { vMaxDate = pList[i].getEnd(); vMaxSet = 1; } vWorkSum += pList[i].getWorkVal(); vEstSum += pList[i].getEstVal(); } } if (pRow >= 0) { pList[pRow].setStart(vMinDate); pList[pRow].setEnd(vMaxDate); pList[pRow].setNumKid(vNumKid); if (pList[pRow].getGroup() == 1) { pList[pRow].setWorkVal(vWorkSum); pList[pRow].setEstVal(vEstSum); } if (vEstSum == 0) { pList[pRow].setCompVal(0); } else { pList[pRow].setCompVal(Math.ceil(100 * vWorkSum / vEstSum)); } } }; /** * Determine the minimum date of all tasks and set lower bound based on format * * @method getMinDate * @param pList {Array} - Array of TaskItem Objects * @param pFormat {String} - current format (minute,hour,day...) * @return {Datetime} */ JSGantt.getMinDate = function getMinDate(pList, pFormat) { var vDate = new Date(); vDate.setFullYear(pList[0].getStart().getFullYear(), pList[0].getStart().getMonth(), pList[0].getStart().getDate()); // Parse all Task End dates to find min for(i = 0; i < pList.length; i++) { if(Date.parse(pList[i].getStart()) < Date.parse(vDate)) vDate.setFullYear(pList[i].getStart().getFullYear(), pList[i].getStart().getMonth(), pList[i].getStart().getDate()); } if ( pFormat== 'minute') { vDate.setHours(0); vDate.setMinutes(0); } else if (pFormat == 'hour' ) { vDate.setHours(0); vDate.setMinutes(0); } // Adjust min date to specific format boundaries (first of week or first of month) else if (pFormat=='day') { vDate.setDate(vDate.getDate() - 1); while(vDate.getDay() % 7 > 0) { vDate.setDate(vDate.getDate() - 1); } } else if (pFormat=='week') { while(vDate.getDay() % 7 > 0) { vDate.setDate(vDate.getDate() - 1); } } else if (pFormat=='month') { while(vDate.getDate() > 1) { vDate.setDate(vDate.getDate() - 1); } } else if (pFormat=='quarter') { if( vDate.getMonth()==0 || vDate.getMonth()==1 || vDate.getMonth()==2 ) {vDate.setFullYear(vDate.getFullYear(), 0, 1);} else if( vDate.getMonth()==3 || vDate.getMonth()==4 || vDate.getMonth()==5 ) {vDate.setFullYear(vDate.getFullYear(), 3, 1);} else if( vDate.getMonth()==6 || vDate.getMonth()==7 || vDate.getMonth()==8 ) {vDate.setFullYear(vDate.getFullYear(), 6, 1);} else if( vDate.getMonth()==9 || vDate.getMonth()==10 || vDate.getMonth()==11 ) {vDate.setFullYear(vDate.getFullYear(), 9, 1);} }; return(vDate); }; /** * Used to determine the minimum date of all tasks and set lower bound based on format * * @method getMaxDate * @param pList {Array} - Array of TaskItem Objects * @param pFormat {String} - current format (minute,hour,day...) * @return {Datetime} */ JSGantt.getMaxDate = function (pList, pFormat) { var vDate = new Date(); vDate.setFullYear(pList[0].getEnd().getFullYear(), pList[0].getEnd().getMonth(), pList[0].getEnd().getDate()); // Parse all Task End dates to find max for(i = 0; i < pList.length; i++) { if(Date.parse(pList[i].getEnd()) > Date.parse(vDate)) { //vDate.setFullYear(pList[0].getEnd().getFullYear(), pList[0].getEnd().getMonth(), pList[0].getEnd().getDate()); vDate.setTime(Date.parse(pList[i].getEnd())); } } if (pFormat == 'minute') { vDate.setHours(vDate.getHours() + 1); vDate.setMinutes(59); } if (pFormat == 'hour') { vDate.setHours(vDate.getHours() + 2); } // Adjust max date to specific format boundaries (end of week or end of month) if (pFormat=='day') { vDate.setDate(vDate.getDate() + 1); while(vDate.getDay() % 6 > 0) { vDate.setDate(vDate.getDate() + 1); } } if (pFormat=='week') { //For weeks, what is the last logical boundary? vDate.setDate(vDate.getDate() + 11); while(vDate.getDay() % 6 > 0) { vDate.setDate(vDate.getDate() + 1); } } // Set to last day of current Month if (pFormat=='month') { while(vDate.getDay() > 1) { vDate.setDate(vDate.getDate() + 1); } vDate.setDate(vDate.getDate() - 1); } // Set to last day of current Quarter if (pFormat=='quarter') { if( vDate.getMonth()==0 || vDate.getMonth()==1 || vDate.getMonth()==2 ) vDate.setFullYear(vDate.getFullYear(), 2, 31); else if( vDate.getMonth()==3 || vDate.getMonth()==4 || vDate.getMonth()==5 ) vDate.setFullYear(vDate.getFullYear(), 5, 30); else if( vDate.getMonth()==6 || vDate.getMonth()==7 || vDate.getMonth()==8 ) vDate.setFullYear(vDate.getFullYear(), 8, 30); else if( vDate.getMonth()==9 || vDate.getMonth()==10 || vDate.getMonth()==11 ) vDate.setFullYear(vDate.getFullYear(), 11, 31); } return(vDate); }; /** * Returns an object from the current DOM * * @method findObj * @param theObj {String} - Object name * @param theDoc {Document} - current document (DOM) * @return {Object} */ JSGantt.findObj = function (theObj, theDoc) { var p, i, foundObj; if(!theDoc) {theDoc = document;} if( (p = theObj.indexOf("?")) > 0 && parent.frames.length){ theDoc = parent.frames[theObj.substring(p+1)].document; theObj = theObj.substring(0,p); } if(!(foundObj = theDoc[theObj]) && theDoc.all) {foundObj = theDoc.all[theObj];} for (i=0; !foundObj && i < theDoc.forms.length; i++) {foundObj = theDoc.forms[i][theObj];} for(i=0; !foundObj && theDoc.layers && i < theDoc.layers.length; i++) {foundObj = JSGantt.findObj(theObj,theDoc.layers[i].document);} if(!foundObj && document.getElementById) {foundObj = document.getElementById(theObj);} return foundObj; }; /** * Change display format of current gantt chart * * @method changeFormat * @param pFormat {String} - Current format (minute,hour,day...) * @param ganttObj {GanttChart} - The gantt object * @return {void} */ JSGantt.changeFormat = function(pFormat,ganttObj) { if(ganttObj) { ganttObj.setFormat(pFormat); ganttObj.DrawDependencies(); } else {alert('Chart undefined');}; }; /** * Open/Close and hide/show children of specified task * * @method folder * @param pID {Number} - Task ID * @param ganttObj {GanttChart} - The gantt object * @return {void} */ JSGantt.folder= function (pID,ganttObj) { var vList = ganttObj.getList(); for(i = 0; i < vList.length; i++) { if(vList[i].getID() == pID) { var objID = ganttObj.getID()+"_group_"+pID; if( vList[i].getOpen() == 1 ) { vList[i].setOpen(0); JSGantt.hide(pID,ganttObj); if (JSGantt.isIE()) {JSGantt.findObj(objID).innerText = '+';} else {JSGantt.findObj(objID).textContent = '+';} } else { vList[i].setOpen(1); JSGantt.show(pID, 1, ganttObj); if (JSGantt.isIE()) {JSGantt.findObj(objID).innerText = '-';} else {JSGantt.findObj(objID).textContent = '-';} } } } }; /** * Hide children of a task * * @method hide * @param pID {Number} - Task ID * @param ganttObj {GanttChart} - The gantt object * @return {void} */ JSGantt.hide= function (pID,ganttObj) { var vList = ganttObj.getList(); var vID = 0; for(var i = 0; i < vList.length; i++) { if(vList[i].getParent() == pID) { vID = vList[i].getID(); JSGantt.findObj(ganttObj.getID()+'_child_' + vID).style.display = "none"; JSGantt.findObj(ganttObj.getID()+'_childgrid_' + vID).style.display = "none"; vList[i].setVisible(0); if(vList[i].getGroup() == 1) {JSGantt.hide(vID,ganttObj);} } } }; /** * Show children of a task * * @method show * @param pID {Number} - Task ID * @param ganttObj {GanttChart} - The gantt object * @return {void} */ JSGantt.show = function (pID, pTop, ganttObj) { var vList = ganttObj.getList(); var vID = 0; for(var i = 0; i < vList.length; i++) { if(vList[i].getParent() == pID) { vID = vList[i].getID(); if(pTop == 1) { if (JSGantt.isIE()) { // IE; if( JSGantt.findObj(ganttObj.getID()+"_group_"+pID).innerText == '+') { JSGantt.findObj(ganttObj.getID()+'_child_'+vID).style.display = ""; JSGantt.findObj(ganttObj.getID()+'_childgrid_'+vID).style.display = ""; vList[i].setVisible(1); } } else { if( JSGantt.findObj(ganttObj.getID()+"_group_"+pID).textContent == '+') { JSGantt.findObj(ganttObj.getID()+'_child_'+vID).style.display = ""; JSGantt.findObj(ganttObj.getID()+'_childgrid_'+vID).style.display = ""; vList[i].setVisible(1); } } } else { if (JSGantt.isIE()) { // IE; if( JSGantt.findObj(ganttObj.getID()+"_group_"+pID).innerText == '-') { JSGantt.findObj(ganttObj.getID()+'_child_'+vID).style.display = ""; JSGantt.findObj(ganttObj.getID()+'_childgrid_'+vID).style.display = ""; vList[i].setVisible(1); } } else { if( JSGantt.findObj(ganttObj.getID()+"_group_"+pID).textContent == '-') { JSGantt.findObj(ganttObj.getID()+'_child_'+vID).style.display = ""; JSGantt.findObj(ganttObj.getID()+'_childgrid_'+vID).style.display = ""; vList[i].setVisible(1); } } } if(vList[i].getGroup() == 1) {JSGantt.show(vID, 0,ganttObj);} } } }; /** * Handles click events on task name, currently opens a new window * * @method taskLink * @param pRef {String} - URL for window * @param pFeatures (String) - Feature string (e.g., "status=1,toolbar=1") * @return {void} */ JSGantt.taskLink = function(pRef,pFeatures,pStyle) { var vFeatures, vWinName; if (pFeatures && pFeatures.length != 0) { vFeatures = pFeatures } else { vFeatures = "height=200,width=300" } // Old JSGantt behavior if (pStyle == "jsgantt") { vWinName = "newwin"; } else if (JSGantt.mouse["button"]=="MIDDLE" || JSGantt.mouse["shift"]) { vWinName = "_blank"; } else { vWinName = "_self"; } window.open(pRef, vWinName, vFeatures); }; /** * Parse dates based on gantt date format setting as defined in JSGantt.GanttChart.setDateInputFormat() * * @method parseDateStr * @param pDateStr {String} - A string that contains the date (i.e. "01/01/09") * @param pFormatStr {String} - The date format (mm/dd/yyyy,dd/mm/yyyy,yyyy-mm-dd) * @return {Datetime} */ JSGantt.parseDateStr = function(pDateStr,pFormatStr) { var vDate =new Date(); vDate.setTime( Date.parse(pDateStr)); switch(pFormatStr) { case 'mm/dd/yyyy': var vDateParts = pDateStr.split('/'); vDate.setFullYear(parseInt(vDateParts[2], 10), parseInt(vDateParts[0], 10) - 1, parseInt(vDateParts[1], 10)); break; case 'dd/mm/yyyy': var vDateParts = pDateStr.split('/'); vDate.setFullYear(parseInt(vDateParts[2], 10), parseInt(vDateParts[1], 10) - 1, parseInt(vDateParts[0], 10)); break; case 'yyyy-mm-dd': var vDateParts = pDateStr.split('-'); vDate.setFullYear(parseInt(vDateParts[0], 10), parseInt(vDateParts[1], 10) - 1, parseInt(vDateParts[2], 10)); break; } return(vDate); }; /** * Display a formatted date based on gantt date format setting as defined in JSGantt.GanttChart.setDateDisplayFormat() * * @method formatDateStr * @param pDate {Date} - A javascript date object * @param pFormatStr {String} - The date format (mm/dd/yyyy,dd/mm/yyyy,yyyy-mm-dd...) * @return {String} */ JSGantt.formatDateStr = function(pDate,pFormatStr) { vYear4Str = pDate.getFullYear() + ''; vYear2Str = vYear4Str.substring(2,4); vMonthStr = (pDate.getMonth()+1) + ''; vDayStr = pDate.getDate() + ''; var vDateStr = ""; switch(pFormatStr) { case 'mm/dd/yyyy': return( vMonthStr + '/' + vDayStr + '/' + vYear4Str ); case 'dd/mm/yyyy': return( vDayStr + '/' + vMonthStr + '/' + vYear4Str ); case 'yyyy-mm-dd': return( vYear4Str + '-' + vMonthStr + '-' + vDayStr ); case 'mm/dd/yy': return( vMonthStr + '/' + vDayStr + '/' + vYear2Str ); case 'dd/mm/yy': return( vDayStr + '/' + vMonthStr + '/' + vYear2Str ); case 'yy-mm-dd': return( vYear2Str + '-' + vMonthStr + '-' + vDayStr ); case 'mm/dd': return( vMonthStr + '/' + vDayStr ); case 'dd/mm': return( vDayStr + '/' + vMonthStr ); } }; /** * Parse an external XML file containing task items. * * @method parseXML * @param ThisFile {String} - URL to XML file * @param pGanttVar {Gantt} - Gantt object * @return {void} */ JSGantt.parseXML = function(ThisFile,pGanttVar){ var is_chrome = navigator.userAgent.toLowerCase().indexOf('chrome') > -1; // Is this Chrome try { //Internet Explorer xmlDoc=new ActiveXObject("Microsoft.XMLDOM"); } catch(e) { try { //Firefox, Mozilla, Opera, Chrome etc. if (is_chrome==false) { xmlDoc=document.implementation.createDocument("","",null); } } catch(e) { alert(e.message); return; } } if (is_chrome==false) { // can't use xmlDoc.load in chrome at the moment xmlDoc.async=false; xmlDoc.load(ThisFile); // we can use loadxml JSGantt.AddXMLTask(pGanttVar); xmlDoc=null; // a little tidying Task = null; } else { JSGantt.ChromeLoadXML(ThisFile,pGanttVar); ta=null; // a little tidying } }; /** * Add a task based on parsed XML doc * * @method AddXMLTask * @param pGanttVar {Gantt} - Gantt object * @return {void} */ JSGantt.AddXMLTask = function(pGanttVar){ Task=xmlDoc.getElementsByTagName("task"); var n = xmlDoc.documentElement.childNodes.length; // the number of tasks. IE gets this right, but mozilla add extra ones (Whitespace) for(var i=0;i/gi); var n = ta.length; // the number of tasks. for(var i=1;i/i); if(te.length> 2){var pID=te[1];} else {var pID = 0;} pID *= 1; var te = Task.split(//i); if(te.length> 2){var pName=te[1];} else {var pName = "No Task Name";} var te = Task.split(//i); if(te.length> 2){var pStart=te[1];} else {var pStart = "";} var te = Task.split(//i); if(te.length> 2){var pEnd=te[1];} else {var pEnd = "";} var te = Task.split(//i); if(te.length> 2){var pColor=te[1];} else {var pColor = '0000ff';} var te = Task.split(//i); if(te.length> 2){var pLink=te[1];} else {var pLink = "";} var te = Task.split(//i); if(te.length> 2){var pMile=te[1];} else {var pMile = 0;} pMile *= 1; var te = Task.split(//i); if(te.length> 2){var pRes=te[1];} else {var pRes = "";} var te = Task.split(//i); if(te.length> 2){var pComp=te[1];} else {var pComp = 0;} pComp *= 1; var te = Task.split(//i); if(te.length> 2){var pGroup=te[1];} else {var pGroup = 0;} pGroup *= 1; var te = Task.split(//i); if(te.length> 2){var pParent=te[1];} else {var pParent = 0;} pParent *= 1; var te = Task.split(//i); if(te.length> 2){var pOpen=te[1];} else {var pOpen = 1;} pOpen *= 1; var te = Task.split(//i); if(te.length> 2){var pDepend=te[1];} else {var pDepend = "";} //pDepend *= 1; if (pDepend.length==0){pDepend=''} // need this to draw the dependency lines var te = Task.split(//i); if(te.length> 2){var pCaption=te[1];} else {var pCaption = "";} // Finally add the task pGanttVar.AddTaskItem(new JSGantt.TaskItem(pID , pName, pStart, pEnd, pColor, pLink, pMile, pRes, pComp, pGroup, pParent, pOpen, pDepend,pCaption )); }; }; }; /** * Used for benchmarking performace * * @method benchMark * @param pItem {TaskItem} - TaskItem object * @return {void} */ JSGantt.benchMark = function(pItem){ var vEndTime=new Date().getTime(); alert(pItem + ': Elapsed time: '+((vEndTime-vBenchTime)/1000)+' seconds.'); vBenchTime=new Date().getTime(); }; tracjsganttplugin/0.11/tracjsgantt/htdocs/jsgantt_Minutes.html0000644000175500017550000005324011477252437024626 0ustar debacledebacle FREE javascript gantt - JSGantt HTML and CSS only
  jsGantt - 1.2
  Bugs/Issues    Download    License    Usage    Examples    Documenation    Subscribe    Credits




  100% Free Javascript / CSS/ HTML Gantt chart control. Completely buzzword compliant including AJAX !


Basic Features
  • Tasks & Collapsible Task Groups
  • Multiple Dependencies
  • Task Completion
  • Task Color
  • Milestones
  • Resources
  • No images needed
Advanced Features
  • Dynamic Loading of Tasks
  • Dynamic change of format
    • Day
    • Week
    • Month
    • Quarter
    • Hour
    • Minute
  • Load Gantt from XML file


Current Issues:
  1. Currently only one gantt chart is allowed per page.

New in 1.2:
  • Support for half-days
  • Hour/Minute format

Click here to download the jsgantt
You can download the latest bleeding edge version, request features and report issues at http://code.google.com/p/jsgantt/

JSGantt is released under BSD license. If you require another license please contact shlomygantz@hotmail.com
If you plan to use it in a commercial product please consider donating the first sale to charity.



1. Include JSGantt CSS and Javascript

<link rel="stylesheet" type="text/css" href="jsgantt.css" />
<script language="javascript" src="jsgantt.js"></script>

2. Create a div element to hold the gantt chart

<div style="position:relative" class="gantt" id="GanttChartDIV"></div>

3. Start a <script> block

<script language="javascript">

4. Instantiate JSGantt using GanttChart()

var g = new JSGantt.GanttChart('g',document.getElementById('GanttChartDIV'), 'day');

    

GanttChart(pGanttVar, pDiv, pFormat)
pGanttVar: (required) name of the variable assigned
pDiv: (required) this is a DIV object created in HTML
pFormat: (required) - used to indicate whether chart should be drawn in "day", "week", "month", or "quarter" format

Customize the look and feel using the following setters

g.setShowRes(1); // Show/Hide Responsible (0/1)
g.setShowDur(1); // Show/Hide Duration (0/1)
g.setShowComp(1); // Show/Hide % Complete(0/1)
g.setCaptionType('Resource');  // Set to Show Caption (None,Caption,Resource,Duration,Complete)
g.setShowStartDate(1); // Show/Hide Start Date(0/1)
g.setShowEndDate(1); // Show/Hide End Date(0/1)
g.setDateInputFormat('mm/dd/yyyy')  // Set format of input dates ('mm/dd/yyyy', 'dd/mm/yyyy', 'yyyy-mm-dd')
g.setDateDisplayFormat('mm/dd/yyyy') // Set format to display dates ('mm/dd/yyyy', 'dd/mm/yyyy', 'yyyy-mm-dd')
g.setFormatArr("day","week","month","quarter") // Set format options (up to 4 : "minute","hour","day","week","month","quarter")

5. Add Tasks using AddTaskItem()

 
g.AddTaskItem(new JSGantt.TaskItem(1,   'Define Chart API',     '',          '',          'ff0000', 'http://help.com', 0, 'Brian',     0, 1, 0, 1));
g.AddTaskItem(new JSGantt.TaskItem(11,  'Chart Object',         '2/10/2008', '2/10/2008', 'ff00ff', 'http://www.yahoo.com', 1, 'Shlomy',  100, 0, 1, 1, "121,122", "My Caption"));
TaskItem(pID, pName, pStart, pEnd, pColor, pLink, pMile, pRes, pComp, pGroup, pParent, pOpen, pDepend)
pID: (required) is a unique ID used to identify each row for parent functions and for setting dom id for hiding/showing
pName: (required) is the task Label
pStart: (required) the task start date, can enter empty date ('') for groups. You can also enter specific time (2/10/2008 12:00) for additional percision or half days.
pEnd: (required) the task end date, can enter empty date ('') for groups
pColor: (required) the html color for this task; e.g. '00ff00'
pLink: (optional) any http link navigated to when task bar is clicked.
pMile:(optional) represent a milestone
pRes: (optional) resource name
pComp: (required) completion percent
pGroup: (optional) indicates whether this is a group(parent) - 0=NOT Parent; 1=IS Parent
pParent: (required) identifies a parent pID, this causes this task to be a child of identified task
pOpen: can be initially set to close folder when chart is first drawn
pDepend: optional list of id's this task is dependent on ... line drawn from dependent to this item
pCaption: optional caption that will be added after task bar if CaptionType set to "Caption"
*You should be able to add items to the chart in realtime via javascript and issuing "g.Draw()" command.

5a. Another way to add tasks is to use an external XML file with parseXML()

 
JSGantt.parseXML("project.xml",g);
The structure of the XML file:

6. Call Draw() and DrawDependencies()

 

g.Draw();	
g.DrawDependencies();


7. Close the <script> block

</script>


Final code should look like

Enter your email address to receive JSGantt announcements


Developed by Shlomy Gantz and Brian Twidt
Contributed: Paul Labuschagne, Kevin Badgett, Ilan Admon

tracjsganttplugin/0.11/tracjsgantt/htdocs/jsgantt.compressed.js0000644000175500017550000013270511477252437024741 0ustar debacledebaclevar JSGantt;if(!JSGantt)JSGantt={};var vTimeout=0;var vBenchTime=new Date().getTime();JSGantt.TaskItem=function(pID,pName,pStart,pEnd,pColor,pLink,pMile,pRes,pComp,pGroup,pParent,pOpen,pDepend,pCaption){var vID=pID;var vName=pName;var vStart=new Date();var vEnd=new Date();var vColor=pColor;var vLink=pLink;var vMile=pMile;var vRes=pRes;var vComp=pComp;var vGroup=pGroup;var vParent=pParent;var vOpen=pOpen;var vDepend=pDepend;var vCaption=pCaption;var vDuration='';var vLevel=0;var vNumKid=0;var vVisible=1;var x1,y1,x2,y2;if(vGroup!=1){vStart=JSGantt.parseDateStr(pStart,g.getDateInputFormat());vEnd=JSGantt.parseDateStr(pEnd,g.getDateInputFormat())}this.getID=function(){return vID};this.getName=function(){return vName};this.getStart=function(){return vStart};this.getEnd=function(){return vEnd};this.getColor=function(){return vColor};this.getLink=function(){return vLink};this.getMile=function(){return vMile};this.getDepend=function(){if(vDepend)return vDepend;else return null};this.getCaption=function(){if(vCaption)return vCaption;else return''};this.getResource=function(){if(vRes)return vRes;else return' '};this.getCompVal=function(){if(vComp)return vComp;else return 0};this.getCompStr=function(){if(vComp)return vComp+'%';else return''};this.getDuration=function(vFormat){if(vMile)vDuration='-';else if(vFormat=='hour'){tmpPer=Math.ceil((this.getEnd()-this.getStart())/(60*60*1000));if(tmpPer==1)vDuration='1 Hour';else vDuration=tmpPer+' Hours'}else if(vFormat=='minute'){tmpPer=Math.ceil((this.getEnd()-this.getStart())/(60*1000));if(tmpPer==1)vDuration='1 Minute';else vDuration=tmpPer+' Minutes'}else{tmpPer=Math.ceil((this.getEnd()-this.getStart())/(24*60*60*1000)+1);if(tmpPer==1)vDuration='1 Day';else vDuration=tmpPer+' Days'}return(vDuration)};this.getParent=function(){return vParent};this.getGroup=function(){return vGroup};this.getOpen=function(){return vOpen};this.getLevel=function(){return vLevel};this.getNumKids=function(){return vNumKid};this.getStartX=function(){return x1};this.getStartY=function(){return y1};this.getEndX=function(){return x2};this.getEndY=function(){return y2};this.getVisible=function(){return vVisible};this.setDepend=function(pDepend){vDepend=pDepend};this.setStart=function(pStart){vStart=pStart};this.setEnd=function(pEnd){vEnd=pEnd};this.setLevel=function(pLevel){vLevel=pLevel};this.setNumKid=function(pNumKid){vNumKid=pNumKid};this.setCompVal=function(pCompVal){vComp=pCompVal};this.setStartX=function(pX){x1=pX};this.setStartY=function(pY){y1=pY};this.setEndX=function(pX){x2=pX};this.setEndY=function(pY){y2=pY};this.setOpen=function(pOpen){vOpen=pOpen};this.setVisible=function(pVisible){vVisible=pVisible}};JSGantt.GanttChart=function(pGanttVar,pDiv,pFormat){var vGanttVar=pGanttVar;var vDiv=pDiv;var vFormat=pFormat;var vShowRes=1;var vShowDur=1;var vShowComp=1;var vShowStartDate=1;var vShowEndDate=1;var vDateInputFormat="mm/dd/yyyy";var vDateDisplayFormat="mm/dd/yy";var vNumUnits=0;var vCaptionType;var vDepId=1;var vTaskList=new Array();var vFormatArr=new Array("day","week","month","quarter");var vQuarterArr=new Array(1,1,1,2,2,2,3,3,3,4,4,4);var vMonthDaysArr=new Array(31,28,31,30,31,30,31,31,30,31,30,31);var vMonthArr=new Array("January","February","March","April","May","June","July","August","September","October","November","December");this.setFormatArr=function(){vFormatArr=new Array();for(var i=0;i4){vFormatArr.length=4}};this.setShowRes=function(pShow){vShowRes=pShow};this.setShowDur=function(pShow){vShowDur=pShow};this.setShowComp=function(pShow){vShowComp=pShow};this.setShowStartDate=function(pShow){vShowStartDate=pShow};this.setShowEndDate=function(pShow){vShowEndDate=pShow};this.setDateInputFormat=function(pShow){vDateInputFormat=pShow};this.setDateDisplayFormat=function(pShow){vDateDisplayFormat=pShow};this.setCaptionType=function(pType){vCaptionType=pType};this.setFormat=function(pFormat){vFormat=pFormat;this.Draw()};this.getShowRes=function(){return vShowRes};this.getShowDur=function(){return vShowDur};this.getShowComp=function(){return vShowComp};this.getShowStartDate=function(){return vShowStartDate};this.getShowEndDate=function(){return vShowEndDate};this.getDateInputFormat=function(){return vDateInputFormat};this.getDateDisplayFormat=function(){return vDateDisplayFormat};this.getCaptionType=function(){return vCaptionType};this.CalcTaskXY=function(){var vList=this.getList();var vTaskDiv;var vParDiv;var vLeft,vTop,vHeight,vWidth;for(i=0;i0){JSGantt.processRows(vTaskList,0,-1,1,1);vMinDate=JSGantt.getMinDate(vTaskList,vFormat);vMaxDate=JSGantt.getMaxDate(vTaskList,vFormat);if(vFormat=='day'){vColWidth=18;vColUnit=1}else if(vFormat=='week'){vColWidth=37;vColUnit=7}else if(vFormat=='month'){vColWidth=37;vColUnit=30}else if(vFormat=='quarter'){vColWidth=60;vColUnit=90}else if(vFormat=='hour'){vColWidth=18;vColUnit=1}else if(vFormat=='minute'){vColWidth=18;vColUnit=1}vNumDays=(Date.parse(vMaxDate)-Date.parse(vMinDate))/(24*60*60*1000);vNumUnits=vNumDays/vColUnit;vChartWidth=vNumUnits*vColWidth+1;vDayWidth=(vColWidth/vColUnit)+(1/vColUnit);vMainTable=''+'';vMainTable+=vLeftTable;vRightTable='
';if(vShowRes!=1)vNameWidth+=vStatusWidth;if(vShowDur!=1)vNameWidth+=vStatusWidth;if(vShowComp!=1)vNameWidth+=vStatusWidth;if(vShowStartDate!=1)vNameWidth+=vStatusWidth;if(vShowEndDate!=1)vNameWidth+=vStatusWidth;vLeftTable='
'+''+' '+' ';if(vShowRes==1)vLeftTable+=' ';if(vShowDur==1)vLeftTable+=' ';if(vShowComp==1)vLeftTable+=' ';if(vShowStartDate==1)vLeftTable+=' ';if(vShowEndDate==1)vLeftTable+=' ';vLeftTable+=''+' '+' ';if(vShowRes==1)vLeftTable+=' ';if(vShowDur==1)vLeftTable+=' ';if(vShowComp==1)vLeftTable+=' ';if(vShowStartDate==1)vLeftTable+=' ';if(vShowEndDate==1)vLeftTable+=' ';vLeftTable+='';for(i=0;i';vLeftTable+=' '+' ';if(vShowRes==1)vLeftTable+=' ';if(vShowDur==1)vLeftTable+=' ';if(vShowComp==1)vLeftTable+=' ';if(vShowStartDate==1)vLeftTable+=' ';if(vShowEndDate==1)vLeftTable+=' ';vLeftTable+=''}vLeftTable+=''+'
ResourceDuration% Comp.Start DateEnd Date
 ';for(j=1;j';if(vTaskList[i].getGroup()){if(vTaskList[i].getOpen()==1)vLeftTable+=' ';else vLeftTable+='+ '}else{vLeftTable+='   '}vLeftTable+=' '+vTaskList[i].getName()+''+vTaskList[i].getResource()+''+vTaskList[i].getDuration(vFormat)+''+vTaskList[i].getCompStr()+''+JSGantt.formatDateStr(vTaskList[i].getStart(),vDateDisplayFormat)+''+JSGantt.formatDateStr(vTaskList[i].getEnd(),vDateDisplayFormat)+'
  Powered by jsGantt      Format:';if(vFormatArr.join().indexOf("minute")!=-1){if(vFormat=='minute')vLeftTable+='Minute';else vLeftTable+='Minute'}if(vFormatArr.join().indexOf("hour")!=-1){if(vFormat=='hour')vLeftTable+='Hour';else vLeftTable+='Hour'}if(vFormatArr.join().indexOf("day")!=-1){if(vFormat=='day')vLeftTable+='Day';else vLeftTable+='Day'}if(vFormatArr.join().indexOf("week")!=-1){if(vFormat=='week')vLeftTable+='Week';else vLeftTable+='Week'}if(vFormatArr.join().indexOf("month")!=-1){if(vFormat=='month')vLeftTable+='Month';else vLeftTable+='Month'}if(vFormatArr.join().indexOf("quarter")!=-1){if(vFormat=='quarter')vLeftTable+='Quarter';else vLeftTable+='Quarter'}vLeftTable+='
'+'
'+''+'';vTmpDate.setFullYear(vMinDate.getFullYear(),vMinDate.getMonth(),vMinDate.getDate());vTmpDate.setHours(0);vTmpDate.setMinutes(0);while(Date.parse(vTmpDate)<=Date.parse(vMaxDate)){vStr=vTmpDate.getFullYear()+'';vStr=vStr.substring(2,4);if(vFormat=='minute'){vRightTable+='';vTmpDate.setHours(vTmpDate.getHours()+1)}if(vFormat=='hour'){vRightTable+='';vTmpDate.setDate(vTmpDate.getDate()+1)}if(vFormat=='day'){vRightTable+='';vTmpDate.setDate(vTmpDate.getDate()+1)}else if(vFormat=='week'){vRightTable+='';vTmpDate.setDate(vTmpDate.getDate()+7)}else if(vFormat=='month'){vRightTable+='';vTmpDate.setDate(vTmpDate.getDate()+1);while(vTmpDate.getDate()>1){vTmpDate.setDate(vTmpDate.getDate()+1)}}else if(vFormat=='quarter'){vRightTable+='';vTmpDate.setDate(vTmpDate.getDate()+81);while(vTmpDate.getDate()>1){vTmpDate.setDate(vTmpDate.getDate()+1)}}}vRightTable+='';vTmpDate.setFullYear(vMinDate.getFullYear(),vMinDate.getMonth(),vMinDate.getDate());vNxtDate.setFullYear(vMinDate.getFullYear(),vMinDate.getMonth(),vMinDate.getDate());vNumCols=0;while(Date.parse(vTmpDate)<=Date.parse(vMaxDate)){if(vFormat=='minute'){if(vTmpDate.getMinutes()==0)vWeekdayColor="ccccff";else vWeekdayColor="ffffff";vDateRowStr+='';vItemRowStr+='';vTmpDate.setMinutes(vTmpDate.getMinutes()+1)}else if(vFormat=='hour'){if(vTmpDate.getHours()==0)vWeekdayColor="ccccff";else vWeekdayColor="ffffff";vDateRowStr+='';vItemRowStr+='';vTmpDate.setHours(vTmpDate.getHours()+1)}else if(vFormat=='day'){if(JSGantt.formatDateStr(vCurrDate,'mm/dd/yyyy')==JSGantt.formatDateStr(vTmpDate,'mm/dd/yyyy')){vWeekdayColor="ccccff";vWeekendColor="9999ff";vWeekdayGColor="bbbbff";vWeekendGColor="8888ff"}else{vWeekdayColor="ffffff";vWeekendColor="cfcfcf";vWeekdayGColor="f3f3f3";vWeekendGColor="c3c3c3"}if(vTmpDate.getDay()%6==0){vDateRowStr+='';vItemRowStr+=''}else{vDateRowStr+='';if(JSGantt.formatDateStr(vCurrDate,'mm/dd/yyyy')==JSGantt.formatDateStr(vTmpDate,'mm/dd/yyyy'))vItemRowStr+='';else vItemRowStr+=''}vTmpDate.setDate(vTmpDate.getDate()+1)}else if(vFormat=='week'){vNxtDate.setDate(vNxtDate.getDate()+7);if(vCurrDate>=vTmpDate&&vCurrDate
'+(vTmpDate.getMonth()+1)+'/'+vTmpDate.getDate()+'
';if(vCurrDate>=vTmpDate&&vCurrDate
  
';else vItemRowStr+='
'}else{vDateRowStr+='';if(vCurrDate>=vTmpDate&&vCurrDate
  
';else vItemRowStr+='
'}vTmpDate.setDate(vTmpDate.getDate()+7)}else if(vFormat=='month'){vNxtDate.setFullYear(vTmpDate.getFullYear(),vTmpDate.getMonth(),vMonthDaysArr[vTmpDate.getMonth()]);if(vCurrDate>=vTmpDate&&vCurrDate
'+vMonthArr[vTmpDate.getMonth()].substr(0,3)+'
';if(vCurrDate>=vTmpDate&&vCurrDate
  
';else vItemRowStr+='
'}else{vDateRowStr+='';if(vCurrDate>=vTmpDate&&vCurrDate
  
';else vItemRowStr+='
'}vTmpDate.setDate(vTmpDate.getDate()+1);while(vTmpDate.getDate()>1){vTmpDate.setDate(vTmpDate.getDate()+1)}}else if(vFormat=='quarter'){vNxtDate.setDate(vNxtDate.getDate()+122);if(vTmpDate.getMonth()==0||vTmpDate.getMonth()==1||vTmpDate.getMonth()==2)vNxtDate.setFullYear(vTmpDate.getFullYear(),2,31);else if(vTmpDate.getMonth()==3||vTmpDate.getMonth()==4||vTmpDate.getMonth()==5)vNxtDate.setFullYear(vTmpDate.getFullYear(),5,30);else if(vTmpDate.getMonth()==6||vTmpDate.getMonth()==7||vTmpDate.getMonth()==8)vNxtDate.setFullYear(vTmpDate.getFullYear(),8,30);else if(vTmpDate.getMonth()==9||vTmpDate.getMonth()==10||vTmpDate.getMonth()==11)vNxtDate.setFullYear(vTmpDate.getFullYear(),11,31);if(vCurrDate>=vTmpDate&&vCurrDate
Qtr. '+vQuarterArr[vTmpDate.getMonth()]+'
';if(vCurrDate>=vTmpDate&&vCurrDate
  
';else vItemRowStr+='
'}else{vDateRowStr+='';if(vCurrDate>=vTmpDate&&vCurrDate
  
';else vItemRowStr+='
'}vTmpDate.setDate(vTmpDate.getDate()+81);while(vTmpDate.getDate()>1){vTmpDate.setDate(vTmpDate.getDate()+1)}}}vRightTable+=vDateRowStr+'';vRightTable+='
';vRightTable+=JSGantt.formatDateStr(vTmpDate,vDateDisplayFormat)+' '+vTmpDate.getHours()+':00 -'+vTmpDate.getHours()+':59 ';vRightTable+=JSGantt.formatDateStr(vTmpDate,vDateDisplayFormat)+''+JSGantt.formatDateStr(vTmpDate,vDateDisplayFormat.substring(0,5))+' - ';vTmpDate.setDate(vTmpDate.getDate()+6);vRightTable+=JSGantt.formatDateStr(vTmpDate,vDateDisplayFormat)+'`'+vStr+'`'+vStr+'`'+vStr+'
'+vTmpDate.getMinutes()+'
  
'+vTmpDate.getHours()+'
  
'+vTmpDate.getDate()+'
 
'+vTmpDate.getDate()+'
  
  
  
'+(vTmpDate.getMonth()+1)+'/'+vTmpDate.getDate()+'
  
  
'+vMonthArr[vTmpDate.getMonth()].substr(0,3)+'
  
  
Qtr. '+vQuarterArr[vTmpDate.getMonth()]+'
  
';for(i=0;i
'}vMainTable+=vRightTable+'
';vDiv.innerHTML=vMainTable}};this.mouseOver=function(pObj,pID,pPos,pType){if(pPos=='right')vID='child_'+pID;else vID='childrow_'+pID;pObj.bgColor="#ffffaa";vRowObj=JSGantt.findObj(vID);if(vRowObj)vRowObj.bgColor="#ffffaa"};this.mouseOut=function(pObj,pID,pPos,pType){if(pPos=='right')vID='child_'+pID;else vID='childrow_'+pID;pObj.bgColor="#ffffff";vRowObj=JSGantt.findObj(vID);if(vRowObj){if(pType=="group"){pObj.bgColor="#f3f3f3";vRowObj.bgColor="#f3f3f3"}else{pObj.bgColor="#ffffff";vRowObj.bgColor="#ffffff"}}}};JSGantt.isIE=function(){if(typeof document.all!='undefined'){return true}else{return false}};JSGantt.processRows=function(pList,pID,pRow,pLevel,pOpen){var vMinDate=new Date();var vMaxDate=new Date();var vMinSet=0;var vMaxSet=0;var vList=pList;var vLevel=pLevel;var i=0;var vNumKid=0;var vCompSum=0;var vVisible=pOpen;for(i=0;ivMaxDate){vMaxDate=pList[i].getEnd();vMaxSet=1};vCompSum+=pList[i].getCompVal()}}if(pRow>=0){pList[pRow].setStart(vMinDate);pList[pRow].setEnd(vMaxDate);pList[pRow].setNumKid(vNumKid);pList[pRow].setCompVal(Math.ceil(vCompSum/vNumKid))}};JSGantt.getMinDate=function getMinDate(pList,pFormat){var vDate=new Date();vDate.setFullYear(pList[0].getStart().getFullYear(),pList[0].getStart().getMonth(),pList[0].getStart().getDate());for(i=0;i0){vDate.setDate(vDate.getDate()-1)}}else if(pFormat=='week'){vDate.setDate(vDate.getDate()-7);while(vDate.getDay()%7>0){vDate.setDate(vDate.getDate()-1)}}else if(pFormat=='month'){while(vDate.getDate()>1){vDate.setDate(vDate.getDate()-1)}}else if(pFormat=='quarter'){if(vDate.getMonth()==0||vDate.getMonth()==1||vDate.getMonth()==2){vDate.setFullYear(vDate.getFullYear(),0,1)}else if(vDate.getMonth()==3||vDate.getMonth()==4||vDate.getMonth()==5){vDate.setFullYear(vDate.getFullYear(),3,1)}else if(vDate.getMonth()==6||vDate.getMonth()==7||vDate.getMonth()==8){vDate.setFullYear(vDate.getFullYear(),6,1)}else if(vDate.getMonth()==9||vDate.getMonth()==10||vDate.getMonth()==11){vDate.setFullYear(vDate.getFullYear(),9,1)}};return(vDate)};JSGantt.getMaxDate=function(pList,pFormat){var vDate=new Date();vDate.setFullYear(pList[0].getEnd().getFullYear(),pList[0].getEnd().getMonth(),pList[0].getEnd().getDate());for(i=0;iDate.parse(vDate)){vDate.setTime(Date.parse(pList[i].getEnd()))}}if(pFormat=='minute'){vDate.setHours(vDate.getHours()+1);vDate.setMinutes(59)}if(pFormat=='hour'){vDate.setHours(vDate.getHours()+2)}if(pFormat=='day'){vDate.setDate(vDate.getDate()+1);while(vDate.getDay()%6>0){vDate.setDate(vDate.getDate()+1)}}if(pFormat=='week'){vDate.setDate(vDate.getDate()+11);while(vDate.getDay()%6>0){vDate.setDate(vDate.getDate()+1)}}if(pFormat=='month'){while(vDate.getDay()>1){vDate.setDate(vDate.getDate()+1)}vDate.setDate(vDate.getDate()-1)}if(pFormat=='quarter'){if(vDate.getMonth()==0||vDate.getMonth()==1||vDate.getMonth()==2)vDate.setFullYear(vDate.getFullYear(),2,31);else if(vDate.getMonth()==3||vDate.getMonth()==4||vDate.getMonth()==5)vDate.setFullYear(vDate.getFullYear(),5,30);else if(vDate.getMonth()==6||vDate.getMonth()==7||vDate.getMonth()==8)vDate.setFullYear(vDate.getFullYear(),8,30);else if(vDate.getMonth()==9||vDate.getMonth()==10||vDate.getMonth()==11)vDate.setFullYear(vDate.getFullYear(),11,31)}return(vDate)};JSGantt.findObj=function(theObj,theDoc){var p,i,foundObj;if(!theDoc){theDoc=document}if((p=theObj.indexOf("?"))>0&&parent.frames.length){theDoc=parent.frames[theObj.substring(p+1)].document;theObj=theObj.substring(0,p)}if(!(foundObj=theDoc[theObj])&&theDoc.all){foundObj=theDoc.all[theObj]}for(i=0;!foundObj&&i-1;try{xmlDoc=new ActiveXObject("Microsoft.XMLDOM")}catch(e){try{if(is_chrome==false){xmlDoc=document.implementation.createDocument("","",null)}}catch(e){alert(e.message);return}}if(is_chrome==false){xmlDoc.async=false;xmlDoc.load(ThisFile);JSGantt.AddXMLTask(pGanttVar);xmlDoc=null;Task=null}else{JSGantt.ChromeLoadXML(ThisFile,pGanttVar);ta=null}};JSGantt.AddXMLTask=function(pGanttVar){Task=xmlDoc.getElementsByTagName("task");var n=xmlDoc.documentElement.childNodes.length;for(var i=0;i/gi);var n=ta.length;for(var i=1;i/i);if(te.length>2){var pID=te[1]}else{var pID=0}pID*=1;var te=Task.split(//i);if(te.length>2){var pName=te[1]}else{var pName="No Task Name"}var te=Task.split(//i);if(te.length>2){var pStart=te[1]}else{var pStart=""}var te=Task.split(//i);if(te.length>2){var pEnd=te[1]}else{var pEnd=""}var te=Task.split(//i);if(te.length>2){var pColor=te[1]}else{var pColor='0000ff'}var te=Task.split(//i);if(te.length>2){var pLink=te[1]}else{var pLink=""}var te=Task.split(//i);if(te.length>2){var pMile=te[1]}else{var pMile=0}pMile*=1;var te=Task.split(//i);if(te.length>2){var pRes=te[1]}else{var pRes=""}var te=Task.split(//i);if(te.length>2){var pComp=te[1]}else{var pComp=0}pComp*=1;var te=Task.split(//i);if(te.length>2){var pGroup=te[1]}else{var pGroup=0}pGroup*=1;var te=Task.split(//i);if(te.length>2){var pParent=te[1]}else{var pParent=0}pParent*=1;var te=Task.split(//i);if(te.length>2){var pOpen=te[1]}else{var pOpen=1}pOpen*=1;var te=Task.split(//i);if(te.length>2){var pDepend=te[1]}else{var pDepend=""}if(pDepend.length==0){pDepend=''}var te=Task.split(//i);if(te.length>2){var pCaption=te[1]}else{var pCaption=""}pGanttVar.AddTaskItem(new JSGantt.TaskItem(pID,pName,pStart,pEnd,pColor,pLink,pMile,pRes,pComp,pGroup,pParent,pOpen,pDepend,pCaption))}}};JSGantt.benchMark=function(pItem){var vEndTime=new Date().getTime();alert(pItem+': Elapsed time: '+((vEndTime-vBenchTime)/1000)+' seconds.');vBenchTime=new Date().getTime()};tracjsganttplugin/0.11/tracjsgantt/htdocs/jsgantt.css0000644000175500017550000000646511546640173022750 0ustar debacledebacle /* * These are the class/styles used by various objects in GanttChart. * However, Firefox has problems deciphering class style when DIVs are * embedded in other DIVs. GanttChart makes heavy use of embedded * DIVS, thus the style are often embedded directly in the objects html. * If this could be resolved with Firefox, it would make a lot of the * code look simpler/cleaner without all the embedded styles */ .gantt { font-family:tahoma, arial, verdana; font-size:10px;} .gdatehead { BORDER-TOP: #efefef 1px solid; FONT-SIZE: 12px; BORDER-LEFT: #efefef 1px solid; HEIGHT: 18px } .ghead { BORDER-TOP: #efefef 1px solid; FONT-SIZE: 12px; BORDER-LEFT: #efefef 1px solid; WIDTH: 24px; HEIGHT: 20px } .gname { BORDER-TOP: #efefef 1px solid; FONT-SIZE: 12px; WIDTH: 18px; HEIGHT: 18px } .ghead A { FONT-SIZE: 10px; COLOR: #000000; TEXT-DECORATION: none } .gheadwkend A { FONT-SIZE: 10px; COLOR: #000000; TEXT-DECORATION: none } .gheadwkend { BORDER-TOP: #efefef 1px solid; FONT-SIZE: 12px; BORDER-LEFT: #efefef 1px solid; WIDTH: 24px; HEIGHT: 20px; background-color: #cfcfcf } .gfiller { BORDER-TOP: #efefef 1px solid; BORDER-LEFT: #efefef 1px solid; WIDTH: 18px; HEIGHT: 18px } .gfillerwkend { BORDER-LEFT: #efefef 1px solid; WIDTH: 18px; HEIGHT: 18px; BACKGROUND-COLOR: #cfcfcf } .gitem { BORDER-TOP: #cccccc 1px solid; WIDTH: 18px; HEIGHT: 18px } .gitemwkend { BORDER-TOP: #cccccc 1px solid; BORDER-LEFT: #cccccc 1px solid; WIDTH: 18px; HEIGHT: 18px } .gmilestone { BORDER-TOP: #efefef 1px solid; FONT-SIZE: 14px; OVERFLOW: hidden; BORDER-LEFT: #efefef 1px solid; WIDTH: 18px; HEIGHT: 18px} .gmilestonewkend { BORDER-TOP: #efefef 1px solid; BORDER-LEFT: #cccccc 1px solid; WIDTH: 18px; HEIGHT: 18px} .btn { BORDER-RIGHT: #ffffff; BORDER-TOP: #ffffff; FONT-WEIGHT: bold; FONT-SIZE: 10px; BORDER-LEFT: #ffffff; WIDTH: 12px; COLOR: #cccccc; BORDER-BOTTOM: #ffffff; BACKGROUND-COLOR: #ffffff } .hrcomplete { BORDER-RIGHT: #000000 2px solid; PADDING-RIGHT: 0px; BORDER-TOP: #000000 2px solid; PADDING-LEFT: 0px; PADDING-BOTTOM: 0px; BORDER-LEFT: #000000 2px solid; WIDTH: 20px; COLOR: #000000; PADDING-TOP: 0px; BORDER-BOTTOM: #000000 2px solid; HEIGHT: 4px } .hrhalfcomplete { BORDER-RIGHT: #000000 2px solid; BORDER-TOP: #000000 2px solid; BORDER-LEFT: #000000 2px solid; WIDTH: 9px; COLOR: #000000; BORDER-BOTTOM: #000000 2px solid; HEIGHT: 4px } .gweekend { font-family:tahoma, arial, verdana; font-size:11px; background-color:#EEEEEE; text-align:center; } .gtask { font-family:tahoma, arial, verdana; font-size:11px; background-color:#00FF00; text-align:center; } .gday { font-family:tahoma, arial, verdana; font-size:11px; text-align:center; } .gcomplete { background-color:black; height:5px; overflow: auto; margin-top:4px; } DIV.scroll { BORDER-RIGHT: #efefef 1px solid; PADDING-RIGHT: 0px; BORDER-TOP: #efefef 1px solid; PADDING-LEFT: 0px; PADDING-BOTTOM: 0px; OVERFLOW: hidden; BORDER-LEFT: #efefef 1px solid; PADDING-TOP: 0px; BORDER-BOTTOM: #efefef 1px solid; BACKGROUND-COLOR: #ffffff; float: left; } DIV.scroll2 { position:relative; PADDING-RIGHT: 0px; overflow-x:scroll; overflow-y:hidden; PADDING-LEFT: 0px; PADDING-BOTTOM: 0px; PADDING-TOP: 0px; BACKGROUND-COLOR: #ffffff } tracjsganttplugin/0.11/tracjsgantt/htdocs/tracjsgantt.css0000644000175500017550000000213011620555553023603 0ustar debacledebacle/* * These styles are adapted from the standard report.css in Trac. * The border-color is a medium-dark gray so that tasks are more visible. */ .ticket-closed { text-decoration: line-through; } .ticket-class1 { background-color: #fdc; } div.ticket-class1 { border: 1px; border-color: #949494; border-style: solid; color: #a22; } .ticket-class2 { background-color: #ffb; } div.ticket-class2 { border: 1px; border-color: #949494; border-style: solid; color: #880; } .ticket-class3 { background-color: #fbfbfb; } div.ticket-class3 { border: 1px; border-color: #949494; border-style: solid; color: #444; } .ticket-class4 { background-color: #e7ffff; } div.ticket-class4 { border: 1px; border-color: #949494; border-style: solid; color: #099; } .ticket-class5 { background-color: #e7eeff; } div.ticket-class5 { border: 1px; border-color: #949494; border-style: solid; color: #469; } .ticket-class6 { background-color: #f0f0f0; } div.ticket-class6 { border: 1px; border-color: #949494; border-style: solid; color: #888; } tracjsganttplugin/0.11/tracjsgantt/db_default.py0000644000175500017550000000203712364006341021722 0ustar debacledebacle# -*- coding: utf-8 -*- # # Copyright (C) 2010-2014 Chris Nelson # All rights reserved. # # This software is licensed as described in the file COPYING, which # you should have received as part of this distribution. from trac.db import Table, Column, Index # The TracPM environment name = 'TracPM' # Version 1 is the current schedule and history version = 1 # The schedule table holds the current calculated start and finish for # each ticket tables = [ Table('schedule', key=('ticket')) [ Column('ticket', type='int'), Column('start', type='int64'), Column('finish', type='int64'), Index(['ticket']), ], Table('schedule_change', key=('ticket', 'time')) [ Column('ticket', type='int'), Column('time', type='int64'), Column('oldstart', type='int64'), Column('oldfinish', type='int64'), Column('newstart', type='int64'), Column('newfinish', type='int64'), Index(['ticket']), Index(['time']), ], ] tracjsganttplugin/0.11/COPYING0000644000175500017550000000263612364006341015773 0ustar debacledebacleCopyright (C) 2010-2014 Chris Nelson All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 3. The name of the author may not be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. tracjsganttplugin/0.11/setup.cfg0000644000175500017550000000006011607677530016563 0ustar debacledebacle[egg_info] tag_build = tag_svn_revision = true tracjsganttplugin/0.11/setup.py0000644000175500017550000000142012370671555016454 0ustar debacledebacle#!/usr/bin/env python # -*- coding: utf-8 -*- # # Copyright (C) 2010-2014 Chris Nelson # All rights reserved. # # This software is licensed as described in the file COPYING, which # you should have received as part of this distribution. from setuptools import setup setup( name = 'Trac-jsGantt', author = 'Chris Nelson', author_email = 'Chris.Nelson@SIXNET.com', description = 'Trac plugin displaying jsGantt charts in Trac', version = '0.11', url = 'http://trac-hacks.org/wiki/TracJsGanttPlugin', license='3-Clause BSD', packages=['tracjsgantt'], package_data = { 'tracjsgantt': ['htdocs/*.js', 'htdocs/*.css'] }, entry_points = { 'trac.plugins': [ 'tracjsgantt = tracjsgantt' ] } )