Code

Clarify listTemplates docstring using http://sourceforge.net/mailarchive/forum.php...
[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.59 2003-10-24 19:48:05 jlgijsbers 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 = 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(listTemplates(tdir))
321         # Try subdirs of the current dir
322         templates.update(listTemplates(os.getcwd()))
324         # Finally, try the current directory as a template
325         template = loadTemplate(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                 proptype =  properties[key]
573                 if isinstance(proptype, hyperdb.Multilink):
574                     if value is None:
575                         props[key] = []
576                     else:
577                         props[key] = value.split(',')
578                 elif value is None:
579                     continue
580                 elif isinstance(proptype, hyperdb.String):
581                     continue
582                 elif isinstance(proptype, hyperdb.Password):
583                     m = pwre.match(value)
584                     if m:
585                         # password is being given to us encrypted
586                         p = password.Password()
587                         p.scheme = m.group(1)
588                         p.password = m.group(2)
589                         props[key] = p
590                     else:
591                         props[key] = password.Password(value)
592                 elif isinstance(proptype, hyperdb.Date):
593                     try:
594                         props[key] = date.Date(value)
595                     except ValueError, message:
596                         raise UsageError, '"%s": %s'%(value, message)
597                 elif isinstance(proptype, hyperdb.Interval):
598                     try:
599                         props[key] = date.Interval(value)
600                     except ValueError, message:
601                         raise UsageError, '"%s": %s'%(value, message)
602                 elif isinstance(proptype, hyperdb.Link):
603                     props[key] = value
604                 elif isinstance(proptype, hyperdb.Boolean):
605                     props[key] = value.lower() in ('yes', 'true', 'on', '1')
606                 elif isinstance(proptype, hyperdb.Number):
607                     props[key] = float(value)
609             # try the set
610             try:
611                 apply(cl.set, (itemid, ), props)
612             except (TypeError, IndexError, ValueError), message:
613                 import traceback; traceback.print_exc()
614                 raise UsageError, message
615         return 0
617     def do_find(self, args):
618         '''Usage: find classname propname=value ...
619         Find the nodes of the given class with a given link property value.
621         Find the nodes of the given class with a given link property value. The
622         value may be either the nodeid of the linked node, or its key value.
623         '''
624         if len(args) < 1:
625             raise UsageError, _('Not enough arguments supplied')
626         classname = args[0]
627         # get the class
628         cl = self.get_class(classname)
630         # handle the propname=value argument
631         props = self.props_from_args(args[1:])
633         # if the value isn't a number, look up the linked class to get the
634         # number
635         for propname, value in props.items():
636             num_re = re.compile('^\d+$')
637             if value == '-1':
638                 props[propname] = None
639             elif not num_re.match(value):
640                 # get the property
641                 try:
642                     property = cl.properties[propname]
643                 except KeyError:
644                     raise UsageError, _('%(classname)s has no property '
645                         '"%(propname)s"')%locals()
647                 # make sure it's a link
648                 if (not isinstance(property, hyperdb.Link) and not
649                         isinstance(property, hyperdb.Multilink)):
650                     raise UsageError, _('You may only "find" link properties')
652                 # get the linked-to class and look up the key property
653                 link_class = self.db.getclass(property.classname)
654                 try:
655                     props[propname] = link_class.lookup(value)
656                 except TypeError:
657                     raise UsageError, _('%(classname)s has no key property"')%{
658                         'classname': link_class.classname}
660         # now do the find 
661         try:
662             id = []
663             designator = []
664             if self.separator:
665                 if self.print_designator:
666                     id=apply(cl.find, (), props)
667                     for i in id:
668                         designator.append(classname + i)
669                     print self.separator.join(designator)
670                 else:
671                     print self.separator.join(apply(cl.find, (), props))
673             else:
674                 if self.print_designator:
675                     id=apply(cl.find, (), props)
676                     for i in id:
677                         designator.append(classname + i)
678                     print designator
679                 else:
680                     print apply(cl.find, (), props)
681         except KeyError:
682             raise UsageError, _('%(classname)s has no property '
683                 '"%(propname)s"')%locals()
684         except (ValueError, TypeError), message:
685             raise UsageError, message
686         return 0
688     def do_specification(self, args):
689         '''Usage: specification classname
690         Show the properties for a classname.
692         This lists the properties for a given class.
693         '''
694         if len(args) < 1:
695             raise UsageError, _('Not enough arguments supplied')
696         classname = args[0]
697         # get the class
698         cl = self.get_class(classname)
700         # get the key property
701         keyprop = cl.getkey()
702         for key, value in cl.properties.items():
703             if keyprop == key:
704                 print _('%(key)s: %(value)s (key property)')%locals()
705             else:
706                 print _('%(key)s: %(value)s')%locals()
708     def do_display(self, args):
709         '''Usage: display designator[,designator]*
710         Show the property values for the given node(s).
712         This lists the properties and their associated values for the given
713         node.
714         '''
715         if len(args) < 1:
716             raise UsageError, _('Not enough arguments supplied')
718         # decode the node designator
719         for designator in args[0].split(','):
720             try:
721                 classname, nodeid = hyperdb.splitDesignator(designator)
722             except hyperdb.DesignatorError, message:
723                 raise UsageError, message
725             # get the class
726             cl = self.get_class(classname)
728             # display the values
729             keys = cl.properties.keys()
730             keys.sort()
731             for key in keys:
732                 value = cl.get(nodeid, key)
733                 print _('%(key)s: %(value)s')%locals()
735     def do_create(self, args, pwre = re.compile(r'{(\w+)}(.+)')):
736         '''Usage: create classname property=value ...
737         Create a new entry of a given class.
739         This creates a new entry of the given class using the property
740         name=value arguments provided on the command line after the "create"
741         command.
742         '''
743         if len(args) < 1:
744             raise UsageError, _('Not enough arguments supplied')
745         from roundup import hyperdb
747         classname = args[0]
749         # get the class
750         cl = self.get_class(classname)
752         # now do a create
753         props = {}
754         properties = cl.getprops(protected = 0)
755         if len(args) == 1:
756             # ask for the properties
757             for key, value in properties.items():
758                 if key == 'id': continue
759                 name = value.__class__.__name__
760                 if isinstance(value , hyperdb.Password):
761                     again = None
762                     while value != again:
763                         value = getpass.getpass(_('%(propname)s (Password): ')%{
764                             'propname': key.capitalize()})
765                         again = getpass.getpass(_('   %(propname)s (Again): ')%{
766                             'propname': key.capitalize()})
767                         if value != again: print _('Sorry, try again...')
768                     if value:
769                         props[key] = value
770                 else:
771                     value = raw_input(_('%(propname)s (%(proptype)s): ')%{
772                         'propname': key.capitalize(), 'proptype': name})
773                     if value:
774                         props[key] = value
775         else:
776             props = self.props_from_args(args[1:])
778         # convert types
779         for propname, value in props.items():
780             # get the property
781             try:
782                 proptype = properties[propname]
783             except KeyError:
784                 raise UsageError, _('%(classname)s has no property '
785                     '"%(propname)s"')%locals()
787             if isinstance(proptype, hyperdb.Date):
788                 try:
789                     props[propname] = date.Date(value)
790                 except ValueError, message:
791                     raise UsageError, _('"%(value)s": %(message)s')%locals()
792             elif isinstance(proptype, hyperdb.Interval):
793                 try:
794                     props[propname] = date.Interval(value)
795                 except ValueError, message:
796                     raise UsageError, _('"%(value)s": %(message)s')%locals()
797             elif isinstance(proptype, hyperdb.Password):
798                 m = pwre.match(value)
799                 if m:
800                     # password is being given to us encrypted
801                     p = password.Password()
802                     p.scheme = m.group(1)
803                     p.password = m.group(2)
804                     props[propname] = p
805                 else:
806                     props[propname] = password.Password(value)
807             elif isinstance(proptype, hyperdb.Multilink):
808                 props[propname] = value.split(',')
809             elif isinstance(proptype, hyperdb.Boolean):
810                 props[propname] = value.lower() in ('yes', 'true', 'on', '1')
811             elif isinstance(proptype, hyperdb.Number):
812                 props[propname] = float(value)
814         # check for the key property
815         propname = cl.getkey()
816         if propname and not props.has_key(propname):
817             raise UsageError, _('you must provide the "%(propname)s" '
818                 'property.')%locals()
820         # do the actual create
821         try:
822             print apply(cl.create, (), props)
823         except (TypeError, IndexError, ValueError), message:
824             raise UsageError, message
825         return 0
827     def do_list(self, args):
828         '''Usage: list classname [property]
829         List the instances of a class.
831         Lists all instances of the given class. If the property is not
832         specified, the  "label" property is used. The label property is tried
833         in order: the key, "name", "title" and then the first property,
834         alphabetically.
836         With -c, -S or -s print a list of item id's if no property specified.
837         If property specified, print list of that property for every class
838         instance.
839         '''
840         if len(args) > 2:
841             raise UsageError, _('Too many arguments supplied')
842         if len(args) < 1:
843             raise UsageError, _('Not enough arguments supplied')
844         classname = args[0]
846         # get the class
847         cl = self.get_class(classname)
849         # figure the property
850         if len(args) > 1:
851             propname = args[1]
852         else:
853             propname = cl.labelprop()
855         if self.separator:
856             if len(args) == 2:
857                # create a list of propnames since user specified propname
858                 proplist=[]
859                 for nodeid in cl.list():
860                     try:
861                         proplist.append(cl.get(nodeid, propname))
862                     except KeyError:
863                         raise UsageError, _('%(classname)s has no property '
864                             '"%(propname)s"')%locals()
865                 print self.separator.join(proplist)
866             else:
867                 # create a list of index id's since user didn't specify
868                 # otherwise
869                 print self.separator.join(cl.list())
870         else:
871             for nodeid in cl.list():
872                 try:
873                     value = cl.get(nodeid, propname)
874                 except KeyError:
875                     raise UsageError, _('%(classname)s has no property '
876                         '"%(propname)s"')%locals()
877                 print _('%(nodeid)4s: %(value)s')%locals()
878         return 0
880     def do_table(self, args):
881         '''Usage: table classname [property[,property]*]
882         List the instances of a class in tabular form.
884         Lists all instances of the given class. If the properties are not
885         specified, all properties are displayed. By default, the column widths
886         are the width of the largest value. The width may be explicitly defined
887         by defining the property as "name:width". For example::
888           roundup> table priority id,name:10
889           Id Name
890           1  fatal-bug 
891           2  bug       
892           3  usability 
893           4  feature   
895         Also to make the width of the column the width of the label,
896         leave a trailing : without a width on the property. E.G.
897           roundup> table priority id,name:
898           Id Name
899           1  fata
900           2  bug       
901           3  usab
902           4  feat
904         will result in a the 4 character wide "Name" column.
905         '''
906         if len(args) < 1:
907             raise UsageError, _('Not enough arguments supplied')
908         classname = args[0]
910         # get the class
911         cl = self.get_class(classname)
913         # figure the property names to display
914         if len(args) > 1:
915             prop_names = args[1].split(',')
916             all_props = cl.getprops()
917             for spec in prop_names:
918                 if ':' in spec:
919                     try:
920                         propname, width = spec.split(':')
921                     except (ValueError, TypeError):
922                         raise UsageError, _('"%(spec)s" not name:width')%locals()
923                 else:
924                     propname = spec
925                 if not all_props.has_key(propname):
926                     raise UsageError, _('%(classname)s has no property '
927                         '"%(propname)s"')%locals()
928         else:
929             prop_names = cl.getprops().keys()
931         # now figure column widths
932         props = []
933         for spec in prop_names:
934             if ':' in spec:
935                 name, width = spec.split(':')
936                 if width == '':
937                     props.append((name, len(spec)))
938                 else:
939                     props.append((name, int(width)))
940             else:
941                # this is going to be slow
942                maxlen = len(spec)
943                for nodeid in cl.list():
944                    curlen = len(str(cl.get(nodeid, spec)))
945                    if curlen > maxlen:
946                        maxlen = curlen
947                props.append((spec, maxlen))
948                
949         # now display the heading
950         print ' '.join([name.capitalize().ljust(width) for name,width in props])
952         # and the table data
953         for nodeid in cl.list():
954             l = []
955             for name, width in props:
956                 if name != 'id':
957                     try:
958                         value = str(cl.get(nodeid, name))
959                     except KeyError:
960                         # we already checked if the property is valid - a
961                         # KeyError here means the node just doesn't have a
962                         # value for it
963                         value = ''
964                 else:
965                     value = str(nodeid)
966                 f = '%%-%ds'%width
967                 l.append(f%value[:width])
968             print ' '.join(l)
969         return 0
971     def do_history(self, args):
972         '''Usage: history designator
973         Show the history entries of a designator.
975         Lists the journal entries for the node identified by the designator.
976         '''
977         if len(args) < 1:
978             raise UsageError, _('Not enough arguments supplied')
979         try:
980             classname, nodeid = hyperdb.splitDesignator(args[0])
981         except hyperdb.DesignatorError, message:
982             raise UsageError, message
984         try:
985             print self.db.getclass(classname).history(nodeid)
986         except KeyError:
987             raise UsageError, _('no such class "%(classname)s"')%locals()
988         except IndexError:
989             raise UsageError, _('no such %(classname)s node "%(nodeid)s"')%locals()
990         return 0
992     def do_commit(self, args):
993         '''Usage: commit
994         Commit all changes made to the database.
996         The changes made during an interactive session are not
997         automatically written to the database - they must be committed
998         using this command.
1000         One-off commands on the command-line are automatically committed if
1001         they are successful.
1002         '''
1003         self.db.commit()
1004         return 0
1006     def do_rollback(self, args):
1007         '''Usage: rollback
1008         Undo all changes that are pending commit to the database.
1010         The changes made during an interactive session are not
1011         automatically written to the database - they must be committed
1012         manually. This command undoes all those changes, so a commit
1013         immediately after would make no changes to the database.
1014         '''
1015         self.db.rollback()
1016         return 0
1018     def do_retire(self, args):
1019         '''Usage: retire designator[,designator]*
1020         Retire the node specified by designator.
1022         This action indicates that a particular node is not to be retrieved by
1023         the list or find commands, and its key value may be re-used.
1024         '''
1025         if len(args) < 1:
1026             raise UsageError, _('Not enough arguments supplied')
1027         designators = args[0].split(',')
1028         for designator in designators:
1029             try:
1030                 classname, nodeid = hyperdb.splitDesignator(designator)
1031             except hyperdb.DesignatorError, message:
1032                 raise UsageError, message
1033             try:
1034                 self.db.getclass(classname).retire(nodeid)
1035             except KeyError:
1036                 raise UsageError, _('no such class "%(classname)s"')%locals()
1037             except IndexError:
1038                 raise UsageError, _('no such %(classname)s node "%(nodeid)s"')%locals()
1039         return 0
1041     def do_restore(self, args):
1042         '''Usage: restore designator[,designator]*
1043         Restore the retired node specified by designator.
1045         The given nodes will become available for users again.
1046         '''
1047         if len(args) < 1:
1048             raise UsageError, _('Not enough arguments supplied')
1049         designators = args[0].split(',')
1050         for designator in designators:
1051             try:
1052                 classname, nodeid = hyperdb.splitDesignator(designator)
1053             except hyperdb.DesignatorError, message:
1054                 raise UsageError, message
1055             try:
1056                 self.db.getclass(classname).restore(nodeid)
1057             except KeyError:
1058                 raise UsageError, _('no such class "%(classname)s"')%locals()
1059             except IndexError:
1060                 raise UsageError, _('no such %(classname)s node "%(nodeid)s"')%locals()
1061         return 0
1063     def do_export(self, args):
1064         '''Usage: export [class[,class]] export_dir
1065         Export the database to colon-separated-value files.
1067         This action exports the current data from the database into
1068         colon-separated-value files that are placed in the nominated
1069         destination directory. The journals are not exported.
1070         '''
1071         # grab the directory to export to
1072         if len(args) < 1:
1073             raise UsageError, _('Not enough arguments supplied')
1074         if rcsv.error:
1075             raise UsageError, _(rcsv.error)
1077         dir = args[-1]
1079         # get the list of classes to export
1080         if len(args) == 2:
1081             classes = args[0].split(',')
1082         else:
1083             classes = self.db.classes.keys()
1085         # do all the classes specified
1086         for classname in classes:
1087             cl = self.get_class(classname)
1088             f = open(os.path.join(dir, classname+'.csv'), 'w')
1089             writer = rcsv.writer(f, rcsv.colon_separated)
1090             properties = cl.getprops()
1091             propnames = properties.keys()
1092             propnames.sort()
1093             fields = propnames[:]
1094             fields.append('is retired')
1095             writer.writerow(fields)
1097             # all nodes for this class (not using list() 'cos it doesn't
1098             # include retired nodes)
1100             for nodeid in self.db.getclass(classname).getnodeids():
1101                 # get the regular props
1102                 writer.writerow (cl.export_list(propnames, nodeid))
1104             # close this file
1105             f.close()
1106         return 0
1108     def do_import(self, args):
1109         '''Usage: import import_dir
1110         Import a database from the directory containing CSV files, one per
1111         class to import.
1113         The files must define the same properties as the class (including having
1114         a "header" line with those property names.)
1116         The imported nodes will have the same nodeid as defined in the
1117         import file, thus replacing any existing content.
1119         The new nodes are added to the existing database - if you want to
1120         create a new database using the imported data, then create a new
1121         database (or, tediously, retire all the old data.)
1122         '''
1123         if len(args) < 1:
1124             raise UsageError, _('Not enough arguments supplied')
1125         if rcsv.error:
1126             raise UsageError, _(rcsv.error)
1127         from roundup import hyperdb
1129         for file in os.listdir(args[0]):
1130             # we only care about CSV files
1131             if not file.endswith('.csv'):
1132                 continue
1134             f = open(os.path.join(args[0], file))
1136             # get the classname
1137             classname = os.path.splitext(file)[0]
1139             # ensure that the properties and the CSV file headings match
1140             cl = self.get_class(classname)
1141             reader = rcsv.reader(f, rcsv.colon_separated)
1142             file_props = None
1143             maxid = 1
1145             # loop through the file and create a node for each entry
1146             for r in reader:
1147                 if file_props is None:
1148                     file_props = r
1149                     continue
1151                 # do the import and figure the current highest nodeid
1152                 maxid = max(maxid, int(cl.import_list(file_props, r)))
1154             # set the id counter
1155             print 'setting', classname, maxid+1
1156             self.db.setid(classname, str(maxid+1))
1157         return 0
1159     def do_pack(self, args):
1160         '''Usage: pack period | date
1162 Remove journal entries older than a period of time specified or
1163 before a certain date.
1165 A period is specified using the suffixes "y", "m", and "d". The
1166 suffix "w" (for "week") means 7 days.
1168       "3y" means three years
1169       "2y 1m" means two years and one month
1170       "1m 25d" means one month and 25 days
1171       "2w 3d" means two weeks and three days
1173 Date format is "YYYY-MM-DD" eg:
1174     2001-01-01
1175     
1176         '''
1177         if len(args) <> 1:
1178             raise UsageError, _('Not enough arguments supplied')
1179         
1180         # are we dealing with a period or a date
1181         value = args[0]
1182         date_re = re.compile(r'''
1183               (?P<date>\d\d\d\d-\d\d?-\d\d?)? # yyyy-mm-dd
1184               (?P<period>(\d+y\s*)?(\d+m\s*)?(\d+d\s*)?)?
1185               ''', re.VERBOSE)
1186         m = date_re.match(value)
1187         if not m:
1188             raise ValueError, _('Invalid format')
1189         m = m.groupdict()
1190         if m['period']:
1191             pack_before = date.Date(". - %s"%value)
1192         elif m['date']:
1193             pack_before = date.Date(value)
1194         self.db.pack(pack_before)
1195         return 0
1197     def do_reindex(self, args):
1198         '''Usage: reindex
1199         Re-generate a tracker's search indexes.
1201         This will re-generate the search indexes for a tracker. This will
1202         typically happen automatically.
1203         '''
1204         self.db.indexer.force_reindex()
1205         self.db.reindex()
1206         return 0
1208     def do_security(self, args):
1209         '''Usage: security [Role name]
1210         Display the Permissions available to one or all Roles.
1211         '''
1212         if len(args) == 1:
1213             role = args[0]
1214             try:
1215                 roles = [(args[0], self.db.security.role[args[0]])]
1216             except KeyError:
1217                 print _('No such Role "%(role)s"')%locals()
1218                 return 1
1219         else:
1220             roles = self.db.security.role.items()
1221             role = self.db.config.NEW_WEB_USER_ROLES
1222             if ',' in role:
1223                 print _('New Web users get the Roles "%(role)s"')%locals()
1224             else:
1225                 print _('New Web users get the Role "%(role)s"')%locals()
1226             role = self.db.config.NEW_EMAIL_USER_ROLES
1227             if ',' in role:
1228                 print _('New Email users get the Roles "%(role)s"')%locals()
1229             else:
1230                 print _('New Email users get the Role "%(role)s"')%locals()
1231         roles.sort()
1232         for rolename, role in roles:
1233             print _('Role "%(name)s":')%role.__dict__
1234             for permission in role.permissions:
1235                 if permission.klass:
1236                     print _(' %(description)s (%(name)s for "%(klass)s" '
1237                         'only)')%permission.__dict__
1238                 else:
1239                     print _(' %(description)s (%(name)s)')%permission.__dict__
1240         return 0
1242     def run_command(self, args):
1243         '''Run a single command
1244         '''
1245         command = args[0]
1247         # handle help now
1248         if command == 'help':
1249             if len(args)>1:
1250                 self.do_help(args[1:])
1251                 return 0
1252             self.do_help(['help'])
1253             return 0
1254         if command == 'morehelp':
1255             self.do_help(['help'])
1256             self.help_commands()
1257             self.help_all()
1258             return 0
1260         # figure what the command is
1261         try:
1262             functions = self.commands.get(command)
1263         except KeyError:
1264             # not a valid command
1265             print _('Unknown command "%(command)s" ("help commands" for a '
1266                 'list)')%locals()
1267             return 1
1269         # check for multiple matches
1270         if len(functions) > 1:
1271             print _('Multiple commands match "%(command)s": %(list)s')%{'command':
1272                 command, 'list': ', '.join([i[0] for i in functions])}
1273             return 1
1274         command, function = functions[0]
1276         # make sure we have a tracker_home
1277         while not self.tracker_home:
1278             self.tracker_home = raw_input(_('Enter tracker home: ')).strip()
1280         # before we open the db, we may be doing an install or init
1281         if command == 'initialise':
1282             try:
1283                 return self.do_initialise(self.tracker_home, args)
1284             except UsageError, message:
1285                 print _('Error: %(message)s')%locals()
1286                 return 1
1287         elif command == 'install':
1288             try:
1289                 return self.do_install(self.tracker_home, args)
1290             except UsageError, message:
1291                 print _('Error: %(message)s')%locals()
1292                 return 1
1294         # get the tracker
1295         try:
1296             tracker = roundup.instance.open(self.tracker_home)
1297         except ValueError, message:
1298             self.tracker_home = ''
1299             print _("Error: Couldn't open tracker: %(message)s")%locals()
1300             return 1
1302         # only open the database once!
1303         if not self.db:
1304             self.db = tracker.open('admin')
1306         # do the command
1307         ret = 0
1308         try:
1309             ret = function(args[1:])
1310         except UsageError, message:
1311             print _('Error: %(message)s')%locals()
1312             print
1313             print function.__doc__
1314             ret = 1
1315         except:
1316             import traceback
1317             traceback.print_exc()
1318             ret = 1
1319         return ret
1321     def interactive(self):
1322         '''Run in an interactive mode
1323         '''
1324         print _('Roundup %s ready for input.'%roundup_version)
1325         print _('Type "help" for help.')
1326         try:
1327             import readline
1328         except ImportError:
1329             print _('Note: command history and editing not available')
1331         while 1:
1332             try:
1333                 command = raw_input(_('roundup> '))
1334             except EOFError:
1335                 print _('exit...')
1336                 break
1337             if not command: continue
1338             args = token.token_split(command)
1339             if not args: continue
1340             if args[0] in ('quit', 'exit'): break
1341             self.run_command(args)
1343         # exit.. check for transactions
1344         if self.db and self.db.transactions:
1345             commit = raw_input(_('There are unsaved changes. Commit them (y/N)? '))
1346             if commit and commit[0].lower() == 'y':
1347                 self.db.commit()
1348         return 0
1350     def main(self):
1351         try:
1352             opts, args = getopt.getopt(sys.argv[1:], 'i:u:hcdsS:')
1353         except getopt.GetoptError, e:
1354             self.usage(str(e))
1355             return 1
1357         # handle command-line args
1358         self.tracker_home = os.environ.get('TRACKER_HOME', '')
1359         # TODO: reinstate the user/password stuff (-u arg too)
1360         name = password = ''
1361         if os.environ.has_key('ROUNDUP_LOGIN'):
1362             l = os.environ['ROUNDUP_LOGIN'].split(':')
1363             name = l[0]
1364             if len(l) > 1:
1365                 password = l[1]
1366         self.separator = None
1367         self.print_designator = 0
1368         for opt, arg in opts:
1369             if opt == '-h':
1370                 self.usage()
1371                 return 0
1372             if opt == '-i':
1373                 self.tracker_home = arg
1374             if opt == '-c':
1375                 if self.separator != None:
1376                     self.usage('Only one of -c, -S and -s may be specified')
1377                     return 1
1378                 self.separator = ','
1379             if opt == '-S':
1380                 if self.separator != None:
1381                     self.usage('Only one of -c, -S and -s may be specified')
1382                     return 1
1383                 self.separator = arg
1384             if opt == '-s':
1385                 if self.separator != None:
1386                     self.usage('Only one of -c, -S and -s may be specified')
1387                     return 1
1388                 self.separator = ' '
1389             if opt == '-d':
1390                 self.print_designator = 1
1392         # if no command - go interactive
1393         # wrap in a try/finally so we always close off the db
1394         ret = 0
1395         try:
1396             if not args:
1397                 self.interactive()
1398             else:
1399                 ret = self.run_command(args)
1400                 if self.db: self.db.commit()
1401             return ret
1402         finally:
1403             if self.db:
1404                 self.db.close()
1407 def listTemplates(dir):
1408     ''' List all the Roundup template directories in a given directory.
1410         Find all the dirs that contain a TEMPLATE-INFO.txt and parse it.
1412         Return a list of dicts of info about the templates.
1413     '''
1414     ret = {}
1415     for idir in os.listdir(dir):
1416         idir = os.path.join(dir, idir)
1417         ti = loadTemplate(idir)
1418         if ti:
1419             ret[ti['name']] = ti
1420     return ret
1422 def loadTemplate(dir):
1423     ''' Attempt to load a Roundup template from the indicated directory.
1425         Return None if there's no template, otherwise a template info
1426         dictionary.
1427     '''
1428     ti = os.path.join(dir, 'TEMPLATE-INFO.txt')
1429     if not os.path.exists(ti):
1430         return None
1432     # load up the template's information
1433     m = rfc822.Message(open(ti))
1434     ti = {}
1435     ti['name'] = m['name']
1436     ti['description'] = m['description']
1437     ti['intended-for'] = m['intended-for']
1438     ti['path'] = dir
1439     return ti
1441 if __name__ == '__main__':
1442     tool = AdminTool()
1443     sys.exit(tool.main())
1445 # vim: set filetype=python ts=4 sw=4 et si