Code

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