Code

Add Number and Boolean types to hyperdb.
[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.18 2002-07-18 11:17:30 gmcm Exp $
21 import sys, os, getpass, getopt, re, UserDict, shlex, shutil
22 try:
23     import csv
24 except ImportError:
25     csv = None
26 from roundup import date, hyperdb, roundupdb, init, password, token
27 from roundup import __version__ as roundup_version
28 import roundup.instance
29 from roundup.i18n import _
31 class CommandDict(UserDict.UserDict):
32     '''Simple dictionary that lets us do lookups using partial keys.
34     Original code submitted by Engelbert Gruber.
35     '''
36     _marker = []
37     def get(self, key, default=_marker):
38         if self.data.has_key(key):
39             return [(key, self.data[key])]
40         keylist = self.data.keys()
41         keylist.sort()
42         l = []
43         for ki in keylist:
44             if ki.startswith(key):
45                 l.append((ki, self.data[ki]))
46         if not l and default is self._marker:
47             raise KeyError, key
48         return l
50 class UsageError(ValueError):
51     pass
53 class AdminTool:
55     def __init__(self):
56         self.commands = CommandDict()
57         for k in AdminTool.__dict__.keys():
58             if k[:3] == 'do_':
59                 self.commands[k[3:]] = getattr(self, k)
60         self.help = {}
61         for k in AdminTool.__dict__.keys():
62             if k[:5] == 'help_':
63                 self.help[k[5:]] = getattr(self, k)
64         self.instance_home = ''
65         self.db = None
67     def get_class(self, classname):
68         '''Get the class - raise an exception if it doesn't exist.
69         '''
70         try:
71             return self.db.getclass(classname)
72         except KeyError:
73             raise UsageError, _('no such class "%(classname)s"')%locals()
75     def props_from_args(self, args):
76         props = {}
77         for arg in args:
78             if arg.find('=') == -1:
79                 raise UsageError, _('argument "%(arg)s" not propname=value')%locals()
80             try:
81                 key, value = arg.split('=')
82             except ValueError:
83                 raise UsageError, _('argument "%(arg)s" not propname=value')%locals()
84             props[key] = value
85         return props
87     def usage(self, message=''):
88         if message:
89             message = _('Problem: %(message)s)\n\n')%locals()
90         print _('''%(message)sUsage: roundup-admin [-i instance home] [-u login] [-c] <command> <arguments>
92 Help:
93  roundup-admin -h
94  roundup-admin help                       -- this help
95  roundup-admin help <command>             -- command-specific help
96  roundup-admin help all                   -- all available help
97 Options:
98  -i instance home  -- specify the issue tracker "home directory" to administer
99  -u                -- the user[:password] to use for commands
100  -c                -- when outputting lists of data, just comma-separate them''')%locals()
101         self.help_commands()
103     def help_commands(self):
104         print _('Commands:'),
105         commands = ['']
106         for command in self.commands.values():
107             h = command.__doc__.split('\n')[0]
108             commands.append(' '+h[7:])
109         commands.sort()
110         commands.append(_('Commands may be abbreviated as long as the abbreviation matches only one'))
111         commands.append(_('command, e.g. l == li == lis == list.'))
112         print '\n'.join(commands)
113         print
115     def help_commands_html(self, indent_re=re.compile(r'^(\s+)\S+')):
116         commands = self.commands.values()
117         def sortfun(a, b):
118             return cmp(a.__name__, b.__name__)
119         commands.sort(sortfun)
120         for command in commands:
121             h = command.__doc__.split('\n')
122             name = command.__name__[3:]
123             usage = h[0]
124             print _('''
125 <tr><td valign=top><strong>%(name)s</strong></td>
126     <td><tt>%(usage)s</tt><p>
127 <pre>''')%locals()
128             indent = indent_re.match(h[3])
129             if indent: indent = len(indent.group(1))
130             for line in h[3:]:
131                 if indent:
132                     print line[indent:]
133                 else:
134                     print line
135             print _('</pre></td></tr>\n')
137     def help_all(self):
138         print _('''
139 All commands (except help) require an instance specifier. This is just the path
140 to the roundup instance you're working with. A roundup instance is where 
141 roundup keeps the database and configuration file that defines an issue
142 tracker. It may be thought of as the issue tracker's "home directory". It may
143 be specified in the environment variable ROUNDUP_INSTANCE or on the command
144 line as "-i instance".
146 A designator is a classname and a nodeid concatenated, eg. bug1, user10, ...
148 Property values are represented as strings in command arguments and in the
149 printed results:
150  . Strings are, well, strings.
151  . Date values are printed in the full date format in the local time zone, and
152    accepted in the full format or any of the partial formats explained below.
153  . Link values are printed as node designators. When given as an argument,
154    node designators and key strings are both accepted.
155  . Multilink values are printed as lists of node designators joined by commas.
156    When given as an argument, node designators and key strings are both
157    accepted; an empty string, a single node, or a list of nodes joined by
158    commas is accepted.
160 When property values must contain spaces, just surround the value with
161 quotes, either ' or ". A single space may also be backslash-quoted. If a
162 valuu must contain a quote character, it must be backslash-quoted or inside
163 quotes. Examples:
164            hello world      (2 tokens: hello, world)
165            "hello world"    (1 token: hello world)
166            "Roch'e" Compaan (2 tokens: Roch'e Compaan)
167            Roch\'e Compaan  (2 tokens: Roch'e Compaan)
168            address="1 2 3"  (1 token: address=1 2 3)
169            \\               (1 token: \)
170            \n\r\t           (1 token: a newline, carriage-return and tab)
172 When multiple nodes are specified to the roundup get or roundup set
173 commands, the specified properties are retrieved or set on all the listed
174 nodes. 
176 When multiple results are returned by the roundup get or roundup find
177 commands, they are printed one per line (default) or joined by commas (with
178 the -c) option. 
180 Where the command changes data, a login name/password is required. The
181 login may be specified as either "name" or "name:password".
182  . ROUNDUP_LOGIN environment variable
183  . the -u command-line option
184 If either the name or password is not supplied, they are obtained from the
185 command-line. 
187 Date format examples:
188   "2000-04-17.03:45" means <Date 2000-04-17.08:45:00>
189   "2000-04-17" means <Date 2000-04-17.00:00:00>
190   "01-25" means <Date yyyy-01-25.00:00:00>
191   "08-13.22:13" means <Date yyyy-08-14.03:13:00>
192   "11-07.09:32:43" means <Date yyyy-11-07.14:32:43>
193   "14:25" means <Date yyyy-mm-dd.19:25:00>
194   "8:47:11" means <Date yyyy-mm-dd.13:47:11>
195   "." means "right now"
197 Command help:
198 ''')
199         for name, command in self.commands.items():
200             print _('%s:')%name
201             print _('   '), command.__doc__
203     def do_help(self, args, nl_re=re.compile('[\r\n]'),
204             indent_re=re.compile(r'^(\s+)\S+')):
205         '''Usage: help topic
206         Give help about topic.
208         commands  -- list commands
209         <command> -- help specific to a command
210         initopts  -- init command options
211         all       -- all available help
212         '''
213         if len(args)>0:
214             topic = args[0]
215         else:
216             topic = 'help'
217  
219         # try help_ methods
220         if self.help.has_key(topic):
221             self.help[topic]()
222             return 0
224         # try command docstrings
225         try:
226             l = self.commands.get(topic)
227         except KeyError:
228             print _('Sorry, no help for "%(topic)s"')%locals()
229             return 1
231         # display the help for each match, removing the docsring indent
232         for name, help in l:
233             lines = nl_re.split(help.__doc__)
234             print lines[0]
235             indent = indent_re.match(lines[1])
236             if indent: indent = len(indent.group(1))
237             for line in lines[1:]:
238                 if indent:
239                     print line[indent:]
240                 else:
241                     print line
242         return 0
244     def help_initopts(self):
245         import roundup.templates
246         templates = roundup.templates.listTemplates()
247         print _('Templates:'), ', '.join(templates)
248         import roundup.backends
249         backends = roundup.backends.__all__
250         print _('Back ends:'), ', '.join(backends)
253     def do_install(self, instance_home, args):
254         '''Usage: install [template [backend [admin password]]]
255         Install a new Roundup instance.
257         The command will prompt for the instance home directory (if not supplied
258         through INSTANCE_HOME or the -i option). The template, backend and admin
259         password may be specified on the command-line as arguments, in that
260         order.
262         The initialise command must be called after this command in order
263         to initialise the instance's database. You may edit the instance's
264         initial database contents before running that command by editing
265         the instance's dbinit.py module init() function.
267         See also initopts help.
268         '''
269         if len(args) < 1:
270             raise UsageError, _('Not enough arguments supplied')
272         # make sure the instance home can be created
273         parent = os.path.split(instance_home)[0]
274         if not os.path.exists(parent):
275             raise UsageError, _('Instance home parent directory "%(parent)s"'
276                 ' does not exist')%locals()
278         # select template
279         import roundup.templates
280         templates = roundup.templates.listTemplates()
281         template = len(args) > 1 and args[1] or ''
282         if template not in templates:
283             print _('Templates:'), ', '.join(templates)
284         while template not in templates:
285             template = raw_input(_('Select template [classic]: ')).strip()
286             if not template:
287                 template = 'classic'
289         # select hyperdb backend
290         import roundup.backends
291         backends = roundup.backends.__all__
292         backend = len(args) > 2 and args[2] or ''
293         if backend not in backends:
294             print _('Back ends:'), ', '.join(backends)
295         while backend not in backends:
296             backend = raw_input(_('Select backend [anydbm]: ')).strip()
297             if not backend:
298                 backend = 'anydbm'
300         # install!
301         init.install(instance_home, template, backend)
303         print _('''
304  You should now edit the instance configuration file:
305    %(instance_config_file)s
306  ... at a minimum, you must set MAILHOST, MAIL_DOMAIN and ADMIN_EMAIL.
308  If you wish to modify the default schema, you should also edit the database
309  initialisation file:
310    %(database_config_file)s
311  ... see the documentation on customizing for more information.
312 ''')%{
313     'instance_config_file': os.path.join(instance_home, 'instance_config.py'),
314     'database_config_file': os.path.join(instance_home, 'dbinit.py')
316         return 0
319     def do_initialise(self, instance_home, args):
320         '''Usage: initialise [adminpw]
321         Initialise a new Roundup instance.
323         The administrator details will be set at this step.
325         Execute the instance's initialisation function dbinit.init()
326         '''
327         # password
328         if len(args) > 1:
329             adminpw = args[1]
330         else:
331             adminpw = ''
332             confirm = 'x'
333             while adminpw != confirm:
334                 adminpw = getpass.getpass(_('Admin Password: '))
335                 confirm = getpass.getpass(_('       Confirm: '))
337         # make sure the instance home is installed
338         if not os.path.exists(instance_home):
339             raise UsageError, _('Instance home does not exist')%locals()
340         if not os.path.exists(os.path.join(instance_home, 'html')):
341             raise UsageError, _('Instance has not been installed')%locals()
343         # is there already a database?
344         if os.path.exists(os.path.join(instance_home, 'db')):
345             print _('WARNING: The database is already initialised!')
346             print _('If you re-initialise it, you will lose all the data!')
347             ok = raw_input(_('Erase it? Y/[N]: ')).strip()
348             if ok.lower() != 'y':
349                 return 0
351             # nuke it
352             shutil.rmtree(os.path.join(instance_home, 'db'))
354         # GO
355         init.initialise(instance_home, adminpw)
357         return 0
360     def do_get(self, args):
361         '''Usage: get property designator[,designator]*
362         Get the given property of one or more designator(s).
364         Retrieves the property value of the nodes specified by the designators.
365         '''
366         if len(args) < 2:
367             raise UsageError, _('Not enough arguments supplied')
368         propname = args[0]
369         designators = args[1].split(',')
370         l = []
371         for designator in designators:
372             # decode the node designator
373             try:
374                 classname, nodeid = roundupdb.splitDesignator(designator)
375             except roundupdb.DesignatorError, message:
376                 raise UsageError, message
378             # get the class
379             cl = self.get_class(classname)
380             try:
381                 if self.comma_sep:
382                     l.append(cl.get(nodeid, propname))
383                 else:
384                     print cl.get(nodeid, propname)
385             except IndexError:
386                 raise UsageError, _('no such %(classname)s node "%(nodeid)s"')%locals()
387             except KeyError:
388                 raise UsageError, _('no such %(classname)s property '
389                     '"%(propname)s"')%locals()
390         if self.comma_sep:
391             print ','.join(l)
392         return 0
395     def do_set(self, args):
396         '''Usage: set designator[,designator]* propname=value ...
397         Set the given property of one or more designator(s).
399         Sets the property to the value for all designators given.
400         '''
401         if len(args) < 2:
402             raise UsageError, _('Not enough arguments supplied')
403         from roundup import hyperdb
405         designators = args[0].split(',')
407         # get the props from the args
408         props = self.props_from_args(args[1:])
410         # now do the set for all the nodes
411         for designator in designators:
412             # decode the node designator
413             try:
414                 classname, nodeid = roundupdb.splitDesignator(designator)
415             except roundupdb.DesignatorError, message:
416                 raise UsageError, message
418             # get the class
419             cl = self.get_class(classname)
421             properties = cl.getprops()
422             for key, value in props.items():
423                 proptype =  properties[key]
424                 if isinstance(proptype, hyperdb.String):
425                     continue
426                 elif isinstance(proptype, hyperdb.Password):
427                     props[key] = password.Password(value)
428                 elif isinstance(proptype, hyperdb.Date):
429                     try:
430                         props[key] = date.Date(value)
431                     except ValueError, message:
432                         raise UsageError, '"%s": %s'%(value, message)
433                 elif isinstance(proptype, hyperdb.Interval):
434                     try:
435                         props[key] = date.Interval(value)
436                     except ValueError, message:
437                         raise UsageError, '"%s": %s'%(value, message)
438                 elif isinstance(proptype, hyperdb.Link):
439                     props[key] = value
440                 elif isinstance(proptype, hyperdb.Multilink):
441                     props[key] = value.split(',')
442                 elif isinstance(proptype, hyperdb.Boolean):
443                     props[key] = value.lower() in ('yes', 'true', 'on', '1')
444                 elif isinstance(proptype, hyperdb.Number):
445                     props[key] = int(value)
447             # try the set
448             try:
449                 apply(cl.set, (nodeid, ), props)
450             except (TypeError, IndexError, ValueError), message:
451                 raise UsageError, message
452         return 0
454     def do_find(self, args):
455         '''Usage: find classname propname=value ...
456         Find the nodes of the given class with a given link property value.
458         Find the nodes of the given class with a given link property value. The
459         value may be either the nodeid of the linked node, or its key value.
460         '''
461         if len(args) < 1:
462             raise UsageError, _('Not enough arguments supplied')
463         classname = args[0]
464         # get the class
465         cl = self.get_class(classname)
467         # handle the propname=value argument
468         props = self.props_from_args(args[1:])
470         # if the value isn't a number, look up the linked class to get the
471         # number
472         for propname, value in props.items():
473             num_re = re.compile('^\d+$')
474             if not num_re.match(value):
475                 # get the property
476                 try:
477                     property = cl.properties[propname]
478                 except KeyError:
479                     raise UsageError, _('%(classname)s has no property '
480                         '"%(propname)s"')%locals()
482                 # make sure it's a link
483                 if (not isinstance(property, hyperdb.Link) and not
484                         isinstance(property, hyperdb.Multilink)):
485                     raise UsageError, _('You may only "find" link properties')
487                 # get the linked-to class and look up the key property
488                 link_class = self.db.getclass(property.classname)
489                 try:
490                     props[propname] = link_class.lookup(value)
491                 except TypeError:
492                     raise UsageError, _('%(classname)s has no key property"')%{
493                         'classname': link_class.classname}
495         # now do the find 
496         try:
497             if self.comma_sep:
498                 print ','.join(apply(cl.find, (), props))
499             else:
500                 print apply(cl.find, (), props)
501         except KeyError:
502             raise UsageError, _('%(classname)s has no property '
503                 '"%(propname)s"')%locals()
504         except (ValueError, TypeError), message:
505             raise UsageError, message
506         return 0
508     def do_specification(self, args):
509         '''Usage: specification classname
510         Show the properties for a classname.
512         This lists the properties for a given class.
513         '''
514         if len(args) < 1:
515             raise UsageError, _('Not enough arguments supplied')
516         classname = args[0]
517         # get the class
518         cl = self.get_class(classname)
520         # get the key property
521         keyprop = cl.getkey()
522         for key, value in cl.properties.items():
523             if keyprop == key:
524                 print _('%(key)s: %(value)s (key property)')%locals()
525             else:
526                 print _('%(key)s: %(value)s')%locals()
528     def do_display(self, args):
529         '''Usage: display designator
530         Show the property values for the given node.
532         This lists the properties and their associated values for the given
533         node.
534         '''
535         if len(args) < 1:
536             raise UsageError, _('Not enough arguments supplied')
538         # decode the node designator
539         try:
540             classname, nodeid = roundupdb.splitDesignator(args[0])
541         except roundupdb.DesignatorError, message:
542             raise UsageError, message
544         # get the class
545         cl = self.get_class(classname)
547         # display the values
548         for key in cl.properties.keys():
549             value = cl.get(nodeid, key)
550             print _('%(key)s: %(value)s')%locals()
552     def do_create(self, args):
553         '''Usage: create classname property=value ...
554         Create a new entry of a given class.
556         This creates a new entry of the given class using the property
557         name=value arguments provided on the command line after the "create"
558         command.
559         '''
560         if len(args) < 1:
561             raise UsageError, _('Not enough arguments supplied')
562         from roundup import hyperdb
564         classname = args[0]
566         # get the class
567         cl = self.get_class(classname)
569         # now do a create
570         props = {}
571         properties = cl.getprops(protected = 0)
572         if len(args) == 1:
573             # ask for the properties
574             for key, value in properties.items():
575                 if key == 'id': continue
576                 name = value.__class__.__name__
577                 if isinstance(value , hyperdb.Password):
578                     again = None
579                     while value != again:
580                         value = getpass.getpass(_('%(propname)s (Password): ')%{
581                             'propname': key.capitalize()})
582                         again = getpass.getpass(_('   %(propname)s (Again): ')%{
583                             'propname': key.capitalize()})
584                         if value != again: print _('Sorry, try again...')
585                     if value:
586                         props[key] = value
587                 else:
588                     value = raw_input(_('%(propname)s (%(proptype)s): ')%{
589                         'propname': key.capitalize(), 'proptype': name})
590                     if value:
591                         props[key] = value
592         else:
593             props = self.props_from_args(args[1:])
595         # convert types
596         for propname, value in props.items():
597             # get the property
598             try:
599                 proptype = properties[propname]
600             except KeyError:
601                 raise UsageError, _('%(classname)s has no property '
602                     '"%(propname)s"')%locals()
604             if isinstance(proptype, hyperdb.Date):
605                 try:
606                     props[propname] = date.Date(value)
607                 except ValueError, message:
608                     raise UsageError, _('"%(value)s": %(message)s')%locals()
609             elif isinstance(proptype, hyperdb.Interval):
610                 try:
611                     props[propname] = date.Interval(value)
612                 except ValueError, message:
613                     raise UsageError, _('"%(value)s": %(message)s')%locals()
614             elif isinstance(proptype, hyperdb.Password):
615                 props[propname] = password.Password(value)
616             elif isinstance(proptype, hyperdb.Multilink):
617                 props[propname] = value.split(',')
618             elif isinstance(proptype, hyperdb.Boolean):
619                 props[propname] = value.lower() in ('yes', 'true', 'on', '1')
620             elif isinstance(proptype, hyperdb.Number):
621                 props[propname] = int(value)
623         # check for the key property
624         propname = cl.getkey()
625         if propname and not props.has_key(propname):
626             raise UsageError, _('you must provide the "%(propname)s" '
627                 'property.')%locals()
629         # do the actual create
630         try:
631             print apply(cl.create, (), props)
632         except (TypeError, IndexError, ValueError), message:
633             raise UsageError, message
634         return 0
636     def do_list(self, args):
637         '''Usage: list classname [property]
638         List the instances of a class.
640         Lists all instances of the given class. If the property is not
641         specified, the  "label" property is used. The label property is tried
642         in order: the key, "name", "title" and then the first property,
643         alphabetically.
644         '''
645         if len(args) < 1:
646             raise UsageError, _('Not enough arguments supplied')
647         classname = args[0]
649         # get the class
650         cl = self.get_class(classname)
652         # figure the property
653         if len(args) > 1:
654             propname = args[1]
655         else:
656             propname = cl.labelprop()
658         if self.comma_sep:
659             print ','.join(cl.list())
660         else:
661             for nodeid in cl.list():
662                 try:
663                     value = cl.get(nodeid, propname)
664                 except KeyError:
665                     raise UsageError, _('%(classname)s has no property '
666                         '"%(propname)s"')%locals()
667                 print _('%(nodeid)4s: %(value)s')%locals()
668         return 0
670     def do_table(self, args):
671         '''Usage: table classname [property[,property]*]
672         List the instances of a class in tabular form.
674         Lists all instances of the given class. If the properties are not
675         specified, all properties are displayed. By default, the column widths
676         are the width of the property names. The width may be explicitly defined
677         by defining the property as "name:width". For example::
678           roundup> table priority id,name:10
679           Id Name
680           1  fatal-bug 
681           2  bug       
682           3  usability 
683           4  feature   
684         '''
685         if len(args) < 1:
686             raise UsageError, _('Not enough arguments supplied')
687         classname = args[0]
689         # get the class
690         cl = self.get_class(classname)
692         # figure the property names to display
693         if len(args) > 1:
694             prop_names = args[1].split(',')
695             all_props = cl.getprops()
696             for spec in prop_names:
697                 if ':' in spec:
698                     try:
699                         propname, width = spec.split(':')
700                     except (ValueError, TypeError):
701                         raise UsageError, _('"%(spec)s" not name:width')%locals()
702                 else:
703                     propname = spec
704                 if not all_props.has_key(propname):
705                     raise UsageError, _('%(classname)s has no property '
706                         '"%(propname)s"')%locals()
707         else:
708             prop_names = cl.getprops().keys()
710         # now figure column widths
711         props = []
712         for spec in prop_names:
713             if ':' in spec:
714                 name, width = spec.split(':')
715                 props.append((name, int(width)))
716             else:
717                 props.append((spec, len(spec)))
719         # now display the heading
720         print ' '.join([name.capitalize().ljust(width) for name,width in props])
722         # and the table data
723         for nodeid in cl.list():
724             l = []
725             for name, width in props:
726                 if name != 'id':
727                     try:
728                         value = str(cl.get(nodeid, name))
729                     except KeyError:
730                         # we already checked if the property is valid - a
731                         # KeyError here means the node just doesn't have a
732                         # value for it
733                         value = ''
734                 else:
735                     value = str(nodeid)
736                 f = '%%-%ds'%width
737                 l.append(f%value[:width])
738             print ' '.join(l)
739         return 0
741     def do_history(self, args):
742         '''Usage: history designator
743         Show the history entries of a designator.
745         Lists the journal entries for the node identified by the designator.
746         '''
747         if len(args) < 1:
748             raise UsageError, _('Not enough arguments supplied')
749         try:
750             classname, nodeid = roundupdb.splitDesignator(args[0])
751         except roundupdb.DesignatorError, message:
752             raise UsageError, message
754         try:
755             print self.db.getclass(classname).history(nodeid)
756         except KeyError:
757             raise UsageError, _('no such class "%(classname)s"')%locals()
758         except IndexError:
759             raise UsageError, _('no such %(classname)s node "%(nodeid)s"')%locals()
760         return 0
762     def do_commit(self, args):
763         '''Usage: commit
764         Commit all changes made to the database.
766         The changes made during an interactive session are not
767         automatically written to the database - they must be committed
768         using this command.
770         One-off commands on the command-line are automatically committed if
771         they are successful.
772         '''
773         self.db.commit()
774         return 0
776     def do_rollback(self, args):
777         '''Usage: rollback
778         Undo all changes that are pending commit to the database.
780         The changes made during an interactive session are not
781         automatically written to the database - they must be committed
782         manually. This command undoes all those changes, so a commit
783         immediately after would make no changes to the database.
784         '''
785         self.db.rollback()
786         return 0
788     def do_retire(self, args):
789         '''Usage: retire designator[,designator]*
790         Retire the node specified by designator.
792         This action indicates that a particular node is not to be retrieved by
793         the list or find commands, and its key value may be re-used.
794         '''
795         if len(args) < 1:
796             raise UsageError, _('Not enough arguments supplied')
797         designators = args[0].split(',')
798         for designator in designators:
799             try:
800                 classname, nodeid = roundupdb.splitDesignator(designator)
801             except roundupdb.DesignatorError, message:
802                 raise UsageError, message
803             try:
804                 self.db.getclass(classname).retire(nodeid)
805             except KeyError:
806                 raise UsageError, _('no such class "%(classname)s"')%locals()
807             except IndexError:
808                 raise UsageError, _('no such %(classname)s node "%(nodeid)s"')%locals()
809         return 0
811     def do_export(self, args):
812         '''Usage: export class[,class] destination_dir
813         Export the database to tab-separated-value files.
815         This action exports the current data from the database into
816         tab-separated-value files that are placed in the nominated destination
817         directory. The journals are not exported.
818         '''
819         if len(args) < 2:
820             raise UsageError, _('Not enough arguments supplied')
821         classes = args[0].split(',')
822         dir = args[1]
824         # use the csv parser if we can - it's faster
825         if csv is not None:
826             p = csv.parser(field_sep=':')
828         # do all the classes specified
829         for classname in classes:
830             cl = self.get_class(classname)
831             f = open(os.path.join(dir, classname+'.csv'), 'w')
832             f.write(':'.join(cl.properties.keys()) + '\n')
834             # all nodes for this class
835             properties = cl.properties.items()
836             for nodeid in cl.list():
837                 l = []
838                 for prop, proptype in properties:
839                     value = cl.get(nodeid, prop)
840                     # convert data where needed
841                     if isinstance(proptype, hyperdb.Date):
842                         value = value.get_tuple()
843                     elif isinstance(proptype, hyperdb.Interval):
844                         value = value.get_tuple()
845                     elif isinstance(proptype, hyperdb.Password):
846                         value = str(value)
847                     l.append(repr(value))
849                 # now write
850                 if csv is not None:
851                    f.write(p.join(l) + '\n')
852                 else:
853                    # escape the individual entries to they're valid CSV
854                    m = []
855                    for entry in l:
856                       if '"' in entry:
857                           entry = '""'.join(entry.split('"'))
858                       if ':' in entry:
859                           entry = '"%s"'%entry
860                       m.append(entry)
861                    f.write(':'.join(m) + '\n')
862         return 0
864     def do_import(self, args):
865         '''Usage: import class file
866         Import the contents of the tab-separated-value file.
868         The file must define the same properties as the class (including having
869         a "header" line with those property names.) The new nodes are added to
870         the existing database - if you want to create a new database using the
871         imported data, then create a new database (or, tediously, retire all
872         the old data.)
873         '''
874         if len(args) < 2:
875             raise UsageError, _('Not enough arguments supplied')
876         if csv is None:
877             raise UsageError, \
878                 _('Sorry, you need the csv module to use this function.\n'
879                 'Get it from: http://www.object-craft.com.au/projects/csv/')
881         from roundup import hyperdb
883         # ensure that the properties and the CSV file headings match
884         classname = args[0]
885         cl = self.get_class(classname)
886         f = open(args[1])
887         p = csv.parser(field_sep=':')
888         file_props = p.parse(f.readline())
889         props = cl.properties.keys()
890         m = file_props[:]
891         m.sort()
892         props.sort()
893         if m != props:
894             raise UsageError, _('Import file doesn\'t define the same '
895                 'properties as "%(arg0)s".')%{'arg0': args[0]}
897         # loop through the file and create a node for each entry
898         n = range(len(props))
899         while 1:
900             line = f.readline()
901             if not line: break
903             # parse lines until we get a complete entry
904             while 1:
905                 l = p.parse(line)
906                 if l: break
907                 line = f.readline()
908                 if not line:
909                     raise ValueError, "Unexpected EOF during CSV parse"
911             # make the new node's property map
912             d = {}
913             for i in n:
914                 # Use eval to reverse the repr() used to output the CSV
915                 value = eval(l[i])
916                 # Figure the property for this column
917                 key = file_props[i]
918                 proptype = cl.properties[key]
919                 # Convert for property type
920                 if isinstance(proptype, hyperdb.Date):
921                     value = date.Date(value)
922                 elif isinstance(proptype, hyperdb.Interval):
923                     value = date.Interval(value)
924                 elif isinstance(proptype, hyperdb.Password):
925                     pwd = password.Password()
926                     pwd.unpack(value)
927                     value = pwd
928                 if value is not None:
929                     d[key] = value
931             # and create the new node
932             apply(cl.create, (), d)
933         return 0
935     def do_pack(self, args):
936         '''Usage: pack period | date
938 Remove journal entries older than a period of time specified or
939 before a certain date.
941 A period is specified using the suffixes "y", "m", and "d". The
942 suffix "w" (for "week") means 7 days.
944       "3y" means three years
945       "2y 1m" means two years and one month
946       "1m 25d" means one month and 25 days
947       "2w 3d" means two weeks and three days
949 Date format is "YYYY-MM-DD" eg:
950     2001-01-01
951     
952         '''
953         if len(args) <> 1:
954             raise UsageError, _('Not enough arguments supplied')
955         
956         # are we dealing with a period or a date
957         value = args[0]
958         date_re = re.compile(r'''
959               (?P<date>\d\d\d\d-\d\d?-\d\d?)? # yyyy-mm-dd
960               (?P<period>(\d+y\s*)?(\d+m\s*)?(\d+d\s*)?)?
961               ''', re.VERBOSE)
962         m = date_re.match(value)
963         if not m:
964             raise ValueError, _('Invalid format')
965         m = m.groupdict()
966         if m['period']:
967             pack_before = date.Date(". - %s"%value)
968         elif m['date']:
969             pack_before = date.Date(value)
970         self.db.pack(pack_before)
971         return 0
973     def do_reindex(self, args):
974         '''Usage: reindex
975         Re-generate an instance's search indexes.
977         This will re-generate the search indexes for an instance. This will
978         typically happen automatically.
979         '''
980         self.db.indexer.force_reindex()
981         self.db.reindex()
982         return 0
984     def run_command(self, args):
985         '''Run a single command
986         '''
987         command = args[0]
989         # handle help now
990         if command == 'help':
991             if len(args)>1:
992                 self.do_help(args[1:])
993                 return 0
994             self.do_help(['help'])
995             return 0
996         if command == 'morehelp':
997             self.do_help(['help'])
998             self.help_commands()
999             self.help_all()
1000             return 0
1002         # figure what the command is
1003         try:
1004             functions = self.commands.get(command)
1005         except KeyError:
1006             # not a valid command
1007             print _('Unknown command "%(command)s" ("help commands" for a '
1008                 'list)')%locals()
1009             return 1
1011         # check for multiple matches
1012         if len(functions) > 1:
1013             print _('Multiple commands match "%(command)s": %(list)s')%{'command':
1014                 command, 'list': ', '.join([i[0] for i in functions])}
1015             return 1
1016         command, function = functions[0]
1018         # make sure we have an instance_home
1019         while not self.instance_home:
1020             self.instance_home = raw_input(_('Enter instance home: ')).strip()
1022         # before we open the db, we may be doing an install or init
1023         if command == 'initialise':
1024             try:
1025                 return self.do_initialise(self.instance_home, args)
1026             except UsageError, message:
1027                 print _('Error: %(message)s')%locals()
1028                 return 1
1029         elif command == 'install':
1030             try:
1031                 return self.do_install(self.instance_home, args)
1032             except UsageError, message:
1033                 print _('Error: %(message)s')%locals()
1034                 return 1
1036         # get the instance
1037         try:
1038             instance = roundup.instance.open(self.instance_home)
1039         except ValueError, message:
1040             self.instance_home = ''
1041             print _("Error: Couldn't open instance: %(message)s")%locals()
1042             return 1
1044         # only open the database once!
1045         if not self.db:
1046             self.db = instance.open('admin')
1048         # do the command
1049         ret = 0
1050         try:
1051             ret = function(args[1:])
1052         except UsageError, message:
1053             print _('Error: %(message)s')%locals()
1054             print
1055             print function.__doc__
1056             ret = 1
1057         except:
1058             import traceback
1059             traceback.print_exc()
1060             ret = 1
1061         return ret
1063     def interactive(self):
1064         '''Run in an interactive mode
1065         '''
1066         print _('Roundup %s ready for input.'%roundup_version)
1067         print _('Type "help" for help.')
1068         try:
1069             import readline
1070         except ImportError:
1071             print _('Note: command history and editing not available')
1073         while 1:
1074             try:
1075                 command = raw_input(_('roundup> '))
1076             except EOFError:
1077                 print _('exit...')
1078                 break
1079             if not command: continue
1080             args = token.token_split(command)
1081             if not args: continue
1082             if args[0] in ('quit', 'exit'): break
1083             self.run_command(args)
1085         # exit.. check for transactions
1086         if self.db and self.db.transactions:
1087             commit = raw_input(_('There are unsaved changes. Commit them (y/N)? '))
1088             if commit and commit[0].lower() == 'y':
1089                 self.db.commit()
1090         return 0
1092     def main(self):
1093         try:
1094             opts, args = getopt.getopt(sys.argv[1:], 'i:u:hc')
1095         except getopt.GetoptError, e:
1096             self.usage(str(e))
1097             return 1
1099         # handle command-line args
1100         self.instance_home = os.environ.get('ROUNDUP_INSTANCE', '')
1101         # TODO: reinstate the user/password stuff (-u arg too)
1102         name = password = ''
1103         if os.environ.has_key('ROUNDUP_LOGIN'):
1104             l = os.environ['ROUNDUP_LOGIN'].split(':')
1105             name = l[0]
1106             if len(l) > 1:
1107                 password = l[1]
1108         self.comma_sep = 0
1109         for opt, arg in opts:
1110             if opt == '-h':
1111                 self.usage()
1112                 return 0
1113             if opt == '-i':
1114                 self.instance_home = arg
1115             if opt == '-c':
1116                 self.comma_sep = 1
1118         # if no command - go interactive
1119         ret = 0
1120         if not args:
1121             self.interactive()
1122         else:
1123             ret = self.run_command(args)
1124             if self.db: self.db.commit()
1125         return ret
1128 if __name__ == '__main__':
1129     tool = AdminTool()
1130     sys.exit(tool.main())
1133 # $Log: not supported by cvs2svn $
1134 # Revision 1.17  2002/07/14 06:05:50  richard
1135 #  . fixed the date module so that Date(". - 2d") works
1137 # Revision 1.16  2002/07/09 04:19:09  richard
1138 # Added reindex command to roundup-admin.
1139 # Fixed reindex on first access.
1140 # Also fixed reindexing of entries that change.
1142 # Revision 1.15  2002/06/17 23:14:44  richard
1143 # . #569415 ] {version}
1145 # Revision 1.14  2002/06/11 06:41:50  richard
1146 # Removed prompt for admin email in initialisation.
1148 # Revision 1.13  2002/05/30 23:58:14  richard
1149 # oops
1151 # Revision 1.12  2002/05/26 09:04:42  richard
1152 # out by one in the init args
1154 # Revision 1.11  2002/05/23 01:14:20  richard
1155 #  . split instance initialisation into two steps, allowing config changes
1156 #    before the database is initialised.
1158 # Revision 1.10  2002/04/27 10:07:23  richard
1159 # minor fix to error message
1161 # Revision 1.9  2002/03/12 22:51:47  richard
1162 #  . #527416 ] roundup-admin uses undefined value
1163 #  . #527503 ] unfriendly init blowup when parent dir
1164 #    (also handles UsageError correctly now in init)
1166 # Revision 1.8  2002/02/27 03:28:21  richard
1167 # Ran it through pychecker, made fixes
1169 # Revision 1.7  2002/02/20 05:04:32  richard
1170 # Wasn't handling the cvs parser feeding properly.
1172 # Revision 1.6  2002/01/23 07:27:19  grubert
1173 #  . allow abbreviation of "help" in admin tool too.
1175 # Revision 1.5  2002/01/21 16:33:19  rochecompaan
1176 # You can now use the roundup-admin tool to pack the database
1178 # Revision 1.4  2002/01/14 06:51:09  richard
1179 #  . #503164 ] create and passwords
1181 # Revision 1.3  2002/01/08 05:26:32  rochecompaan
1182 # Missing "self" in props_from_args
1184 # Revision 1.2  2002/01/07 10:41:44  richard
1185 # #500140 ] AdminTool.get_class() returns nothing
1187 # Revision 1.1  2002/01/05 02:11:22  richard
1188 # I18N'ed roundup admin - and split the code off into a module so it can be used
1189 # elsewhere.
1190 # Big issue with this is the doc strings - that's the help. We're probably going to
1191 # have to switch to not use docstrings, which will suck a little :(
1195 # vim: set filetype=python ts=4 sw=4 et si