Code

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