Code

Fix to handle bad form submissions, Links and the magic -1 form value.
[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.35 2002-10-03 06:56:28 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             try:
99                 key, value = arg.split('=')
100             except ValueError:
101                 raise UsageError, _('argument "%(arg)s" not propname=value'
102                     )%locals()
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  -c                -- when outputting lists of data, just comma-separate them
121 Help:
122  roundup-admin -h
123  roundup-admin help                       -- this help
124  roundup-admin help <command>             -- command-specific help
125  roundup-admin help all                   -- all available help
126 ''')%locals()
127         self.help_commands()
129     def help_commands(self):
130         ''' List the commands available with their precis help.
131         '''
132         print _('Commands:'),
133         commands = ['']
134         for command in self.commands.values():
135             h = command.__doc__.split('\n')[0]
136             commands.append(' '+h[7:])
137         commands.sort()
138         commands.append(_('Commands may be abbreviated as long as the abbreviation matches only one'))
139         commands.append(_('command, e.g. l == li == lis == list.'))
140         print '\n'.join(commands)
141         print
143     def help_commands_html(self, indent_re=re.compile(r'^(\s+)\S+')):
144         ''' Produce an HTML command list.
145         '''
146         commands = self.commands.values()
147         def sortfun(a, b):
148             return cmp(a.__name__, b.__name__)
149         commands.sort(sortfun)
150         for command in commands:
151             h = command.__doc__.split('\n')
152             name = command.__name__[3:]
153             usage = h[0]
154             print _('''
155 <tr><td valign=top><strong>%(name)s</strong></td>
156     <td><tt>%(usage)s</tt><p>
157 <pre>''')%locals()
158             indent = indent_re.match(h[3])
159             if indent: indent = len(indent.group(1))
160             for line in h[3:]:
161                 if indent:
162                     print line[indent:]
163                 else:
164                     print line
165             print _('</pre></td></tr>\n')
167     def help_all(self):
168         print _('''
169 All commands (except help) require a tracker specifier. This is just the path
170 to the roundup tracker you're working with. A roundup tracker is where 
171 roundup keeps the database and configuration file that defines an issue
172 tracker. It may be thought of as the issue tracker's "home directory". It may
173 be specified in the environment variable TRACKER_HOME or on the command
174 line as "-i tracker".
176 A designator is a classname and a nodeid concatenated, eg. bug1, user10, ...
178 Property values are represented as strings in command arguments and in the
179 printed results:
180  . Strings are, well, strings.
181  . Date values are printed in the full date format in the local time zone, and
182    accepted in the full format or any of the partial formats explained below.
183  . Link values are printed as node designators. When given as an argument,
184    node designators and key strings are both accepted.
185  . Multilink values are printed as lists of node designators joined by commas.
186    When given as an argument, node designators and key strings are both
187    accepted; an empty string, a single node, or a list of nodes joined by
188    commas is accepted.
190 When property values must contain spaces, just surround the value with
191 quotes, either ' or ". A single space may also be backslash-quoted. If a
192 valuu must contain a quote character, it must be backslash-quoted or inside
193 quotes. Examples:
194            hello world      (2 tokens: hello, world)
195            "hello world"    (1 token: hello world)
196            "Roch'e" Compaan (2 tokens: Roch'e Compaan)
197            Roch\'e Compaan  (2 tokens: Roch'e Compaan)
198            address="1 2 3"  (1 token: address=1 2 3)
199            \\               (1 token: \)
200            \n\r\t           (1 token: a newline, carriage-return and tab)
202 When multiple nodes are specified to the roundup get or roundup set
203 commands, the specified properties are retrieved or set on all the listed
204 nodes. 
206 When multiple results are returned by the roundup get or roundup find
207 commands, they are printed one per line (default) or joined by commas (with
208 the -c) option. 
210 Where the command changes data, a login name/password is required. The
211 login may be specified as either "name" or "name:password".
212  . ROUNDUP_LOGIN environment variable
213  . the -u command-line option
214 If either the name or password is not supplied, they are obtained from the
215 command-line. 
217 Date format examples:
218   "2000-04-17.03:45" means <Date 2000-04-17.08:45:00>
219   "2000-04-17" means <Date 2000-04-17.00:00:00>
220   "01-25" means <Date yyyy-01-25.00:00:00>
221   "08-13.22:13" means <Date yyyy-08-14.03:13:00>
222   "11-07.09:32:43" means <Date yyyy-11-07.14:32:43>
223   "14:25" means <Date yyyy-mm-dd.19:25:00>
224   "8:47:11" means <Date yyyy-mm-dd.13:47:11>
225   "." means "right now"
227 Command help:
228 ''')
229         for name, command in self.commands.items():
230             print _('%s:')%name
231             print _('   '), command.__doc__
233     def do_help(self, args, nl_re=re.compile('[\r\n]'),
234             indent_re=re.compile(r'^(\s+)\S+')):
235         '''Usage: help topic
236         Give help about topic.
238         commands  -- list commands
239         <command> -- help specific to a command
240         initopts  -- init command options
241         all       -- all available help
242         '''
243         if len(args)>0:
244             topic = args[0]
245         else:
246             topic = 'help'
247  
249         # try help_ methods
250         if self.help.has_key(topic):
251             self.help[topic]()
252             return 0
254         # try command docstrings
255         try:
256             l = self.commands.get(topic)
257         except KeyError:
258             print _('Sorry, no help for "%(topic)s"')%locals()
259             return 1
261         # display the help for each match, removing the docsring indent
262         for name, help in l:
263             lines = nl_re.split(help.__doc__)
264             print lines[0]
265             indent = indent_re.match(lines[1])
266             if indent: indent = len(indent.group(1))
267             for line in lines[1:]:
268                 if indent:
269                     print line[indent:]
270                 else:
271                     print line
272         return 0
274     def help_initopts(self):
275         import roundup.templates
276         templates = roundup.templates.listTemplates()
277         print _('Templates:'), ', '.join(templates)
278         import roundup.backends
279         backends = roundup.backends.__all__
280         print _('Back ends:'), ', '.join(backends)
282     def do_install(self, tracker_home, args):
283         '''Usage: install [template [backend [admin password]]]
284         Install a new Roundup tracker.
286         The command will prompt for the tracker home directory (if not supplied
287         through TRACKER_HOME or the -i option). The template, backend and admin
288         password may be specified on the command-line as arguments, in that
289         order.
291         The initialise command must be called after this command in order
292         to initialise the tracker's database. You may edit the tracker's
293         initial database contents before running that command by editing
294         the tracker's dbinit.py module init() function.
296         See also initopts help.
297         '''
298         if len(args) < 1:
299             raise UsageError, _('Not enough arguments supplied')
301         # make sure the tracker home can be created
302         parent = os.path.split(tracker_home)[0]
303         if not os.path.exists(parent):
304             raise UsageError, _('Instance home parent directory "%(parent)s"'
305                 ' does not exist')%locals()
307         # select template
308         import roundup.templates
309         templates = roundup.templates.listTemplates()
310         template = len(args) > 1 and args[1] or ''
311         if template not in templates:
312             print _('Templates:'), ', '.join(templates)
313         while template not in templates:
314             template = raw_input(_('Select template [classic]: ')).strip()
315             if not template:
316                 template = 'classic'
318         # select hyperdb backend
319         import roundup.backends
320         backends = roundup.backends.__all__
321         backend = len(args) > 2 and args[2] or ''
322         if backend not in backends:
323             print _('Back ends:'), ', '.join(backends)
324         while backend not in backends:
325             backend = raw_input(_('Select backend [anydbm]: ')).strip()
326             if not backend:
327                 backend = 'anydbm'
328         # XXX perform a unit test based on the user's selections
330         # install!
331         init.install(tracker_home, template, backend)
333         print _('''
334  You should now edit the tracker configuration file:
335    %(config_file)s
336  ... at a minimum, you must set MAILHOST, TRACKER_WEB, MAIL_DOMAIN and
337  ADMIN_EMAIL.
339  If you wish to modify the default schema, you should also edit the database
340  initialisation file:
341    %(database_config_file)s
342  ... see the documentation on customizing for more information.
343 ''')%{
344     'config_file': os.path.join(tracker_home, 'config.py'),
345     'database_config_file': os.path.join(tracker_home, 'dbinit.py')
347         return 0
350     def do_initialise(self, tracker_home, args):
351         '''Usage: initialise [adminpw]
352         Initialise a new Roundup tracker.
354         The administrator details will be set at this step.
356         Execute the tracker's initialisation function dbinit.init()
357         '''
358         # password
359         if len(args) > 1:
360             adminpw = args[1]
361         else:
362             adminpw = ''
363             confirm = 'x'
364             while adminpw != confirm:
365                 adminpw = getpass.getpass(_('Admin Password: '))
366                 confirm = getpass.getpass(_('       Confirm: '))
368         # make sure the tracker home is installed
369         if not os.path.exists(tracker_home):
370             raise UsageError, _('Instance home does not exist')%locals()
371         if not os.path.exists(os.path.join(tracker_home, 'html')):
372             raise UsageError, _('Instance has not been installed')%locals()
374         # is there already a database?
375         if os.path.exists(os.path.join(tracker_home, 'db')):
376             print _('WARNING: The database is already initialised!')
377             print _('If you re-initialise it, you will lose all the data!')
378             ok = raw_input(_('Erase it? Y/[N]: ')).strip()
379             if ok.lower() != 'y':
380                 return 0
382             # nuke it
383             shutil.rmtree(os.path.join(tracker_home, 'db'))
385         # GO
386         init.initialise(tracker_home, adminpw)
388         return 0
391     def do_get(self, args):
392         '''Usage: get property designator[,designator]*
393         Get the given property of one or more designator(s).
395         Retrieves the property value of the nodes specified by the designators.
396         '''
397         if len(args) < 2:
398             raise UsageError, _('Not enough arguments supplied')
399         propname = args[0]
400         designators = args[1].split(',')
401         l = []
402         for designator in designators:
403             # decode the node designator
404             try:
405                 classname, nodeid = hyperdb.splitDesignator(designator)
406             except hyperdb.DesignatorError, message:
407                 raise UsageError, message
409             # get the class
410             cl = self.get_class(classname)
411             try:
412                 if self.comma_sep:
413                     l.append(cl.get(nodeid, propname))
414                 else:
415                     print cl.get(nodeid, propname)
416             except IndexError:
417                 raise UsageError, _('no such %(classname)s node "%(nodeid)s"')%locals()
418             except KeyError:
419                 raise UsageError, _('no such %(classname)s property '
420                     '"%(propname)s"')%locals()
421         if self.comma_sep:
422             print ','.join(l)
423         return 0
426     def do_set(self, args, pwre = re.compile(r'{(\w+)}(.+)')):
427         '''Usage: set [items] property=value property=value ...
428         Set the given properties of one or more items(s).
430         The items may be specified as a class or as a comma-separeted
431         list of item designators (ie "designator[,designator,...]").
433         This command sets the properties to the values for all designators
434         given. If the value is missing (ie. "property=") then the property is
435         un-set.
436         '''
437         if len(args) < 2:
438             raise UsageError, _('Not enough arguments supplied')
439         from roundup import hyperdb
441         designators = args[0].split(',')
442         if len(designators) == 1:
443             designator = designators[0]
444             try:
445                 designator = hyperdb.splitDesignator(designator)
446                 designators = [designator]
447             except hyperdb.DesignatorError:
448                 cl = self.get_class(designator)
449                 designators = [(designator, x) for x in cl.list()]
450         else:
451             try:
452                 designators = [hyperdb.splitDesignator(x) for x in designators]
453             except hyperdb.DesignatorError, message:
454                 raise UsageError, message
456         # get the props from the args
457         props = self.props_from_args(args[1:])
459         # now do the set for all the nodes
460         for classname, itemid in designators:
461             cl = self.get_class(classname)
463             properties = cl.getprops()
464             for key, value in props.items():
465                 proptype =  properties[key]
466                 if isinstance(proptype, hyperdb.Multilink):
467                     if value is None:
468                         props[key] = []
469                     else:
470                         props[key] = value.split(',')
471                 elif value is None:
472                     continue
473                 elif isinstance(proptype, hyperdb.String):
474                     continue
475                 elif isinstance(proptype, hyperdb.Password):
476                     m = pwre.match(value)
477                     if m:
478                         # password is being given to us encrypted
479                         p = password.Password()
480                         p.scheme = m.group(1)
481                         p.password = m.group(2)
482                         props[key] = p
483                     else:
484                         props[key] = password.Password(value)
485                 elif isinstance(proptype, hyperdb.Date):
486                     try:
487                         props[key] = date.Date(value)
488                     except ValueError, message:
489                         raise UsageError, '"%s": %s'%(value, message)
490                 elif isinstance(proptype, hyperdb.Interval):
491                     try:
492                         props[key] = date.Interval(value)
493                     except ValueError, message:
494                         raise UsageError, '"%s": %s'%(value, message)
495                 elif isinstance(proptype, hyperdb.Link):
496                     props[key] = value
497                 elif isinstance(proptype, hyperdb.Boolean):
498                     props[key] = value.lower() in ('yes', 'true', 'on', '1')
499                 elif isinstance(proptype, hyperdb.Number):
500                     props[key] = int(value)
502             # try the set
503             try:
504                 apply(cl.set, (itemid, ), props)
505             except (TypeError, IndexError, ValueError), message:
506                 import traceback; traceback.print_exc()
507                 raise UsageError, message
508         return 0
510     def do_find(self, args):
511         '''Usage: find classname propname=value ...
512         Find the nodes of the given class with a given link property value.
514         Find the nodes of the given class with a given link property value. The
515         value may be either the nodeid of the linked node, or its key value.
516         '''
517         if len(args) < 1:
518             raise UsageError, _('Not enough arguments supplied')
519         classname = args[0]
520         # get the class
521         cl = self.get_class(classname)
523         # handle the propname=value argument
524         props = self.props_from_args(args[1:])
526         # if the value isn't a number, look up the linked class to get the
527         # number
528         for propname, value in props.items():
529             num_re = re.compile('^\d+$')
530             if not num_re.match(value):
531                 # get the property
532                 try:
533                     property = cl.properties[propname]
534                 except KeyError:
535                     raise UsageError, _('%(classname)s has no property '
536                         '"%(propname)s"')%locals()
538                 # make sure it's a link
539                 if (not isinstance(property, hyperdb.Link) and not
540                         isinstance(property, hyperdb.Multilink)):
541                     raise UsageError, _('You may only "find" link properties')
543                 # get the linked-to class and look up the key property
544                 link_class = self.db.getclass(property.classname)
545                 try:
546                     props[propname] = link_class.lookup(value)
547                 except TypeError:
548                     raise UsageError, _('%(classname)s has no key property"')%{
549                         'classname': link_class.classname}
551         # now do the find 
552         try:
553             if self.comma_sep:
554                 print ','.join(apply(cl.find, (), props))
555             else:
556                 print apply(cl.find, (), props)
557         except KeyError:
558             raise UsageError, _('%(classname)s has no property '
559                 '"%(propname)s"')%locals()
560         except (ValueError, TypeError), message:
561             raise UsageError, message
562         return 0
564     def do_specification(self, args):
565         '''Usage: specification classname
566         Show the properties for a classname.
568         This lists the properties for a given class.
569         '''
570         if len(args) < 1:
571             raise UsageError, _('Not enough arguments supplied')
572         classname = args[0]
573         # get the class
574         cl = self.get_class(classname)
576         # get the key property
577         keyprop = cl.getkey()
578         for key, value in cl.properties.items():
579             if keyprop == key:
580                 print _('%(key)s: %(value)s (key property)')%locals()
581             else:
582                 print _('%(key)s: %(value)s')%locals()
584     def do_display(self, args):
585         '''Usage: display designator
586         Show the property values for the given node.
588         This lists the properties and their associated values for the given
589         node.
590         '''
591         if len(args) < 1:
592             raise UsageError, _('Not enough arguments supplied')
594         # decode the node designator
595         try:
596             classname, nodeid = hyperdb.splitDesignator(args[0])
597         except hyperdb.DesignatorError, message:
598             raise UsageError, message
600         # get the class
601         cl = self.get_class(classname)
603         # display the values
604         for key in cl.properties.keys():
605             value = cl.get(nodeid, key)
606             print _('%(key)s: %(value)s')%locals()
608     def do_create(self, args, pwre = re.compile(r'{(\w+)}(.+)')):
609         '''Usage: create classname property=value ...
610         Create a new entry of a given class.
612         This creates a new entry of the given class using the property
613         name=value arguments provided on the command line after the "create"
614         command.
615         '''
616         if len(args) < 1:
617             raise UsageError, _('Not enough arguments supplied')
618         from roundup import hyperdb
620         classname = args[0]
622         # get the class
623         cl = self.get_class(classname)
625         # now do a create
626         props = {}
627         properties = cl.getprops(protected = 0)
628         if len(args) == 1:
629             # ask for the properties
630             for key, value in properties.items():
631                 if key == 'id': continue
632                 name = value.__class__.__name__
633                 if isinstance(value , hyperdb.Password):
634                     again = None
635                     while value != again:
636                         value = getpass.getpass(_('%(propname)s (Password): ')%{
637                             'propname': key.capitalize()})
638                         again = getpass.getpass(_('   %(propname)s (Again): ')%{
639                             'propname': key.capitalize()})
640                         if value != again: print _('Sorry, try again...')
641                     if value:
642                         props[key] = value
643                 else:
644                     value = raw_input(_('%(propname)s (%(proptype)s): ')%{
645                         'propname': key.capitalize(), 'proptype': name})
646                     if value:
647                         props[key] = value
648         else:
649             props = self.props_from_args(args[1:])
651         # convert types
652         for propname, value in props.items():
653             # get the property
654             try:
655                 proptype = properties[propname]
656             except KeyError:
657                 raise UsageError, _('%(classname)s has no property '
658                     '"%(propname)s"')%locals()
660             if isinstance(proptype, hyperdb.Date):
661                 try:
662                     props[propname] = date.Date(value)
663                 except ValueError, message:
664                     raise UsageError, _('"%(value)s": %(message)s')%locals()
665             elif isinstance(proptype, hyperdb.Interval):
666                 try:
667                     props[propname] = date.Interval(value)
668                 except ValueError, message:
669                     raise UsageError, _('"%(value)s": %(message)s')%locals()
670             elif isinstance(proptype, hyperdb.Password):
671                 m = pwre.match(value)
672                 if m:
673                     # password is being given to us encrypted
674                     p = password.Password()
675                     p.scheme = m.group(1)
676                     p.password = m.group(2)
677                     props[propname] = p
678                 else:
679                     props[propname] = password.Password(value)
680             elif isinstance(proptype, hyperdb.Multilink):
681                 props[propname] = value.split(',')
682             elif isinstance(proptype, hyperdb.Boolean):
683                 props[propname] = value.lower() in ('yes', 'true', 'on', '1')
684             elif isinstance(proptype, hyperdb.Number):
685                 props[propname] = int(value)
687         # check for the key property
688         propname = cl.getkey()
689         if propname and not props.has_key(propname):
690             raise UsageError, _('you must provide the "%(propname)s" '
691                 'property.')%locals()
693         # do the actual create
694         try:
695             print apply(cl.create, (), props)
696         except (TypeError, IndexError, ValueError), message:
697             raise UsageError, message
698         return 0
700     def do_list(self, args):
701         '''Usage: list classname [property]
702         List the instances of a class.
704         Lists all instances of the given class. If the property is not
705         specified, the  "label" property is used. The label property is tried
706         in order: the key, "name", "title" and then the first property,
707         alphabetically.
708         '''
709         if len(args) < 1:
710             raise UsageError, _('Not enough arguments supplied')
711         classname = args[0]
713         # get the class
714         cl = self.get_class(classname)
716         # figure the property
717         if len(args) > 1:
718             propname = args[1]
719         else:
720             propname = cl.labelprop()
722         if self.comma_sep:
723             print ','.join(cl.list())
724         else:
725             for nodeid in cl.list():
726                 try:
727                     value = cl.get(nodeid, propname)
728                 except KeyError:
729                     raise UsageError, _('%(classname)s has no property '
730                         '"%(propname)s"')%locals()
731                 print _('%(nodeid)4s: %(value)s')%locals()
732         return 0
734     def do_table(self, args):
735         '''Usage: table classname [property[,property]*]
736         List the instances of a class in tabular form.
738         Lists all instances of the given class. If the properties are not
739         specified, all properties are displayed. By default, the column widths
740         are the width of the property names. The width may be explicitly defined
741         by defining the property as "name:width". For example::
742           roundup> table priority id,name:10
743           Id Name
744           1  fatal-bug 
745           2  bug       
746           3  usability 
747           4  feature   
748         '''
749         if len(args) < 1:
750             raise UsageError, _('Not enough arguments supplied')
751         classname = args[0]
753         # get the class
754         cl = self.get_class(classname)
756         # figure the property names to display
757         if len(args) > 1:
758             prop_names = args[1].split(',')
759             all_props = cl.getprops()
760             for spec in prop_names:
761                 if ':' in spec:
762                     try:
763                         propname, width = spec.split(':')
764                     except (ValueError, TypeError):
765                         raise UsageError, _('"%(spec)s" not name:width')%locals()
766                 else:
767                     propname = spec
768                 if not all_props.has_key(propname):
769                     raise UsageError, _('%(classname)s has no property '
770                         '"%(propname)s"')%locals()
771         else:
772             prop_names = cl.getprops().keys()
774         # now figure column widths
775         props = []
776         for spec in prop_names:
777             if ':' in spec:
778                 name, width = spec.split(':')
779                 props.append((name, int(width)))
780             else:
781                 props.append((spec, len(spec)))
783         # now display the heading
784         print ' '.join([name.capitalize().ljust(width) for name,width in props])
786         # and the table data
787         for nodeid in cl.list():
788             l = []
789             for name, width in props:
790                 if name != 'id':
791                     try:
792                         value = str(cl.get(nodeid, name))
793                     except KeyError:
794                         # we already checked if the property is valid - a
795                         # KeyError here means the node just doesn't have a
796                         # value for it
797                         value = ''
798                 else:
799                     value = str(nodeid)
800                 f = '%%-%ds'%width
801                 l.append(f%value[:width])
802             print ' '.join(l)
803         return 0
805     def do_history(self, args):
806         '''Usage: history designator
807         Show the history entries of a designator.
809         Lists the journal entries for the node identified by the designator.
810         '''
811         if len(args) < 1:
812             raise UsageError, _('Not enough arguments supplied')
813         try:
814             classname, nodeid = hyperdb.splitDesignator(args[0])
815         except hyperdb.DesignatorError, message:
816             raise UsageError, message
818         try:
819             print self.db.getclass(classname).history(nodeid)
820         except KeyError:
821             raise UsageError, _('no such class "%(classname)s"')%locals()
822         except IndexError:
823             raise UsageError, _('no such %(classname)s node "%(nodeid)s"')%locals()
824         return 0
826     def do_commit(self, args):
827         '''Usage: commit
828         Commit all changes made to the database.
830         The changes made during an interactive session are not
831         automatically written to the database - they must be committed
832         using this command.
834         One-off commands on the command-line are automatically committed if
835         they are successful.
836         '''
837         self.db.commit()
838         return 0
840     def do_rollback(self, args):
841         '''Usage: rollback
842         Undo all changes that are pending commit to the database.
844         The changes made during an interactive session are not
845         automatically written to the database - they must be committed
846         manually. This command undoes all those changes, so a commit
847         immediately after would make no changes to the database.
848         '''
849         self.db.rollback()
850         return 0
852     def do_retire(self, args):
853         '''Usage: retire designator[,designator]*
854         Retire the node specified by designator.
856         This action indicates that a particular node is not to be retrieved by
857         the list or find commands, and its key value may be re-used.
858         '''
859         if len(args) < 1:
860             raise UsageError, _('Not enough arguments supplied')
861         designators = args[0].split(',')
862         for designator in designators:
863             try:
864                 classname, nodeid = hyperdb.splitDesignator(designator)
865             except hyperdb.DesignatorError, message:
866                 raise UsageError, message
867             try:
868                 self.db.getclass(classname).retire(nodeid)
869             except KeyError:
870                 raise UsageError, _('no such class "%(classname)s"')%locals()
871             except IndexError:
872                 raise UsageError, _('no such %(classname)s node "%(nodeid)s"')%locals()
873         return 0
875     def do_export(self, args):
876         '''Usage: export [class[,class]] export_dir
877         Export the database to colon-separated-value files.
879         This action exports the current data from the database into
880         colon-separated-value files that are placed in the nominated
881         destination directory. The journals are not exported.
882         '''
883         # we need the CSV module
884         if csv is None:
885             raise UsageError, \
886                 _('Sorry, you need the csv module to use this function.\n'
887                 'Get it from: http://www.object-craft.com.au/projects/csv/')
889         # grab the directory to export to
890         if len(args) < 1:
891             raise UsageError, _('Not enough arguments supplied')
892         dir = args[-1]
894         # get the list of classes to export
895         if len(args) == 2:
896             classes = args[0].split(',')
897         else:
898             classes = self.db.classes.keys()
900         # use the csv parser if we can - it's faster
901         p = csv.parser(field_sep=':')
903         # do all the classes specified
904         for classname in classes:
905             cl = self.get_class(classname)
906             f = open(os.path.join(dir, classname+'.csv'), 'w')
907             properties = cl.getprops()
908             propnames = properties.keys()
909             propnames.sort()
910             print >> f, p.join(propnames)
912             # all nodes for this class
913             for nodeid in cl.list():
914                 print >>f, p.join(cl.export_list(propnames, nodeid))
915         return 0
917     def do_import(self, args):
918         '''Usage: import import_dir
919         Import a database from the directory containing CSV files, one per
920         class to import.
922         The files must define the same properties as the class (including having
923         a "header" line with those property names.)
925         The imported nodes will have the same nodeid as defined in the
926         import file, thus replacing any existing content.
928         The new nodes are added to the existing database - if you want to
929         create a new database using the imported data, then create a new
930         database (or, tediously, retire all the old data.)
931         '''
932         if len(args) < 1:
933             raise UsageError, _('Not enough arguments supplied')
934         if csv is None:
935             raise UsageError, \
936                 _('Sorry, you need the csv module to use this function.\n'
937                 'Get it from: http://www.object-craft.com.au/projects/csv/')
939         from roundup import hyperdb
941         for file in os.listdir(args[0]):
942             f = open(os.path.join(args[0], file))
944             # get the classname
945             classname = os.path.splitext(file)[0]
947             # ensure that the properties and the CSV file headings match
948             cl = self.get_class(classname)
949             p = csv.parser(field_sep=':')
950             file_props = p.parse(f.readline())
951             properties = cl.getprops()
952             propnames = properties.keys()
953             propnames.sort()
954             m = file_props[:]
955             m.sort()
956             if m != propnames:
957                 raise UsageError, _('Import file doesn\'t define the same '
958                     'properties as "%(arg0)s".')%{'arg0': args[0]}
960             # loop through the file and create a node for each entry
961             maxid = 1
962             while 1:
963                 line = f.readline()
964                 if not line: break
966                 # parse lines until we get a complete entry
967                 while 1:
968                     l = p.parse(line)
969                     if l: break
970                     line = f.readline()
971                     if not line:
972                         raise ValueError, "Unexpected EOF during CSV parse"
974                 # do the import and figure the current highest nodeid
975                 maxid = max(maxid, int(cl.import_list(propnames, l)))
977             print 'setting', classname, maxid+1
978             self.db.setid(classname, str(maxid+1))
979         return 0
981     def do_pack(self, args):
982         '''Usage: pack period | date
984 Remove journal entries older than a period of time specified or
985 before a certain date.
987 A period is specified using the suffixes "y", "m", and "d". The
988 suffix "w" (for "week") means 7 days.
990       "3y" means three years
991       "2y 1m" means two years and one month
992       "1m 25d" means one month and 25 days
993       "2w 3d" means two weeks and three days
995 Date format is "YYYY-MM-DD" eg:
996     2001-01-01
997     
998         '''
999         if len(args) <> 1:
1000             raise UsageError, _('Not enough arguments supplied')
1001         
1002         # are we dealing with a period or a date
1003         value = args[0]
1004         date_re = re.compile(r'''
1005               (?P<date>\d\d\d\d-\d\d?-\d\d?)? # yyyy-mm-dd
1006               (?P<period>(\d+y\s*)?(\d+m\s*)?(\d+d\s*)?)?
1007               ''', re.VERBOSE)
1008         m = date_re.match(value)
1009         if not m:
1010             raise ValueError, _('Invalid format')
1011         m = m.groupdict()
1012         if m['period']:
1013             pack_before = date.Date(". - %s"%value)
1014         elif m['date']:
1015             pack_before = date.Date(value)
1016         self.db.pack(pack_before)
1017         return 0
1019     def do_reindex(self, args):
1020         '''Usage: reindex
1021         Re-generate a tracker's search indexes.
1023         This will re-generate the search indexes for a tracker. This will
1024         typically happen automatically.
1025         '''
1026         self.db.indexer.force_reindex()
1027         self.db.reindex()
1028         return 0
1030     def do_security(self, args):
1031         '''Usage: security [Role name]
1032         Display the Permissions available to one or all Roles.
1033         '''
1034         if len(args) == 1:
1035             role = args[0]
1036             try:
1037                 roles = [(args[0], self.db.security.role[args[0]])]
1038             except KeyError:
1039                 print _('No such Role "%(role)s"')%locals()
1040                 return 1
1041         else:
1042             roles = self.db.security.role.items()
1043             role = self.db.config.NEW_WEB_USER_ROLES
1044             if ',' in role:
1045                 print _('New Web users get the Roles "%(role)s"')%locals()
1046             else:
1047                 print _('New Web users get the Role "%(role)s"')%locals()
1048             role = self.db.config.NEW_EMAIL_USER_ROLES
1049             if ',' in role:
1050                 print _('New Email users get the Roles "%(role)s"')%locals()
1051             else:
1052                 print _('New Email users get the Role "%(role)s"')%locals()
1053         roles.sort()
1054         for rolename, role in roles:
1055             print _('Role "%(name)s":')%role.__dict__
1056             for permission in role.permissions:
1057                 if permission.klass:
1058                     print _(' %(description)s (%(name)s for "%(klass)s" '
1059                         'only)')%permission.__dict__
1060                 else:
1061                     print _(' %(description)s (%(name)s)')%permission.__dict__
1062         return 0
1064     def run_command(self, args):
1065         '''Run a single command
1066         '''
1067         command = args[0]
1069         # handle help now
1070         if command == 'help':
1071             if len(args)>1:
1072                 self.do_help(args[1:])
1073                 return 0
1074             self.do_help(['help'])
1075             return 0
1076         if command == 'morehelp':
1077             self.do_help(['help'])
1078             self.help_commands()
1079             self.help_all()
1080             return 0
1082         # figure what the command is
1083         try:
1084             functions = self.commands.get(command)
1085         except KeyError:
1086             # not a valid command
1087             print _('Unknown command "%(command)s" ("help commands" for a '
1088                 'list)')%locals()
1089             return 1
1091         # check for multiple matches
1092         if len(functions) > 1:
1093             print _('Multiple commands match "%(command)s": %(list)s')%{'command':
1094                 command, 'list': ', '.join([i[0] for i in functions])}
1095             return 1
1096         command, function = functions[0]
1098         # make sure we have a tracker_home
1099         while not self.tracker_home:
1100             self.tracker_home = raw_input(_('Enter tracker home: ')).strip()
1102         # before we open the db, we may be doing an install or init
1103         if command == 'initialise':
1104             try:
1105                 return self.do_initialise(self.tracker_home, args)
1106             except UsageError, message:
1107                 print _('Error: %(message)s')%locals()
1108                 return 1
1109         elif command == 'install':
1110             try:
1111                 return self.do_install(self.tracker_home, args)
1112             except UsageError, message:
1113                 print _('Error: %(message)s')%locals()
1114                 return 1
1116         # get the tracker
1117         try:
1118             tracker = roundup.instance.open(self.tracker_home)
1119         except ValueError, message:
1120             self.tracker_home = ''
1121             print _("Error: Couldn't open tracker: %(message)s")%locals()
1122             return 1
1124         # only open the database once!
1125         if not self.db:
1126             self.db = tracker.open('admin')
1128         # do the command
1129         ret = 0
1130         try:
1131             ret = function(args[1:])
1132         except UsageError, message:
1133             print _('Error: %(message)s')%locals()
1134             print
1135             print function.__doc__
1136             ret = 1
1137         except:
1138             import traceback
1139             traceback.print_exc()
1140             ret = 1
1141         return ret
1143     def interactive(self):
1144         '''Run in an interactive mode
1145         '''
1146         print _('Roundup %s ready for input.'%roundup_version)
1147         print _('Type "help" for help.')
1148         try:
1149             import readline
1150         except ImportError:
1151             print _('Note: command history and editing not available')
1153         while 1:
1154             try:
1155                 command = raw_input(_('roundup> '))
1156             except EOFError:
1157                 print _('exit...')
1158                 break
1159             if not command: continue
1160             args = token.token_split(command)
1161             if not args: continue
1162             if args[0] in ('quit', 'exit'): break
1163             self.run_command(args)
1165         # exit.. check for transactions
1166         if self.db and self.db.transactions:
1167             commit = raw_input(_('There are unsaved changes. Commit them (y/N)? '))
1168             if commit and commit[0].lower() == 'y':
1169                 self.db.commit()
1170         return 0
1172     def main(self):
1173         try:
1174             opts, args = getopt.getopt(sys.argv[1:], 'i:u:hc')
1175         except getopt.GetoptError, e:
1176             self.usage(str(e))
1177             return 1
1179         # handle command-line args
1180         self.tracker_home = os.environ.get('TRACKER_HOME', '')
1181         # TODO: reinstate the user/password stuff (-u arg too)
1182         name = password = ''
1183         if os.environ.has_key('ROUNDUP_LOGIN'):
1184             l = os.environ['ROUNDUP_LOGIN'].split(':')
1185             name = l[0]
1186             if len(l) > 1:
1187                 password = l[1]
1188         self.comma_sep = 0
1189         for opt, arg in opts:
1190             if opt == '-h':
1191                 self.usage()
1192                 return 0
1193             if opt == '-i':
1194                 self.tracker_home = arg
1195             if opt == '-c':
1196                 self.comma_sep = 1
1198         # if no command - go interactive
1199         # wrap in a try/finally so we always close off the db
1200         ret = 0
1201         try:
1202             if not args:
1203                 self.interactive()
1204             else:
1205                 ret = self.run_command(args)
1206                 if self.db: self.db.commit()
1207             return ret
1208         finally:
1209             if self.db:
1210                 self.db.close()
1212 if __name__ == '__main__':
1213     tool = AdminTool()
1214     sys.exit(tool.main())
1216 # vim: set filetype=python ts=4 sw=4 et si