Code

Add clearCache method to DB.
[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.34 2002-09-26 07:41:54 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, pwre = re.compile(r'{(\w+)}(.+)')):
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                 m = pwre.match(value)
646                 if m:
647                     # password is being given to us encrypted
648                     p = password.Password()
649                     p.scheme = m.group(1)
650                     p.password = m.group(2)
651                     props[propname] = p
652                 else:
653                     props[propname] = password.Password(value)
654             elif isinstance(proptype, hyperdb.Multilink):
655                 props[propname] = value.split(',')
656             elif isinstance(proptype, hyperdb.Boolean):
657                 props[propname] = value.lower() in ('yes', 'true', 'on', '1')
658             elif isinstance(proptype, hyperdb.Number):
659                 props[propname] = int(value)
661         # check for the key property
662         propname = cl.getkey()
663         if propname and not props.has_key(propname):
664             raise UsageError, _('you must provide the "%(propname)s" '
665                 'property.')%locals()
667         # do the actual create
668         try:
669             print apply(cl.create, (), props)
670         except (TypeError, IndexError, ValueError), message:
671             raise UsageError, message
672         return 0
674     def do_list(self, args):
675         '''Usage: list classname [property]
676         List the instances of a class.
678         Lists all instances of the given class. If the property is not
679         specified, the  "label" property is used. The label property is tried
680         in order: the key, "name", "title" and then the first property,
681         alphabetically.
682         '''
683         if len(args) < 1:
684             raise UsageError, _('Not enough arguments supplied')
685         classname = args[0]
687         # get the class
688         cl = self.get_class(classname)
690         # figure the property
691         if len(args) > 1:
692             propname = args[1]
693         else:
694             propname = cl.labelprop()
696         if self.comma_sep:
697             print ','.join(cl.list())
698         else:
699             for nodeid in cl.list():
700                 try:
701                     value = cl.get(nodeid, propname)
702                 except KeyError:
703                     raise UsageError, _('%(classname)s has no property '
704                         '"%(propname)s"')%locals()
705                 print _('%(nodeid)4s: %(value)s')%locals()
706         return 0
708     def do_table(self, args):
709         '''Usage: table classname [property[,property]*]
710         List the instances of a class in tabular form.
712         Lists all instances of the given class. If the properties are not
713         specified, all properties are displayed. By default, the column widths
714         are the width of the property names. The width may be explicitly defined
715         by defining the property as "name:width". For example::
716           roundup> table priority id,name:10
717           Id Name
718           1  fatal-bug 
719           2  bug       
720           3  usability 
721           4  feature   
722         '''
723         if len(args) < 1:
724             raise UsageError, _('Not enough arguments supplied')
725         classname = args[0]
727         # get the class
728         cl = self.get_class(classname)
730         # figure the property names to display
731         if len(args) > 1:
732             prop_names = args[1].split(',')
733             all_props = cl.getprops()
734             for spec in prop_names:
735                 if ':' in spec:
736                     try:
737                         propname, width = spec.split(':')
738                     except (ValueError, TypeError):
739                         raise UsageError, _('"%(spec)s" not name:width')%locals()
740                 else:
741                     propname = spec
742                 if not all_props.has_key(propname):
743                     raise UsageError, _('%(classname)s has no property '
744                         '"%(propname)s"')%locals()
745         else:
746             prop_names = cl.getprops().keys()
748         # now figure column widths
749         props = []
750         for spec in prop_names:
751             if ':' in spec:
752                 name, width = spec.split(':')
753                 props.append((name, int(width)))
754             else:
755                 props.append((spec, len(spec)))
757         # now display the heading
758         print ' '.join([name.capitalize().ljust(width) for name,width in props])
760         # and the table data
761         for nodeid in cl.list():
762             l = []
763             for name, width in props:
764                 if name != 'id':
765                     try:
766                         value = str(cl.get(nodeid, name))
767                     except KeyError:
768                         # we already checked if the property is valid - a
769                         # KeyError here means the node just doesn't have a
770                         # value for it
771                         value = ''
772                 else:
773                     value = str(nodeid)
774                 f = '%%-%ds'%width
775                 l.append(f%value[:width])
776             print ' '.join(l)
777         return 0
779     def do_history(self, args):
780         '''Usage: history designator
781         Show the history entries of a designator.
783         Lists the journal entries for the node identified by the designator.
784         '''
785         if len(args) < 1:
786             raise UsageError, _('Not enough arguments supplied')
787         try:
788             classname, nodeid = hyperdb.splitDesignator(args[0])
789         except hyperdb.DesignatorError, message:
790             raise UsageError, message
792         try:
793             print self.db.getclass(classname).history(nodeid)
794         except KeyError:
795             raise UsageError, _('no such class "%(classname)s"')%locals()
796         except IndexError:
797             raise UsageError, _('no such %(classname)s node "%(nodeid)s"')%locals()
798         return 0
800     def do_commit(self, args):
801         '''Usage: commit
802         Commit all changes made to the database.
804         The changes made during an interactive session are not
805         automatically written to the database - they must be committed
806         using this command.
808         One-off commands on the command-line are automatically committed if
809         they are successful.
810         '''
811         self.db.commit()
812         return 0
814     def do_rollback(self, args):
815         '''Usage: rollback
816         Undo all changes that are pending commit to the database.
818         The changes made during an interactive session are not
819         automatically written to the database - they must be committed
820         manually. This command undoes all those changes, so a commit
821         immediately after would make no changes to the database.
822         '''
823         self.db.rollback()
824         return 0
826     def do_retire(self, args):
827         '''Usage: retire designator[,designator]*
828         Retire the node specified by designator.
830         This action indicates that a particular node is not to be retrieved by
831         the list or find commands, and its key value may be re-used.
832         '''
833         if len(args) < 1:
834             raise UsageError, _('Not enough arguments supplied')
835         designators = args[0].split(',')
836         for designator in designators:
837             try:
838                 classname, nodeid = hyperdb.splitDesignator(designator)
839             except hyperdb.DesignatorError, message:
840                 raise UsageError, message
841             try:
842                 self.db.getclass(classname).retire(nodeid)
843             except KeyError:
844                 raise UsageError, _('no such class "%(classname)s"')%locals()
845             except IndexError:
846                 raise UsageError, _('no such %(classname)s node "%(nodeid)s"')%locals()
847         return 0
849     def do_export(self, args):
850         '''Usage: export [class[,class]] export_dir
851         Export the database to colon-separated-value files.
853         This action exports the current data from the database into
854         colon-separated-value files that are placed in the nominated
855         destination directory. The journals are not exported.
856         '''
857         # we need the CSV module
858         if csv is None:
859             raise UsageError, \
860                 _('Sorry, you need the csv module to use this function.\n'
861                 'Get it from: http://www.object-craft.com.au/projects/csv/')
863         # grab the directory to export to
864         if len(args) < 1:
865             raise UsageError, _('Not enough arguments supplied')
866         dir = args[-1]
868         # get the list of classes to export
869         if len(args) == 2:
870             classes = args[0].split(',')
871         else:
872             classes = self.db.classes.keys()
874         # use the csv parser if we can - it's faster
875         p = csv.parser(field_sep=':')
877         # do all the classes specified
878         for classname in classes:
879             cl = self.get_class(classname)
880             f = open(os.path.join(dir, classname+'.csv'), 'w')
881             properties = cl.getprops()
882             propnames = properties.keys()
883             propnames.sort()
884             print >> f, p.join(propnames)
886             # all nodes for this class
887             for nodeid in cl.list():
888                 print >>f, p.join(cl.export_list(propnames, nodeid))
889         return 0
891     def do_import(self, args):
892         '''Usage: import import_dir
893         Import a database from the directory containing CSV files, one per
894         class to import.
896         The files must define the same properties as the class (including having
897         a "header" line with those property names.)
899         The imported nodes will have the same nodeid as defined in the
900         import file, thus replacing any existing content.
902         The new nodes are added to the existing database - if you want to
903         create a new database using the imported data, then create a new
904         database (or, tediously, retire all the old data.)
905         '''
906         if len(args) < 1:
907             raise UsageError, _('Not enough arguments supplied')
908         if csv is None:
909             raise UsageError, \
910                 _('Sorry, you need the csv module to use this function.\n'
911                 'Get it from: http://www.object-craft.com.au/projects/csv/')
913         from roundup import hyperdb
915         for file in os.listdir(args[0]):
916             f = open(os.path.join(args[0], file))
918             # get the classname
919             classname = os.path.splitext(file)[0]
921             # ensure that the properties and the CSV file headings match
922             cl = self.get_class(classname)
923             p = csv.parser(field_sep=':')
924             file_props = p.parse(f.readline())
925             properties = cl.getprops()
926             propnames = properties.keys()
927             propnames.sort()
928             m = file_props[:]
929             m.sort()
930             if m != propnames:
931                 raise UsageError, _('Import file doesn\'t define the same '
932                     'properties as "%(arg0)s".')%{'arg0': args[0]}
934             # loop through the file and create a node for each entry
935             maxid = 1
936             while 1:
937                 line = f.readline()
938                 if not line: break
940                 # parse lines until we get a complete entry
941                 while 1:
942                     l = p.parse(line)
943                     if l: break
944                     line = f.readline()
945                     if not line:
946                         raise ValueError, "Unexpected EOF during CSV parse"
948                 # do the import and figure the current highest nodeid
949                 maxid = max(maxid, int(cl.import_list(propnames, l)))
951             print 'setting', classname, maxid+1
952             self.db.setid(classname, str(maxid+1))
953         return 0
955     def do_pack(self, args):
956         '''Usage: pack period | date
958 Remove journal entries older than a period of time specified or
959 before a certain date.
961 A period is specified using the suffixes "y", "m", and "d". The
962 suffix "w" (for "week") means 7 days.
964       "3y" means three years
965       "2y 1m" means two years and one month
966       "1m 25d" means one month and 25 days
967       "2w 3d" means two weeks and three days
969 Date format is "YYYY-MM-DD" eg:
970     2001-01-01
971     
972         '''
973         if len(args) <> 1:
974             raise UsageError, _('Not enough arguments supplied')
975         
976         # are we dealing with a period or a date
977         value = args[0]
978         date_re = re.compile(r'''
979               (?P<date>\d\d\d\d-\d\d?-\d\d?)? # yyyy-mm-dd
980               (?P<period>(\d+y\s*)?(\d+m\s*)?(\d+d\s*)?)?
981               ''', re.VERBOSE)
982         m = date_re.match(value)
983         if not m:
984             raise ValueError, _('Invalid format')
985         m = m.groupdict()
986         if m['period']:
987             pack_before = date.Date(". - %s"%value)
988         elif m['date']:
989             pack_before = date.Date(value)
990         self.db.pack(pack_before)
991         return 0
993     def do_reindex(self, args):
994         '''Usage: reindex
995         Re-generate a tracker's search indexes.
997         This will re-generate the search indexes for a tracker. This will
998         typically happen automatically.
999         '''
1000         self.db.indexer.force_reindex()
1001         self.db.reindex()
1002         return 0
1004     def do_security(self, args):
1005         '''Usage: security [Role name]
1006         Display the Permissions available to one or all Roles.
1007         '''
1008         if len(args) == 1:
1009             role = args[0]
1010             try:
1011                 roles = [(args[0], self.db.security.role[args[0]])]
1012             except KeyError:
1013                 print _('No such Role "%(role)s"')%locals()
1014                 return 1
1015         else:
1016             roles = self.db.security.role.items()
1017             role = self.db.config.NEW_WEB_USER_ROLES
1018             if ',' in role:
1019                 print _('New Web users get the Roles "%(role)s"')%locals()
1020             else:
1021                 print _('New Web users get the Role "%(role)s"')%locals()
1022             role = self.db.config.NEW_EMAIL_USER_ROLES
1023             if ',' in role:
1024                 print _('New Email users get the Roles "%(role)s"')%locals()
1025             else:
1026                 print _('New Email users get the Role "%(role)s"')%locals()
1027         roles.sort()
1028         for rolename, role in roles:
1029             print _('Role "%(name)s":')%role.__dict__
1030             for permission in role.permissions:
1031                 if permission.klass:
1032                     print _(' %(description)s (%(name)s for "%(klass)s" '
1033                         'only)')%permission.__dict__
1034                 else:
1035                     print _(' %(description)s (%(name)s)')%permission.__dict__
1036         return 0
1038     def run_command(self, args):
1039         '''Run a single command
1040         '''
1041         command = args[0]
1043         # handle help now
1044         if command == 'help':
1045             if len(args)>1:
1046                 self.do_help(args[1:])
1047                 return 0
1048             self.do_help(['help'])
1049             return 0
1050         if command == 'morehelp':
1051             self.do_help(['help'])
1052             self.help_commands()
1053             self.help_all()
1054             return 0
1056         # figure what the command is
1057         try:
1058             functions = self.commands.get(command)
1059         except KeyError:
1060             # not a valid command
1061             print _('Unknown command "%(command)s" ("help commands" for a '
1062                 'list)')%locals()
1063             return 1
1065         # check for multiple matches
1066         if len(functions) > 1:
1067             print _('Multiple commands match "%(command)s": %(list)s')%{'command':
1068                 command, 'list': ', '.join([i[0] for i in functions])}
1069             return 1
1070         command, function = functions[0]
1072         # make sure we have a tracker_home
1073         while not self.tracker_home:
1074             self.tracker_home = raw_input(_('Enter tracker home: ')).strip()
1076         # before we open the db, we may be doing an install or init
1077         if command == 'initialise':
1078             try:
1079                 return self.do_initialise(self.tracker_home, args)
1080             except UsageError, message:
1081                 print _('Error: %(message)s')%locals()
1082                 return 1
1083         elif command == 'install':
1084             try:
1085                 return self.do_install(self.tracker_home, args)
1086             except UsageError, message:
1087                 print _('Error: %(message)s')%locals()
1088                 return 1
1090         # get the tracker
1091         try:
1092             tracker = roundup.instance.open(self.tracker_home)
1093         except ValueError, message:
1094             self.tracker_home = ''
1095             print _("Error: Couldn't open tracker: %(message)s")%locals()
1096             return 1
1098         # only open the database once!
1099         if not self.db:
1100             self.db = tracker.open('admin')
1102         # do the command
1103         ret = 0
1104         try:
1105             ret = function(args[1:])
1106         except UsageError, message:
1107             print _('Error: %(message)s')%locals()
1108             print
1109             print function.__doc__
1110             ret = 1
1111         except:
1112             import traceback
1113             traceback.print_exc()
1114             ret = 1
1115         return ret
1117     def interactive(self):
1118         '''Run in an interactive mode
1119         '''
1120         print _('Roundup %s ready for input.'%roundup_version)
1121         print _('Type "help" for help.')
1122         try:
1123             import readline
1124         except ImportError:
1125             print _('Note: command history and editing not available')
1127         while 1:
1128             try:
1129                 command = raw_input(_('roundup> '))
1130             except EOFError:
1131                 print _('exit...')
1132                 break
1133             if not command: continue
1134             args = token.token_split(command)
1135             if not args: continue
1136             if args[0] in ('quit', 'exit'): break
1137             self.run_command(args)
1139         # exit.. check for transactions
1140         if self.db and self.db.transactions:
1141             commit = raw_input(_('There are unsaved changes. Commit them (y/N)? '))
1142             if commit and commit[0].lower() == 'y':
1143                 self.db.commit()
1144         return 0
1146     def main(self):
1147         try:
1148             opts, args = getopt.getopt(sys.argv[1:], 'i:u:hc')
1149         except getopt.GetoptError, e:
1150             self.usage(str(e))
1151             return 1
1153         # handle command-line args
1154         self.tracker_home = os.environ.get('TRACKER_HOME', '')
1155         # TODO: reinstate the user/password stuff (-u arg too)
1156         name = password = ''
1157         if os.environ.has_key('ROUNDUP_LOGIN'):
1158             l = os.environ['ROUNDUP_LOGIN'].split(':')
1159             name = l[0]
1160             if len(l) > 1:
1161                 password = l[1]
1162         self.comma_sep = 0
1163         for opt, arg in opts:
1164             if opt == '-h':
1165                 self.usage()
1166                 return 0
1167             if opt == '-i':
1168                 self.tracker_home = arg
1169             if opt == '-c':
1170                 self.comma_sep = 1
1172         # if no command - go interactive
1173         # wrap in a try/finally so we always close off the db
1174         ret = 0
1175         try:
1176             if not args:
1177                 self.interactive()
1178             else:
1179                 ret = self.run_command(args)
1180                 if self.db: self.db.commit()
1181             return ret
1182         finally:
1183             if self.db:
1184                 self.db.close()
1186 if __name__ == '__main__':
1187     tool = AdminTool()
1188     sys.exit(tool.main())
1190 # vim: set filetype=python ts=4 sw=4 et si