Code

sqlite backend!
[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.31 2002-09-18 05:07:47 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):
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                     props[key] = password.Password(value)
451                 elif isinstance(proptype, hyperdb.Date):
452                     try:
453                         props[key] = date.Date(value)
454                     except ValueError, message:
455                         raise UsageError, '"%s": %s'%(value, message)
456                 elif isinstance(proptype, hyperdb.Interval):
457                     try:
458                         props[key] = date.Interval(value)
459                     except ValueError, message:
460                         raise UsageError, '"%s": %s'%(value, message)
461                 elif isinstance(proptype, hyperdb.Link):
462                     props[key] = value
463                 elif isinstance(proptype, hyperdb.Boolean):
464                     props[key] = value.lower() in ('yes', 'true', 'on', '1')
465                 elif isinstance(proptype, hyperdb.Number):
466                     props[key] = int(value)
468             # try the set
469             try:
470                 apply(cl.set, (itemid, ), props)
471             except (TypeError, IndexError, ValueError), message:
472                 raise UsageError, message
473         return 0
475     def do_find(self, args):
476         '''Usage: find classname propname=value ...
477         Find the nodes of the given class with a given link property value.
479         Find the nodes of the given class with a given link property value. The
480         value may be either the nodeid of the linked node, or its key value.
481         '''
482         if len(args) < 1:
483             raise UsageError, _('Not enough arguments supplied')
484         classname = args[0]
485         # get the class
486         cl = self.get_class(classname)
488         # handle the propname=value argument
489         props = self.props_from_args(args[1:])
491         # if the value isn't a number, look up the linked class to get the
492         # number
493         for propname, value in props.items():
494             num_re = re.compile('^\d+$')
495             if not num_re.match(value):
496                 # get the property
497                 try:
498                     property = cl.properties[propname]
499                 except KeyError:
500                     raise UsageError, _('%(classname)s has no property '
501                         '"%(propname)s"')%locals()
503                 # make sure it's a link
504                 if (not isinstance(property, hyperdb.Link) and not
505                         isinstance(property, hyperdb.Multilink)):
506                     raise UsageError, _('You may only "find" link properties')
508                 # get the linked-to class and look up the key property
509                 link_class = self.db.getclass(property.classname)
510                 try:
511                     props[propname] = link_class.lookup(value)
512                 except TypeError:
513                     raise UsageError, _('%(classname)s has no key property"')%{
514                         'classname': link_class.classname}
516         # now do the find 
517         try:
518             if self.comma_sep:
519                 print ','.join(apply(cl.find, (), props))
520             else:
521                 print apply(cl.find, (), props)
522         except KeyError:
523             raise UsageError, _('%(classname)s has no property '
524                 '"%(propname)s"')%locals()
525         except (ValueError, TypeError), message:
526             raise UsageError, message
527         return 0
529     def do_specification(self, args):
530         '''Usage: specification classname
531         Show the properties for a classname.
533         This lists the properties for a given class.
534         '''
535         if len(args) < 1:
536             raise UsageError, _('Not enough arguments supplied')
537         classname = args[0]
538         # get the class
539         cl = self.get_class(classname)
541         # get the key property
542         keyprop = cl.getkey()
543         for key, value in cl.properties.items():
544             if keyprop == key:
545                 print _('%(key)s: %(value)s (key property)')%locals()
546             else:
547                 print _('%(key)s: %(value)s')%locals()
549     def do_display(self, args):
550         '''Usage: display designator
551         Show the property values for the given node.
553         This lists the properties and their associated values for the given
554         node.
555         '''
556         if len(args) < 1:
557             raise UsageError, _('Not enough arguments supplied')
559         # decode the node designator
560         try:
561             classname, nodeid = hyperdb.splitDesignator(args[0])
562         except hyperdb.DesignatorError, message:
563             raise UsageError, message
565         # get the class
566         cl = self.get_class(classname)
568         # display the values
569         for key in cl.properties.keys():
570             value = cl.get(nodeid, key)
571             print _('%(key)s: %(value)s')%locals()
573     def do_create(self, args):
574         '''Usage: create classname property=value ...
575         Create a new entry of a given class.
577         This creates a new entry of the given class using the property
578         name=value arguments provided on the command line after the "create"
579         command.
580         '''
581         if len(args) < 1:
582             raise UsageError, _('Not enough arguments supplied')
583         from roundup import hyperdb
585         classname = args[0]
587         # get the class
588         cl = self.get_class(classname)
590         # now do a create
591         props = {}
592         properties = cl.getprops(protected = 0)
593         if len(args) == 1:
594             # ask for the properties
595             for key, value in properties.items():
596                 if key == 'id': continue
597                 name = value.__class__.__name__
598                 if isinstance(value , hyperdb.Password):
599                     again = None
600                     while value != again:
601                         value = getpass.getpass(_('%(propname)s (Password): ')%{
602                             'propname': key.capitalize()})
603                         again = getpass.getpass(_('   %(propname)s (Again): ')%{
604                             'propname': key.capitalize()})
605                         if value != again: print _('Sorry, try again...')
606                     if value:
607                         props[key] = value
608                 else:
609                     value = raw_input(_('%(propname)s (%(proptype)s): ')%{
610                         'propname': key.capitalize(), 'proptype': name})
611                     if value:
612                         props[key] = value
613         else:
614             props = self.props_from_args(args[1:])
616         # convert types
617         for propname, value in props.items():
618             # get the property
619             try:
620                 proptype = properties[propname]
621             except KeyError:
622                 raise UsageError, _('%(classname)s has no property '
623                     '"%(propname)s"')%locals()
625             if isinstance(proptype, hyperdb.Date):
626                 try:
627                     props[propname] = date.Date(value)
628                 except ValueError, message:
629                     raise UsageError, _('"%(value)s": %(message)s')%locals()
630             elif isinstance(proptype, hyperdb.Interval):
631                 try:
632                     props[propname] = date.Interval(value)
633                 except ValueError, message:
634                     raise UsageError, _('"%(value)s": %(message)s')%locals()
635             elif isinstance(proptype, hyperdb.Password):
636                 props[propname] = password.Password(value)
637             elif isinstance(proptype, hyperdb.Multilink):
638                 props[propname] = value.split(',')
639             elif isinstance(proptype, hyperdb.Boolean):
640                 props[propname] = value.lower() in ('yes', 'true', 'on', '1')
641             elif isinstance(proptype, hyperdb.Number):
642                 props[propname] = int(value)
644         # check for the key property
645         propname = cl.getkey()
646         if propname and not props.has_key(propname):
647             raise UsageError, _('you must provide the "%(propname)s" '
648                 'property.')%locals()
650         # do the actual create
651         try:
652             print apply(cl.create, (), props)
653         except (TypeError, IndexError, ValueError), message:
654             raise UsageError, message
655         return 0
657     def do_list(self, args):
658         '''Usage: list classname [property]
659         List the instances of a class.
661         Lists all instances of the given class. If the property is not
662         specified, the  "label" property is used. The label property is tried
663         in order: the key, "name", "title" and then the first property,
664         alphabetically.
665         '''
666         if len(args) < 1:
667             raise UsageError, _('Not enough arguments supplied')
668         classname = args[0]
670         # get the class
671         cl = self.get_class(classname)
673         # figure the property
674         if len(args) > 1:
675             propname = args[1]
676         else:
677             propname = cl.labelprop()
679         if self.comma_sep:
680             print ','.join(cl.list())
681         else:
682             for nodeid in cl.list():
683                 try:
684                     value = cl.get(nodeid, propname)
685                 except KeyError:
686                     raise UsageError, _('%(classname)s has no property '
687                         '"%(propname)s"')%locals()
688                 print _('%(nodeid)4s: %(value)s')%locals()
689         return 0
691     def do_table(self, args):
692         '''Usage: table classname [property[,property]*]
693         List the instances of a class in tabular form.
695         Lists all instances of the given class. If the properties are not
696         specified, all properties are displayed. By default, the column widths
697         are the width of the property names. The width may be explicitly defined
698         by defining the property as "name:width". For example::
699           roundup> table priority id,name:10
700           Id Name
701           1  fatal-bug 
702           2  bug       
703           3  usability 
704           4  feature   
705         '''
706         if len(args) < 1:
707             raise UsageError, _('Not enough arguments supplied')
708         classname = args[0]
710         # get the class
711         cl = self.get_class(classname)
713         # figure the property names to display
714         if len(args) > 1:
715             prop_names = args[1].split(',')
716             all_props = cl.getprops()
717             for spec in prop_names:
718                 if ':' in spec:
719                     try:
720                         propname, width = spec.split(':')
721                     except (ValueError, TypeError):
722                         raise UsageError, _('"%(spec)s" not name:width')%locals()
723                 else:
724                     propname = spec
725                 if not all_props.has_key(propname):
726                     raise UsageError, _('%(classname)s has no property '
727                         '"%(propname)s"')%locals()
728         else:
729             prop_names = cl.getprops().keys()
731         # now figure column widths
732         props = []
733         for spec in prop_names:
734             if ':' in spec:
735                 name, width = spec.split(':')
736                 props.append((name, int(width)))
737             else:
738                 props.append((spec, len(spec)))
740         # now display the heading
741         print ' '.join([name.capitalize().ljust(width) for name,width in props])
743         # and the table data
744         for nodeid in cl.list():
745             l = []
746             for name, width in props:
747                 if name != 'id':
748                     try:
749                         value = str(cl.get(nodeid, name))
750                     except KeyError:
751                         # we already checked if the property is valid - a
752                         # KeyError here means the node just doesn't have a
753                         # value for it
754                         value = ''
755                 else:
756                     value = str(nodeid)
757                 f = '%%-%ds'%width
758                 l.append(f%value[:width])
759             print ' '.join(l)
760         return 0
762     def do_history(self, args):
763         '''Usage: history designator
764         Show the history entries of a designator.
766         Lists the journal entries for the node identified by the designator.
767         '''
768         if len(args) < 1:
769             raise UsageError, _('Not enough arguments supplied')
770         try:
771             classname, nodeid = hyperdb.splitDesignator(args[0])
772         except hyperdb.DesignatorError, message:
773             raise UsageError, message
775         try:
776             print self.db.getclass(classname).history(nodeid)
777         except KeyError:
778             raise UsageError, _('no such class "%(classname)s"')%locals()
779         except IndexError:
780             raise UsageError, _('no such %(classname)s node "%(nodeid)s"')%locals()
781         return 0
783     def do_commit(self, args):
784         '''Usage: commit
785         Commit all changes made to the database.
787         The changes made during an interactive session are not
788         automatically written to the database - they must be committed
789         using this command.
791         One-off commands on the command-line are automatically committed if
792         they are successful.
793         '''
794         self.db.commit()
795         return 0
797     def do_rollback(self, args):
798         '''Usage: rollback
799         Undo all changes that are pending commit to the database.
801         The changes made during an interactive session are not
802         automatically written to the database - they must be committed
803         manually. This command undoes all those changes, so a commit
804         immediately after would make no changes to the database.
805         '''
806         self.db.rollback()
807         return 0
809     def do_retire(self, args):
810         '''Usage: retire designator[,designator]*
811         Retire the node specified by designator.
813         This action indicates that a particular node is not to be retrieved by
814         the list or find commands, and its key value may be re-used.
815         '''
816         if len(args) < 1:
817             raise UsageError, _('Not enough arguments supplied')
818         designators = args[0].split(',')
819         for designator in designators:
820             try:
821                 classname, nodeid = hyperdb.splitDesignator(designator)
822             except hyperdb.DesignatorError, message:
823                 raise UsageError, message
824             try:
825                 self.db.getclass(classname).retire(nodeid)
826             except KeyError:
827                 raise UsageError, _('no such class "%(classname)s"')%locals()
828             except IndexError:
829                 raise UsageError, _('no such %(classname)s node "%(nodeid)s"')%locals()
830         return 0
832     def do_export(self, args):
833         '''Usage: export [class[,class]] export_dir
834         Export the database to colon-separated-value files.
836         This action exports the current data from the database into
837         colon-separated-value files that are placed in the nominated
838         destination directory. The journals are not exported.
839         '''
840         # we need the CSV module
841         if csv is None:
842             raise UsageError, \
843                 _('Sorry, you need the csv module to use this function.\n'
844                 'Get it from: http://www.object-craft.com.au/projects/csv/')
846         # grab the directory to export to
847         if len(args) < 1:
848             raise UsageError, _('Not enough arguments supplied')
849         dir = args[-1]
851         # get the list of classes to export
852         if len(args) == 2:
853             classes = args[0].split(',')
854         else:
855             classes = self.db.classes.keys()
857         # use the csv parser if we can - it's faster
858         p = csv.parser(field_sep=':')
860         # do all the classes specified
861         for classname in classes:
862             cl = self.get_class(classname)
863             f = open(os.path.join(dir, classname+'.csv'), 'w')
864             properties = cl.getprops()
865             propnames = properties.keys()
866             propnames.sort()
867             print >> f, p.join(propnames)
869             # all nodes for this class
870             for nodeid in cl.list():
871                 print >>f, p.join(cl.export_list(propnames, nodeid))
872         return 0
874     def do_import(self, args):
875         '''Usage: import import_dir
876         Import a database from the directory containing CSV files, one per
877         class to import.
879         The files must define the same properties as the class (including having
880         a "header" line with those property names.)
882         The imported nodes will have the same nodeid as defined in the
883         import file, thus replacing any existing content.
885         The new nodes are added to the existing database - if you want to
886         create a new database using the imported data, then create a new
887         database (or, tediously, retire all the old data.)
888         '''
889         if len(args) < 1:
890             raise UsageError, _('Not enough arguments supplied')
891         if csv is None:
892             raise UsageError, \
893                 _('Sorry, you need the csv module to use this function.\n'
894                 'Get it from: http://www.object-craft.com.au/projects/csv/')
896         from roundup import hyperdb
898         for file in os.listdir(args[0]):
899             f = open(os.path.join(args[0], file))
901             # get the classname
902             classname = os.path.splitext(file)[0]
904             # ensure that the properties and the CSV file headings match
905             cl = self.get_class(classname)
906             p = csv.parser(field_sep=':')
907             file_props = p.parse(f.readline())
908             properties = cl.getprops()
909             propnames = properties.keys()
910             propnames.sort()
911             m = file_props[:]
912             m.sort()
913             if m != propnames:
914                 raise UsageError, _('Import file doesn\'t define the same '
915                     'properties as "%(arg0)s".')%{'arg0': args[0]}
917             # loop through the file and create a node for each entry
918             maxid = 1
919             while 1:
920                 line = f.readline()
921                 if not line: break
923                 # parse lines until we get a complete entry
924                 while 1:
925                     l = p.parse(line)
926                     if l: break
927                     line = f.readline()
928                     if not line:
929                         raise ValueError, "Unexpected EOF during CSV parse"
931                 # do the import and figure the current highest nodeid
932                 maxid = max(maxid, int(cl.import_list(propnames, l)))
934             print 'setting', classname, maxid
935             self.db.setid(classname, str(maxid))
936         return 0
938     def do_pack(self, args):
939         '''Usage: pack period | date
941 Remove journal entries older than a period of time specified or
942 before a certain date.
944 A period is specified using the suffixes "y", "m", and "d". The
945 suffix "w" (for "week") means 7 days.
947       "3y" means three years
948       "2y 1m" means two years and one month
949       "1m 25d" means one month and 25 days
950       "2w 3d" means two weeks and three days
952 Date format is "YYYY-MM-DD" eg:
953     2001-01-01
954     
955         '''
956         if len(args) <> 1:
957             raise UsageError, _('Not enough arguments supplied')
958         
959         # are we dealing with a period or a date
960         value = args[0]
961         date_re = re.compile(r'''
962               (?P<date>\d\d\d\d-\d\d?-\d\d?)? # yyyy-mm-dd
963               (?P<period>(\d+y\s*)?(\d+m\s*)?(\d+d\s*)?)?
964               ''', re.VERBOSE)
965         m = date_re.match(value)
966         if not m:
967             raise ValueError, _('Invalid format')
968         m = m.groupdict()
969         if m['period']:
970             pack_before = date.Date(". - %s"%value)
971         elif m['date']:
972             pack_before = date.Date(value)
973         self.db.pack(pack_before)
974         return 0
976     def do_reindex(self, args):
977         '''Usage: reindex
978         Re-generate a tracker's search indexes.
980         This will re-generate the search indexes for a tracker. This will
981         typically happen automatically.
982         '''
983         self.db.indexer.force_reindex()
984         self.db.reindex()
985         return 0
987     def do_security(self, args):
988         '''Usage: security [Role name]
989         Display the Permissions available to one or all Roles.
990         '''
991         if len(args) == 1:
992             role = args[0]
993             try:
994                 roles = [(args[0], self.db.security.role[args[0]])]
995             except KeyError:
996                 print _('No such Role "%(role)s"')%locals()
997                 return 1
998         else:
999             roles = self.db.security.role.items()
1000             role = self.db.config.NEW_WEB_USER_ROLES
1001             if ',' in role:
1002                 print _('New Web users get the Roles "%(role)s"')%locals()
1003             else:
1004                 print _('New Web users get the Role "%(role)s"')%locals()
1005             role = self.db.config.NEW_EMAIL_USER_ROLES
1006             if ',' in role:
1007                 print _('New Email users get the Roles "%(role)s"')%locals()
1008             else:
1009                 print _('New Email users get the Role "%(role)s"')%locals()
1010         roles.sort()
1011         for rolename, role in roles:
1012             print _('Role "%(name)s":')%role.__dict__
1013             for permission in role.permissions:
1014                 if permission.klass:
1015                     print _(' %(description)s (%(name)s for "%(klass)s" '
1016                         'only)')%permission.__dict__
1017                 else:
1018                     print _(' %(description)s (%(name)s)')%permission.__dict__
1019         return 0
1021     def run_command(self, args):
1022         '''Run a single command
1023         '''
1024         command = args[0]
1026         # handle help now
1027         if command == 'help':
1028             if len(args)>1:
1029                 self.do_help(args[1:])
1030                 return 0
1031             self.do_help(['help'])
1032             return 0
1033         if command == 'morehelp':
1034             self.do_help(['help'])
1035             self.help_commands()
1036             self.help_all()
1037             return 0
1039         # figure what the command is
1040         try:
1041             functions = self.commands.get(command)
1042         except KeyError:
1043             # not a valid command
1044             print _('Unknown command "%(command)s" ("help commands" for a '
1045                 'list)')%locals()
1046             return 1
1048         # check for multiple matches
1049         if len(functions) > 1:
1050             print _('Multiple commands match "%(command)s": %(list)s')%{'command':
1051                 command, 'list': ', '.join([i[0] for i in functions])}
1052             return 1
1053         command, function = functions[0]
1055         # make sure we have a tracker_home
1056         while not self.tracker_home:
1057             self.tracker_home = raw_input(_('Enter tracker home: ')).strip()
1059         # before we open the db, we may be doing an install or init
1060         if command == 'initialise':
1061             try:
1062                 return self.do_initialise(self.tracker_home, args)
1063             except UsageError, message:
1064                 print _('Error: %(message)s')%locals()
1065                 return 1
1066         elif command == 'install':
1067             try:
1068                 return self.do_install(self.tracker_home, args)
1069             except UsageError, message:
1070                 print _('Error: %(message)s')%locals()
1071                 return 1
1073         # get the tracker
1074         try:
1075             tracker = roundup.instance.open(self.tracker_home)
1076         except ValueError, message:
1077             self.tracker_home = ''
1078             print _("Error: Couldn't open tracker: %(message)s")%locals()
1079             return 1
1081         # only open the database once!
1082         if not self.db:
1083             self.db = tracker.open('admin')
1085         # do the command
1086         ret = 0
1087         try:
1088             ret = function(args[1:])
1089         except UsageError, message:
1090             print _('Error: %(message)s')%locals()
1091             print
1092             print function.__doc__
1093             ret = 1
1094         except:
1095             import traceback
1096             traceback.print_exc()
1097             ret = 1
1098         return ret
1100     def interactive(self):
1101         '''Run in an interactive mode
1102         '''
1103         print _('Roundup %s ready for input.'%roundup_version)
1104         print _('Type "help" for help.')
1105         try:
1106             import readline
1107         except ImportError:
1108             print _('Note: command history and editing not available')
1110         while 1:
1111             try:
1112                 command = raw_input(_('roundup> '))
1113             except EOFError:
1114                 print _('exit...')
1115                 break
1116             if not command: continue
1117             args = token.token_split(command)
1118             if not args: continue
1119             if args[0] in ('quit', 'exit'): break
1120             self.run_command(args)
1122         # exit.. check for transactions
1123         if self.db and self.db.transactions:
1124             commit = raw_input(_('There are unsaved changes. Commit them (y/N)? '))
1125             if commit and commit[0].lower() == 'y':
1126                 self.db.commit()
1127         return 0
1129     def main(self):
1130         try:
1131             opts, args = getopt.getopt(sys.argv[1:], 'i:u:hc')
1132         except getopt.GetoptError, e:
1133             self.usage(str(e))
1134             return 1
1136         # handle command-line args
1137         self.tracker_home = os.environ.get('TRACKER_HOME', '')
1138         # TODO: reinstate the user/password stuff (-u arg too)
1139         name = password = ''
1140         if os.environ.has_key('ROUNDUP_LOGIN'):
1141             l = os.environ['ROUNDUP_LOGIN'].split(':')
1142             name = l[0]
1143             if len(l) > 1:
1144                 password = l[1]
1145         self.comma_sep = 0
1146         for opt, arg in opts:
1147             if opt == '-h':
1148                 self.usage()
1149                 return 0
1150             if opt == '-i':
1151                 self.tracker_home = arg
1152             if opt == '-c':
1153                 self.comma_sep = 1
1155         # if no command - go interactive
1156         # wrap in a try/finally so we always close off the db
1157         ret = 0
1158         try:
1159             if not args:
1160                 self.interactive()
1161             else:
1162                 ret = self.run_command(args)
1163                 if self.db: self.db.commit()
1164             return ret
1165         finally:
1166             if self.db:
1167                 self.db.close()
1169 if __name__ == '__main__':
1170     tool = AdminTool()
1171     sys.exit(tool.main())
1173 # vim: set filetype=python ts=4 sw=4 et si