#                Perforce Defect Tracking Integration Project
#                 <http://www.ravenbrook.com/project/p4dti/>
#
#          DT_TEAMTRACK.PY -- DEFECT TRACKING INTERFACE (TEAMTRACK)
#
#                 Gareth Rees, Ravenbrook Limited, 2000-09-07
#
#
# 1. INTRODUCTION
#
# This Python module implements an interface between the P4DTI replicator and
# the TeamTrack 4 defect tracker [Requirements, 6], by defining the classes
# listed in [GDR 2000-10-16, 7].  In particular, it defines the following
# classes:
#
# [3] teamtrack_case(dt_interface.defect_tracker_issue) [GDR 2000-10-16, 7.2]
#
# [4] teamtrack_fix(dt_interface.defect_tracker_fix) [GDR 2000-10-16, 7.3]
#
# [5] teamtrack_filespec(dt_interface.defect_tracker_filespec) [GDR 2000-10-16,
# 7.4].
#
# [6] dt_teamtrack(dt_interface.defect_tracker) [GDR 2000-10-16, 7.1]
#
# [7] Translators [GDR 2000-10-16, 7.5] for dates [GDR 2000-10-16, 7.5.1],
# elapsed times, foreign keys, single select fields, states [GDR 2000-10-16,
# 7.5.2], multi-line text fields [GDR 2000-10-16, 7.5.3] and users [GDR
# 2000-10-16, 7.5.4].
#
# This module accesses the TeamTrack database using the Python interface to
# TeamTrack [GDR 2000-08-08] and it accesses and stores data according to the
# TeamTrack database schema [TeamShare 2000-01-20] and the TeamTrack schema
# extensions [GDR 2000-09-04].
#
# The intended readership of this document is project developers.
#
# This document is not confidential.

import catalog
import dt_interface
import replicator
import re
import socket
import string
import teamtrack
import time
import translator
import types


# 2. DATA AND UTILITIES


# 2.1. Error object
#
# All exceptions raised by this module use 'error' as the exception object.

error = 'P4DTI TeamTrack interface error'


# 2.2. Field types in the VCACTIONS table
#
# The TeamTrack integration stores all its relations in the TS_VCACTIONS table,
# distinguishing them by the value in the TS_TYPE field [GDR 2000-09-04, 2.1].
# these variables are the types for filespecs, fixes, changelist descriptions,
# and replicator configuration records respectively.

vcactions_type_filespec = 1
vcactions_type_fix = 2
vcactions_type_changelist = 3
vcactions_type_config = 4


# 2.3. Escape quotes in a string for SQL
#
# sql_escape(string).  Return the input string, escaped for inclusion in a SQL
# query by doubling single quotes.  This works for Microsoft Access, but I'm
# not sure that it's correct ANSI SQL.  See job000031.

def sql_escape(s):
    return string.replace(s, "'", "''")


# 2.4. String representation (workaround for job000223)
#
# string_repr_workaround(string) returns a representation of its argument, just
# like the build in Python function repr(), but it always surround the argument
# with single quotes, and precedes both single quotes and double quotes with
# backslashes (despite the fact that in Python double quotes need not be
# backslashed in a string surrounded by single quotes).
#
# The reason for using this function rather than repr() is because of a bug in
# TeamTrack 4.5 (see job000223).  The TeamTrack schema extensions store
# multiple values in a single field by storing the Python representation of a
# dictionary in the field; see [GDR 2000-09-04, 2.2].  TeamTrack 4.5 doesn't
# implement Python's string syntax correctly: it assumes that strings are
# surrounded with single quotes, and it assumes that an unescaped double quote
# terminates a string.
#
# So this function, and dict_repr_workaround(), work around that bug.  If
# TeamShare later fixes the bug then this workaround can be dropped, and repr()
# used instead.
#
# The code is based directly on Python's string_repr() function.

def string_repr_workaround(s):
    assert isinstance(s, types.StringType)
    result = "'"
    for c in s:
        if c in ['"', "'", '\\']:
            result = result + '\\' + c
        elif c < ' ' or c >= '\177':
            result = result + ('\\%03o' % (ord(c) & 0377))
        else:
            result = result + c
    return result + "'"


# 2.5. Dictionary representation (workaround for job000223)
#
# dict_repr_workaround(dict) returns a string representation of its argument
# for storage in a field in the TeamTrack database [GDR 2000-09-04, 2.2].  Its
# result is just like that of the built in Python function repr(), but it uses
# string_repr_workaround() for string keys and values [2.4].  It also checks
# that all keys are strings and values are strings or integers, as promised in
# [GDR 2001-09-04, 2.2].

def dict_repr_workaround(d):
    assert isinstance(d, types.DictType)
    result = '{'
    for k, v in d.items():
        assert isinstance(k, types.StringType)
        result = result + string_repr_workaround(k) + ': '
        if isinstance(v, types.IntType):
            result = result + repr(v)
        elif isinstance(v, types.StringType):
            result = result + string_repr_workaround(v)
        else:
            assert(0)
        result = result + ', '
    return result[0:-2] + '}'


# 2.6. Cursors
#
# Instances of these cursor classes behave like database cursors: you
# can repeatedly call the fetchone() method to get one record from the
# result set; finally the query returns None.
#
# The TeamShare API provides no real cursor interface so this emulates
# cursors by making repeated queries that each return a single result
# (the next result from the original query).  Because these are not real
# cursors and the TeamShare API provides no locking or transactions, the
# results you get may be inconsistent if the database changes in the
# course of getting the results.
#
# These classes helps to work around job000277 and job000278.
#
# See [GDR 2000-10-16, 7.6] for the specification of a cursor.


# 2.6.1. Generic cursor
#
# This cursor can handle any query to any table.  See [GDR 2001-05-16]
# for the analysis that led to this implementation.
#
# To create an instance of this cursor, pass the following arguments to
# the constructor:
#
# dt:           The defect tracker instance.
# table_name:   The table you're querying, for example 'CASES'.
# query:        The SQL query you want, for example 'TS_TYPE=5'.  If
#               query is None, then the cursor will fetch no records.
# wrapper:      A function or class with which to wrap the returned
#               results, for example teamtrack_case.  None means don't
#               wrap.  Defaults to None.
# wrapper_args: Additional arguments to pass to the wrapper function.

class cursor:
    chunk_size = 20

    def __init__(self, dt, table_name, query, wrapper = None,
                 wrapper_args = ()):
        assert isinstance(dt, dt_teamtrack)
        assert isinstance(table_name, types.StringType)
        assert query == None or isinstance(query, types.StringType)
        self.cache = []
        self.dt = dt
        self.finished = (query == None)
        self.last_id = -1
        self.query = query
        self.table_id = teamtrack.table[table_name]
        if table_name == 'CASES':
            self.table_name = dt.server.case_table_name()
        else:
            self.table_name = 'TS_' + table_name
        self.wrapper = wrapper
        self.wrapper_args = wrapper_args
        if query == '':
            self.where_1 = ''
            self.where_2 = ''
        else:
            self.where_1 = '(%s) AND ' % self.query
            self.where_2 = ' AND (%s)' % self.query
        if query:
            # "TeamTrack query: SELECT * FROM %s WHERE %s."
            dt.log(631, (self.table_name, query))
        elif query == '':
            # "TeamTrack query: SELECT * FROM %s."
            dt.log(632, self.table_name)

    def fetchone(self):
        if self.finished:
            return None
        if not self.cache:
            query = ("%sTS_ID BETWEEN (SELECT MIN(TS_ID) FROM %s "
                     "WHERE TS_ID > %d%s) AND (SELECT MIN(TS_ID)+%d "
                     "FROM %s WHERE TS_ID > %d%s) ORDER BY TS_ID"
                     % (self.where_1, self.table_name, self.last_id,
                        self.where_2, self.chunk_size - 1,
                        self.table_name, self.last_id, self.where_2))
            self.cache = self.dt.server.query(self.table_id, query)
            if self.cache:
                self.last_id = max(self.cache[-1]['ID'],
                                   self.last_id + self.chunk_size)
            else:
                self.finished = 1
                return None
        result = self.cache[0]
        self.cache = self.cache[1:]
        if self.wrapper:
            return apply(self.wrapper, (result,) + self.wrapper_args)
        else:
            return result


# 2.6.2. Cases cursor
#
# This is a cursor that returns cases from a list of cases.
#
# To create an instance of this cursor, pass the following argumntes to
# the constructor:
#
# dt:    The defect tracker instance.
# cases: A list of case ids to query.
# query: Query that the returned cases must match.

class cases_cursor:
    chunk_size = 20

    def __init__(self, dt, cases, query):
        assert isinstance(dt, dt_teamtrack)
        assert isinstance(cases, types.ListType)
        self.cache = []
        self.cases = cases
        self.dt = dt
        self.finished = 0
        self.query = query
        self.table_id = dt.server.case_table_id()
        if query:
            self.where = ' AND (%s)' % query
        else:
            self.where = ''
        if cases:
            q = ('TS_ID IN (%s)%s' % (str(self.cases)[1:-1], self.where))
            # "TeamTrack query: SELECT * FROM %s WHERE %s."
            self.dt.log(631, ('TS_CASES', q))

    def fetchone(self):
        if not self.cache:
            if self.cases:
                n = min(self.chunk_size, len(self.cases))
                cases = self.cases[0:n]
                self.cases = self.cases[n:]
                query = ("TS_ID IN (%s)%s"
                         % (str(cases)[1:-1], self.where))
                self.cache = self.dt.server.query(self.table_id, query)
        if self.cache:
            result = self.cache[0]
            self.cache = self.cache[1:]
            return teamtrack_case(result, self.dt)
        else:
            return None


# 3. TEAMTRACK CASE INTERFACE
#
# This class implements the replicator's interface to the cases in TeamTrack
# [GDR 2000-10-16, 7.2].

class teamtrack_case(dt_interface.defect_tracker_issue):
    dt = None # The defect tracker this case belongs to.
    case = None # The teamtrack_record object representing the case.

    def __init__(self, case, dt):
        self.case = case
        self.dt = dt

    def __getitem__(self, key):
        assert isinstance(key, types.StringType)
        return self.case[key]

    def __repr__(self):
        return repr(self.case)

    def __setitem__(self, key, value):
        assert isinstance(key, types.StringType)
        self.case[key] = value

    def add_filespec(self, filespec):
        filespec_record = self.dt.server.new_record(teamtrack.table['VCACTIONS'])
        filespec_record['TYPE'] = vcactions_type_filespec
        filespec_record['CHAR2'] = self.dt.sid
        filespec_record['CHAR1'] = self.rid()
        filespec_record['TABLEID'] = self.dt.server.case_table_id()
        filespec_record['RECID'] = self.case['ID']
        filespec_record['TIME1'] = 0
        filespec_record['TIME2'] = 0
        filespec_record['FILENAME'] = dict_repr_workaround({'filespec': filespec})
        filespec_record.add()

    def add_fix(self, change, client, date, status, user):
        fix_record = self.dt.server.new_record(teamtrack.table['VCACTIONS'])
        fix_record['TYPE'] = vcactions_type_fix
        fix_record['CHAR2'] = self.dt.sid
        fix_record['CHAR1'] = self.rid()
        fix_record['TABLEID'] = self.dt.server.case_table_id()
        fix_record['RECID'] = self.case['ID']
        fix_record['TIME2'] = 0
        fix = teamtrack_fix(fix_record, self)
        fix.transform_from_p4(change, client, date, status, user)
        fix.add()

    def corresponding_id(self):
        if self['P4DTI_JOBNAME']:
            return self['P4DTI_JOBNAME']
        else:
            return self.readable_name()

    def filespecs(self):
        query = ("TS_TYPE=%d AND TS_RECID=%s"
                 % (vcactions_type_filespec, self.id()))
        return map(lambda f, s=self: teamtrack_filespec(f, s),
                   self.dt.query('VCACTIONS', query))

    # find_transition(old_state, new_state).  Given an issue and the old and
    # new states, find a transition that corresponds to this state change, or
    # return None if there is no such transition.
    def find_transition(self, old_state, new_state):
        assert isinstance(old_state, types.IntType)
        assert isinstance(new_state, types.IntType)
        project = self['PROJECTID']
        ss = (old_state, new_state)
        if not (self.dt.project_to_states_to_transition.has_key(project)
            and self.dt.project_to_states_to_transition[project].has_key(ss)):
            # The transitions may have changed since we last looked in the
            # database, so refresh our cache.
            self.dt.read_transitions()
        if (self.dt.project_to_states_to_transition.has_key(project)
            and self.dt.project_to_states_to_transition[project].has_key(ss)):
            return self.dt.project_to_states_to_transition[project][ss]
        else:
            # No appropriate transition found.
            return None

    def fixes(self):
        query = ("TS_TYPE=%d AND TS_RECID=%s"
                 % (vcactions_type_fix, self.id()))
        return map(lambda f, s=self: teamtrack_fix(f, s),
                   self.dt.query('VCACTIONS', query))

    def id(self):
        return str(self['ID'])

    def readable_name(self):
        if self.dt.type_id_to_prefix.has_key(self['ISSUETYPE']):
            return ('%s%s'
                    % (self.dt.type_id_to_prefix[self['ISSUETYPE']],
                       self['ISSUEID']))
        else:
            return '%s' % (self['ISSUEID'],)

    def rid(self):
        return self['P4DTI_RID']

    def setup_for_replication(self, jobname):
        self['P4DTI_RID'] = self.dt.rid
        self['P4DTI_SID'] = self.dt.sid
        self['P4DTI_JOBNAME'] = jobname
        self.case.update()

    def update(self, user, changes = {}):
        assert isinstance(user, types.IntType)
        assert isinstance(changes, types.DictType)

        # Work out a transition based on the old case state and the new case
        # state, if the state changed.  Otherwise, update the case by using
        # transition 0 (this is a secret feature of the transition function -
        # see John McGinley's e-mail).
        transition = 0
        if changes.has_key('STATE') and changes['STATE'] != self['STATE']:
            transition = self.find_transition(self['STATE'], changes['STATE'])
            if not transition:
                # "No transition from state '%s' to state '%s'."
                raise error, catalog.msg(614, (self.dt.state_id_to_name[self['STATE']], self.dt.state_id_to_name[changes['STATE']]))
        for key, value in changes.items():
            self[key] = value
        user = self.dt.user_id_to_name[user]
        if transition:
            # "-- Transition: %d; User: %s."
            self.dt.log(600, (transition, user))
        self.case.transition(user, transition)


# 4. TEAMTRACK FIX INTERFACE
#
# This class implements the replicator's interface to a fix record in TeamTrack
# [GDR 2000-10-16, 7.3].

class teamtrack_fix(dt_interface.defect_tracker_fix):
    case = None # The TeamTrack case to which the fix refers.
    fix = None # The teamtrack_record object representing the fix record.
    data = { 'status': '', 'client': '' } # The data that goes in the
					  # TS_FILENAME field.

    def __init__(self, fix, case):
        assert isinstance(case, teamtrack_case)
        self.fix = fix
        self.case = case
        if fix['FILENAME']:
            self.data = eval(fix['FILENAME'])

    def __getitem__(self, key):
        return self.fix[key]

    def __repr__(self):
        return repr(self.fix)

    def __setitem__(self, key, value):
        assert isinstance(key, types.StringType)
        self.fix[key] = value
        if key == 'FILENAME':
            self.data = eval(value)

    def add(self):
        self.fix.add()

    def change(self):
        return self['INFO1']

    def delete(self):
        self.case.dt.server.delete_record(teamtrack.table['VCACTIONS'],
                                          self.fix['ID'])

    def status(self):
        return self.data['status']

    def transform_from_p4(self, change, client, date, status, user):
        assert isinstance(change, types.IntType)
        assert isinstance(client, types.StringType)
        assert isinstance(date, types.IntType)
        assert isinstance(status, types.StringType)
        assert isinstance(user, types.IntType)
        self['INFO1'] = change
        self['TIME1'] = date
        self['AUTHOR1'] = user
        self['FILENAME'] = dict_repr_workaround({ 'status': status,
                                                  'client': client })

    def update(self, change, client, date, status, user):
        self.transform_from_p4(change, client, date, status, user)
        self.fix.update()



# 5. TEAMTRACK FILESPEC INTERFACE
#
# This class implements the replicator's interface to a filespec record in
# TeamTrack [GDR 2000-10-16, 7.4].

class teamtrack_filespec(dt_interface.defect_tracker_filespec):
    case = None # The TeamTrack case to which the filespec refers.
    filespec = None # The teamtrack_record object representing the filespec.
    data = { 'filespec': '' } # The data that goes in the TS_FILENAME field.

    def __init__(self, filespec, case):
        self.filespec = filespec
        self.case = case
        if self['FILENAME']:
            self.data = eval(self['FILENAME'])

    def __getitem__(self, key):
        assert isinstance(key, types.StringType)
        return self.filespec[key]

    def __setitem__(self, key, value):
        assert isinstance(key, types.StringType)
        self.filespec[key] = value
        if key == 'FILENAME':
            data = eval(value)

    def delete(self):
        self.case.dt.server.delete_record(teamtrack.table['VCACTIONS'],
                                          self.filespec['ID'])

    def name(self):
        return self.data['filespec']


# 6. TEAMTRACK INTERFACE
#
# This class implements the replicator's interface to TeamTrack [GDR
# 2000-10-16, 7.1].

class dt_teamtrack(dt_interface.defect_tracker):
    # TeamTrack server connection.
    server = None

    # Userid on the TeamTrack server
    userid = None

    # Replicator identifier.
    rid = None

    # Perforce server identifier.
    sid = None

    # A map from auxiliary table ID to the ID of the entities in that table to
    # the name for that entity.  For example, if entity 7 in the PROJECTS table
    # (table 8) has the name "Image Builder", then table_to_id_to_name[8][7] ==
    # "Image Builder".
    table_to_id_to_name = None

    # A map from auxiliary table ID to the ID of the entities in that table to
    # the name for that entity.  For example, if entity 7 in the PROJECTS table
    # (table 8) has the name "Image Builder", then
    # table_to_name_to_id[8]["Image Builder"] == 7.
    table_to_name_to_id = None

    # A map from field name to selection name to the id for that selection.
    # For example, if selection called "Foo" in field "PROJECTS" has id 7, then
    # field_to_selection_to_id['PROJECTS']['Foo'] == 7.
    field_to_selection_to_id = None

    # A map from TeamTrack project to a map from lowercased state name to state
    # id.  So for example, if project 7 has a state called "Open" with id 25,
    # then project_to_name_to_state[7]['open'] == 25.  The reason why the state
    # names are lowercase here is because they are lowercase in Perforce for
    # usability, to avoid having two states which differ only in case.
    project_to_name_to_state = None

    # A map from project and a pair of states to a transition that is available
    # to cases in that project and which transitions between the states.  For
    # example, if transition 27 is available in project 5 and transitions
    # between state 6 and state 7, then
    # project_to_states_to_transition[5][(6,7)] == 27.
    project_to_states_to_transition = None

    # A map from selection id to name.
    selection_id_to_name = None

    # A map from TeamTrack state id to state name.
    state_id_to_name = None

    # A map from the type of an issue to the prefix for that type (e.g., "BUG",
    # "ENH").
    type_id_to_prefix = None

    # A map from TeamTrack user id (the ID field in the USERS table) to their
    # user name.
    user_id_to_name = None

    # A map from TeamTrack user name to their user id.
    user_name_to_id = None

    # A map from TeamTrack user id to their e-mail address.
    user_id_to_email = None

    # A map from TeamTrack user's e-mail address to their user id.
    user_email_to_id = None

    # These members are flags that indicate whether data has been read
    # from TeamTrack and cached.  The flags are cleared at the start of
    # each poll (in changed_entities).  The idea is to ensure that the
    # caches are filled no more than once per poll (see job000148).
    cached_auxiliary_table = None
    cached_transitions = 0
    cached_selections = 0
    cached_states = 0
    cached_types = 0
    cached_users = 0

    def __init__(self, config):
        # Initialize the caches (see the rule code/python/instance).
        self.table_to_id_to_name = { }
        self.table_to_name_to_id = { }
        self.field_to_selection_to_id = { }
        self.project_to_name_to_state = { }
        self.project_to_states_to_transition = { }
        self.selection_id_to_name = { }
        self.state_id_to_name = { }
        self.type_id_to_prefix = { }
        self.user_id_to_name = { }
        self.user_name_to_id = { }
        self.user_id_to_email = { }
        self.user_email_to_id = { }
        self.cached_auxiliary_table = { }

        # Store configuration, connect and initialize.
        self.config = config
        self.rid = config.rid
        self.sid = config.sid
        if not self.config.teamtrack_user:
            self.config.teamtrack_user = 'P4DTI-%s' % self.rid
        self.server = teamtrack.connect(self.config.teamtrack_user,
                                        self.config.teamtrack_password,
                                        self.config.teamtrack_server)

        self.read_types()
        self.read_users()

        # Get the userid corresponding to the replicator's userid.  This will
        # be used in queries to ignore records changed most recently by the
        # replicator.
        user = self.query('USERS', "TS_LOGINID = '%s'"
                          % sql_escape(self.config.teamtrack_user))
        if len(user) != 1:
            # "No login id in TeamTrack's USERS table corresponding to
            # replicator's login id '%s'."
            raise error, catalog.msg(615, self.config.teamtrack_user)
        self.userid = user[0]['ID']

    def query(self, table_name, query):
        if query:
            # "TeamTrack query: SELECT * FROM %s WHERE %s."
            self.log(631, (table_name, query))
        elif query == '':
            # "TeamTrack query: SELECT * FROM %s."
            self.log(632, table_name)
        return self.server.query(teamtrack.table[table_name], query)

    def all_issues(self):
        query = ("TS_P4DTI_RID='%s' OR ((TS_P4DTI_RID='' OR "
                 "TS_P4DTI_RID IS NULL) AND TS_LASTMODIFIEDDATE>=%d)"
                 % (self.rid, self.config.start_date))
        return cursor(self, 'CASES', query, teamtrack_case, (self,))

    def changed_entities(self):
        # Reset the cache flags at the start of this poll, so that
        # caches may be re-read.  The idea is to make sure that tables
        # only get read once per poll even if there are many misses.
        # See job000148.
        self.cached_auxiliary_table = { }
        self.cached_transitions = 0
        self.cached_selections = 0
        self.cached_states = 0
        self.cached_types = 0
        self.cached_users = 0

        # Get the last change record that was dealt with.
        query = ("TS_TYPE=%d AND TS_CHAR1='%s' AND TS_CHAR2='LAST_CHANGE'"
                 % (vcactions_type_config, self.rid))
        last_change = self.query('VCACTIONS', query)
        if not last_change:
            # "No LAST_CHANGE record for this replicator."
            raise error, catalog.msg(616)

        # Get the list of changes to cases that haven't been dealt with yet.
        # Ignore changes made by the replicator.  See job000033 and job000233.
        last_change_id = last_change[0]['INFO1']
        query = ("TS_TABLEID = %d AND TS_ID > %d AND TS_REALUSERID <> %d "
                 "AND TS_USERID <> %d"
                 % (self.server.case_table_id(), last_change_id,
                    self.userid, self.userid))
        changes_cursor = cursor(self, 'CHANGES', query)

        # Work out the set of changed cases (since a changed case may appear
        # several times in the CHANGES table but we don't want to replicate it
        # more than once) and the last change id.
        case_id_found = {}
        while 1:
            c = changes_cursor.fetchone()
            if c == None:
                break
            case_id_found[c['CASEID']] = 1
            if c['ID'] > last_change_id:
                last_change_id = c['ID']

        # Get a cursor that will fetch the changed cases.  The IS NULL
        # condition is there because the TeamShare API doesn't reliably
        # set a NULL field to the empty string when you assign the empty
        # string to the field and update the record.  See e-mail to
        # Larry Fish, 2000-09-19.
        query = ("TS_P4DTI_RID='%s' OR TS_P4DTI_RID='' OR "
                 "TS_P4DTI_RID IS NULL" % (self.rid))
        case_ids = case_id_found.keys()
        case_ids.sort()
        changed_cases_cursor = cases_cursor(self, case_ids, query)

        # Make a marker that can passed to mark_changes_done to record
        # that these changes have been replicated.
        if last_change[0]['INFO1'] != last_change_id:
            last_change[0]['INFO1'] = last_change_id
            marker = last_change[0]
        else:
            marker = None

        # Note that there are no changed changelists.
        return changed_cases_cursor, [], last_change[0]

    def mark_changes_done(self, last_change):
        if last_change:
            last_change.update()

    def init(self):
        # Check that the TeamTrack database version is supported.
        supported_dbver = 27
        system_info = self.server.read_record(teamtrack.table['SYSTEMINFO'], 1)
        if system_info['DBVER'] < supported_dbver:
            # "TeamTrack database version %d is not supported by the P4DTI.
            # The minimum supported version is %d."
            raise error, catalog.msg(617, (system_info['DBVER'], supported_dbver))

        # Fields to add to the TS_CASES table.
        new_fields = [
            { 'name': 'P4DTI_RID',
              'type': teamtrack.field_type['TEXT'],
              'length': 32,
              'attributes': 1,          # Fixed-width text.
              'description': "P4DTI replicator identifier",
              'value': '' },
            { 'name': 'P4DTI_SID',
              'type': teamtrack.field_type['TEXT'],
              'length': 32,
              'attributes': 1,          # Fixed-width text.
              'description': "P4DTI Perforce server identifier",
              'value': '' },
            { 'name': 'P4DTI_JOBNAME',
              'type': teamtrack.field_type['TEXT'],
              'length': 0,              # Arbitrarily long.
              'attributes': 0,          # "Memo" = variable-width.
              'description': "P4DTI Perforce jobname",
              'value': '' },
            ]

        # Make a TS_CASES record so we can see if the new fields are already
        # present.
        case = self.server.new_record(self.server.case_table_id())

        # Add each new field if not present.
        added_fields = []
        for new_field in new_fields:
            if not case.has_key(new_field['name']):
                # "Installing field '%s' in the TS_CASES table."
                self.log(601, new_field['name'])
                f = self.server.new_record(teamtrack.table['FIELDS'])
                f['TABLEID']     = self.server.case_table_id()
                f['NAME']        = new_field['description']
                f['DBNAME']      = new_field['name']
                f['FLDTYPE']     = new_field['type']
                f['LEN']         = new_field['length']
                f['ATTRIBUTES']  = new_field['attributes']
                f['STATUS']      = 0    # Active, not deleted.
                f['PROPERTY']    = 1    # Not editable.
                f['DESCRIPTION'] = new_field['description']
                f['DEFAULTCHAR'] = new_field['value']
                f.add_field()
                added_fields.append(new_field)
        if added_fields:
            if len(added_fields) != len(new_fields):
                # "Partially installed the new fields in the TS_CASES
                # table. Previous installation was not up to date."
                self.log(602)
            else:
                # "Installed all new fields in the TS_CASES table."
                self.log(603)

        # Really we shouldn't be doing all this nonsense up front, because
        # there's usually no need to change the LAST_CHANGE record.

        # Find the first change made on or after the start date.  If there is
        # one, then use the previous change record as the "last change".  See
        # job000189.  It's important to make sure not to fetch large numbers of
        # change records from TeamTrack here since there may be very many, and
        # TeamTrack may be very slow; hence the roundabout nature of this
        # query.
        query = ("TS_ID IN (SELECT MIN(TS_ID) FROM TS_CHANGES WHERE "
                 "TS_TIME>=%d)" % self.config.start_date)
        first_change = self.query('CHANGES', query)
        if first_change:
            last_change = first_change[0]['ID'] - 1
        else:
            # Look up the LASTID field in the TABLES table for the CHANGES
            # table; this is the highest value for the ID used in the CHANGES
            # table.  We'll use this for the initial value of the LAST_CHANGE
            # parameter (unless there's a LAST_CHANGE parameter there already).
            # See job000047 and the TeamTrack schema documentation.
            query = 'TS_ID = %d' % teamtrack.table['CHANGES']
            last_change = self.query('TABLES', query)[0]['LASTID']

        # Build a string of status values separated by / for the STATUS_VALUES
        # keyword.
        status_values = string.join(map(lambda p: p[1],
                                        self.config.state_pairs),'/')

        # config_params gives the values that should appear in the Replicator's
        # configuration parameters table.  Each entry in the config_params list
        # is a 4-tuple ( parameter name, field name, field value, force
        # update?)  See the TeamTrack schema extensions document for the
        # meaning of these configuration parameters.
        config_params = [ ( 'LAST_CHANGE', 'INFO1', last_change, 0 ),
                          ( 'SERVER', 'FILENAME',
                            dict_repr_workaround({ 'sid': self.sid, 'description':
                                                   self.config.p4_server_description}), 1 ),
                          ( 'STATUS_VALUES', 'FILENAME',
                            dict_repr_workaround({ 'sid': self.sid, 'description':
                                                   status_values }), 1 ),
                          ]
        if self.config.changelist_url:
            config_params.append(( 'CHANGELIST_URL', 'FILENAME',
                                   dict_repr_workaround({ 'sid': self.sid, 'description':
                                                          self.config.changelist_url}), 1 ))
        if self.config.job_url:
            config_params.append(( 'JOB_URL', 'FILENAME',
                                   dict_repr_workaround({ 'sid': self.sid, 'description':
                                                          self.config.job_url}), 1 ))

        # Get all the configuration parameters for this replicator; make a hash
        # by parameter name.
        query = ("TS_TYPE=%d AND TS_CHAR1='%s'"
                 % (vcactions_type_config, self.rid))
        params = {}
        for p in self.query('VCACTIONS', query):
            params[p['CHAR2']] = p

        # Now add or update.
        for name, field, value, force_p in config_params:
            if not params.has_key(name):
                r = self.server.new_record(teamtrack.table['VCACTIONS'])
                r['TYPE'] = vcactions_type_config
                r['CHAR1'] = self.rid
                r['CHAR2'] = name
                r[field] = value
                r.add()
                # "Put '%s' parameter in replicator configuration with value
                # '%s'."
                self.log(604, (name, repr(value)))
            elif force_p and params[name][field] != value:
                params[name][field] = value
                params[name].update()
                # "Updated '%s' parameter in replicator configuration to have
                # value '%s'."
                self.log(605, (name, repr(value)))

        # Delete CHANGELIST_URL parameter if changelist-url is None.  See
        # job000169.
        if (params.has_key('CHANGELIST_URL')
            and not self.config.changelist_url):
            self.server.delete_record(teamtrack.table['VCACTIONS'],
                                      params['CHANGELIST_URL']['ID'])

        # Delete JOB_URL parameter if job_url is None.  See
        # job000169.
        if (params.has_key('JOB_URL')
            and not self.config.job_url):
            self.server.delete_record(teamtrack.table['VCACTIONS'],
                                      params['JOB_URL']['ID'])

    def issue(self, case_id):
        assert isinstance(case_id, types.StringType)
        try:
            case = self.server.read_record(self.server.case_table_id(),
                                           int(case_id))
            return teamtrack_case(case, self)
        except ValueError:
            # case_id was not a number and int() failed.
            return None
        except teamtrack.tsapi_error:
            # No such issue.
            return None

    # read_auxiliary_table(table).  Record mappings between id and name
    # for a given auxiliary table (the tables PROJECTS, COMPANIES, PRODUCTS,
    # SERVICEAGREEMENTS are suitable for this method).
    def read_auxiliary_table(self, table_name):
        assert isinstance(table_name, types.StringType)
        table_id = teamtrack.table[table_name]
        if self.cached_auxiliary_table.get(table_id, 0):
            return
        else:
            self.cached_auxiliary_table[table_id] = 1
        aux_cursor = cursor(self, table_name, '')
        self.table_to_id_to_name[table_id] = { }
        self.table_to_name_to_id[table_id] = { }
        while 1:
            r = aux_cursor.fetchone()
            if r == None:
                break
            self.table_to_id_to_name[table_id][r['ID']] = r['NAME']
            if self.table_to_name_to_id[table_id].has_key(r['NAME']):
                # "Warning: table '%s' has two entries called '%s'."
                self.log(607, (table_name, r['NAME']))
            self.table_to_name_to_id[table_id][r['NAME']] = r['ID']

    # read_selections().  Record mappings between selection name and id, so
    # that we can transform single-select fields.
    def read_selections(self):
        if self.cached_selections:
            return
        else:
            self.cached_selections = 1

        # fn_map is a map from field id to field name, for fields in the
        # CASES table.
        fn_map = {}
        fields_cursor = cursor(self, 'FIELDS', '')
        while 1:
            f = fields_cursor.fetchone()
            if f == None:
                break
            if f['TABLEID'] == self.server.case_table_id():
                # Normalise the case of the database field name so that we can
                # rely on it being uppercase throughout the code.  See defect
                # 14 of "Alpha test report for Quokka, 2000-11-01.  GDR
                # 2000-11-01.
                fn_map[f['ID']] = string.upper(f['DBNAME'])

        # sn_map is a map from selection id to selection name, used to
        # map from TeamTrack to Perforce.
        sn_map = {}

        # fns_map is a map from selection name to field name to
        # selection id.  This is used to map from Perforce to TeamTrack.
        fns_map = {}

        selections_cursor = cursor(self, 'SELECTIONS', '')
        while 1:
            s = selections_cursor.fetchone()
            if s == None:
                break
            sn_map[s['ID']] = s['NAME']
            if fn_map.has_key(s['FLDID']):
                field_name = fn_map[s['FLDID']]
                if not fns_map.has_key(field_name):
                    fns_map[field_name] = {}
                fns_map[field_name][s['NAME']] = s['ID']

        # Store these maps for later use.
        self.field_to_selection_to_id = fns_map
        self.selection_id_to_name = sn_map

    # Determine a mapping from project id and transition name to the transition
    # id.  Determine a mapping from project id and state name to state id.

    def read_states(self):
        if self.cached_states:
            return
        else:
            self.cached_states = 1

        # sn_map is a map from state id to state name.
        sn_map = { }
        states_cursor = cursor(self, 'STATES', '')
        while 1:
            s = states_cursor.fetchone()
            if s == None:
                break
            # Normalize the case of the state name.  See "Case of state names"
            # design decisions [RB 2000-11-28].
            sn_map[s['ID']] = string.lower(s['NAME'])

        # pns_map is a map from project id and lower-cased state name to the
        # state id corresponding to that state in that project.
        pns_map = { }
        projects_cursor = cursor(self, 'PROJECTS', '')
        while 1:
            p = projects_cursor.fetchone()
            if p == None:
                break
            pid = p['ID']
            if not pns_map.has_key(pid):
                pns_map[pid] = {}
            for s in self.server.read_state_list(pid, 1):
                # Normalize the case of the state name.  See "Case of state
                # names" design decisions [RB 2000-11-28].
                pns_map[pid][string.lower(s['NAME'])] = s['ID']

        # Remember these maps for use later.
        self.project_to_name_to_state = pns_map
        self.state_id_to_name = sn_map

    # Determine a mapping from project id and transition name to the transition
    # id.  Determine a mapping from project id and state name to state id.

    def read_transitions(self):
        if self.cached_transitions:
            return
        else:
            self.cached_transitions = 1

        # psst_map is a map from project id and a pair of state ids to the
        # transition id in that project that takes a case from one state to
        # the other.
        psst_map = { }
        projects_cursor = cursor(self, 'PROJECTS', '')
        while 1:
            p = projects_cursor.fetchone()
            if p == None:
                break
            pid = p['ID']
            if not psst_map.has_key(pid):
                psst_map[pid] = {}
            for t in self.server.read_transition_list(pid):
                psst_map[pid][(t['OLDSTATEID'], t['NEWSTATEID'])] = t['ID']

        # Remember this map for use when choosing transitions.
        self.project_to_states_to_transition = psst_map

    # read_types().  Record the mapping between issue type and the prefix
    # for that type.

    def read_types(self):
        if self.cached_types:
            return
        else:
            self.cached_types = 1

        selections_cursor = cursor(self, 'SELECTIONS', '')
        while 1:
            t = selections_cursor.fetchone()
            if t == None:
                break
            self.type_id_to_prefix[t['ID']] = t['PREFIX']

    # read_users().  Record the mapping between userid and username; and
    # between userid and e-mail address (we'll use the latter to map Perforce
    # users to TeamTrack users under the assumption that they have the same
    # e-mail address in both systems).

    def read_users(self):
        if self.cached_users:
            return
        else:
            self.cached_users = 1

        users_cursor = cursor(self, 'USERS', '')
        while 1:
            u = users_cursor.fetchone()
            if u == None:
                break
            self.user_name_to_id[u['LOGINID']] = u['ID']
            self.user_id_to_name[u['ID']] = u['LOGINID']
            self.user_email_to_id[string.lower(u['EMAIL'])] = u['ID']
            self.user_id_to_email[u['ID']] = string.lower(u['EMAIL'])

    def replicate_changelist(self, change, client, date, description, status, user):
        query = ("TS_TYPE=%d AND TS_CHAR1='%s' AND TS_INFO1=%d"
                 % (vcactions_type_changelist, self.rid, change))
        changelists = self.query('VCACTIONS', query)
        if len(changelists) == 0:
            changelist = self.server.new_record(teamtrack.table['VCACTIONS'])
            self.translate_changelist(changelist, change, client, date, description, status, user)
            changelist.add()
            return 1
        elif self.translate_changelist(changelists[0], change, client, date, description, status, user):
            changelists[0].update()
            return 1
        else:
            return 0

    # translate_changelist(tt_changelist, change, client, date, description,
    # status, user).  Return the changes that were made to tt_changelist.

    def translate_changelist(self, tt_changelist, change, client, date, description, status, user):
        assert isinstance(change, types.IntType)
        assert isinstance(client, types.StringType)
        assert isinstance(date, types.IntType)
        assert isinstance(description, types.StringType)
        assert isinstance(status, types.StringType)
        assert isinstance(user, types.IntType)
        changes = {}
        changes['TYPE'] = vcactions_type_changelist
        changes['CHAR1'] = self.rid
        changes['CHAR2'] = self.sid
        changes['INFO1'] = change
        changes['AUTHOR1'] = user
        changes['INFO2'] = (status == 'submitted')
        changes['TIME1'] = date
        changes['FILENAME'] = dict_repr_workaround({'description': description,
                                                    'client': client })
        for key, value in changes.items():
            if tt_changelist[key] != value:
                tt_changelist[key] = value
            else:
                del changes[key]
        return changes


# 7. TRANSLATORS
#
# These classes translate values of particular types between TeamTrack and
# Perforce [GDR 2000-10-16, 7.5].


# 7.1. Date translator
#
# This translator class translates dates [GDR 2000-10-16, 7.5.1]
#
# Dates in changelists and jobs are represented as strings in the format
# "2000/01/01 00:00:00".  Dates in fixes are represnted as strings giving the
# number of seconds since 1970-01-01 00:00:00.

class date_translator(translator.translator):
    readable_date_re = re.compile("^([0-9][0-9][0-9][0-9])/([0-9][0-9])/([0-9][0-9]) ([0-9][0-9]):([0-9][0-9]):([0-9][0-9])$")
    seconds_date_re = re.compile("^[0-9]+$")

    def translate_0_to_1(self, tt_date, tt, p4, case = None, job = None):
        assert isinstance(tt_date, types.IntType)
        assert isinstance(tt, dt_teamtrack)
        assert isinstance(p4, dt_interface.defect_tracker)
        assert case == None or isinstance(case, teamtrack_case)
        # Empty date fields in TeamTrack appear to be represented by -2.  See
        # job000146.
        if tt_date < 0:
            return ''
        else:
            # TeamTrack stores dates in local time (see job000379).
            return time.strftime("%Y/%m/%d %H:%M:%S", time.localtime(tt_date))

    def translate_1_to_0(self, p4_date, tt, p4, case = None, job = None):
        assert isinstance(p4_date, types.StringType)
        assert isinstance(tt, dt_teamtrack)
        assert isinstance(p4, dt_interface.defect_tracker)
        assert case == None or isinstance(case, teamtrack_case)
        # Empty date fields in TeamTrack appear to be represented by -2.  See
        # job000146.
        if p4_date == '':
            return -2
        match = self.readable_date_re.match(p4_date)
        if match:
            # Note that months are 1-12 in Python, unlike in C.  Specify
            # -1 for the DST flag -- see job000381.
            return int(time.mktime((int(match.group(1)), int(match.group(2)),
                                    int(match.group(3)), int(match.group(4)),
                                    int(match.group(5)), int(match.group(6)),
                                    0, 0, -1)))
        elif self.seconds_date_re.match(p4_date):
            return int(p4_date)
        else:
            # "Incorrect date in Perforce: '%s'."
            raise error, catalog.msg(618, p4_date)


# 7.2. Elapsed time translator
#
# This translator class translates elapsed times (for example, the ESTTIMETOFIX
# field in the default TeamTrack workflow).  Elapsed times are represented in
# TeamTrack as a number of seconds; there's no corresponding field type in
# Perforce so we represent them as strings in the format "Hours:Mins:Secs".

class elapsed_time_translator(translator.translator):
    elapsed_time_re = re.compile("^([0-9]+):([0-9][0-9]):([0-9][0-9])$")

    def translate_0_to_1(self, tt_time, tt, p4, case = None, job = None):
        assert isinstance(tt_time, types.IntType)
        assert isinstance(tt, dt_teamtrack)
        assert isinstance(p4, dt_interface.defect_tracker)
        assert case == None or isinstance(case, teamtrack_case)
        # Empty time fields in TeamTrack appear to be represented by -2.  See
        # job000146.
        if tt_time < 0:
            return ''
        else:
            return '%d:%02d:%02d' % (tt_time / 3600, (tt_time / 60) % 60,
                                     tt_time % 60)

    def translate_1_to_0(self, p4_time, tt, p4, case = None, job = None):
        assert isinstance(p4_time, types.StringType)
        assert isinstance(tt, dt_teamtrack)
        assert isinstance(p4, dt_interface.defect_tracker)
        assert case == None or isinstance(case, teamtrack_case)
        # Empty time fields in TeamTrack appear to be represented by -2.  See
        # job000146.
        if p4_time == '':
            return -2
        match = self.elapsed_time_re.match(p4_time)
        if match:
            return (int(match.group(1)) * 3600 + int(match.group(2)) * 60
                    + int(match.group(3)))
        else:
            # "Incorrect time in Perforce: '%s'."
            raise error, catalog.msg(619, p4_time)


# 7.3. Foreign key translator
#
# This class translates foreign key fields; that is, fields in TeamTrack that
# reference the TS_ID field in an auxiliary table (for example, the
# TS_PROJECTID field in the default TeamTrack database refers to the TS_ID
# field in the TS_PROJECTS table).
#
# The name of the auxiliary table must be supplied to the constructor.

class auxiliary_translator(translator.translator):
    table_name = None
    table = None

    def __init__(self, table_name):
        if not teamtrack.table.has_key(table_name):
            # "No such table: %s."
            raise error, catalog.msg(620, table_name)
        self.table_name = table_name
        self.table = teamtrack.table[table_name]

    def translate_0_to_1(self, tt_value, tt, p4, case = None, job = None):
        assert isinstance(tt_value, types.IntType)
        assert isinstance(tt, dt_teamtrack)
        assert isinstance(p4, dt_interface.defect_tracker)
        assert case == None or isinstance(case, teamtrack_case)
        if tt_value == 0:
            return '(None)'
        if not (tt.table_to_id_to_name.has_key(self.table) and
                tt.table_to_id_to_name[self.table].has_key(tt_value)):
            # The entity might have been added since we last looked in the
            # database, so refresh our cache.
            tt.read_auxiliary_table(self.table_name)
        if (tt.table_to_id_to_name.has_key(self.table) and
            tt.table_to_id_to_name[self.table].has_key(tt_value)):
            return tt.table_to_id_to_name[self.table][tt_value]
        else:
            # "No TeamTrack entity in table '%s' with id %d."
            raise error, catalog.msg(621, (self.table_name, tt_selection))

    def translate_1_to_0(self, p4_value, tt, p4, case = None, job = None):
        assert isinstance(p4_value, types.StringType)
        assert isinstance(tt, dt_teamtrack)
        assert isinstance(p4, dt_interface.defect_tracker)
        assert case == None or isinstance(case, teamtrack_case)
        if p4_value == '(None)' or p4_value == '':
            return 0
        if not (tt.table_to_name_to_id.has_key(self.table) and
                tt.table_to_name_to_id[self.table].has_key(p4_value)):
            # The entity might have been added since we last looked in the
            # database, so refresh our cache.
            tt.read_auxiliary_table(self.table_name)
        if (tt.table_to_name_to_id.has_key(self.table) and
            tt.table_to_name_to_id[self.table].has_key(p4_value)):
            return tt.table_to_name_to_id[self.table][p4_value]
        else:
            # "No TeamTrack entity in table '%s' with name '%s'."
            raise error, catalog.msg(622, (self.table_name, p4_value))


# 7.4. Single select translator
#
# This class translates values in single select fields.  In TeamTrack the value
# of a single select field is a reference to the TS_ID field in the
# TS_SELECTIONS table [TeamShare 2000-01-20].  In Perforce we represent the
# value as the name of the selection.  Because single selection fields in
# TeamTrack are mapped to select fields in Perforce, we have to translate the
# name using the keyword translator [GDR 2000-10-16, 7.5.2] so that it is valid
# in Perforce.

class single_select_translator(translator.translator):
    # The field that this translator translates.
    field = None

    # The Perforce keyword translator
    keyword_translator = None

    def __init__(self, field, keyword_translator):
        self.field = field
        self.keyword_translator = keyword_translator

    def translate_0_to_1(self, tt_selection, tt, p4, case = None, job = None):
        assert isinstance(tt_selection, types.IntType)
        assert isinstance(tt, dt_teamtrack)
        assert isinstance(p4, dt_interface.defect_tracker)
        assert case == None or isinstance(case, teamtrack_case)
        if not (tt.selection_id_to_name.has_key(tt_selection)):
            # The selection might have been added since we last looked in the
            # database, so refresh our cache.
            tt.read_selections()
        if (tt.selection_id_to_name.has_key(tt_selection)):
            # Selections are 'word's in Perforce and arbitrary strings in
            # TeamTrack, so translate it.
            return self.keyword_translator.translate_0_to_1(
                        tt.selection_id_to_name[tt_selection])
        else:
            # "No TeamTrack selection name for selection id '%d'."
            raise error, catalog.msg(623, tt_selection)

    def translate_1_to_0(self, p4_selection, tt, p4, case = None, job = None):
        assert isinstance(p4_selection, types.StringType)
        assert isinstance(tt, dt_teamtrack)
        assert isinstance(p4, dt_interface.defect_tracker)
        assert case == None or isinstance(case, teamtrack_case)
        # Selections are 'word's in Perforce and arbitrary strings in
        # TeamTrack, so translate it.
        tt_selection = self.keyword_translator.translate_1_to_0(p4_selection)
        if p4_selection == '(None)' or p4_selection == '':
            return 0
        if not (tt.field_to_selection_to_id.has_key(self.field)
                and tt.field_to_selection_to_id[self.field].has_key(tt_selection)):
            # The selection might have been added since we last looked in the
            # database, so refresh our cache.
            tt.read_selections()
        if (tt.field_to_selection_to_id.has_key(self.field)
            and tt.field_to_selection_to_id[self.field].has_key(tt_selection)):
            return tt.field_to_selection_to_id[self.field][tt_selection]
        else:
            # "No TeamTrack selection for field '%s' corresponding to Perforce
            # selection '%s'."
            raise error, catalog.msg(624, (self.field, p4_selection))


# 7.5. State translator
#
# This class translates case states [GDR 2000-10-16, 7.5.2].  In TeamTrack a
# state is a reference to the TS_ID field in the TS_STATES table.  In Perforce
# we store the name of the state.  Because the state field is a select field in
# Perforce, we have to translate the states name using the keyword translator
# so that it is valid in Perforce.
#
# Note that when translating from Perforce to TeamTrack we use the project of
# the case in case we need to disambiguate between states that have the same
# name.

class state_translator(translator.translator):
    # A map from TeamTrack state name to Perforce state name.
    state_tt_to_p4 = { }

    # A map from Perforce state name to TeamTrack state name (the reverse of
    # the above map).
    state_p4_to_tt = { }

    # The states argument is a list of pairs (TeamTrack state name, Perforce
    # state name).
    def __init__(self, states):
        # Compute the maps.
        for tt_state, p4_state in states:
            assert isinstance(tt_state, types.StringType)
            assert isinstance(p4_state, types.StringType)
            self.state_tt_to_p4[tt_state] = p4_state
            self.state_p4_to_tt[p4_state] = tt_state

    def translate_0_to_1(self, tt_state, tt, p4, case = None, job = None):
        assert isinstance(tt_state, types.IntType)
        assert isinstance(tt, dt_teamtrack)
        assert isinstance(p4, dt_interface.defect_tracker)
        assert case == None or isinstance(case, teamtrack_case)
        if not tt.state_id_to_name.has_key(tt_state):
            # The workflows may have changed since we last looked in the
            # database, so refresh our cache.
            tt.read_states()
        if tt.state_id_to_name.has_key(tt_state):
            tt_name = tt.state_id_to_name[tt_state]
            if self.state_tt_to_p4.has_key(tt_name):
                return self.state_tt_to_p4[tt_name]
            else:
                # "No Perforce state corresponding to TeamTrack state '%s'."
                raise error, catalog.msg(625, tt_name)
        else:
            # "No state name for TeamTrack state %d."
            raise error, catalog.msg(626, tt_state)

    def translate_1_to_0(self, p4_state, tt, p4, case, job = None):
        assert isinstance(p4_state, types.StringType)
        assert isinstance(tt, dt_teamtrack)
        assert isinstance(p4, dt_interface.defect_tracker)
        assert isinstance(case, teamtrack_case)
        if not self.state_p4_to_tt.has_key(p4_state):
            # "Perforce state '%s' is unknown."
            raise error, catalog.msg(627, p4_state)
        tt_state = self.state_p4_to_tt[p4_state]
        project = case['PROJECTID']
        if not (tt.project_to_name_to_state.has_key(project)
                and tt.project_to_name_to_state[project].has_key(tt_state)):
            # The state might have been added or the workflows changed since we
            # last looked in the database, so refresh our cache.
            tt.read_states()
        if (tt.project_to_name_to_state.has_key(project)
            and tt.project_to_name_to_state[project].has_key(tt_state)):
            return tt.project_to_name_to_state[project][tt_state]
        else:
            # "No TeamTrack state in project '%s' corresponding to Perforce
            # state '%s'."
            raise error, catalog.msg(628, (project, p4_state))


# 7.6. Text translator
#
# This class translates multi-line text fields [GDR 2000-10-16, 7.5.3].

class text_translator(translator.translator):
    # Transform TeamTrack memo field contents to Perforce text field contents
    # by converting line endings.  See job000008 and job000009.

    def translate_0_to_1(self, tt_string, tt, p4, case = None, job = None):
        assert isinstance(tt_string, types.StringType)
        assert isinstance(tt, dt_teamtrack)
        assert isinstance(p4, dt_interface.defect_tracker)
        assert case == None or isinstance(case, teamtrack_case)
        # Replace \r\n with \n.
        tt_string = string.replace(tt_string, '\r\n', '\n')
        # Add final newline, unless the string is empty.
        if tt_string:
            tt_string = tt_string + '\n'
        return tt_string

    # Transform Perforce text field contents to TeamTrack memo field contents
    # by converting line endings.  See job000008 and job000009.

    def translate_1_to_0(self, p4_string, tt, p4, case = None, job = None):
        assert isinstance(p4_string, types.StringType)
        assert isinstance(tt, dt_teamtrack)
        assert isinstance(p4, dt_interface.defect_tracker)
        assert case == None or isinstance(case, teamtrack_case)
        # Remove final newline (if any).
        if p4_string and p4_string[-1] == '\n':
            p4_string = p4_string[:-1]
        # Replace \n with \r\n.
        p4_string = string.replace(p4_string, '\n', '\r\n')
        return p4_string


# 7.7. User translator
#
# This class translates users [GDR 2000-10-16, 7.5.3].
#
# The user_translator needs to cope with three special cases.
#
# First, user fields in TeamTrack can be empty (that is, userid is 0).  We
# replicate these to and from the dummy user "(None)" in Perforce, since that's
# how non-existent users show up in TeamTrack.
#
# Second, we don't insist that all users in Perforce have licences in TeamTrack
# [GDR 2000-10-16, 4.8].  For example, a user who made a changelist long ago
# and has left the company.  These users are mapped to the TeamTrack userid 0.
# (But note that people shouldn't be able to *change* issues in TeamTrack
# unless they have a licence -- this was agreed with TeamShare [GDR 2000-08-18,
# 3.3.1].  It isn't the responsibility of the user_translator to worry about
# this.  That's up to the teamtrack_case.update method).  See job000087.
#
# Third, we don't insist that all users in TeamTrack have licences in Perforce.
# For these users we simply put their TeamTrack user name in Perforce -- this
# works because Perforce doesn't check user names.

class user_translator(translator.user_translator):
    # Have the maps been build yet?  (Each instance of this class only runs
    # match_users once.)
    matched_users = 0

    # A map from Perforce user's e-mail address to Perforce user id.
    p4_email_to_user = { }

    # A map from TeamTrack user id to Perforce username (for users where we can
    # work out a correspondence by e-mail address).
    user_tt_to_p4 = { }

    # A map from Perforce username to TeamTrack user id (for users where we can
    # work out a correspondence by e-mail address).
    user_p4_to_tt = { }

    # Obtain a dictionary e-mail -> Perforce id.  (This method copied from
    # dt_bugzilla.user_translator; it probably belongs in the dt_perforce class
    # so it can be shared between user translators for various defect tracking
    # systems.)
    def p4_user_dict(self, p4) :
        p4_users = p4.p4.run("users")
        dict = {}
        for user in p4_users :
            dict[string.lower(user['Email'])] = user['User']
        return dict

    # Build the user_tt_to_p4 and user_p4_to_tt maps.
    def match_users(self, tt, p4):
        assert isinstance(tt, dt_teamtrack)
        assert isinstance(p4, replicator.dt_perforce)
        if self.matched_users:
            return
        # Read TeamTrack users and e-mail addresses.
        tt.read_users()
        # Read Perforce users and e-mail addresses.
        self.p4_email_to_user = self.p4_user_dict(p4)
        # Clear the maps.
        self.user_tt_to_p4 = {}
        self.user_p4_to_tt = {}
        # Pair up users by e-mail address.
        for user, email in tt.user_id_to_email.items():
            if self.p4_email_to_user.has_key(email):
                # "Matched TeamTrack user '%s' with Perforce user '%s' by
                # e-mail address '%s'."
                tt.log(613, (tt.user_id_to_name[user], self.p4_email_to_user[email], email))
                self.user_tt_to_p4[user] = self.p4_email_to_user[email]
                self.user_p4_to_tt[self.p4_email_to_user[email]] = user
        self.matched_users = 1

    def unmatched_users(self, tt, p4):
        assert isinstance(tt, dt_teamtrack)
        assert isinstance(p4, replicator.dt_perforce)
        self.match_users(tt, p4)
        # Find unmatched TeamTrack users.
        unmatched_tt_users = {}
        for user, email in tt.user_id_to_email.items():
            if user != 0 and not self.user_tt_to_p4.has_key(user):
                unmatched_tt_users[tt.user_id_to_name[user]] = email
        # Find unmatched Perforce users.
        unmatched_p4_users = {}
        for email, user in self.p4_email_to_user.items():
            if not self.user_p4_to_tt.has_key(user):
                unmatched_p4_users[user] = email
        # "These TeamTrack users will appear as themselves in Perforce even
        # though there is no such Perforce user."
        tt_user_string = catalog.msg(629)
        # "These Perforce users will appear in TeamTrack as the user (None).
        # It will not be possible to assign issues to these users."
        p4_user_string = catalog.msg(630)
        return (unmatched_tt_users, unmatched_p4_users,
                tt_user_string, p4_user_string)

    def translate_0_to_1(self, tt_user, tt, p4, case = None, job = None):
        assert isinstance(tt_user, types.IntType)
        assert isinstance(tt, dt_teamtrack)
        assert isinstance(p4, replicator.dt_perforce)
        assert case == None or isinstance(case, teamtrack_case)
        if tt_user == 0:
            return '(None)'
        self.match_users(tt, p4)
        # Is the user in our map by e-mail address?
        if self.user_tt_to_p4.has_key(tt_user):
            return self.user_tt_to_p4[tt_user]
        # They are not, so use the TeamTrack username as the Perforce username.
        if not tt.user_id_to_name.has_key(tt_user):
            # The user might have been added since we last looked in the
            # TS_USERS table, so refresh our cache.
            tt.read_users()
        if tt.user_id_to_name.has_key(tt_user):
            return tt.user_id_to_name[tt_user]
        else:
            return '(None)'

    def translate_1_to_0(self, p4_user, tt, p4, case = None, job = None):
        assert isinstance(p4_user, types.StringType)
        assert isinstance(tt, dt_teamtrack)
        assert isinstance(p4, replicator.dt_perforce)
        assert case == None or isinstance(case, teamtrack_case)
        if p4_user == '(None)' or p4_user == '':
            return 0
        self.match_users(tt, p4)
        # Is the user in our map by e-mail address?
        if self.user_p4_to_tt.has_key(p4_user):
            return self.user_p4_to_tt[p4_user]
        # They are not, so use the Perforce username as the TeamTrack username.
        if not tt.user_name_to_id.has_key(p4_user):
            # The user might have been added since we last looked in the
            # TS_USERS table, so refresh our cache.
            tt.read_users()
        if tt.user_name_to_id.has_key(p4_user):
            return tt.user_name_to_id[p4_user]
        else:
            return 0


# A. REFERENCES
#
# [GDR 2000-08-08] "Python interface to TeamTrack: design"; Gareth Rees;
# Ravenbrook Limited; 2000-08-08.
#
# [GDR 2000-08-18] "TeamShare design meetings, 2000-08-14/2000-08-16"; Gareth
# Rees; Ravenbrook Limited; 2000-08-18;
# <http://www.ravenbrook.com/project/p4dti/doc/2000-08-18/teamshare-meeting/>.
#
# [GDR 2000-09-04] "TeamTrack database schema extensions for integration with
# Perforce"; Gareth Rees; Ravenbrook Limited; 2000-09-04.
#
# [GDR 2000-10-16] "Perforce Defect Tracking Integration Integrator's Guide";
# Gareth Rees; Ravenbrook Limited; 2000-10-16.
#
# [GDR 2001-05-16] "Performance analysis of TeamShare API workarounds"; Gareth
# Rees; Ravenbrook Limited; 2001-05-16.
#
# [Requirements] "Perforce Defect Tracking Integration Project Requirements"
# (living document); Gareth Rees; Ravenbrook Limited; 2000-05-24;
# <http://www.ravenbrook.com/project/p4dti/req/>.
#
# [TeamShare 2000-01-20] "TeamTrack Database Schema (Database Version: 21)";
# TeamShare; 2000-01-20;
# <http://www.ravenbrook.com/project/p4dti/import/2000-01-20/teamtrack-schema/TTschema21.pdf>.
#
#
# B. DOCUMENT HISTORY
#
# 2000-12-05 GDR Starts replicating TeamTrack data from installation and not
# from the beginning of time.
#
# 2000-12-06 GDR Report both the user and the transition when a transition is
# generated, to make job000133 easier to spot and debug if it happens again.
# Improved message sent when a transition can't be found, by using the state
# names rather than the state ID numbers.
#
# 2000-12-07 GDR Updated user_translator so that it matches users by e-mail
# address if it can; otherwise it defaults to the original algorithm: assume
# that they are identical.
#
# 2000-12-08 GDR The date_translator copes with empty date fields.
#
# 2001-01-19 GDR Handle empty optional fields.
#
# 2001-01-23 GDR Added unmatched_users() method to user_translator class.
#
# 2001-02-04 GDR Added start date; changed the initialization of the
# LAST_CHANGE parameter.
#
# 2001-02-12 GDR Fixed queries involving start_date (now seconds since epoch).
#
# 2001-02-13 GDR Don't needlessly read/write the whole CASES table when
# starting up.
#
# 2001-02-15 GDR Added elapsed_time_translator class.  Delete CHANGELIST_URL
# configuration parameter if changelist_url is None.
#
# 2001-02-19 NB  Moved keyword translation to p4.py.
#
# 2001-02-21 GDR Added string_repr_workaround() and dict_repr_workaround()
# functions that do the job of repr() but work around a bug in TeamTrack 4.5's
# string parsing.
#
# 2001-02-23 GDR Added corresponding_id() method to teamtrack_case class.
#
# 2001-02-27 GDR Replicator ignores changes with TS_USERID=<self> as well as
# changes with TS_REALUSERID=<self>, to fix job000233.
#
# 2001-03-02 RB Transferred copyright to Perforce under their license.
#
# 2001-03-12 GDR Use messages for errors, logging and e-mail.  Removed the
# P4DTI_ACTION field since conflict resolution is now always immediate.  Get
# translator class from translator, not replicator.  Get defect tracker classes
# from dt_interface, not replicator.
#
# 2001-03-15 GDR Get configuration from the config module.
#
# 2001-03-21 GDR The setup_for_replication() method takes a jobname argument.
#
# 2001-04-29 GDR Formatted as a document.  Added many references to
# requirements and design.
#
# 2001-05-15 GDR Added cursor class.  Changed calls to query() that may
# return many results so that they use cursors instead.
#
# 2001-05-18 GDR Log all calls to query() in the TeamTrack interface, so
# don't bother to log explicitly in read_*() methods.  Improved
# debugging output of read_auxiliary_table().
#
# 2001-06-21 NB Treat email addresses case-insensitively.  job000337.
#
# 2001-06-27 NB Make all_issues return all issues replicated by this
# replicator regardless of modification date.
#
# 2001-06-29 GDR Made portable between TeamTrack 4.5 and TeamTrack 5.0
# by using case_table_id() and case_table_name().
#
# 2001-06-30 GDR Added flags which indicate whether TeamTrack data has
# been read and cached this poll, so that we don't read these tables
# more than once per poll, to fix job000148.
#
# 2001-07-02 GDR Fixed bug in cursor implementation.  The query was
# being formed as "TS_ID > n AND query", which meant that if the query
# had an OR operator at top level then the meaning would be changed.
# Added extra parentheses.
#
# 2001-07-09 NB Add job_url by analogy with changelist_url.
#
# 2001-07-19 GDR Fixed bug in cursor implementation; added cases_cursor
# for efficient implementation of fetching set of cases.
#
# 2001-07-24 GDR Specify default value for fields added to cases table
# in TeamTrack.
#
# 2001-08-02 GDR Use time.localtime when converting dates, since
# TeamTrack stores dates in local time.
#
# 2001-08-06 GDR Specify -1 for DST argument to mktime().
#
#
# C. COPYRIGHT AND LICENCE
#
# This file is copyright (c) 2001 Perforce Software, 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:
#
# 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.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "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 COPYRIGHT HOLDERS AND CONTRIBUTORS 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.
#
#
# $Id: //info.ravenbrook.com/project/p4dti/version/1.1/code/replicator/dt_teamtrack.py#17 $
