Code

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