Code

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