Code

sqlite doesn't need external locking
[roundup.git] / roundup / admin.py
index 34ec404e672527ce05817fbc137fdeceb4ad1b43..e89f8643df326727cf15b1142a8be69d564fa09b 100644 (file)
 # BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
 # SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
 # 
 # BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
 # SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
 # 
-# $Id: admin.py,v 1.4 2002-01-14 06:51:09 richard Exp $
-
-import sys, os, getpass, getopt, re, UserDict, shlex
-try:
-    import csv
-except ImportError:
-    csv = None
-from roundup import date, hyperdb, roundupdb, init, password, token
+# $Id: admin.py,v 1.63 2004-03-21 23:39:08 richard Exp $
+
+'''Administration commands for maintaining Roundup trackers.
+'''
+__docformat__ = 'restructuredtext'
+
+import sys, os, getpass, getopt, re, UserDict, shutil, rfc822
+from roundup import date, hyperdb, roundupdb, init, password, token, rcsv
+from roundup import __version__ as roundup_version
 import roundup.instance
 from roundup.i18n import _
 
 import roundup.instance
 from roundup.i18n import _
 
@@ -50,7 +51,17 @@ class UsageError(ValueError):
     pass
 
 class AdminTool:
     pass
 
 class AdminTool:
+    ''' A collection of methods used in maintaining Roundup trackers.
+
+        Typically these methods are accessed through the roundup-admin
+        script. The main() method provided on this class gives the main
+        loop for the roundup-admin script.
 
 
+        Actions are defined by do_*() methods, with help for the action
+        given in the method docstring.
+
+        Additional help may be supplied by help_*() methods.
+    '''
     def __init__(self):
         self.commands = CommandDict()
         for k in AdminTool.__dict__.keys():
     def __init__(self):
         self.commands = CommandDict()
         for k in AdminTool.__dict__.keys():
@@ -60,7 +71,7 @@ class AdminTool:
         for k in AdminTool.__dict__.keys():
             if k[:5] == 'help_':
                 self.help[k[5:]] = getattr(self, k)
         for k in AdminTool.__dict__.keys():
             if k[:5] == 'help_':
                 self.help[k[5:]] = getattr(self, k)
-        self.instance_home = ''
+        self.tracker_home = ''
         self.db = None
 
     def get_class(self, classname):
         self.db = None
 
     def get_class(self, classname):
@@ -71,35 +82,57 @@ class AdminTool:
         except KeyError:
             raise UsageError, _('no such class "%(classname)s"')%locals()
 
         except KeyError:
             raise UsageError, _('no such class "%(classname)s"')%locals()
 
-    def props_from_args(self, args, klass=None):
+    def props_from_args(self, args):
+        ''' Produce a dictionary of prop: value from the args list.
+
+            The args list is specified as ``prop=value prop=value ...``.
+        '''
         props = {}
         for arg in args:
             if arg.find('=') == -1:
         props = {}
         for arg in args:
             if arg.find('=') == -1:
-                raise UsageError, _('argument "%(arg)s" not propname=value')%locals()
-            try:
-                key, value = arg.split('=')
-            except ValueError:
-                raise UsageError, _('argument "%(arg)s" not propname=value')%locals()
-            props[key] = value
+                raise UsageError, _('argument "%(arg)s" not propname=value'
+                    )%locals()
+            l = arg.split('=')
+            if len(l) < 2:
+                raise UsageError, _('argument "%(arg)s" not propname=value'
+                    )%locals()
+            key, value = l[0], '='.join(l[1:])
+            if value:
+                props[key] = value
+            else:
+                props[key] = None
         return props
 
     def usage(self, message=''):
         return props
 
     def usage(self, message=''):
+        ''' Display a simple usage message.
+        '''
         if message:
         if message:
-            message = _('Problem: %(message)s)\n\n')%locals()
-        print _('''%(message)sUsage: roundup-admin [-i instance home] [-u login] [-c] <command> <arguments>
+            message = _('Problem: %(message)s\n\n')%locals()
+        print _('''%(message)sUsage: roundup-admin [options] [<command> <arguments>]
+
+Options:
+ -i instance home  -- specify the issue tracker "home directory" to administer
+ -u                -- the user[:password] to use for commands
+ -d                -- print full designators not just class id numbers
+ -c                -- when outputting lists of data, comma-separate them.
+                      Same as '-S ","'.
+ -S <string>       -- when outputting lists of data, string-separate them
+ -s                -- when outputting lists of data, space-separate them.
+                      Same as '-S " "'.
+
+ Only one of -s, -c or -S can be specified.
 
 Help:
  roundup-admin -h
  roundup-admin help                       -- this help
  roundup-admin help <command>             -- command-specific help
  roundup-admin help all                   -- all available help
 
 Help:
  roundup-admin -h
  roundup-admin help                       -- this help
  roundup-admin help <command>             -- command-specific help
  roundup-admin help all                   -- all available help
-Options:
- -i instance home  -- specify the issue tracker "home directory" to administer
- -u                -- the user[:password] to use for commands
- -c                -- when outputting lists of data, just comma-separate them''')%locals()
+''')%locals()
         self.help_commands()
 
     def help_commands(self):
         self.help_commands()
 
     def help_commands(self):
+        ''' List the commands available with their precis help.
+        '''
         print _('Commands:'),
         commands = ['']
         for command in self.commands.values():
         print _('Commands:'),
         commands = ['']
         for command in self.commands.values():
@@ -112,11 +145,13 @@ Options:
         print
 
     def help_commands_html(self, indent_re=re.compile(r'^(\s+)\S+')):
         print
 
     def help_commands_html(self, indent_re=re.compile(r'^(\s+)\S+')):
-       commands = self.commands.values()
+        ''' Produce an HTML command list.
+        '''
+        commands = self.commands.values()
         def sortfun(a, b):
             return cmp(a.__name__, b.__name__)
         commands.sort(sortfun)
         def sortfun(a, b):
             return cmp(a.__name__, b.__name__)
         commands.sort(sortfun)
-       for command in commands:
+        for command in commands:
             h = command.__doc__.split('\n')
             name = command.__name__[3:]
             usage = h[0]
             h = command.__doc__.split('\n')
             name = command.__name__[3:]
             usage = h[0]
@@ -135,12 +170,12 @@ Options:
 
     def help_all(self):
         print _('''
 
     def help_all(self):
         print _('''
-All commands (except help) require an instance specifier. This is just the path
-to the roundup instance you're working with. A roundup instance is where 
+All commands (except help) require a tracker specifier. This is just the path
+to the roundup tracker you're working with. A roundup tracker is where 
 roundup keeps the database and configuration file that defines an issue
 tracker. It may be thought of as the issue tracker's "home directory". It may
 roundup keeps the database and configuration file that defines an issue
 tracker. It may be thought of as the issue tracker's "home directory". It may
-be specified in the environment variable ROUNDUP_INSTANCE or on the command
-line as "-i instance".
+be specified in the environment variable TRACKER_HOME or on the command
+line as "-i tracker".
 
 A designator is a classname and a nodeid concatenated, eg. bug1, user10, ...
 
 
 A designator is a classname and a nodeid concatenated, eg. bug1, user10, ...
 
@@ -209,7 +244,11 @@ Command help:
         initopts  -- init command options
         all       -- all available help
         '''
         initopts  -- init command options
         all       -- all available help
         '''
-        topic = args[0]
+        if len(args)>0:
+            topic = args[0]
+        else:
+            topic = 'help'
 
         # try help_ methods
         if self.help.has_key(topic):
 
         # try help_ methods
         if self.help.has_key(topic):
@@ -236,39 +275,103 @@ Command help:
                     print line
         return 0
 
                     print line
         return 0
 
+    def listTemplates(self):
+        ''' List all the available templates.
+
+        Look in the following places, where the later rules take precedence:
+
+         1. <prefix>/share/roundup/templates/*
+            this should be the standard place to find them when Roundup is
+            installed
+         2. <roundup.admin.__file__>/../templates/*
+            this will be used if Roundup's run in the distro (aka. source)
+            directory
+         3. <current working dir>/*
+            this is for when someone unpacks a 3rd-party template
+         4. <current working dir>
+            this is for someone who "cd"s to the 3rd-party template dir
+        '''
+        # OK, try <prefix>/share/roundup/templates
+        # -- this module (roundup.admin) will be installed in something
+        # like:
+        #    /usr/lib/python2.2/site-packages/roundup/admin.py  (5 dirs up)
+        #    c:\python22\lib\site-packages\roundup\admin.py     (4 dirs up)
+        # we're interested in where the "lib" directory is - ie. the /usr/
+        # part
+        templates = {}
+        for N in 4, 5:
+            path = __file__
+            # move up N elements in the path
+            for i in range(N):
+                path = os.path.dirname(path)
+            tdir = os.path.join(path, 'share', 'roundup', 'templates')
+            if os.path.isdir(tdir):
+                templates = init.listTemplates(tdir)
+                break
+
+        # OK, now try as if we're in the roundup source distribution
+        # directory, so this module will be in .../roundup-*/roundup/admin.py
+        # and we're interested in the .../roundup-*/ part.
+        path = __file__
+        for i in range(2):
+            path = os.path.dirname(path)
+        tdir = os.path.join(path, 'templates')
+        if os.path.isdir(tdir):
+            templates.update(init.listTemplates(tdir))
+
+        # Try subdirs of the current dir
+        templates.update(init.listTemplates(os.getcwd()))
+
+        # Finally, try the current directory as a template
+        template = init.loadTemplateInfo(os.getcwd())
+        if template:
+            templates[template['name']] = template
+
+        return templates
+
     def help_initopts(self):
     def help_initopts(self):
-        import roundup.templates
-        templates = roundup.templates.listTemplates()
-        print _('Templates:'), ', '.join(templates)
+        templates = self.listTemplates()
+        print _('Templates:'), ', '.join(templates.keys())
         import roundup.backends
         backends = roundup.backends.__all__
         print _('Back ends:'), ', '.join(backends)
 
         import roundup.backends
         backends = roundup.backends.__all__
         print _('Back ends:'), ', '.join(backends)
 
+    def do_install(self, tracker_home, args):
+        '''Usage: install [template [backend [admin password]]]
+        Install a new Roundup tracker.
 
 
-    def do_initialise(self, instance_home, args):
-        '''Usage: initialise [template [backend [admin password]]]
-        Initialise a new Roundup instance.
-
-        The command will prompt for the instance home directory (if not supplied
-        through INSTANCE_HOME or the -i option). The template, backend and admin
+        The command will prompt for the tracker home directory (if not supplied
+        through TRACKER_HOME or the -i option). The template, backend and admin
         password may be specified on the command-line as arguments, in that
         order.
 
         password may be specified on the command-line as arguments, in that
         order.
 
+        The initialise command must be called after this command in order
+        to initialise the tracker's database. You may edit the tracker's
+        initial database contents before running that command by editing
+        the tracker's dbinit.py module init() function.
+
         See also initopts help.
         '''
         if len(args) < 1:
             raise UsageError, _('Not enough arguments supplied')
         See also initopts help.
         '''
         if len(args) < 1:
             raise UsageError, _('Not enough arguments supplied')
+
+        # make sure the tracker home can be created
+        parent = os.path.split(tracker_home)[0]
+        if not os.path.exists(parent):
+            raise UsageError, _('Instance home parent directory "%(parent)s"'
+                ' does not exist')%locals()
+
         # select template
         # select template
-        import roundup.templates
-        templates = roundup.templates.listTemplates()
+        templates = self.listTemplates()
         template = len(args) > 1 and args[1] or ''
         template = len(args) > 1 and args[1] or ''
-        if template not in templates:
-            print _('Templates:'), ', '.join(templates)
-        while template not in templates:
+        if not templates.has_key(template):
+            print _('Templates:'), ', '.join(templates.keys())
+        while not templates.has_key(template):
             template = raw_input(_('Select template [classic]: ')).strip()
             if not template:
                 template = 'classic'
 
             template = raw_input(_('Select template [classic]: ')).strip()
             if not template:
                 template = 'classic'
 
+        # select hyperdb backend
         import roundup.backends
         backends = roundup.backends.__all__
         backend = len(args) > 2 and args[2] or ''
         import roundup.backends
         backends = roundup.backends.__all__
         backend = len(args) > 2 and args[2] or ''
@@ -278,15 +381,79 @@ Command help:
             backend = raw_input(_('Select backend [anydbm]: ')).strip()
             if not backend:
                 backend = 'anydbm'
             backend = raw_input(_('Select backend [anydbm]: ')).strip()
             if not backend:
                 backend = 'anydbm'
-        if len(args) > 3:
-            adminpw = confirm = args[3]
+        # XXX perform a unit test based on the user's selections
+
+        # install!
+        init.install(tracker_home, templates[template]['path'])
+        init.write_select_db(tracker_home, backend)
+
+        print _('''
+ You should now edit the tracker configuration file:
+   %(config_file)s
+ ... at a minimum, you must set MAILHOST, TRACKER_WEB, MAIL_DOMAIN and
+ ADMIN_EMAIL.
+
+ If you wish to modify the default schema, you should also edit the database
+ initialisation file:
+   %(database_config_file)s
+ ... see the documentation on customizing for more information.
+''')%{
+    'config_file': os.path.join(tracker_home, 'config.py'),
+    'database_config_file': os.path.join(tracker_home, 'dbinit.py')
+}
+        return 0
+
+
+    def do_initialise(self, tracker_home, args):
+        '''Usage: initialise [adminpw]
+        Initialise a new Roundup tracker.
+
+        The administrator details will be set at this step.
+
+        Execute the tracker's initialisation function dbinit.init()
+        '''
+        # password
+        if len(args) > 1:
+            adminpw = args[1]
         else:
             adminpw = ''
             confirm = 'x'
         else:
             adminpw = ''
             confirm = 'x'
-        while adminpw != confirm:
-            adminpw = getpass.getpass(_('Admin Password: '))
-            confirm = getpass.getpass(_('       Confirm: '))
-        init.init(instance_home, template, backend, adminpw)
+            while adminpw != confirm:
+                adminpw = getpass.getpass(_('Admin Password: '))
+                confirm = getpass.getpass(_('       Confirm: '))
+
+        # make sure the tracker home is installed
+        if not os.path.exists(tracker_home):
+            raise UsageError, _('Instance home does not exist')%locals()
+        try:
+            tracker = roundup.instance.open(tracker_home)
+        except roundup.instance.TrackerError:
+            raise UsageError, _('Instance has not been installed')%locals()
+
+        # is there already a database?
+        try:
+            db_exists = tracker.select_db.Database.exists(tracker.config)
+        except AttributeError:
+            # TODO: move this code to exists() static method in every backend
+            db_exists = os.path.exists(os.path.join(tracker_home, 'db'))
+        if db_exists:
+            print _('WARNING: The database is already initialised!')
+            print _('If you re-initialise it, you will lose all the data!')
+            ok = raw_input(_('Erase it? Y/[N]: ')).strip()
+            if ok.lower() != 'y':
+                return 0
+
+            # Get a database backend in use by tracker
+            try:
+                # nuke it
+                tracker.select_db.Database.nuke(tracker.config)
+            except AttributeError:
+                # TODO: move this code to nuke() static method in every backend
+                shutil.rmtree(os.path.join(tracker_home, 'db'))
+
+        # GO
+        init.initialise(tracker_home, adminpw)
+
         return 0
 
 
         return 0
 
 
@@ -304,79 +471,116 @@ Command help:
         for designator in designators:
             # decode the node designator
             try:
         for designator in designators:
             # decode the node designator
             try:
-                classname, nodeid = roundupdb.splitDesignator(designator)
-            except roundupdb.DesignatorError, message:
+                classname, nodeid = hyperdb.splitDesignator(designator)
+            except hyperdb.DesignatorError, message:
                 raise UsageError, message
 
             # get the class
             cl = self.get_class(classname)
             try:
                 raise UsageError, message
 
             # get the class
             cl = self.get_class(classname)
             try:
-                if self.comma_sep:
-                    l.append(cl.get(nodeid, propname))
+                id=[]
+                if self.separator:
+                    if self.print_designator:
+                        # see if property is a link or multilink for
+                        # which getting a desginator make sense.
+                        # Algorithm: Get the properties of the
+                        #     current designator's class. (cl.getprops)
+                        # get the property object for the property the
+                        #     user requested (properties[propname])
+                        # verify its type (isinstance...)
+                        # raise error if not link/multilink
+                        # get class name for link/multilink property
+                        # do the get on the designators
+                        # append the new designators
+                        # print
+                        properties = cl.getprops()
+                        property = properties[propname]
+                        if not (isinstance(property, hyperdb.Multilink) or
+                          isinstance(property, hyperdb.Link)):
+                            raise UsageError, _('property %s is not of type Multilink or Link so -d flag does not apply.')%propname
+                        propclassname = self.db.getclass(property.classname).classname
+                        id = cl.get(nodeid, propname)
+                        for i in id:
+                            l.append(propclassname + i)
+                    else:
+                        id = cl.get(nodeid, propname)
+                        for i in id:
+                            l.append(i)
                 else:
                 else:
-                    print cl.get(nodeid, propname)
+                    if self.print_designator:
+                        properties = cl.getprops()
+                        property = properties[propname]
+                        if not (isinstance(property, hyperdb.Multilink) or
+                          isinstance(property, hyperdb.Link)):
+                            raise UsageError, _('property %s is not of type Multilink or Link so -d flag does not apply.')%propname
+                        propclassname = self.db.getclass(property.classname).classname
+                        id = cl.get(nodeid, propname)
+                        for i in id:
+                            print propclassname + i
+                    else:
+                        print cl.get(nodeid, propname)
             except IndexError:
                 raise UsageError, _('no such %(classname)s node "%(nodeid)s"')%locals()
             except KeyError:
                 raise UsageError, _('no such %(classname)s property '
                     '"%(propname)s"')%locals()
             except IndexError:
                 raise UsageError, _('no such %(classname)s node "%(nodeid)s"')%locals()
             except KeyError:
                 raise UsageError, _('no such %(classname)s property '
                     '"%(propname)s"')%locals()
-        if self.comma_sep:
-            print ','.join(l)
+        if self.separator:
+            print self.separator.join(l)
+
         return 0
 
 
         return 0
 
 
-    def do_set(self, args):
-        '''Usage: set designator[,designator]* propname=value ...
-        Set the given property of one or more designator(s).
+    def do_set(self, args, pwre = re.compile(r'{(\w+)}(.+)')):
+        '''Usage: set items property=value property=value ...
+        Set the given properties of one or more items(s).
+
+        The items are specified as a class or as a comma-separated
+        list of item designators (ie "designator[,designator,...]").
 
 
-        Sets the property to the value for all designators given.
+        This command sets the properties to the values for all designators
+        given. If the value is missing (ie. "property=") then the property is
+        un-set. If the property is a multilink, you specify the linked ids
+        for the multilink as comma-separated numbers (ie "1,2,3").
         '''
         if len(args) < 2:
             raise UsageError, _('Not enough arguments supplied')
         from roundup import hyperdb
 
         designators = args[0].split(',')
         '''
         if len(args) < 2:
             raise UsageError, _('Not enough arguments supplied')
         from roundup import hyperdb
 
         designators = args[0].split(',')
+        if len(designators) == 1:
+            designator = designators[0]
+            try:
+                designator = hyperdb.splitDesignator(designator)
+                designators = [designator]
+            except hyperdb.DesignatorError:
+                cl = self.get_class(designator)
+                designators = [(designator, x) for x in cl.list()]
+        else:
+            try:
+                designators = [hyperdb.splitDesignator(x) for x in designators]
+            except hyperdb.DesignatorError, message:
+                raise UsageError, message
 
         # get the props from the args
         props = self.props_from_args(args[1:])
 
         # now do the set for all the nodes
 
         # get the props from the args
         props = self.props_from_args(args[1:])
 
         # now do the set for all the nodes
-        for designator in designators:
-            # decode the node designator
-            try:
-                classname, nodeid = roundupdb.splitDesignator(designator)
-            except roundupdb.DesignatorError, message:
-                raise UsageError, message
-
-            # get the class
+        for classname, itemid in designators:
             cl = self.get_class(classname)
 
             properties = cl.getprops()
             for key, value in props.items():
             cl = self.get_class(classname)
 
             properties = cl.getprops()
             for key, value in props.items():
-                proptype =  properties[key]
-                if isinstance(proptype, hyperdb.String):
-                    continue
-                elif isinstance(proptype, hyperdb.Password):
-                    props[key] = password.Password(value)
-                elif isinstance(proptype, hyperdb.Date):
-                    try:
-                        props[key] = date.Date(value)
-                    except ValueError, message:
-                        raise UsageError, '"%s": %s'%(value, message)
-                elif isinstance(proptype, hyperdb.Interval):
-                    try:
-                        props[key] = date.Interval(value)
-                    except ValueError, message:
-                        raise UsageError, '"%s": %s'%(value, message)
-                elif isinstance(proptype, hyperdb.Link):
-                    props[key] = value
-                elif isinstance(proptype, hyperdb.Multilink):
-                    props[key] = value.split(',')
+                try:
+                    props[key] = hyperdb.rawToHyperdb(self.db, cl, itemid,
+                        key, value)
+                except hyperdb.HyperdbValueError, message:
+                    raise UsageError, message
 
             # try the set
             try:
 
             # try the set
             try:
-                apply(cl.set, (nodeid, ), props)
+                apply(cl.set, (itemid, ), props)
             except (TypeError, IndexError, ValueError), message:
             except (TypeError, IndexError, ValueError), message:
+                import traceback; traceback.print_exc()
                 raise UsageError, message
         return 0
 
                 raise UsageError, message
         return 0
 
@@ -400,7 +604,9 @@ Command help:
         # number
         for propname, value in props.items():
             num_re = re.compile('^\d+$')
         # number
         for propname, value in props.items():
             num_re = re.compile('^\d+$')
-            if not num_re.match(value):
+            if value == '-1':
+                props[propname] = None
+            elif not num_re.match(value):
                 # get the property
                 try:
                     property = cl.properties[propname]
                 # get the property
                 try:
                     property = cl.properties[propname]
@@ -420,16 +626,28 @@ Command help:
                 except TypeError:
                     raise UsageError, _('%(classname)s has no key property"')%{
                         'classname': link_class.classname}
                 except TypeError:
                     raise UsageError, _('%(classname)s has no key property"')%{
                         'classname': link_class.classname}
-                except KeyError:
-                    raise UsageError, _('%(classname)s has no entry "%(propname)s"')%{
-                        'classname': link_class.classname, 'propname': propname}
 
         # now do the find 
         try:
 
         # now do the find 
         try:
-            if self.comma_sep:
-                print ','.join(apply(cl.find, (), props))
+            id = []
+            designator = []
+            if self.separator:
+                if self.print_designator:
+                    id=apply(cl.find, (), props)
+                    for i in id:
+                        designator.append(classname + i)
+                    print self.separator.join(designator)
+                else:
+                    print self.separator.join(apply(cl.find, (), props))
+
             else:
             else:
-                print apply(cl.find, (), props)
+                if self.print_designator:
+                    id=apply(cl.find, (), props)
+                    for i in id:
+                        designator.append(classname + i)
+                    print designator
+                else:
+                    print apply(cl.find, (), props)
         except KeyError:
             raise UsageError, _('%(classname)s has no property '
                 '"%(propname)s"')%locals()
         except KeyError:
             raise UsageError, _('%(classname)s has no property '
                 '"%(propname)s"')%locals()
@@ -458,8 +676,8 @@ Command help:
                 print _('%(key)s: %(value)s')%locals()
 
     def do_display(self, args):
                 print _('%(key)s: %(value)s')%locals()
 
     def do_display(self, args):
-        '''Usage: display designator
-        Show the property values for the given node.
+        '''Usage: display designator[,designator]*
+        Show the property values for the given node(s).
 
         This lists the properties and their associated values for the given
         node.
 
         This lists the properties and their associated values for the given
         node.
@@ -468,20 +686,23 @@ Command help:
             raise UsageError, _('Not enough arguments supplied')
 
         # decode the node designator
             raise UsageError, _('Not enough arguments supplied')
 
         # decode the node designator
-        try:
-            classname, nodeid = roundupdb.splitDesignator(args[0])
-        except roundupdb.DesignatorError, message:
-            raise UsageError, message
+        for designator in args[0].split(','):
+            try:
+                classname, nodeid = hyperdb.splitDesignator(designator)
+            except hyperdb.DesignatorError, message:
+                raise UsageError, message
 
 
-        # get the class
-        cl = self.get_class(classname)
+            # get the class
+            cl = self.get_class(classname)
 
 
-        # display the values
-        for key in cl.properties.keys():
-            value = cl.get(nodeid, key)
-            print _('%(key)s: %(value)s')%locals()
+            # display the values
+            keys = cl.properties.keys()
+            keys.sort()
+            for key in keys:
+                value = cl.get(nodeid, key)
+                print _('%(key)s: %(value)s')%locals()
 
 
-    def do_create(self, args):
+    def do_create(self, args, pwre = re.compile(r'{(\w+)}(.+)')):
         '''Usage: create classname property=value ...
         Create a new entry of a given class.
 
         '''Usage: create classname property=value ...
         Create a new entry of a given class.
 
@@ -525,28 +746,12 @@ Command help:
             props = self.props_from_args(args[1:])
 
         # convert types
             props = self.props_from_args(args[1:])
 
         # convert types
-        for propname in props.keys():
-            # get the property
+        for propname, value in props.items():
             try:
             try:
-                proptype = properties[propname]
-            except KeyError:
-                raise UsageError, _('%(classname)s has no property '
-                    '"%(propname)s"')%locals()
-
-            if isinstance(proptype, hyperdb.Date):
-                try:
-                    props[propname] = date.Date(value)
-                except ValueError, message:
-                    raise UsageError, _('"%(value)s": %(message)s')%locals()
-            elif isinstance(proptype, hyperdb.Interval):
-                try:
-                    props[propname] = date.Interval(value)
-                except ValueError, message:
-                    raise UsageError, _('"%(value)s": %(message)s')%locals()
-            elif isinstance(proptype, hyperdb.Password):
-                props[propname] = password.Password(value)
-            elif isinstance(proptype, hyperdb.Multilink):
-                props[propname] = value.split(',')
+                props[propname] = hyperdb.rawToHyperdb(self.db, cl, None,
+                    propname, value)
+            except hyperdb.HyperdbValueError, message:
+                raise UsageError, message
 
         # check for the key property
         propname = cl.getkey()
 
         # check for the key property
         propname = cl.getkey()
@@ -569,7 +774,13 @@ Command help:
         specified, the  "label" property is used. The label property is tried
         in order: the key, "name", "title" and then the first property,
         alphabetically.
         specified, the  "label" property is used. The label property is tried
         in order: the key, "name", "title" and then the first property,
         alphabetically.
+
+        With -c, -S or -s print a list of item id's if no property specified.
+        If property specified, print list of that property for every class
+        instance.
         '''
         '''
+        if len(args) > 2:
+            raise UsageError, _('Too many arguments supplied')
         if len(args) < 1:
             raise UsageError, _('Not enough arguments supplied')
         classname = args[0]
         if len(args) < 1:
             raise UsageError, _('Not enough arguments supplied')
         classname = args[0]
@@ -583,8 +794,21 @@ Command help:
         else:
             propname = cl.labelprop()
 
         else:
             propname = cl.labelprop()
 
-        if self.comma_sep:
-            print ','.join(cl.list())
+        if self.separator:
+            if len(args) == 2:
+               # create a list of propnames since user specified propname
+                proplist=[]
+                for nodeid in cl.list():
+                    try:
+                        proplist.append(cl.get(nodeid, propname))
+                    except KeyError:
+                        raise UsageError, _('%(classname)s has no property '
+                            '"%(propname)s"')%locals()
+                print self.separator.join(proplist)
+            else:
+                # create a list of index id's since user didn't specify
+                # otherwise
+                print self.separator.join(cl.list())
         else:
             for nodeid in cl.list():
                 try:
         else:
             for nodeid in cl.list():
                 try:
@@ -601,14 +825,27 @@ Command help:
 
         Lists all instances of the given class. If the properties are not
         specified, all properties are displayed. By default, the column widths
 
         Lists all instances of the given class. If the properties are not
         specified, all properties are displayed. By default, the column widths
-        are the width of the property names. The width may be explicitly defined
+        are the width of the largest value. The width may be explicitly defined
         by defining the property as "name:width". For example::
         by defining the property as "name:width". For example::
+
           roundup> table priority id,name:10
           Id Name
           1  fatal-bug 
           2  bug       
           3  usability 
           4  feature   
           roundup> table priority id,name:10
           Id Name
           1  fatal-bug 
           2  bug       
           3  usability 
           4  feature   
+
+        Also to make the width of the column the width of the label,
+        leave a trailing : without a width on the property. For example::
+
+          roundup> table priority id,name:
+          Id Name
+          1  fata
+          2  bug       
+          3  usab
+          4  feat
+
+        will result in a the 4 character wide "Name" column.
         '''
         if len(args) < 1:
             raise UsageError, _('Not enough arguments supplied')
         '''
         if len(args) < 1:
             raise UsageError, _('Not enough arguments supplied')
@@ -640,10 +877,19 @@ Command help:
         for spec in prop_names:
             if ':' in spec:
                 name, width = spec.split(':')
         for spec in prop_names:
             if ':' in spec:
                 name, width = spec.split(':')
-                props.append((name, int(width)))
+                if width == '':
+                    props.append((name, len(spec)))
+                else:
+                    props.append((name, int(width)))
             else:
             else:
-                props.append((spec, len(spec)))
-
+               # this is going to be slow
+               maxlen = len(spec)
+               for nodeid in cl.list():
+                   curlen = len(str(cl.get(nodeid, spec)))
+                   if curlen > maxlen:
+                       maxlen = curlen
+               props.append((spec, maxlen))
+               
         # now display the heading
         print ' '.join([name.capitalize().ljust(width) for name,width in props])
 
         # now display the heading
         print ' '.join([name.capitalize().ljust(width) for name,width in props])
 
@@ -675,8 +921,8 @@ Command help:
         if len(args) < 1:
             raise UsageError, _('Not enough arguments supplied')
         try:
         if len(args) < 1:
             raise UsageError, _('Not enough arguments supplied')
         try:
-            classname, nodeid = roundupdb.splitDesignator(args[0])
-        except roundupdb.DesignatorError, message:
+            classname, nodeid = hyperdb.splitDesignator(args[0])
+        except hyperdb.DesignatorError, message:
             raise UsageError, message
 
         try:
             raise UsageError, message
 
         try:
@@ -725,8 +971,8 @@ Command help:
         designators = args[0].split(',')
         for designator in designators:
             try:
         designators = args[0].split(',')
         for designator in designators:
             try:
-                classname, nodeid = roundupdb.splitDesignator(designator)
-            except roundupdb.DesignatorError, message:
+                classname, nodeid = hyperdb.splitDesignator(designator)
+            except hyperdb.DesignatorError, message:
                 raise UsageError, message
             try:
                 self.db.getclass(classname).retire(nodeid)
                 raise UsageError, message
             try:
                 self.db.getclass(classname).retire(nodeid)
@@ -736,125 +982,205 @@ Command help:
                 raise UsageError, _('no such %(classname)s node "%(nodeid)s"')%locals()
         return 0
 
                 raise UsageError, _('no such %(classname)s node "%(nodeid)s"')%locals()
         return 0
 
+    def do_restore(self, args):
+        '''Usage: restore designator[,designator]*
+        Restore the retired node specified by designator.
+
+        The given nodes will become available for users again.
+        '''
+        if len(args) < 1:
+            raise UsageError, _('Not enough arguments supplied')
+        designators = args[0].split(',')
+        for designator in designators:
+            try:
+                classname, nodeid = hyperdb.splitDesignator(designator)
+            except hyperdb.DesignatorError, message:
+                raise UsageError, message
+            try:
+                self.db.getclass(classname).restore(nodeid)
+            except KeyError:
+                raise UsageError, _('no such class "%(classname)s"')%locals()
+            except IndexError:
+                raise UsageError, _('no such %(classname)s node "%(nodeid)s"')%locals()
+        return 0
+
     def do_export(self, args):
     def do_export(self, args):
-        '''Usage: export class[,class] destination_dir
-        Export the database to tab-separated-value files.
+        '''Usage: export [class[,class]] export_dir
+        Export the database to colon-separated-value files.
 
         This action exports the current data from the database into
 
         This action exports the current data from the database into
-        tab-separated-value files that are placed in the nominated destination
-        directory. The journals are not exported.
+        colon-separated-value files that are placed in the nominated
+        destination directory. The journals are not exported.
         '''
         '''
-        if len(args) < 2:
+        # grab the directory to export to
+        if len(args) < 1:
             raise UsageError, _('Not enough arguments supplied')
             raise UsageError, _('Not enough arguments supplied')
-        classes = args[0].split(',')
-        dir = args[1]
+        if rcsv.error:
+            raise UsageError, _(rcsv.error)
+
+        dir = args[-1]
 
 
-        # use the csv parser if we can - it's faster
-        if csv is not None:
-            p = csv.parser(field_sep=':')
+        # get the list of classes to export
+        if len(args) == 2:
+            classes = args[0].split(',')
+        else:
+            classes = self.db.classes.keys()
 
         # do all the classes specified
         for classname in classes:
             cl = self.get_class(classname)
             f = open(os.path.join(dir, classname+'.csv'), 'w')
 
         # do all the classes specified
         for classname in classes:
             cl = self.get_class(classname)
             f = open(os.path.join(dir, classname+'.csv'), 'w')
-            f.write(':'.join(cl.properties.keys()) + '\n')
+            writer = rcsv.writer(f, rcsv.colon_separated)
+            properties = cl.getprops()
+            propnames = properties.keys()
+            propnames.sort()
+            fields = propnames[:]
+            fields.append('is retired')
+            writer.writerow(fields)
 
 
-            # all nodes for this class
-            properties = cl.properties.items()
-            for nodeid in cl.list():
-                l = []
-                for prop, proptype in properties:
-                    value = cl.get(nodeid, prop)
-                    # convert data where needed
-                    if isinstance(proptype, hyperdb.Date):
-                        value = value.get_tuple()
-                    elif isinstance(proptype, hyperdb.Interval):
-                        value = value.get_tuple()
-                    elif isinstance(proptype, hyperdb.Password):
-                        value = str(value)
-                    l.append(repr(value))
-
-                # now write
-                if csv is not None:
-                   f.write(p.join(l) + '\n')
-                else:
-                   # escape the individual entries to they're valid CSV
-                   m = []
-                   for entry in l:
-                      if '"' in entry:
-                          entry = '""'.join(entry.split('"'))
-                      if ':' in entry:
-                          entry = '"%s"'%entry
-                      m.append(entry)
-                   f.write(':'.join(m) + '\n')
+            # all nodes for this class (not using list() 'cos it doesn't
+            # include retired nodes)
+
+            for nodeid in self.db.getclass(classname).getnodeids():
+                # get the regular props
+                writer.writerow (cl.export_list(propnames, nodeid))
+
+            # close this file
+            f.close()
         return 0
 
     def do_import(self, args):
         return 0
 
     def do_import(self, args):
-        '''Usage: import class file
-        Import the contents of the tab-separated-value file.
-
-        The file must define the same properties as the class (including having
-        a "header" line with those property names.) The new nodes are added to
-        the existing database - if you want to create a new database using the
-        imported data, then create a new database (or, tediously, retire all
-        the old data.)
+        '''Usage: import import_dir
+        Import a database from the directory containing CSV files, one per
+        class to import.
+
+        The files must define the same properties as the class (including having
+        a "header" line with those property names.)
+
+        The imported nodes will have the same nodeid as defined in the
+        import file, thus replacing any existing content.
+
+        The new nodes are added to the existing database - if you want to
+        create a new database using the imported data, then create a new
+        database (or, tediously, retire all the old data.)
         '''
         '''
-        if len(args) < 2:
+        if len(args) < 1:
             raise UsageError, _('Not enough arguments supplied')
             raise UsageError, _('Not enough arguments supplied')
-        if csv is None:
-            raise UsageError, \
-                _('Sorry, you need the csv module to use this function.\n'
-                'Get it from: http://www.object-craft.com.au/projects/csv/')
-
+        if rcsv.error:
+            raise UsageError, _(rcsv.error)
         from roundup import hyperdb
 
         from roundup import hyperdb
 
-        # ensure that the properties and the CSV file headings match
-        classname = args[0]
-        cl = self.get_class(classname)
-        f = open(args[1])
-        p = csv.parser(field_sep=':')
-        file_props = p.parse(f.readline())
-        props = cl.properties.keys()
-        m = file_props[:]
-        m.sort()
-        props.sort()
-        if m != props:
-            raise UsageError, _('Import file doesn\'t define the same '
-                'properties as "%(arg0)s".')%{'arg0': args[0]}
-
-        # loop through the file and create a node for each entry
-        n = range(len(props))
-        while 1:
-            line = f.readline()
-            if not line: break
-
-            # parse lines until we get a complete entry
-            while 1:
-                l = p.parse(line)
-                if l: break
-
-            # make the new node's property map
-            d = {}
-            for i in n:
-                # Use eval to reverse the repr() used to output the CSV
-                value = eval(l[i])
-                # Figure the property for this column
-                key = file_props[i]
-                proptype = cl.properties[key]
-                # Convert for property type
-                if isinstance(proptype, hyperdb.Date):
-                    value = date.Date(value)
-                elif isinstance(proptype, hyperdb.Interval):
-                    value = date.Interval(value)
-                elif isinstance(proptype, hyperdb.Password):
-                    pwd = password.Password()
-                    pwd.unpack(value)
-                    value = pwd
-                if value is not None:
-                    d[key] = value
-
-            # and create the new node
-            apply(cl.create, (), d)
+        for file in os.listdir(args[0]):
+            # we only care about CSV files
+            if not file.endswith('.csv'):
+                continue
+
+            f = open(os.path.join(args[0], file))
+
+            # get the classname
+            classname = os.path.splitext(file)[0]
+
+            # ensure that the properties and the CSV file headings match
+            cl = self.get_class(classname)
+            reader = rcsv.reader(f, rcsv.colon_separated)
+            file_props = None
+            maxid = 1
+
+            # loop through the file and create a node for each entry
+            for r in reader:
+                if file_props is None:
+                    file_props = r
+                    continue
+
+                # do the import and figure the current highest nodeid
+                maxid = max(maxid, int(cl.import_list(file_props, r)))
+
+            # set the id counter
+            print 'setting', classname, maxid+1
+            self.db.setid(classname, str(maxid+1))
+        return 0
+
+    def do_pack(self, args):
+        '''Usage: pack period | date
+
+Remove journal entries older than a period of time specified or
+before a certain date.
+
+A period is specified using the suffixes "y", "m", and "d". The
+suffix "w" (for "week") means 7 days.
+
+      "3y" means three years
+      "2y 1m" means two years and one month
+      "1m 25d" means one month and 25 days
+      "2w 3d" means two weeks and three days
+
+Date format is "YYYY-MM-DD" eg:
+    2001-01-01
+    
+        '''
+        if len(args) <> 1:
+            raise UsageError, _('Not enough arguments supplied')
+        
+        # are we dealing with a period or a date
+        value = args[0]
+        date_re = re.compile(r'''
+              (?P<date>\d\d\d\d-\d\d?-\d\d?)? # yyyy-mm-dd
+              (?P<period>(\d+y\s*)?(\d+m\s*)?(\d+d\s*)?)?
+              ''', re.VERBOSE)
+        m = date_re.match(value)
+        if not m:
+            raise ValueError, _('Invalid format')
+        m = m.groupdict()
+        if m['period']:
+            pack_before = date.Date(". - %s"%value)
+        elif m['date']:
+            pack_before = date.Date(value)
+        self.db.pack(pack_before)
+        return 0
+
+    def do_reindex(self, args):
+        '''Usage: reindex
+        Re-generate a tracker's search indexes.
+
+        This will re-generate the search indexes for a tracker. This will
+        typically happen automatically.
+        '''
+        self.db.indexer.force_reindex()
+        self.db.reindex()
+        return 0
+
+    def do_security(self, args):
+        '''Usage: security [Role name]
+        Display the Permissions available to one or all Roles.
+        '''
+        if len(args) == 1:
+            role = args[0]
+            try:
+                roles = [(args[0], self.db.security.role[args[0]])]
+            except KeyError:
+                print _('No such Role "%(role)s"')%locals()
+                return 1
+        else:
+            roles = self.db.security.role.items()
+            role = self.db.config.NEW_WEB_USER_ROLES
+            if ',' in role:
+                print _('New Web users get the Roles "%(role)s"')%locals()
+            else:
+                print _('New Web users get the Role "%(role)s"')%locals()
+            role = self.db.config.NEW_EMAIL_USER_ROLES
+            if ',' in role:
+                print _('New Email users get the Roles "%(role)s"')%locals()
+            else:
+                print _('New Email users get the Role "%(role)s"')%locals()
+        roles.sort()
+        for rolename, role in roles:
+            print _('Role "%(name)s":')%role.__dict__
+            for permission in role.permissions:
+                if permission.klass:
+                    print _(' %(description)s (%(name)s for "%(klass)s" '
+                        'only)')%permission.__dict__
+                else:
+                    print _(' %(description)s (%(name)s)')%permission.__dict__
         return 0
 
     def run_command(self, args):
         return 0
 
     def run_command(self, args):
@@ -891,25 +1217,35 @@ Command help:
             return 1
         command, function = functions[0]
 
             return 1
         command, function = functions[0]
 
-        # make sure we have an instance_home
-        while not self.instance_home:
-            self.instance_home = raw_input(_('Enter instance home: ')).strip()
+        # make sure we have a tracker_home
+        while not self.tracker_home:
+            self.tracker_home = raw_input(_('Enter tracker home: ')).strip()
 
 
-        # before we open the db, we may be doing an init
+        # before we open the db, we may be doing an install or init
         if command == 'initialise':
         if command == 'initialise':
-            return self.do_initialise(self.instance_home, args)
+            try:
+                return self.do_initialise(self.tracker_home, args)
+            except UsageError, message:
+                print _('Error: %(message)s')%locals()
+                return 1
+        elif command == 'install':
+            try:
+                return self.do_install(self.tracker_home, args)
+            except UsageError, message:
+                print _('Error: %(message)s')%locals()
+                return 1
 
 
-        # get the instance
+        # get the tracker
         try:
         try:
-            instance = roundup.instance.open(self.instance_home)
+            tracker = roundup.instance.open(self.tracker_home)
         except ValueError, message:
         except ValueError, message:
-            self.instance_home = ''
-            print _("Couldn't open instance: %(message)s")%locals()
+            self.tracker_home = ''
+            print _("Error: Couldn't open tracker: %(message)s")%locals()
             return 1
 
         # only open the database once!
         if not self.db:
             return 1
 
         # only open the database once!
         if not self.db:
-            self.db = instance.open('admin')
+            self.db = tracker.open('admin')
 
         # do the command
         ret = 0
 
         # do the command
         ret = 0
@@ -917,6 +1253,7 @@ Command help:
             ret = function(args[1:])
         except UsageError, message:
             print _('Error: %(message)s')%locals()
             ret = function(args[1:])
         except UsageError, message:
             print _('Error: %(message)s')%locals()
+            print
             print function.__doc__
             ret = 1
         except:
             print function.__doc__
             ret = 1
         except:
@@ -928,7 +1265,7 @@ Command help:
     def interactive(self):
         '''Run in an interactive mode
         '''
     def interactive(self):
         '''Run in an interactive mode
         '''
-        print _('Roundup {version} ready for input.')
+        print _('Roundup %s ready for input.'%roundup_version)
         print _('Type "help" for help.')
         try:
             import readline
         print _('Type "help" for help.')
         try:
             import readline
@@ -956,57 +1293,62 @@ Command help:
 
     def main(self):
         try:
 
     def main(self):
         try:
-            opts, args = getopt.getopt(sys.argv[1:], 'i:u:hc')
+            opts, args = getopt.getopt(sys.argv[1:], 'i:u:hcdsS:')
         except getopt.GetoptError, e:
             self.usage(str(e))
             return 1
 
         # handle command-line args
         except getopt.GetoptError, e:
             self.usage(str(e))
             return 1
 
         # handle command-line args
-        self.instance_home = os.environ.get('ROUNDUP_INSTANCE', '')
+        self.tracker_home = os.environ.get('TRACKER_HOME', '')
+        # TODO: reinstate the user/password stuff (-u arg too)
         name = password = ''
         if os.environ.has_key('ROUNDUP_LOGIN'):
             l = os.environ['ROUNDUP_LOGIN'].split(':')
             name = l[0]
             if len(l) > 1:
                 password = l[1]
         name = password = ''
         if os.environ.has_key('ROUNDUP_LOGIN'):
             l = os.environ['ROUNDUP_LOGIN'].split(':')
             name = l[0]
             if len(l) > 1:
                 password = l[1]
-        self.comma_sep = 0
+        self.separator = None
+        self.print_designator = 0
         for opt, arg in opts:
             if opt == '-h':
                 self.usage()
                 return 0
             if opt == '-i':
         for opt, arg in opts:
             if opt == '-h':
                 self.usage()
                 return 0
             if opt == '-i':
-                self.instance_home = arg
+                self.tracker_home = arg
             if opt == '-c':
             if opt == '-c':
-                self.comma_sep = 1
+                if self.separator != None:
+                    self.usage('Only one of -c, -S and -s may be specified')
+                    return 1
+                self.separator = ','
+            if opt == '-S':
+                if self.separator != None:
+                    self.usage('Only one of -c, -S and -s may be specified')
+                    return 1
+                self.separator = arg
+            if opt == '-s':
+                if self.separator != None:
+                    self.usage('Only one of -c, -S and -s may be specified')
+                    return 1
+                self.separator = ' '
+            if opt == '-d':
+                self.print_designator = 1
 
         # if no command - go interactive
 
         # if no command - go interactive
+        # wrap in a try/finally so we always close off the db
         ret = 0
         ret = 0
-        if not args:
-            self.interactive()
-        else:
-            ret = self.run_command(args)
-            if self.db: self.db.commit()
-        return ret
-
+        try:
+            if not args:
+                self.interactive()
+            else:
+                ret = self.run_command(args)
+                if self.db: self.db.commit()
+            return ret
+        finally:
+            if self.db:
+                self.db.close()
 
 if __name__ == '__main__':
     tool = AdminTool()
     sys.exit(tool.main())
 
 
 if __name__ == '__main__':
     tool = AdminTool()
     sys.exit(tool.main())
 
-#
-# $Log: not supported by cvs2svn $
-# Revision 1.3  2002/01/08 05:26:32  rochecompaan
-# Missing "self" in props_from_args
-#
-# Revision 1.2  2002/01/07 10:41:44  richard
-# #500140 ] AdminTool.get_class() returns nothing
-#
-# Revision 1.1  2002/01/05 02:11:22  richard
-# I18N'ed roundup admin - and split the code off into a module so it can be used
-# elsewhere.
-# Big issue with this is the doc strings - that's the help. We're probably going to
-# have to switch to not use docstrings, which will suck a little :(
-#
-#
-#
 # vim: set filetype=python ts=4 sw=4 et si
 # vim: set filetype=python ts=4 sw=4 et si