Code

fix the "set" command usage statement
[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.50 2003-03-27 05:23:39 richard Exp $
21 '''Administration commands for maintaining Roundup trackers.
22 '''
24 import sys, os, getpass, getopt, re, UserDict, shlex, shutil
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 help_initopts(self):
282         import roundup.templates
283         templates = roundup.templates.listTemplates()
284         print _('Templates:'), ', '.join(templates)
285         import roundup.backends
286         backends = roundup.backends.__all__
287         print _('Back ends:'), ', '.join(backends)
289     def do_install(self, tracker_home, args):
290         '''Usage: install [template [backend [admin password]]]
291         Install a new Roundup tracker.
293         The command will prompt for the tracker home directory (if not supplied
294         through TRACKER_HOME or the -i option). The template, backend and admin
295         password may be specified on the command-line as arguments, in that
296         order.
298         The initialise command must be called after this command in order
299         to initialise the tracker's database. You may edit the tracker's
300         initial database contents before running that command by editing
301         the tracker's dbinit.py module init() function.
303         See also initopts help.
304         '''
305         if len(args) < 1:
306             raise UsageError, _('Not enough arguments supplied')
308         # make sure the tracker home can be created
309         parent = os.path.split(tracker_home)[0]
310         if not os.path.exists(parent):
311             raise UsageError, _('Instance home parent directory "%(parent)s"'
312                 ' does not exist')%locals()
314         # select template
315         import roundup.templates
316         templates = roundup.templates.listTemplates()
317         template = len(args) > 1 and args[1] or ''
318         if template not in templates:
319             print _('Templates:'), ', '.join(templates)
320         while template not in templates:
321             template = raw_input(_('Select template [classic]: ')).strip()
322             if not template:
323                 template = 'classic'
325         # select hyperdb backend
326         import roundup.backends
327         backends = roundup.backends.__all__
328         backend = len(args) > 2 and args[2] or ''
329         if backend not in backends:
330             print _('Back ends:'), ', '.join(backends)
331         while backend not in backends:
332             backend = raw_input(_('Select backend [anydbm]: ')).strip()
333             if not backend:
334                 backend = 'anydbm'
335         # XXX perform a unit test based on the user's selections
337         # install!
338         init.install(tracker_home, template)
339         init.write_select_db(tracker_home, backend)
341         print _('''
342  You should now edit the tracker configuration file:
343    %(config_file)s
344  ... at a minimum, you must set MAILHOST, TRACKER_WEB, MAIL_DOMAIN and
345  ADMIN_EMAIL.
347  If you wish to modify the default schema, you should also edit the database
348  initialisation file:
349    %(database_config_file)s
350  ... see the documentation on customizing for more information.
351 ''')%{
352     'config_file': os.path.join(tracker_home, 'config.py'),
353     'database_config_file': os.path.join(tracker_home, 'dbinit.py')
355         return 0
358     def do_initialise(self, tracker_home, args):
359         '''Usage: initialise [adminpw]
360         Initialise a new Roundup tracker.
362         The administrator details will be set at this step.
364         Execute the tracker's initialisation function dbinit.init()
365         '''
366         # password
367         if len(args) > 1:
368             adminpw = args[1]
369         else:
370             adminpw = ''
371             confirm = 'x'
372             while adminpw != confirm:
373                 adminpw = getpass.getpass(_('Admin Password: '))
374                 confirm = getpass.getpass(_('       Confirm: '))
376         # make sure the tracker home is installed
377         if not os.path.exists(tracker_home):
378             raise UsageError, _('Instance home does not exist')%locals()
379         try:
380             tracker = roundup.instance.open(tracker_home)
381         except roundup.instance.TrackerError:
382             raise UsageError, _('Instance has not been installed')%locals()
384         # is there already a database?
385         try:
386             db_exists = tracker.select_db.Database.exists(tracker.config)
387         except AttributeError:
388             # TODO: move this code to exists() static method in every backend
389             db_exists = os.path.exists(os.path.join(tracker_home, 'db'))
390         if db_exists:
391             print _('WARNING: The database is already initialised!')
392             print _('If you re-initialise it, you will lose all the data!')
393             ok = raw_input(_('Erase it? Y/[N]: ')).strip()
394             if ok.lower() != 'y':
395                 return 0
397             # Get a database backend in use by tracker
398             try:
399                 # nuke it
400                 tracker.select_db.Database.nuke(tracker.config)
401             except AttributeError:
402                 # TODO: move this code to nuke() static method in every backend
403                 shutil.rmtree(os.path.join(tracker_home, 'db'))
405         # GO
406         init.initialise(tracker_home, adminpw)
408         return 0
411     def do_get(self, args):
412         '''Usage: get property designator[,designator]*
413         Get the given property of one or more designator(s).
415         Retrieves the property value of the nodes specified by the designators.
416         '''
417         if len(args) < 2:
418             raise UsageError, _('Not enough arguments supplied')
419         propname = args[0]
420         designators = args[1].split(',')
421         l = []
422         for designator in designators:
423             # decode the node designator
424             try:
425                 classname, nodeid = hyperdb.splitDesignator(designator)
426             except hyperdb.DesignatorError, message:
427                 raise UsageError, message
429             # get the class
430             cl = self.get_class(classname)
431             try:
432                 id=[]
433                 if self.separator:
434                     if self.print_designator:
435                         # see if property is a link or multilink for
436                         # which getting a desginator make sense.
437                         # Algorithm: Get the properties of the
438                         #     current designator's class. (cl.getprops)
439                         # get the property object for the property the
440                         #     user requested (properties[propname])
441                         # verify its type (isinstance...)
442                         # raise error if not link/multilink
443                         # get class name for link/multilink property
444                         # do the get on the designators
445                         # append the new designators
446                         # print
447                         properties = cl.getprops()
448                         property = properties[propname]
449                         if not (isinstance(property, hyperdb.Multilink) or
450                           isinstance(property, hyperdb.Link)):
451                             raise UsageError, _('property %s is not of type Multilink or Link so -d flag does not apply.')%propname
452                         propclassname = self.db.getclass(property.classname).classname
453                         id = cl.get(nodeid, propname)
454                         for i in id:
455                             l.append(propclassname + i)
456                     else:
457                         id = cl.get(nodeid, propname)
458                         for i in id:
459                             l.append(i)
460                 else:
461                     if self.print_designator:
462                         properties = cl.getprops()
463                         property = properties[propname]
464                         if not (isinstance(property, hyperdb.Multilink) or
465                           isinstance(property, hyperdb.Link)):
466                             raise UsageError, _('property %s is not of type Multilink or Link so -d flag does not apply.')%propname
467                         propclassname = self.db.getclass(property.classname).classname
468                         id = cl.get(nodeid, propname)
469                         for i in id:
470                             print propclassname + i
471                     else:
472                         print cl.get(nodeid, propname)
473             except IndexError:
474                 raise UsageError, _('no such %(classname)s node "%(nodeid)s"')%locals()
475             except KeyError:
476                 raise UsageError, _('no such %(classname)s property '
477                     '"%(propname)s"')%locals()
478         if self.separator:
479             print self.separator.join(l)
481         return 0
484     def do_set(self, args, pwre = re.compile(r'{(\w+)}(.+)')):
485         '''Usage: set items property=value property=value ...
486         Set the given properties of one or more items(s).
488         The items are specified as a class or as a comma-separated
489         list of item designators (ie "designator[,designator,...]").
491         This command sets the properties to the values for all designators
492         given. If the value is missing (ie. "property=") then the property is
493         un-set. If the property is a multilink, you specify the linked ids
494         for the multilink as comma-separated numbers (ie "1,2,3").
495         '''
496         if len(args) < 2:
497             raise UsageError, _('Not enough arguments supplied')
498         from roundup import hyperdb
500         designators = args[0].split(',')
501         if len(designators) == 1:
502             designator = designators[0]
503             try:
504                 designator = hyperdb.splitDesignator(designator)
505                 designators = [designator]
506             except hyperdb.DesignatorError:
507                 cl = self.get_class(designator)
508                 designators = [(designator, x) for x in cl.list()]
509         else:
510             try:
511                 designators = [hyperdb.splitDesignator(x) for x in designators]
512             except hyperdb.DesignatorError, message:
513                 raise UsageError, message
515         # get the props from the args
516         props = self.props_from_args(args[1:])
518         # now do the set for all the nodes
519         for classname, itemid in designators:
520             cl = self.get_class(classname)
522             properties = cl.getprops()
523             for key, value in props.items():
524                 proptype =  properties[key]
525                 if isinstance(proptype, hyperdb.Multilink):
526                     if value is None:
527                         props[key] = []
528                     else:
529                         props[key] = value.split(',')
530                 elif value is None:
531                     continue
532                 elif isinstance(proptype, hyperdb.String):
533                     continue
534                 elif isinstance(proptype, hyperdb.Password):
535                     m = pwre.match(value)
536                     if m:
537                         # password is being given to us encrypted
538                         p = password.Password()
539                         p.scheme = m.group(1)
540                         p.password = m.group(2)
541                         props[key] = p
542                     else:
543                         props[key] = password.Password(value)
544                 elif isinstance(proptype, hyperdb.Date):
545                     try:
546                         props[key] = date.Date(value)
547                     except ValueError, message:
548                         raise UsageError, '"%s": %s'%(value, message)
549                 elif isinstance(proptype, hyperdb.Interval):
550                     try:
551                         props[key] = date.Interval(value)
552                     except ValueError, message:
553                         raise UsageError, '"%s": %s'%(value, message)
554                 elif isinstance(proptype, hyperdb.Link):
555                     props[key] = value
556                 elif isinstance(proptype, hyperdb.Boolean):
557                     props[key] = value.lower() in ('yes', 'true', 'on', '1')
558                 elif isinstance(proptype, hyperdb.Number):
559                     props[key] = float(value)
561             # try the set
562             try:
563                 apply(cl.set, (itemid, ), props)
564             except (TypeError, IndexError, ValueError), message:
565                 import traceback; traceback.print_exc()
566                 raise UsageError, message
567         return 0
569     def do_find(self, args):
570         '''Usage: find classname propname=value ...
571         Find the nodes of the given class with a given link property value.
573         Find the nodes of the given class with a given link property value. The
574         value may be either the nodeid of the linked node, or its key value.
575         '''
576         if len(args) < 1:
577             raise UsageError, _('Not enough arguments supplied')
578         classname = args[0]
579         # get the class
580         cl = self.get_class(classname)
582         # handle the propname=value argument
583         props = self.props_from_args(args[1:])
585         # if the value isn't a number, look up the linked class to get the
586         # number
587         for propname, value in props.items():
588             num_re = re.compile('^\d+$')
589             if value == '-1':
590                 props[propname] = None
591             elif not num_re.match(value):
592                 # get the property
593                 try:
594                     property = cl.properties[propname]
595                 except KeyError:
596                     raise UsageError, _('%(classname)s has no property '
597                         '"%(propname)s"')%locals()
599                 # make sure it's a link
600                 if (not isinstance(property, hyperdb.Link) and not
601                         isinstance(property, hyperdb.Multilink)):
602                     raise UsageError, _('You may only "find" link properties')
604                 # get the linked-to class and look up the key property
605                 link_class = self.db.getclass(property.classname)
606                 try:
607                     props[propname] = link_class.lookup(value)
608                 except TypeError:
609                     raise UsageError, _('%(classname)s has no key property"')%{
610                         'classname': link_class.classname}
612         # now do the find 
613         try:
614             id = []
615             designator = []
616             if self.separator:
617                 if self.print_designator:
618                     id=apply(cl.find, (), props)
619                     for i in id:
620                         designator.append(classname + i)
621                     print self.separator.join(designator)
622                 else:
623                     print self.separator.join(apply(cl.find, (), props))
625             else:
626                 if self.print_designator:
627                     id=apply(cl.find, (), props)
628                     for i in id:
629                         designator.append(classname + i)
630                     print designator
631                 else:
632                     print apply(cl.find, (), props)
633         except KeyError:
634             raise UsageError, _('%(classname)s has no property '
635                 '"%(propname)s"')%locals()
636         except (ValueError, TypeError), message:
637             raise UsageError, message
638         return 0
640     def do_specification(self, args):
641         '''Usage: specification classname
642         Show the properties for a classname.
644         This lists the properties for a given class.
645         '''
646         if len(args) < 1:
647             raise UsageError, _('Not enough arguments supplied')
648         classname = args[0]
649         # get the class
650         cl = self.get_class(classname)
652         # get the key property
653         keyprop = cl.getkey()
654         for key, value in cl.properties.items():
655             if keyprop == key:
656                 print _('%(key)s: %(value)s (key property)')%locals()
657             else:
658                 print _('%(key)s: %(value)s')%locals()
660     def do_display(self, args):
661         '''Usage: display designator[,designator]*
662         Show the property values for the given node(s).
664         This lists the properties and their associated values for the given
665         node.
666         '''
667         if len(args) < 1:
668             raise UsageError, _('Not enough arguments supplied')
670         # decode the node designator
671         for designator in args[0].split(','):
672             try:
673                 classname, nodeid = hyperdb.splitDesignator(designator)
674             except hyperdb.DesignatorError, message:
675                 raise UsageError, message
677             # get the class
678             cl = self.get_class(classname)
680             # display the values
681             for key in cl.properties.keys():
682                 value = cl.get(nodeid, key)
683                 print _('%(key)s: %(value)s')%locals()
685     def do_create(self, args, pwre = re.compile(r'{(\w+)}(.+)')):
686         '''Usage: create classname property=value ...
687         Create a new entry of a given class.
689         This creates a new entry of the given class using the property
690         name=value arguments provided on the command line after the "create"
691         command.
692         '''
693         if len(args) < 1:
694             raise UsageError, _('Not enough arguments supplied')
695         from roundup import hyperdb
697         classname = args[0]
699         # get the class
700         cl = self.get_class(classname)
702         # now do a create
703         props = {}
704         properties = cl.getprops(protected = 0)
705         if len(args) == 1:
706             # ask for the properties
707             for key, value in properties.items():
708                 if key == 'id': continue
709                 name = value.__class__.__name__
710                 if isinstance(value , hyperdb.Password):
711                     again = None
712                     while value != again:
713                         value = getpass.getpass(_('%(propname)s (Password): ')%{
714                             'propname': key.capitalize()})
715                         again = getpass.getpass(_('   %(propname)s (Again): ')%{
716                             'propname': key.capitalize()})
717                         if value != again: print _('Sorry, try again...')
718                     if value:
719                         props[key] = value
720                 else:
721                     value = raw_input(_('%(propname)s (%(proptype)s): ')%{
722                         'propname': key.capitalize(), 'proptype': name})
723                     if value:
724                         props[key] = value
725         else:
726             props = self.props_from_args(args[1:])
728         # convert types
729         for propname, value in props.items():
730             # get the property
731             try:
732                 proptype = properties[propname]
733             except KeyError:
734                 raise UsageError, _('%(classname)s has no property '
735                     '"%(propname)s"')%locals()
737             if isinstance(proptype, hyperdb.Date):
738                 try:
739                     props[propname] = date.Date(value)
740                 except ValueError, message:
741                     raise UsageError, _('"%(value)s": %(message)s')%locals()
742             elif isinstance(proptype, hyperdb.Interval):
743                 try:
744                     props[propname] = date.Interval(value)
745                 except ValueError, message:
746                     raise UsageError, _('"%(value)s": %(message)s')%locals()
747             elif isinstance(proptype, hyperdb.Password):
748                 m = pwre.match(value)
749                 if m:
750                     # password is being given to us encrypted
751                     p = password.Password()
752                     p.scheme = m.group(1)
753                     p.password = m.group(2)
754                     props[propname] = p
755                 else:
756                     props[propname] = password.Password(value)
757             elif isinstance(proptype, hyperdb.Multilink):
758                 props[propname] = value.split(',')
759             elif isinstance(proptype, hyperdb.Boolean):
760                 props[propname] = value.lower() in ('yes', 'true', 'on', '1')
761             elif isinstance(proptype, hyperdb.Number):
762                 props[propname] = float(value)
764         # check for the key property
765         propname = cl.getkey()
766         if propname and not props.has_key(propname):
767             raise UsageError, _('you must provide the "%(propname)s" '
768                 'property.')%locals()
770         # do the actual create
771         try:
772             print apply(cl.create, (), props)
773         except (TypeError, IndexError, ValueError), message:
774             raise UsageError, message
775         return 0
777     def do_list(self, args):
778         '''Usage: list classname [property]
779         List the instances of a class.
781         Lists all instances of the given class. If the property is not
782         specified, the  "label" property is used. The label property is tried
783         in order: the key, "name", "title" and then the first property,
784         alphabetically.
786         With -c, -S or -s print a list of item id's if no property specified.
787         If property specified, print list of that property for every class
788         instance.
789         '''
790         if len(args) > 2:
791             raise UsageError, _('Too many arguments supplied')
792         if len(args) < 1:
793             raise UsageError, _('Not enough arguments supplied')
794         classname = args[0]
796         # get the class
797         cl = self.get_class(classname)
799         # figure the property
800         if len(args) > 1:
801             propname = args[1]
802         else:
803             propname = cl.labelprop()
805         if self.separator:
806             if len(args) == 2:
807                # create a list of propnames since user specified propname
808                 proplist=[]
809                 for nodeid in cl.list():
810                     try:
811                         proplist.append(cl.get(nodeid, propname))
812                     except KeyError:
813                         raise UsageError, _('%(classname)s has no property '
814                             '"%(propname)s"')%locals()
815                 print self.separator.join(proplist)
816             else:
817                 # create a list of index id's since user didn't specify
818                 # otherwise
819                 print self.separator.join(cl.list())
820         else:
821             for nodeid in cl.list():
822                 try:
823                     value = cl.get(nodeid, propname)
824                 except KeyError:
825                     raise UsageError, _('%(classname)s has no property '
826                         '"%(propname)s"')%locals()
827                 print _('%(nodeid)4s: %(value)s')%locals()
828         return 0
830     def do_table(self, args):
831         '''Usage: table classname [property[,property]*]
832         List the instances of a class in tabular form.
834         Lists all instances of the given class. If the properties are not
835         specified, all properties are displayed. By default, the column widths
836         are the width of the largest value. The width may be explicitly defined
837         by defining the property as "name:width". For example::
838           roundup> table priority id,name:10
839           Id Name
840           1  fatal-bug 
841           2  bug       
842           3  usability 
843           4  feature   
845         Also to make the width of the column the width of the label,
846         leave a trailing : without a width on the property. E.G.
847           roundup> table priority id,name:
848           Id Name
849           1  fata
850           2  bug       
851           3  usab
852           4  feat
854         will result in a the 4 character wide "Name" column.
855         '''
856         if len(args) < 1:
857             raise UsageError, _('Not enough arguments supplied')
858         classname = args[0]
860         # get the class
861         cl = self.get_class(classname)
863         # figure the property names to display
864         if len(args) > 1:
865             prop_names = args[1].split(',')
866             all_props = cl.getprops()
867             for spec in prop_names:
868                 if ':' in spec:
869                     try:
870                         propname, width = spec.split(':')
871                     except (ValueError, TypeError):
872                         raise UsageError, _('"%(spec)s" not name:width')%locals()
873                 else:
874                     propname = spec
875                 if not all_props.has_key(propname):
876                     raise UsageError, _('%(classname)s has no property '
877                         '"%(propname)s"')%locals()
878         else:
879             prop_names = cl.getprops().keys()
881         # now figure column widths
882         props = []
883         for spec in prop_names:
884             if ':' in spec:
885                 name, width = spec.split(':')
886                 if width == '':
887                     props.append((name, len(spec)))
888                 else:
889                     props.append((name, int(width)))
890             else:
891                # this is going to be slow
892                maxlen = len(spec)
893                for nodeid in cl.list():
894                    curlen = len(str(cl.get(nodeid, spec)))
895                    if curlen > maxlen:
896                        maxlen = curlen
897                props.append((spec, maxlen))
898                
899         # now display the heading
900         print ' '.join([name.capitalize().ljust(width) for name,width in props])
902         # and the table data
903         for nodeid in cl.list():
904             l = []
905             for name, width in props:
906                 if name != 'id':
907                     try:
908                         value = str(cl.get(nodeid, name))
909                     except KeyError:
910                         # we already checked if the property is valid - a
911                         # KeyError here means the node just doesn't have a
912                         # value for it
913                         value = ''
914                 else:
915                     value = str(nodeid)
916                 f = '%%-%ds'%width
917                 l.append(f%value[:width])
918             print ' '.join(l)
919         return 0
921     def do_history(self, args):
922         '''Usage: history designator
923         Show the history entries of a designator.
925         Lists the journal entries for the node identified by the designator.
926         '''
927         if len(args) < 1:
928             raise UsageError, _('Not enough arguments supplied')
929         try:
930             classname, nodeid = hyperdb.splitDesignator(args[0])
931         except hyperdb.DesignatorError, message:
932             raise UsageError, message
934         try:
935             print self.db.getclass(classname).history(nodeid)
936         except KeyError:
937             raise UsageError, _('no such class "%(classname)s"')%locals()
938         except IndexError:
939             raise UsageError, _('no such %(classname)s node "%(nodeid)s"')%locals()
940         return 0
942     def do_commit(self, args):
943         '''Usage: commit
944         Commit all changes made to the database.
946         The changes made during an interactive session are not
947         automatically written to the database - they must be committed
948         using this command.
950         One-off commands on the command-line are automatically committed if
951         they are successful.
952         '''
953         self.db.commit()
954         return 0
956     def do_rollback(self, args):
957         '''Usage: rollback
958         Undo all changes that are pending commit to the database.
960         The changes made during an interactive session are not
961         automatically written to the database - they must be committed
962         manually. This command undoes all those changes, so a commit
963         immediately after would make no changes to the database.
964         '''
965         self.db.rollback()
966         return 0
968     def do_retire(self, args):
969         '''Usage: retire designator[,designator]*
970         Retire the node specified by designator.
972         This action indicates that a particular node is not to be retrieved by
973         the list or find commands, and its key value may be re-used.
974         '''
975         if len(args) < 1:
976             raise UsageError, _('Not enough arguments supplied')
977         designators = args[0].split(',')
978         for designator in designators:
979             try:
980                 classname, nodeid = hyperdb.splitDesignator(designator)
981             except hyperdb.DesignatorError, message:
982                 raise UsageError, message
983             try:
984                 self.db.getclass(classname).retire(nodeid)
985             except KeyError:
986                 raise UsageError, _('no such class "%(classname)s"')%locals()
987             except IndexError:
988                 raise UsageError, _('no such %(classname)s node "%(nodeid)s"')%locals()
989         return 0
991     def do_restore(self, args):
992         '''Usage: restore designator[,designator]*
993         Restore the retired node specified by designator.
995         The given nodes will become available for users again.
996         '''
997         if len(args) < 1:
998             raise UsageError, _('Not enough arguments supplied')
999         designators = args[0].split(',')
1000         for designator in designators:
1001             try:
1002                 classname, nodeid = hyperdb.splitDesignator(designator)
1003             except hyperdb.DesignatorError, message:
1004                 raise UsageError, message
1005             try:
1006                 self.db.getclass(classname).restore(nodeid)
1007             except KeyError:
1008                 raise UsageError, _('no such class "%(classname)s"')%locals()
1009             except IndexError:
1010                 raise UsageError, _('no such %(classname)s node "%(nodeid)s"')%locals()
1011         return 0
1013     def do_export(self, args):
1014         '''Usage: export [class[,class]] export_dir
1015         Export the database to colon-separated-value files.
1017         This action exports the current data from the database into
1018         colon-separated-value files that are placed in the nominated
1019         destination directory. The journals are not exported.
1020         '''
1021         # we need the CSV module
1022         if csv is None:
1023             raise UsageError, \
1024                 _('Sorry, you need the csv module to use this function.\n'
1025                 'Get it from: http://www.object-craft.com.au/projects/csv/')
1027         # grab the directory to export to
1028         if len(args) < 1:
1029             raise UsageError, _('Not enough arguments supplied')
1030         dir = args[-1]
1032         # get the list of classes to export
1033         if len(args) == 2:
1034             classes = args[0].split(',')
1035         else:
1036             classes = self.db.classes.keys()
1038         # use the csv parser if we can - it's faster
1039         p = csv.parser(field_sep=':')
1041         # do all the classes specified
1042         for classname in classes:
1043             cl = self.get_class(classname)
1044             f = open(os.path.join(dir, classname+'.csv'), 'w')
1045             properties = cl.getprops()
1046             propnames = properties.keys()
1047             propnames.sort()
1048             l = propnames[:]
1049             l.append('is retired')
1050             print >> f, p.join(l)
1052             # all nodes for this class (not using list() 'cos it doesn't
1053             # include retired nodes)
1055             for nodeid in self.db.getclass(classname).getnodeids():
1056                 # get the regular props
1057                 print >>f, p.join(cl.export_list(propnames, nodeid))
1059             # close this file
1060             f.close()
1061         return 0
1063     def do_import(self, args):
1064         '''Usage: import import_dir
1065         Import a database from the directory containing CSV files, one per
1066         class to import.
1068         The files must define the same properties as the class (including having
1069         a "header" line with those property names.)
1071         The imported nodes will have the same nodeid as defined in the
1072         import file, thus replacing any existing content.
1074         The new nodes are added to the existing database - if you want to
1075         create a new database using the imported data, then create a new
1076         database (or, tediously, retire all the old data.)
1077         '''
1078         if len(args) < 1:
1079             raise UsageError, _('Not enough arguments supplied')
1080         if csv is None:
1081             raise UsageError, \
1082                 _('Sorry, you need the csv module to use this function.\n'
1083                 'Get it from: http://www.object-craft.com.au/projects/csv/')
1085         from roundup import hyperdb
1087         for file in os.listdir(args[0]):
1088             # we only care about CSV files
1089             if not file.endswith('.csv'):
1090                 continue
1092             f = open(os.path.join(args[0], file))
1094             # get the classname
1095             classname = os.path.splitext(file)[0]
1097             # ensure that the properties and the CSV file headings match
1098             cl = self.get_class(classname)
1099             p = csv.parser(field_sep=':')
1100             file_props = p.parse(f.readline())
1102 # XXX we don't _really_ need to do this...
1103 #            properties = cl.getprops()
1104 #            propnames = properties.keys()
1105 #            propnames.sort()
1106 #            m = file_props[:]
1107 #            m.sort()
1108 #            if m != propnames:
1109 #                raise UsageError, _('Import file doesn\'t define the same '
1110 #                    'properties as "%(arg0)s".')%{'arg0': args[0]}
1112             # loop through the file and create a node for each entry
1113             maxid = 1
1114             while 1:
1115                 line = f.readline()
1116                 if not line: break
1118                 # parse lines until we get a complete entry
1119                 while 1:
1120                     l = p.parse(line)
1121                     if l: break
1122                     line = f.readline()
1123                     if not line:
1124                         raise ValueError, "Unexpected EOF during CSV parse"
1126                 # do the import and figure the current highest nodeid
1127                 maxid = max(maxid, int(cl.import_list(file_props, l)))
1129             print 'setting', classname, maxid+1
1130             self.db.setid(classname, str(maxid+1))
1131         return 0
1133     def do_pack(self, args):
1134         '''Usage: pack period | date
1136 Remove journal entries older than a period of time specified or
1137 before a certain date.
1139 A period is specified using the suffixes "y", "m", and "d". The
1140 suffix "w" (for "week") means 7 days.
1142       "3y" means three years
1143       "2y 1m" means two years and one month
1144       "1m 25d" means one month and 25 days
1145       "2w 3d" means two weeks and three days
1147 Date format is "YYYY-MM-DD" eg:
1148     2001-01-01
1149     
1150         '''
1151         if len(args) <> 1:
1152             raise UsageError, _('Not enough arguments supplied')
1153         
1154         # are we dealing with a period or a date
1155         value = args[0]
1156         date_re = re.compile(r'''
1157               (?P<date>\d\d\d\d-\d\d?-\d\d?)? # yyyy-mm-dd
1158               (?P<period>(\d+y\s*)?(\d+m\s*)?(\d+d\s*)?)?
1159               ''', re.VERBOSE)
1160         m = date_re.match(value)
1161         if not m:
1162             raise ValueError, _('Invalid format')
1163         m = m.groupdict()
1164         if m['period']:
1165             pack_before = date.Date(". - %s"%value)
1166         elif m['date']:
1167             pack_before = date.Date(value)
1168         self.db.pack(pack_before)
1169         return 0
1171     def do_reindex(self, args):
1172         '''Usage: reindex
1173         Re-generate a tracker's search indexes.
1175         This will re-generate the search indexes for a tracker. This will
1176         typically happen automatically.
1177         '''
1178         self.db.indexer.force_reindex()
1179         self.db.reindex()
1180         return 0
1182     def do_security(self, args):
1183         '''Usage: security [Role name]
1184         Display the Permissions available to one or all Roles.
1185         '''
1186         if len(args) == 1:
1187             role = args[0]
1188             try:
1189                 roles = [(args[0], self.db.security.role[args[0]])]
1190             except KeyError:
1191                 print _('No such Role "%(role)s"')%locals()
1192                 return 1
1193         else:
1194             roles = self.db.security.role.items()
1195             role = self.db.config.NEW_WEB_USER_ROLES
1196             if ',' in role:
1197                 print _('New Web users get the Roles "%(role)s"')%locals()
1198             else:
1199                 print _('New Web users get the Role "%(role)s"')%locals()
1200             role = self.db.config.NEW_EMAIL_USER_ROLES
1201             if ',' in role:
1202                 print _('New Email users get the Roles "%(role)s"')%locals()
1203             else:
1204                 print _('New Email users get the Role "%(role)s"')%locals()
1205         roles.sort()
1206         for rolename, role in roles:
1207             print _('Role "%(name)s":')%role.__dict__
1208             for permission in role.permissions:
1209                 if permission.klass:
1210                     print _(' %(description)s (%(name)s for "%(klass)s" '
1211                         'only)')%permission.__dict__
1212                 else:
1213                     print _(' %(description)s (%(name)s)')%permission.__dict__
1214         return 0
1216     def run_command(self, args):
1217         '''Run a single command
1218         '''
1219         command = args[0]
1221         # handle help now
1222         if command == 'help':
1223             if len(args)>1:
1224                 self.do_help(args[1:])
1225                 return 0
1226             self.do_help(['help'])
1227             return 0
1228         if command == 'morehelp':
1229             self.do_help(['help'])
1230             self.help_commands()
1231             self.help_all()
1232             return 0
1234         # figure what the command is
1235         try:
1236             functions = self.commands.get(command)
1237         except KeyError:
1238             # not a valid command
1239             print _('Unknown command "%(command)s" ("help commands" for a '
1240                 'list)')%locals()
1241             return 1
1243         # check for multiple matches
1244         if len(functions) > 1:
1245             print _('Multiple commands match "%(command)s": %(list)s')%{'command':
1246                 command, 'list': ', '.join([i[0] for i in functions])}
1247             return 1
1248         command, function = functions[0]
1250         # make sure we have a tracker_home
1251         while not self.tracker_home:
1252             self.tracker_home = raw_input(_('Enter tracker home: ')).strip()
1254         # before we open the db, we may be doing an install or init
1255         if command == 'initialise':
1256             try:
1257                 return self.do_initialise(self.tracker_home, args)
1258             except UsageError, message:
1259                 print _('Error: %(message)s')%locals()
1260                 return 1
1261         elif command == 'install':
1262             try:
1263                 return self.do_install(self.tracker_home, args)
1264             except UsageError, message:
1265                 print _('Error: %(message)s')%locals()
1266                 return 1
1268         # get the tracker
1269         try:
1270             tracker = roundup.instance.open(self.tracker_home)
1271         except ValueError, message:
1272             self.tracker_home = ''
1273             print _("Error: Couldn't open tracker: %(message)s")%locals()
1274             return 1
1276         # only open the database once!
1277         if not self.db:
1278             self.db = tracker.open('admin')
1280         # do the command
1281         ret = 0
1282         try:
1283             ret = function(args[1:])
1284         except UsageError, message:
1285             print _('Error: %(message)s')%locals()
1286             print
1287             print function.__doc__
1288             ret = 1
1289         except:
1290             import traceback
1291             traceback.print_exc()
1292             ret = 1
1293         return ret
1295     def interactive(self):
1296         '''Run in an interactive mode
1297         '''
1298         print _('Roundup %s ready for input.'%roundup_version)
1299         print _('Type "help" for help.')
1300         try:
1301             import readline
1302         except ImportError:
1303             print _('Note: command history and editing not available')
1305         while 1:
1306             try:
1307                 command = raw_input(_('roundup> '))
1308             except EOFError:
1309                 print _('exit...')
1310                 break
1311             if not command: continue
1312             args = token.token_split(command)
1313             if not args: continue
1314             if args[0] in ('quit', 'exit'): break
1315             self.run_command(args)
1317         # exit.. check for transactions
1318         if self.db and self.db.transactions:
1319             commit = raw_input(_('There are unsaved changes. Commit them (y/N)? '))
1320             if commit and commit[0].lower() == 'y':
1321                 self.db.commit()
1322         return 0
1324     def main(self):
1325         try:
1326             opts, args = getopt.getopt(sys.argv[1:], 'i:u:hcdsS:')
1327         except getopt.GetoptError, e:
1328             self.usage(str(e))
1329             return 1
1331         # handle command-line args
1332         self.tracker_home = os.environ.get('TRACKER_HOME', '')
1333         # TODO: reinstate the user/password stuff (-u arg too)
1334         name = password = ''
1335         if os.environ.has_key('ROUNDUP_LOGIN'):
1336             l = os.environ['ROUNDUP_LOGIN'].split(':')
1337             name = l[0]
1338             if len(l) > 1:
1339                 password = l[1]
1340         self.separator = None
1341         self.print_designator = 0
1342         for opt, arg in opts:
1343             if opt == '-h':
1344                 self.usage()
1345                 return 0
1346             if opt == '-i':
1347                 self.tracker_home = arg
1348             if opt == '-c':
1349                 if self.separator != None:
1350                     self.usage('Only one of -c, -S and -s may be specified')
1351                     return 1
1352                 self.separator = ','
1353             if opt == '-S':
1354                 if self.separator != None:
1355                     self.usage('Only one of -c, -S and -s may be specified')
1356                     return 1
1357                 self.separator = arg
1358             if opt == '-s':
1359                 if self.separator != None:
1360                     self.usage('Only one of -c, -S and -s may be specified')
1361                     return 1
1362                 self.separator = ' '
1363             if opt == '-d':
1364                 self.print_designator = 1
1366         # if no command - go interactive
1367         # wrap in a try/finally so we always close off the db
1368         ret = 0
1369         try:
1370             if not args:
1371                 self.interactive()
1372             else:
1373                 ret = self.run_command(args)
1374                 if self.db: self.db.commit()
1375             return ret
1376         finally:
1377             if self.db:
1378                 self.db.close()
1380 if __name__ == '__main__':
1381     tool = AdminTool()
1382     sys.exit(tool.main())
1384 # vim: set filetype=python ts=4 sw=4 et si