Code

That's the last of the RDBMS migration steps done! Yay!
[roundup.git] / roundup / admin.py
1 #! /usr/bin/env python
2 #
3 # Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/)
4 # This module is free software, and you may redistribute it and/or modify
5 # under the same terms as Python, so long as this copyright message and
6 # disclaimer are retained in their original form.
7 #
8 # IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR
9 # DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING
10 # OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE
11 # POSSIBILITY OF SUCH DAMAGE.
12 #
13 # BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING,
14 # BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
15 # FOR A PARTICULAR PURPOSE.  THE CODE PROVIDED HEREUNDER IS ON AN "AS IS"
16 # BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
17 # SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
18
19 # $Id: admin.py,v 1.63 2004-03-21 23:39:08 richard Exp $
21 '''Administration commands for maintaining Roundup trackers.
22 '''
23 __docformat__ = 'restructuredtext'
25 import sys, os, getpass, getopt, re, UserDict, shutil, rfc822
26 from roundup import date, hyperdb, roundupdb, init, password, token, rcsv
27 from roundup import __version__ as roundup_version
28 import roundup.instance
29 from roundup.i18n import _
31 class CommandDict(UserDict.UserDict):
32     '''Simple dictionary that lets us do lookups using partial keys.
34     Original code submitted by Engelbert Gruber.
35     '''
36     _marker = []
37     def get(self, key, default=_marker):
38         if self.data.has_key(key):
39             return [(key, self.data[key])]
40         keylist = self.data.keys()
41         keylist.sort()
42         l = []
43         for ki in keylist:
44             if ki.startswith(key):
45                 l.append((ki, self.data[ki]))
46         if not l and default is self._marker:
47             raise KeyError, key
48         return l
50 class UsageError(ValueError):
51     pass
53 class AdminTool:
54     ''' A collection of methods used in maintaining Roundup trackers.
56         Typically these methods are accessed through the roundup-admin
57         script. The main() method provided on this class gives the main
58         loop for the roundup-admin script.
60         Actions are defined by do_*() methods, with help for the action
61         given in the method docstring.
63         Additional help may be supplied by help_*() methods.
64     '''
65     def __init__(self):
66         self.commands = CommandDict()
67         for k in AdminTool.__dict__.keys():
68             if k[:3] == 'do_':
69                 self.commands[k[3:]] = getattr(self, k)
70         self.help = {}
71         for k in AdminTool.__dict__.keys():
72             if k[:5] == 'help_':
73                 self.help[k[5:]] = getattr(self, k)
74         self.tracker_home = ''
75         self.db = None
77     def get_class(self, classname):
78         '''Get the class - raise an exception if it doesn't exist.
79         '''
80         try:
81             return self.db.getclass(classname)
82         except KeyError:
83             raise UsageError, _('no such class "%(classname)s"')%locals()
85     def props_from_args(self, args):
86         ''' Produce a dictionary of prop: value from the args list.
88             The args list is specified as ``prop=value prop=value ...``.
89         '''
90         props = {}
91         for arg in args:
92             if arg.find('=') == -1:
93                 raise UsageError, _('argument "%(arg)s" not propname=value'
94                     )%locals()
95             l = arg.split('=')
96             if len(l) < 2:
97                 raise UsageError, _('argument "%(arg)s" not propname=value'
98                     )%locals()
99             key, value = l[0], '='.join(l[1:])
100             if value:
101                 props[key] = value
102             else:
103                 props[key] = None
104         return props
106     def usage(self, message=''):
107         ''' Display a simple usage message.
108         '''
109         if message:
110             message = _('Problem: %(message)s\n\n')%locals()
111         print _('''%(message)sUsage: roundup-admin [options] [<command> <arguments>]
113 Options:
114  -i instance home  -- specify the issue tracker "home directory" to administer
115  -u                -- the user[:password] to use for commands
116  -d                -- print full designators not just class id numbers
117  -c                -- when outputting lists of data, comma-separate them.
118                       Same as '-S ","'.
119  -S <string>       -- when outputting lists of data, string-separate them
120  -s                -- when outputting lists of data, space-separate them.
121                       Same as '-S " "'.
123  Only one of -s, -c or -S can be specified.
125 Help:
126  roundup-admin -h
127  roundup-admin help                       -- this help
128  roundup-admin help <command>             -- command-specific help
129  roundup-admin help all                   -- all available help
130 ''')%locals()
131         self.help_commands()
133     def help_commands(self):
134         ''' List the commands available with their precis help.
135         '''
136         print _('Commands:'),
137         commands = ['']
138         for command in self.commands.values():
139             h = command.__doc__.split('\n')[0]
140             commands.append(' '+h[7:])
141         commands.sort()
142         commands.append(_('Commands may be abbreviated as long as the abbreviation matches only one'))
143         commands.append(_('command, e.g. l == li == lis == list.'))
144         print '\n'.join(commands)
145         print
147     def help_commands_html(self, indent_re=re.compile(r'^(\s+)\S+')):
148         ''' Produce an HTML command list.
149         '''
150         commands = self.commands.values()
151         def sortfun(a, b):
152             return cmp(a.__name__, b.__name__)
153         commands.sort(sortfun)
154         for command in commands:
155             h = command.__doc__.split('\n')
156             name = command.__name__[3:]
157             usage = h[0]
158             print _('''
159 <tr><td valign=top><strong>%(name)s</strong></td>
160     <td><tt>%(usage)s</tt><p>
161 <pre>''')%locals()
162             indent = indent_re.match(h[3])
163             if indent: indent = len(indent.group(1))
164             for line in h[3:]:
165                 if indent:
166                     print line[indent:]
167                 else:
168                     print line
169             print _('</pre></td></tr>\n')
171     def help_all(self):
172         print _('''
173 All commands (except help) require a tracker specifier. This is just the path
174 to the roundup tracker you're working with. A roundup tracker is where 
175 roundup keeps the database and configuration file that defines an issue
176 tracker. It may be thought of as the issue tracker's "home directory". It may
177 be specified in the environment variable TRACKER_HOME or on the command
178 line as "-i tracker".
180 A designator is a classname and a nodeid concatenated, eg. bug1, user10, ...
182 Property values are represented as strings in command arguments and in the
183 printed results:
184  . Strings are, well, strings.
185  . Date values are printed in the full date format in the local time zone, and
186    accepted in the full format or any of the partial formats explained below.
187  . Link values are printed as node designators. When given as an argument,
188    node designators and key strings are both accepted.
189  . Multilink values are printed as lists of node designators joined by commas.
190    When given as an argument, node designators and key strings are both
191    accepted; an empty string, a single node, or a list of nodes joined by
192    commas is accepted.
194 When property values must contain spaces, just surround the value with
195 quotes, either ' or ". A single space may also be backslash-quoted. If a
196 valuu must contain a quote character, it must be backslash-quoted or inside
197 quotes. Examples:
198            hello world      (2 tokens: hello, world)
199            "hello world"    (1 token: hello world)
200            "Roch'e" Compaan (2 tokens: Roch'e Compaan)
201            Roch\'e Compaan  (2 tokens: Roch'e Compaan)
202            address="1 2 3"  (1 token: address=1 2 3)
203            \\               (1 token: \)
204            \n\r\t           (1 token: a newline, carriage-return and tab)
206 When multiple nodes are specified to the roundup get or roundup set
207 commands, the specified properties are retrieved or set on all the listed
208 nodes. 
210 When multiple results are returned by the roundup get or roundup find
211 commands, they are printed one per line (default) or joined by commas (with
212 the -c) option. 
214 Where the command changes data, a login name/password is required. The
215 login may be specified as either "name" or "name:password".
216  . ROUNDUP_LOGIN environment variable
217  . the -u command-line option
218 If either the name or password is not supplied, they are obtained from the
219 command-line. 
221 Date format examples:
222   "2000-04-17.03:45" means <Date 2000-04-17.08:45:00>
223   "2000-04-17" means <Date 2000-04-17.00:00:00>
224   "01-25" means <Date yyyy-01-25.00:00:00>
225   "08-13.22:13" means <Date yyyy-08-14.03:13:00>
226   "11-07.09:32:43" means <Date yyyy-11-07.14:32:43>
227   "14:25" means <Date yyyy-mm-dd.19:25:00>
228   "8:47:11" means <Date yyyy-mm-dd.13:47:11>
229   "." means "right now"
231 Command help:
232 ''')
233         for name, command in self.commands.items():
234             print _('%s:')%name
235             print _('   '), command.__doc__
237     def do_help(self, args, nl_re=re.compile('[\r\n]'),
238             indent_re=re.compile(r'^(\s+)\S+')):
239         '''Usage: help topic
240         Give help about topic.
242         commands  -- list commands
243         <command> -- help specific to a command
244         initopts  -- init command options
245         all       -- all available help
246         '''
247         if len(args)>0:
248             topic = args[0]
249         else:
250             topic = 'help'
251  
253         # try help_ methods
254         if self.help.has_key(topic):
255             self.help[topic]()
256             return 0
258         # try command docstrings
259         try:
260             l = self.commands.get(topic)
261         except KeyError:
262             print _('Sorry, no help for "%(topic)s"')%locals()
263             return 1
265         # display the help for each match, removing the docsring indent
266         for name, help in l:
267             lines = nl_re.split(help.__doc__)
268             print lines[0]
269             indent = indent_re.match(lines[1])
270             if indent: indent = len(indent.group(1))
271             for line in lines[1:]:
272                 if indent:
273                     print line[indent:]
274                 else:
275                     print line
276         return 0
278     def listTemplates(self):
279         ''' List all the available templates.
281         Look in the following places, where the later rules take precedence:
283          1. <prefix>/share/roundup/templates/*
284             this should be the standard place to find them when Roundup is
285             installed
286          2. <roundup.admin.__file__>/../templates/*
287             this will be used if Roundup's run in the distro (aka. source)
288             directory
289          3. <current working dir>/*
290             this is for when someone unpacks a 3rd-party template
291          4. <current working dir>
292             this is for someone who "cd"s to the 3rd-party template dir
293         '''
294         # OK, try <prefix>/share/roundup/templates
295         # -- this module (roundup.admin) will be installed in something
296         # like:
297         #    /usr/lib/python2.2/site-packages/roundup/admin.py  (5 dirs up)
298         #    c:\python22\lib\site-packages\roundup\admin.py     (4 dirs up)
299         # we're interested in where the "lib" directory is - ie. the /usr/
300         # part
301         templates = {}
302         for N in 4, 5:
303             path = __file__
304             # move up N elements in the path
305             for i in range(N):
306                 path = os.path.dirname(path)
307             tdir = os.path.join(path, 'share', 'roundup', 'templates')
308             if os.path.isdir(tdir):
309                 templates = init.listTemplates(tdir)
310                 break
312         # OK, now try as if we're in the roundup source distribution
313         # directory, so this module will be in .../roundup-*/roundup/admin.py
314         # and we're interested in the .../roundup-*/ part.
315         path = __file__
316         for i in range(2):
317             path = os.path.dirname(path)
318         tdir = os.path.join(path, 'templates')
319         if os.path.isdir(tdir):
320             templates.update(init.listTemplates(tdir))
322         # Try subdirs of the current dir
323         templates.update(init.listTemplates(os.getcwd()))
325         # Finally, try the current directory as a template
326         template = init.loadTemplateInfo(os.getcwd())
327         if template:
328             templates[template['name']] = template
330         return templates
332     def help_initopts(self):
333         templates = self.listTemplates()
334         print _('Templates:'), ', '.join(templates.keys())
335         import roundup.backends
336         backends = roundup.backends.__all__
337         print _('Back ends:'), ', '.join(backends)
339     def do_install(self, tracker_home, args):
340         '''Usage: install [template [backend [admin password]]]
341         Install a new Roundup tracker.
343         The command will prompt for the tracker home directory (if not supplied
344         through TRACKER_HOME or the -i option). The template, backend and admin
345         password may be specified on the command-line as arguments, in that
346         order.
348         The initialise command must be called after this command in order
349         to initialise the tracker's database. You may edit the tracker's
350         initial database contents before running that command by editing
351         the tracker's dbinit.py module init() function.
353         See also initopts help.
354         '''
355         if len(args) < 1:
356             raise UsageError, _('Not enough arguments supplied')
358         # make sure the tracker home can be created
359         parent = os.path.split(tracker_home)[0]
360         if not os.path.exists(parent):
361             raise UsageError, _('Instance home parent directory "%(parent)s"'
362                 ' does not exist')%locals()
364         # select template
365         templates = self.listTemplates()
366         template = len(args) > 1 and args[1] or ''
367         if not templates.has_key(template):
368             print _('Templates:'), ', '.join(templates.keys())
369         while not templates.has_key(template):
370             template = raw_input(_('Select template [classic]: ')).strip()
371             if not template:
372                 template = 'classic'
374         # select hyperdb backend
375         import roundup.backends
376         backends = roundup.backends.__all__
377         backend = len(args) > 2 and args[2] or ''
378         if backend not in backends:
379             print _('Back ends:'), ', '.join(backends)
380         while backend not in backends:
381             backend = raw_input(_('Select backend [anydbm]: ')).strip()
382             if not backend:
383                 backend = 'anydbm'
384         # XXX perform a unit test based on the user's selections
386         # install!
387         init.install(tracker_home, templates[template]['path'])
388         init.write_select_db(tracker_home, backend)
390         print _('''
391  You should now edit the tracker configuration file:
392    %(config_file)s
393  ... at a minimum, you must set MAILHOST, TRACKER_WEB, MAIL_DOMAIN and
394  ADMIN_EMAIL.
396  If you wish to modify the default schema, you should also edit the database
397  initialisation file:
398    %(database_config_file)s
399  ... see the documentation on customizing for more information.
400 ''')%{
401     'config_file': os.path.join(tracker_home, 'config.py'),
402     'database_config_file': os.path.join(tracker_home, 'dbinit.py')
404         return 0
407     def do_initialise(self, tracker_home, args):
408         '''Usage: initialise [adminpw]
409         Initialise a new Roundup tracker.
411         The administrator details will be set at this step.
413         Execute the tracker's initialisation function dbinit.init()
414         '''
415         # password
416         if len(args) > 1:
417             adminpw = args[1]
418         else:
419             adminpw = ''
420             confirm = 'x'
421             while adminpw != confirm:
422                 adminpw = getpass.getpass(_('Admin Password: '))
423                 confirm = getpass.getpass(_('       Confirm: '))
425         # make sure the tracker home is installed
426         if not os.path.exists(tracker_home):
427             raise UsageError, _('Instance home does not exist')%locals()
428         try:
429             tracker = roundup.instance.open(tracker_home)
430         except roundup.instance.TrackerError:
431             raise UsageError, _('Instance has not been installed')%locals()
433         # is there already a database?
434         try:
435             db_exists = tracker.select_db.Database.exists(tracker.config)
436         except AttributeError:
437             # TODO: move this code to exists() static method in every backend
438             db_exists = os.path.exists(os.path.join(tracker_home, 'db'))
439         if db_exists:
440             print _('WARNING: The database is already initialised!')
441             print _('If you re-initialise it, you will lose all the data!')
442             ok = raw_input(_('Erase it? Y/[N]: ')).strip()
443             if ok.lower() != 'y':
444                 return 0
446             # Get a database backend in use by tracker
447             try:
448                 # nuke it
449                 tracker.select_db.Database.nuke(tracker.config)
450             except AttributeError:
451                 # TODO: move this code to nuke() static method in every backend
452                 shutil.rmtree(os.path.join(tracker_home, 'db'))
454         # GO
455         init.initialise(tracker_home, adminpw)
457         return 0
460     def do_get(self, args):
461         '''Usage: get property designator[,designator]*
462         Get the given property of one or more designator(s).
464         Retrieves the property value of the nodes specified by the designators.
465         '''
466         if len(args) < 2:
467             raise UsageError, _('Not enough arguments supplied')
468         propname = args[0]
469         designators = args[1].split(',')
470         l = []
471         for designator in designators:
472             # decode the node designator
473             try:
474                 classname, nodeid = hyperdb.splitDesignator(designator)
475             except hyperdb.DesignatorError, message:
476                 raise UsageError, message
478             # get the class
479             cl = self.get_class(classname)
480             try:
481                 id=[]
482                 if self.separator:
483                     if self.print_designator:
484                         # see if property is a link or multilink for
485                         # which getting a desginator make sense.
486                         # Algorithm: Get the properties of the
487                         #     current designator's class. (cl.getprops)
488                         # get the property object for the property the
489                         #     user requested (properties[propname])
490                         # verify its type (isinstance...)
491                         # raise error if not link/multilink
492                         # get class name for link/multilink property
493                         # do the get on the designators
494                         # append the new designators
495                         # print
496                         properties = cl.getprops()
497                         property = properties[propname]
498                         if not (isinstance(property, hyperdb.Multilink) or
499                           isinstance(property, hyperdb.Link)):
500                             raise UsageError, _('property %s is not of type Multilink or Link so -d flag does not apply.')%propname
501                         propclassname = self.db.getclass(property.classname).classname
502                         id = cl.get(nodeid, propname)
503                         for i in id:
504                             l.append(propclassname + i)
505                     else:
506                         id = cl.get(nodeid, propname)
507                         for i in id:
508                             l.append(i)
509                 else:
510                     if self.print_designator:
511                         properties = cl.getprops()
512                         property = properties[propname]
513                         if not (isinstance(property, hyperdb.Multilink) or
514                           isinstance(property, hyperdb.Link)):
515                             raise UsageError, _('property %s is not of type Multilink or Link so -d flag does not apply.')%propname
516                         propclassname = self.db.getclass(property.classname).classname
517                         id = cl.get(nodeid, propname)
518                         for i in id:
519                             print propclassname + i
520                     else:
521                         print cl.get(nodeid, propname)
522             except IndexError:
523                 raise UsageError, _('no such %(classname)s node "%(nodeid)s"')%locals()
524             except KeyError:
525                 raise UsageError, _('no such %(classname)s property '
526                     '"%(propname)s"')%locals()
527         if self.separator:
528             print self.separator.join(l)
530         return 0
533     def do_set(self, args, pwre = re.compile(r'{(\w+)}(.+)')):
534         '''Usage: set items property=value property=value ...
535         Set the given properties of one or more items(s).
537         The items are specified as a class or as a comma-separated
538         list of item designators (ie "designator[,designator,...]").
540         This command sets the properties to the values for all designators
541         given. If the value is missing (ie. "property=") then the property is
542         un-set. If the property is a multilink, you specify the linked ids
543         for the multilink as comma-separated numbers (ie "1,2,3").
544         '''
545         if len(args) < 2:
546             raise UsageError, _('Not enough arguments supplied')
547         from roundup import hyperdb
549         designators = args[0].split(',')
550         if len(designators) == 1:
551             designator = designators[0]
552             try:
553                 designator = hyperdb.splitDesignator(designator)
554                 designators = [designator]
555             except hyperdb.DesignatorError:
556                 cl = self.get_class(designator)
557                 designators = [(designator, x) for x in cl.list()]
558         else:
559             try:
560                 designators = [hyperdb.splitDesignator(x) for x in designators]
561             except hyperdb.DesignatorError, message:
562                 raise UsageError, message
564         # get the props from the args
565         props = self.props_from_args(args[1:])
567         # now do the set for all the nodes
568         for classname, itemid in designators:
569             cl = self.get_class(classname)
571             properties = cl.getprops()
572             for key, value in props.items():
573                 try:
574                     props[key] = hyperdb.rawToHyperdb(self.db, cl, itemid,
575                         key, value)
576                 except hyperdb.HyperdbValueError, message:
577                     raise UsageError, message
579             # try the set
580             try:
581                 apply(cl.set, (itemid, ), props)
582             except (TypeError, IndexError, ValueError), message:
583                 import traceback; traceback.print_exc()
584                 raise UsageError, message
585         return 0
587     def do_find(self, args):
588         '''Usage: find classname propname=value ...
589         Find the nodes of the given class with a given link property value.
591         Find the nodes of the given class with a given link property value. The
592         value may be either the nodeid of the linked node, or its key value.
593         '''
594         if len(args) < 1:
595             raise UsageError, _('Not enough arguments supplied')
596         classname = args[0]
597         # get the class
598         cl = self.get_class(classname)
600         # handle the propname=value argument
601         props = self.props_from_args(args[1:])
603         # if the value isn't a number, look up the linked class to get the
604         # number
605         for propname, value in props.items():
606             num_re = re.compile('^\d+$')
607             if value == '-1':
608                 props[propname] = None
609             elif not num_re.match(value):
610                 # get the property
611                 try:
612                     property = cl.properties[propname]
613                 except KeyError:
614                     raise UsageError, _('%(classname)s has no property '
615                         '"%(propname)s"')%locals()
617                 # make sure it's a link
618                 if (not isinstance(property, hyperdb.Link) and not
619                         isinstance(property, hyperdb.Multilink)):
620                     raise UsageError, _('You may only "find" link properties')
622                 # get the linked-to class and look up the key property
623                 link_class = self.db.getclass(property.classname)
624                 try:
625                     props[propname] = link_class.lookup(value)
626                 except TypeError:
627                     raise UsageError, _('%(classname)s has no key property"')%{
628                         'classname': link_class.classname}
630         # now do the find 
631         try:
632             id = []
633             designator = []
634             if self.separator:
635                 if self.print_designator:
636                     id=apply(cl.find, (), props)
637                     for i in id:
638                         designator.append(classname + i)
639                     print self.separator.join(designator)
640                 else:
641                     print self.separator.join(apply(cl.find, (), props))
643             else:
644                 if self.print_designator:
645                     id=apply(cl.find, (), props)
646                     for i in id:
647                         designator.append(classname + i)
648                     print designator
649                 else:
650                     print apply(cl.find, (), props)
651         except KeyError:
652             raise UsageError, _('%(classname)s has no property '
653                 '"%(propname)s"')%locals()
654         except (ValueError, TypeError), message:
655             raise UsageError, message
656         return 0
658     def do_specification(self, args):
659         '''Usage: specification classname
660         Show the properties for a classname.
662         This lists the properties for a given class.
663         '''
664         if len(args) < 1:
665             raise UsageError, _('Not enough arguments supplied')
666         classname = args[0]
667         # get the class
668         cl = self.get_class(classname)
670         # get the key property
671         keyprop = cl.getkey()
672         for key, value in cl.properties.items():
673             if keyprop == key:
674                 print _('%(key)s: %(value)s (key property)')%locals()
675             else:
676                 print _('%(key)s: %(value)s')%locals()
678     def do_display(self, args):
679         '''Usage: display designator[,designator]*
680         Show the property values for the given node(s).
682         This lists the properties and their associated values for the given
683         node.
684         '''
685         if len(args) < 1:
686             raise UsageError, _('Not enough arguments supplied')
688         # decode the node designator
689         for designator in args[0].split(','):
690             try:
691                 classname, nodeid = hyperdb.splitDesignator(designator)
692             except hyperdb.DesignatorError, message:
693                 raise UsageError, message
695             # get the class
696             cl = self.get_class(classname)
698             # display the values
699             keys = cl.properties.keys()
700             keys.sort()
701             for key in keys:
702                 value = cl.get(nodeid, key)
703                 print _('%(key)s: %(value)s')%locals()
705     def do_create(self, args, pwre = re.compile(r'{(\w+)}(.+)')):
706         '''Usage: create classname property=value ...
707         Create a new entry of a given class.
709         This creates a new entry of the given class using the property
710         name=value arguments provided on the command line after the "create"
711         command.
712         '''
713         if len(args) < 1:
714             raise UsageError, _('Not enough arguments supplied')
715         from roundup import hyperdb
717         classname = args[0]
719         # get the class
720         cl = self.get_class(classname)
722         # now do a create
723         props = {}
724         properties = cl.getprops(protected = 0)
725         if len(args) == 1:
726             # ask for the properties
727             for key, value in properties.items():
728                 if key == 'id': continue
729                 name = value.__class__.__name__
730                 if isinstance(value , hyperdb.Password):
731                     again = None
732                     while value != again:
733                         value = getpass.getpass(_('%(propname)s (Password): ')%{
734                             'propname': key.capitalize()})
735                         again = getpass.getpass(_('   %(propname)s (Again): ')%{
736                             'propname': key.capitalize()})
737                         if value != again: print _('Sorry, try again...')
738                     if value:
739                         props[key] = value
740                 else:
741                     value = raw_input(_('%(propname)s (%(proptype)s): ')%{
742                         'propname': key.capitalize(), 'proptype': name})
743                     if value:
744                         props[key] = value
745         else:
746             props = self.props_from_args(args[1:])
748         # convert types
749         for propname, value in props.items():
750             try:
751                 props[propname] = hyperdb.rawToHyperdb(self.db, cl, None,
752                     propname, value)
753             except hyperdb.HyperdbValueError, message:
754                 raise UsageError, message
756         # check for the key property
757         propname = cl.getkey()
758         if propname and not props.has_key(propname):
759             raise UsageError, _('you must provide the "%(propname)s" '
760                 'property.')%locals()
762         # do the actual create
763         try:
764             print apply(cl.create, (), props)
765         except (TypeError, IndexError, ValueError), message:
766             raise UsageError, message
767         return 0
769     def do_list(self, args):
770         '''Usage: list classname [property]
771         List the instances of a class.
773         Lists all instances of the given class. If the property is not
774         specified, the  "label" property is used. The label property is tried
775         in order: the key, "name", "title" and then the first property,
776         alphabetically.
778         With -c, -S or -s print a list of item id's if no property specified.
779         If property specified, print list of that property for every class
780         instance.
781         '''
782         if len(args) > 2:
783             raise UsageError, _('Too many arguments supplied')
784         if len(args) < 1:
785             raise UsageError, _('Not enough arguments supplied')
786         classname = args[0]
788         # get the class
789         cl = self.get_class(classname)
791         # figure the property
792         if len(args) > 1:
793             propname = args[1]
794         else:
795             propname = cl.labelprop()
797         if self.separator:
798             if len(args) == 2:
799                # create a list of propnames since user specified propname
800                 proplist=[]
801                 for nodeid in cl.list():
802                     try:
803                         proplist.append(cl.get(nodeid, propname))
804                     except KeyError:
805                         raise UsageError, _('%(classname)s has no property '
806                             '"%(propname)s"')%locals()
807                 print self.separator.join(proplist)
808             else:
809                 # create a list of index id's since user didn't specify
810                 # otherwise
811                 print self.separator.join(cl.list())
812         else:
813             for nodeid in cl.list():
814                 try:
815                     value = cl.get(nodeid, propname)
816                 except KeyError:
817                     raise UsageError, _('%(classname)s has no property '
818                         '"%(propname)s"')%locals()
819                 print _('%(nodeid)4s: %(value)s')%locals()
820         return 0
822     def do_table(self, args):
823         '''Usage: table classname [property[,property]*]
824         List the instances of a class in tabular form.
826         Lists all instances of the given class. If the properties are not
827         specified, all properties are displayed. By default, the column widths
828         are the width of the largest value. The width may be explicitly defined
829         by defining the property as "name:width". For example::
831           roundup> table priority id,name:10
832           Id Name
833           1  fatal-bug 
834           2  bug       
835           3  usability 
836           4  feature   
838         Also to make the width of the column the width of the label,
839         leave a trailing : without a width on the property. For example::
841           roundup> table priority id,name:
842           Id Name
843           1  fata
844           2  bug       
845           3  usab
846           4  feat
848         will result in a the 4 character wide "Name" column.
849         '''
850         if len(args) < 1:
851             raise UsageError, _('Not enough arguments supplied')
852         classname = args[0]
854         # get the class
855         cl = self.get_class(classname)
857         # figure the property names to display
858         if len(args) > 1:
859             prop_names = args[1].split(',')
860             all_props = cl.getprops()
861             for spec in prop_names:
862                 if ':' in spec:
863                     try:
864                         propname, width = spec.split(':')
865                     except (ValueError, TypeError):
866                         raise UsageError, _('"%(spec)s" not name:width')%locals()
867                 else:
868                     propname = spec
869                 if not all_props.has_key(propname):
870                     raise UsageError, _('%(classname)s has no property '
871                         '"%(propname)s"')%locals()
872         else:
873             prop_names = cl.getprops().keys()
875         # now figure column widths
876         props = []
877         for spec in prop_names:
878             if ':' in spec:
879                 name, width = spec.split(':')
880                 if width == '':
881                     props.append((name, len(spec)))
882                 else:
883                     props.append((name, int(width)))
884             else:
885                # this is going to be slow
886                maxlen = len(spec)
887                for nodeid in cl.list():
888                    curlen = len(str(cl.get(nodeid, spec)))
889                    if curlen > maxlen:
890                        maxlen = curlen
891                props.append((spec, maxlen))
892                
893         # now display the heading
894         print ' '.join([name.capitalize().ljust(width) for name,width in props])
896         # and the table data
897         for nodeid in cl.list():
898             l = []
899             for name, width in props:
900                 if name != 'id':
901                     try:
902                         value = str(cl.get(nodeid, name))
903                     except KeyError:
904                         # we already checked if the property is valid - a
905                         # KeyError here means the node just doesn't have a
906                         # value for it
907                         value = ''
908                 else:
909                     value = str(nodeid)
910                 f = '%%-%ds'%width
911                 l.append(f%value[:width])
912             print ' '.join(l)
913         return 0
915     def do_history(self, args):
916         '''Usage: history designator
917         Show the history entries of a designator.
919         Lists the journal entries for the node identified by the designator.
920         '''
921         if len(args) < 1:
922             raise UsageError, _('Not enough arguments supplied')
923         try:
924             classname, nodeid = hyperdb.splitDesignator(args[0])
925         except hyperdb.DesignatorError, message:
926             raise UsageError, message
928         try:
929             print self.db.getclass(classname).history(nodeid)
930         except KeyError:
931             raise UsageError, _('no such class "%(classname)s"')%locals()
932         except IndexError:
933             raise UsageError, _('no such %(classname)s node "%(nodeid)s"')%locals()
934         return 0
936     def do_commit(self, args):
937         '''Usage: commit
938         Commit all changes made to the database.
940         The changes made during an interactive session are not
941         automatically written to the database - they must be committed
942         using this command.
944         One-off commands on the command-line are automatically committed if
945         they are successful.
946         '''
947         self.db.commit()
948         return 0
950     def do_rollback(self, args):
951         '''Usage: rollback
952         Undo all changes that are pending commit to the database.
954         The changes made during an interactive session are not
955         automatically written to the database - they must be committed
956         manually. This command undoes all those changes, so a commit
957         immediately after would make no changes to the database.
958         '''
959         self.db.rollback()
960         return 0
962     def do_retire(self, args):
963         '''Usage: retire designator[,designator]*
964         Retire the node specified by designator.
966         This action indicates that a particular node is not to be retrieved by
967         the list or find commands, and its key value may be re-used.
968         '''
969         if len(args) < 1:
970             raise UsageError, _('Not enough arguments supplied')
971         designators = args[0].split(',')
972         for designator in designators:
973             try:
974                 classname, nodeid = hyperdb.splitDesignator(designator)
975             except hyperdb.DesignatorError, message:
976                 raise UsageError, message
977             try:
978                 self.db.getclass(classname).retire(nodeid)
979             except KeyError:
980                 raise UsageError, _('no such class "%(classname)s"')%locals()
981             except IndexError:
982                 raise UsageError, _('no such %(classname)s node "%(nodeid)s"')%locals()
983         return 0
985     def do_restore(self, args):
986         '''Usage: restore designator[,designator]*
987         Restore the retired node specified by designator.
989         The given nodes will become available for users again.
990         '''
991         if len(args) < 1:
992             raise UsageError, _('Not enough arguments supplied')
993         designators = args[0].split(',')
994         for designator in designators:
995             try:
996                 classname, nodeid = hyperdb.splitDesignator(designator)
997             except hyperdb.DesignatorError, message:
998                 raise UsageError, message
999             try:
1000                 self.db.getclass(classname).restore(nodeid)
1001             except KeyError:
1002                 raise UsageError, _('no such class "%(classname)s"')%locals()
1003             except IndexError:
1004                 raise UsageError, _('no such %(classname)s node "%(nodeid)s"')%locals()
1005         return 0
1007     def do_export(self, args):
1008         '''Usage: export [class[,class]] export_dir
1009         Export the database to colon-separated-value files.
1011         This action exports the current data from the database into
1012         colon-separated-value files that are placed in the nominated
1013         destination directory. The journals are not exported.
1014         '''
1015         # grab the directory to export to
1016         if len(args) < 1:
1017             raise UsageError, _('Not enough arguments supplied')
1018         if rcsv.error:
1019             raise UsageError, _(rcsv.error)
1021         dir = args[-1]
1023         # get the list of classes to export
1024         if len(args) == 2:
1025             classes = args[0].split(',')
1026         else:
1027             classes = self.db.classes.keys()
1029         # do all the classes specified
1030         for classname in classes:
1031             cl = self.get_class(classname)
1032             f = open(os.path.join(dir, classname+'.csv'), 'w')
1033             writer = rcsv.writer(f, rcsv.colon_separated)
1034             properties = cl.getprops()
1035             propnames = properties.keys()
1036             propnames.sort()
1037             fields = propnames[:]
1038             fields.append('is retired')
1039             writer.writerow(fields)
1041             # all nodes for this class (not using list() 'cos it doesn't
1042             # include retired nodes)
1044             for nodeid in self.db.getclass(classname).getnodeids():
1045                 # get the regular props
1046                 writer.writerow (cl.export_list(propnames, nodeid))
1048             # close this file
1049             f.close()
1050         return 0
1052     def do_import(self, args):
1053         '''Usage: import import_dir
1054         Import a database from the directory containing CSV files, one per
1055         class to import.
1057         The files must define the same properties as the class (including having
1058         a "header" line with those property names.)
1060         The imported nodes will have the same nodeid as defined in the
1061         import file, thus replacing any existing content.
1063         The new nodes are added to the existing database - if you want to
1064         create a new database using the imported data, then create a new
1065         database (or, tediously, retire all the old data.)
1066         '''
1067         if len(args) < 1:
1068             raise UsageError, _('Not enough arguments supplied')
1069         if rcsv.error:
1070             raise UsageError, _(rcsv.error)
1071         from roundup import hyperdb
1073         for file in os.listdir(args[0]):
1074             # we only care about CSV files
1075             if not file.endswith('.csv'):
1076                 continue
1078             f = open(os.path.join(args[0], file))
1080             # get the classname
1081             classname = os.path.splitext(file)[0]
1083             # ensure that the properties and the CSV file headings match
1084             cl = self.get_class(classname)
1085             reader = rcsv.reader(f, rcsv.colon_separated)
1086             file_props = None
1087             maxid = 1
1089             # loop through the file and create a node for each entry
1090             for r in reader:
1091                 if file_props is None:
1092                     file_props = r
1093                     continue
1095                 # do the import and figure the current highest nodeid
1096                 maxid = max(maxid, int(cl.import_list(file_props, r)))
1098             # set the id counter
1099             print 'setting', classname, maxid+1
1100             self.db.setid(classname, str(maxid+1))
1101         return 0
1103     def do_pack(self, args):
1104         '''Usage: pack period | date
1106 Remove journal entries older than a period of time specified or
1107 before a certain date.
1109 A period is specified using the suffixes "y", "m", and "d". The
1110 suffix "w" (for "week") means 7 days.
1112       "3y" means three years
1113       "2y 1m" means two years and one month
1114       "1m 25d" means one month and 25 days
1115       "2w 3d" means two weeks and three days
1117 Date format is "YYYY-MM-DD" eg:
1118     2001-01-01
1119     
1120         '''
1121         if len(args) <> 1:
1122             raise UsageError, _('Not enough arguments supplied')
1123         
1124         # are we dealing with a period or a date
1125         value = args[0]
1126         date_re = re.compile(r'''
1127               (?P<date>\d\d\d\d-\d\d?-\d\d?)? # yyyy-mm-dd
1128               (?P<period>(\d+y\s*)?(\d+m\s*)?(\d+d\s*)?)?
1129               ''', re.VERBOSE)
1130         m = date_re.match(value)
1131         if not m:
1132             raise ValueError, _('Invalid format')
1133         m = m.groupdict()
1134         if m['period']:
1135             pack_before = date.Date(". - %s"%value)
1136         elif m['date']:
1137             pack_before = date.Date(value)
1138         self.db.pack(pack_before)
1139         return 0
1141     def do_reindex(self, args):
1142         '''Usage: reindex
1143         Re-generate a tracker's search indexes.
1145         This will re-generate the search indexes for a tracker. This will
1146         typically happen automatically.
1147         '''
1148         self.db.indexer.force_reindex()
1149         self.db.reindex()
1150         return 0
1152     def do_security(self, args):
1153         '''Usage: security [Role name]
1154         Display the Permissions available to one or all Roles.
1155         '''
1156         if len(args) == 1:
1157             role = args[0]
1158             try:
1159                 roles = [(args[0], self.db.security.role[args[0]])]
1160             except KeyError:
1161                 print _('No such Role "%(role)s"')%locals()
1162                 return 1
1163         else:
1164             roles = self.db.security.role.items()
1165             role = self.db.config.NEW_WEB_USER_ROLES
1166             if ',' in role:
1167                 print _('New Web users get the Roles "%(role)s"')%locals()
1168             else:
1169                 print _('New Web users get the Role "%(role)s"')%locals()
1170             role = self.db.config.NEW_EMAIL_USER_ROLES
1171             if ',' in role:
1172                 print _('New Email users get the Roles "%(role)s"')%locals()
1173             else:
1174                 print _('New Email users get the Role "%(role)s"')%locals()
1175         roles.sort()
1176         for rolename, role in roles:
1177             print _('Role "%(name)s":')%role.__dict__
1178             for permission in role.permissions:
1179                 if permission.klass:
1180                     print _(' %(description)s (%(name)s for "%(klass)s" '
1181                         'only)')%permission.__dict__
1182                 else:
1183                     print _(' %(description)s (%(name)s)')%permission.__dict__
1184         return 0
1186     def run_command(self, args):
1187         '''Run a single command
1188         '''
1189         command = args[0]
1191         # handle help now
1192         if command == 'help':
1193             if len(args)>1:
1194                 self.do_help(args[1:])
1195                 return 0
1196             self.do_help(['help'])
1197             return 0
1198         if command == 'morehelp':
1199             self.do_help(['help'])
1200             self.help_commands()
1201             self.help_all()
1202             return 0
1204         # figure what the command is
1205         try:
1206             functions = self.commands.get(command)
1207         except KeyError:
1208             # not a valid command
1209             print _('Unknown command "%(command)s" ("help commands" for a '
1210                 'list)')%locals()
1211             return 1
1213         # check for multiple matches
1214         if len(functions) > 1:
1215             print _('Multiple commands match "%(command)s": %(list)s')%{'command':
1216                 command, 'list': ', '.join([i[0] for i in functions])}
1217             return 1
1218         command, function = functions[0]
1220         # make sure we have a tracker_home
1221         while not self.tracker_home:
1222             self.tracker_home = raw_input(_('Enter tracker home: ')).strip()
1224         # before we open the db, we may be doing an install or init
1225         if command == 'initialise':
1226             try:
1227                 return self.do_initialise(self.tracker_home, args)
1228             except UsageError, message:
1229                 print _('Error: %(message)s')%locals()
1230                 return 1
1231         elif command == 'install':
1232             try:
1233                 return self.do_install(self.tracker_home, args)
1234             except UsageError, message:
1235                 print _('Error: %(message)s')%locals()
1236                 return 1
1238         # get the tracker
1239         try:
1240             tracker = roundup.instance.open(self.tracker_home)
1241         except ValueError, message:
1242             self.tracker_home = ''
1243             print _("Error: Couldn't open tracker: %(message)s")%locals()
1244             return 1
1246         # only open the database once!
1247         if not self.db:
1248             self.db = tracker.open('admin')
1250         # do the command
1251         ret = 0
1252         try:
1253             ret = function(args[1:])
1254         except UsageError, message:
1255             print _('Error: %(message)s')%locals()
1256             print
1257             print function.__doc__
1258             ret = 1
1259         except:
1260             import traceback
1261             traceback.print_exc()
1262             ret = 1
1263         return ret
1265     def interactive(self):
1266         '''Run in an interactive mode
1267         '''
1268         print _('Roundup %s ready for input.'%roundup_version)
1269         print _('Type "help" for help.')
1270         try:
1271             import readline
1272         except ImportError:
1273             print _('Note: command history and editing not available')
1275         while 1:
1276             try:
1277                 command = raw_input(_('roundup> '))
1278             except EOFError:
1279                 print _('exit...')
1280                 break
1281             if not command: continue
1282             args = token.token_split(command)
1283             if not args: continue
1284             if args[0] in ('quit', 'exit'): break
1285             self.run_command(args)
1287         # exit.. check for transactions
1288         if self.db and self.db.transactions:
1289             commit = raw_input(_('There are unsaved changes. Commit them (y/N)? '))
1290             if commit and commit[0].lower() == 'y':
1291                 self.db.commit()
1292         return 0
1294     def main(self):
1295         try:
1296             opts, args = getopt.getopt(sys.argv[1:], 'i:u:hcdsS:')
1297         except getopt.GetoptError, e:
1298             self.usage(str(e))
1299             return 1
1301         # handle command-line args
1302         self.tracker_home = os.environ.get('TRACKER_HOME', '')
1303         # TODO: reinstate the user/password stuff (-u arg too)
1304         name = password = ''
1305         if os.environ.has_key('ROUNDUP_LOGIN'):
1306             l = os.environ['ROUNDUP_LOGIN'].split(':')
1307             name = l[0]
1308             if len(l) > 1:
1309                 password = l[1]
1310         self.separator = None
1311         self.print_designator = 0
1312         for opt, arg in opts:
1313             if opt == '-h':
1314                 self.usage()
1315                 return 0
1316             if opt == '-i':
1317                 self.tracker_home = arg
1318             if opt == '-c':
1319                 if self.separator != None:
1320                     self.usage('Only one of -c, -S and -s may be specified')
1321                     return 1
1322                 self.separator = ','
1323             if opt == '-S':
1324                 if self.separator != None:
1325                     self.usage('Only one of -c, -S and -s may be specified')
1326                     return 1
1327                 self.separator = arg
1328             if opt == '-s':
1329                 if self.separator != None:
1330                     self.usage('Only one of -c, -S and -s may be specified')
1331                     return 1
1332                 self.separator = ' '
1333             if opt == '-d':
1334                 self.print_designator = 1
1336         # if no command - go interactive
1337         # wrap in a try/finally so we always close off the db
1338         ret = 0
1339         try:
1340             if not args:
1341                 self.interactive()
1342             else:
1343                 ret = self.run_command(args)
1344                 if self.db: self.db.commit()
1345             return ret
1346         finally:
1347             if self.db:
1348                 self.db.close()
1350 if __name__ == '__main__':
1351     tool = AdminTool()
1352     sys.exit(tool.main())
1354 # vim: set filetype=python ts=4 sw=4 et si