Code

Class.find() may now find unset Links (sf bug 700620)
[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.48 2003-03-26 10:43:58 richard Exp $
21 '''Administration commands for maintaining Roundup trackers.
22 '''
24 import sys, os, getpass, getopt, re, UserDict, shlex, shutil
25 try:
26     import csv
27 except ImportError:
28     csv = None
29 from roundup import date, hyperdb, roundupdb, init, password, token
30 from roundup import __version__ as roundup_version
31 import roundup.instance
32 from roundup.i18n import _
34 class CommandDict(UserDict.UserDict):
35     '''Simple dictionary that lets us do lookups using partial keys.
37     Original code submitted by Engelbert Gruber.
38     '''
39     _marker = []
40     def get(self, key, default=_marker):
41         if self.data.has_key(key):
42             return [(key, self.data[key])]
43         keylist = self.data.keys()
44         keylist.sort()
45         l = []
46         for ki in keylist:
47             if ki.startswith(key):
48                 l.append((ki, self.data[ki]))
49         if not l and default is self._marker:
50             raise KeyError, key
51         return l
53 class UsageError(ValueError):
54     pass
56 class AdminTool:
57     ''' A collection of methods used in maintaining Roundup trackers.
59         Typically these methods are accessed through the roundup-admin
60         script. The main() method provided on this class gives the main
61         loop for the roundup-admin script.
63         Actions are defined by do_*() methods, with help for the action
64         given in the method docstring.
66         Additional help may be supplied by help_*() methods.
67     '''
68     def __init__(self):
69         self.commands = CommandDict()
70         for k in AdminTool.__dict__.keys():
71             if k[:3] == 'do_':
72                 self.commands[k[3:]] = getattr(self, k)
73         self.help = {}
74         for k in AdminTool.__dict__.keys():
75             if k[:5] == 'help_':
76                 self.help[k[5:]] = getattr(self, k)
77         self.tracker_home = ''
78         self.db = None
80     def get_class(self, classname):
81         '''Get the class - raise an exception if it doesn't exist.
82         '''
83         try:
84             return self.db.getclass(classname)
85         except KeyError:
86             raise UsageError, _('no such class "%(classname)s"')%locals()
88     def props_from_args(self, args):
89         ''' Produce a dictionary of prop: value from the args list.
91             The args list is specified as ``prop=value prop=value ...``.
92         '''
93         props = {}
94         for arg in args:
95             if arg.find('=') == -1:
96                 raise UsageError, _('argument "%(arg)s" not propname=value'
97                     )%locals()
98             l = arg.split('=')
99             if len(l) < 2:
100                 raise UsageError, _('argument "%(arg)s" not propname=value'
101                     )%locals()
102             key, value = l[0], '='.join(l[1:])
103             if value:
104                 props[key] = value
105             else:
106                 props[key] = None
107         return props
109     def usage(self, message=''):
110         ''' Display a simple usage message.
111         '''
112         if message:
113             message = _('Problem: %(message)s)\n\n')%locals()
114         print _('''%(message)sUsage: roundup-admin [options] <command> <arguments>
116 Options:
117  -i instance home  -- specify the issue tracker "home directory" to administer
118  -u                -- the user[:password] to use for commands
119  -c                -- when outputting lists of data, just comma-separate them
121 Help:
122  roundup-admin -h
123  roundup-admin help                       -- this help
124  roundup-admin help <command>             -- command-specific help
125  roundup-admin help all                   -- all available help
126 ''')%locals()
127         self.help_commands()
129     def help_commands(self):
130         ''' List the commands available with their precis help.
131         '''
132         print _('Commands:'),
133         commands = ['']
134         for command in self.commands.values():
135             h = command.__doc__.split('\n')[0]
136             commands.append(' '+h[7:])
137         commands.sort()
138         commands.append(_('Commands may be abbreviated as long as the abbreviation matches only one'))
139         commands.append(_('command, e.g. l == li == lis == list.'))
140         print '\n'.join(commands)
141         print
143     def help_commands_html(self, indent_re=re.compile(r'^(\s+)\S+')):
144         ''' Produce an HTML command list.
145         '''
146         commands = self.commands.values()
147         def sortfun(a, b):
148             return cmp(a.__name__, b.__name__)
149         commands.sort(sortfun)
150         for command in commands:
151             h = command.__doc__.split('\n')
152             name = command.__name__[3:]
153             usage = h[0]
154             print _('''
155 <tr><td valign=top><strong>%(name)s</strong></td>
156     <td><tt>%(usage)s</tt><p>
157 <pre>''')%locals()
158             indent = indent_re.match(h[3])
159             if indent: indent = len(indent.group(1))
160             for line in h[3:]:
161                 if indent:
162                     print line[indent:]
163                 else:
164                     print line
165             print _('</pre></td></tr>\n')
167     def help_all(self):
168         print _('''
169 All commands (except help) require a tracker specifier. This is just the path
170 to the roundup tracker you're working with. A roundup tracker is where 
171 roundup keeps the database and configuration file that defines an issue
172 tracker. It may be thought of as the issue tracker's "home directory". It may
173 be specified in the environment variable TRACKER_HOME or on the command
174 line as "-i tracker".
176 A designator is a classname and a nodeid concatenated, eg. bug1, user10, ...
178 Property values are represented as strings in command arguments and in the
179 printed results:
180  . Strings are, well, strings.
181  . Date values are printed in the full date format in the local time zone, and
182    accepted in the full format or any of the partial formats explained below.
183  . Link values are printed as node designators. When given as an argument,
184    node designators and key strings are both accepted.
185  . Multilink values are printed as lists of node designators joined by commas.
186    When given as an argument, node designators and key strings are both
187    accepted; an empty string, a single node, or a list of nodes joined by
188    commas is accepted.
190 When property values must contain spaces, just surround the value with
191 quotes, either ' or ". A single space may also be backslash-quoted. If a
192 valuu must contain a quote character, it must be backslash-quoted or inside
193 quotes. Examples:
194            hello world      (2 tokens: hello, world)
195            "hello world"    (1 token: hello world)
196            "Roch'e" Compaan (2 tokens: Roch'e Compaan)
197            Roch\'e Compaan  (2 tokens: Roch'e Compaan)
198            address="1 2 3"  (1 token: address=1 2 3)
199            \\               (1 token: \)
200            \n\r\t           (1 token: a newline, carriage-return and tab)
202 When multiple nodes are specified to the roundup get or roundup set
203 commands, the specified properties are retrieved or set on all the listed
204 nodes. 
206 When multiple results are returned by the roundup get or roundup find
207 commands, they are printed one per line (default) or joined by commas (with
208 the -c) option. 
210 Where the command changes data, a login name/password is required. The
211 login may be specified as either "name" or "name:password".
212  . ROUNDUP_LOGIN environment variable
213  . the -u command-line option
214 If either the name or password is not supplied, they are obtained from the
215 command-line. 
217 Date format examples:
218   "2000-04-17.03:45" means <Date 2000-04-17.08:45:00>
219   "2000-04-17" means <Date 2000-04-17.00:00:00>
220   "01-25" means <Date yyyy-01-25.00:00:00>
221   "08-13.22:13" means <Date yyyy-08-14.03:13:00>
222   "11-07.09:32:43" means <Date yyyy-11-07.14:32:43>
223   "14:25" means <Date yyyy-mm-dd.19:25:00>
224   "8:47:11" means <Date yyyy-mm-dd.13:47:11>
225   "." means "right now"
227 Command help:
228 ''')
229         for name, command in self.commands.items():
230             print _('%s:')%name
231             print _('   '), command.__doc__
233     def do_help(self, args, nl_re=re.compile('[\r\n]'),
234             indent_re=re.compile(r'^(\s+)\S+')):
235         '''Usage: help topic
236         Give help about topic.
238         commands  -- list commands
239         <command> -- help specific to a command
240         initopts  -- init command options
241         all       -- all available help
242         '''
243         if len(args)>0:
244             topic = args[0]
245         else:
246             topic = 'help'
247  
249         # try help_ methods
250         if self.help.has_key(topic):
251             self.help[topic]()
252             return 0
254         # try command docstrings
255         try:
256             l = self.commands.get(topic)
257         except KeyError:
258             print _('Sorry, no help for "%(topic)s"')%locals()
259             return 1
261         # display the help for each match, removing the docsring indent
262         for name, help in l:
263             lines = nl_re.split(help.__doc__)
264             print lines[0]
265             indent = indent_re.match(lines[1])
266             if indent: indent = len(indent.group(1))
267             for line in lines[1:]:
268                 if indent:
269                     print line[indent:]
270                 else:
271                     print line
272         return 0
274     def help_initopts(self):
275         import roundup.templates
276         templates = roundup.templates.listTemplates()
277         print _('Templates:'), ', '.join(templates)
278         import roundup.backends
279         backends = roundup.backends.__all__
280         print _('Back ends:'), ', '.join(backends)
282     def do_install(self, tracker_home, args):
283         '''Usage: install [template [backend [admin password]]]
284         Install a new Roundup tracker.
286         The command will prompt for the tracker home directory (if not supplied
287         through TRACKER_HOME or the -i option). The template, backend and admin
288         password may be specified on the command-line as arguments, in that
289         order.
291         The initialise command must be called after this command in order
292         to initialise the tracker's database. You may edit the tracker's
293         initial database contents before running that command by editing
294         the tracker's dbinit.py module init() function.
296         See also initopts help.
297         '''
298         if len(args) < 1:
299             raise UsageError, _('Not enough arguments supplied')
301         # make sure the tracker home can be created
302         parent = os.path.split(tracker_home)[0]
303         if not os.path.exists(parent):
304             raise UsageError, _('Instance home parent directory "%(parent)s"'
305                 ' does not exist')%locals()
307         # select template
308         import roundup.templates
309         templates = roundup.templates.listTemplates()
310         template = len(args) > 1 and args[1] or ''
311         if template not in templates:
312             print _('Templates:'), ', '.join(templates)
313         while template not in templates:
314             template = raw_input(_('Select template [classic]: ')).strip()
315             if not template:
316                 template = 'classic'
318         # select hyperdb backend
319         import roundup.backends
320         backends = roundup.backends.__all__
321         backend = len(args) > 2 and args[2] or ''
322         if backend not in backends:
323             print _('Back ends:'), ', '.join(backends)
324         while backend not in backends:
325             backend = raw_input(_('Select backend [anydbm]: ')).strip()
326             if not backend:
327                 backend = 'anydbm'
328         # XXX perform a unit test based on the user's selections
330         # install!
331         init.install(tracker_home, template)
332         init.write_select_db(tracker_home, backend)
334         print _('''
335  You should now edit the tracker configuration file:
336    %(config_file)s
337  ... at a minimum, you must set MAILHOST, TRACKER_WEB, MAIL_DOMAIN and
338  ADMIN_EMAIL.
340  If you wish to modify the default schema, you should also edit the database
341  initialisation file:
342    %(database_config_file)s
343  ... see the documentation on customizing for more information.
344 ''')%{
345     'config_file': os.path.join(tracker_home, 'config.py'),
346     'database_config_file': os.path.join(tracker_home, 'dbinit.py')
348         return 0
351     def do_initialise(self, tracker_home, args):
352         '''Usage: initialise [adminpw]
353         Initialise a new Roundup tracker.
355         The administrator details will be set at this step.
357         Execute the tracker's initialisation function dbinit.init()
358         '''
359         # password
360         if len(args) > 1:
361             adminpw = args[1]
362         else:
363             adminpw = ''
364             confirm = 'x'
365             while adminpw != confirm:
366                 adminpw = getpass.getpass(_('Admin Password: '))
367                 confirm = getpass.getpass(_('       Confirm: '))
369         # make sure the tracker home is installed
370         if not os.path.exists(tracker_home):
371             raise UsageError, _('Instance home does not exist')%locals()
372         try:
373             tracker = roundup.instance.open(tracker_home)
374         except roundup.instance.TrackerError:
375             raise UsageError, _('Instance has not been installed')%locals()
377         # is there already a database?
378         try:
379             db_exists = tracker.select_db.Database.exists(tracker.config)
380         except AttributeError:
381             # TODO: move this code to exists() static method in every backend
382             db_exists = os.path.exists(os.path.join(tracker_home, 'db'))
383         if db_exists:
384             print _('WARNING: The database is already initialised!')
385             print _('If you re-initialise it, you will lose all the data!')
386             ok = raw_input(_('Erase it? Y/[N]: ')).strip()
387             if ok.lower() != 'y':
388                 return 0
390             # Get a database backend in use by tracker
391             try:
392                 # nuke it
393                 tracker.select_db.Database.nuke(tracker.config)
394             except AttributeError:
395                 # TODO: move this code to nuke() static method in every backend
396                 shutil.rmtree(os.path.join(tracker_home, 'db'))
398         # GO
399         init.initialise(tracker_home, adminpw)
401         return 0
404     def do_get(self, args):
405         '''Usage: get property designator[,designator]*
406         Get the given property of one or more designator(s).
408         Retrieves the property value of the nodes specified by the designators.
409         '''
410         if len(args) < 2:
411             raise UsageError, _('Not enough arguments supplied')
412         propname = args[0]
413         designators = args[1].split(',')
414         l = []
415         for designator in designators:
416             # decode the node designator
417             try:
418                 classname, nodeid = hyperdb.splitDesignator(designator)
419             except hyperdb.DesignatorError, message:
420                 raise UsageError, message
422             # get the class
423             cl = self.get_class(classname)
424             try:
425                 if self.comma_sep:
426                     l.append(cl.get(nodeid, propname))
427                 else:
428                     print cl.get(nodeid, propname)
429             except IndexError:
430                 raise UsageError, _('no such %(classname)s node "%(nodeid)s"')%locals()
431             except KeyError:
432                 raise UsageError, _('no such %(classname)s property '
433                     '"%(propname)s"')%locals()
434         if self.comma_sep:
435             print ','.join(l)
436         return 0
439     def do_set(self, args, pwre = re.compile(r'{(\w+)}(.+)')):
440         '''Usage: set [items] property=value property=value ...
441         Set the given properties of one or more items(s).
443         The items may be specified as a class or as a comma-separeted
444         list of item designators (ie "designator[,designator,...]").
446         This command sets the properties to the values for all designators
447         given. If the value is missing (ie. "property=") then the property is
448         un-set. If the property is a multilink, you specify the linked ids
449         for the multilink as comma-separated numbers (ie "1,2,3").
450         '''
451         if len(args) < 2:
452             raise UsageError, _('Not enough arguments supplied')
453         from roundup import hyperdb
455         designators = args[0].split(',')
456         if len(designators) == 1:
457             designator = designators[0]
458             try:
459                 designator = hyperdb.splitDesignator(designator)
460                 designators = [designator]
461             except hyperdb.DesignatorError:
462                 cl = self.get_class(designator)
463                 designators = [(designator, x) for x in cl.list()]
464         else:
465             try:
466                 designators = [hyperdb.splitDesignator(x) for x in designators]
467             except hyperdb.DesignatorError, message:
468                 raise UsageError, message
470         # get the props from the args
471         props = self.props_from_args(args[1:])
473         # now do the set for all the nodes
474         for classname, itemid in designators:
475             cl = self.get_class(classname)
477             properties = cl.getprops()
478             for key, value in props.items():
479                 proptype =  properties[key]
480                 if isinstance(proptype, hyperdb.Multilink):
481                     if value is None:
482                         props[key] = []
483                     else:
484                         props[key] = value.split(',')
485                 elif value is None:
486                     continue
487                 elif isinstance(proptype, hyperdb.String):
488                     continue
489                 elif isinstance(proptype, hyperdb.Password):
490                     m = pwre.match(value)
491                     if m:
492                         # password is being given to us encrypted
493                         p = password.Password()
494                         p.scheme = m.group(1)
495                         p.password = m.group(2)
496                         props[key] = p
497                     else:
498                         props[key] = password.Password(value)
499                 elif isinstance(proptype, hyperdb.Date):
500                     try:
501                         props[key] = date.Date(value)
502                     except ValueError, message:
503                         raise UsageError, '"%s": %s'%(value, message)
504                 elif isinstance(proptype, hyperdb.Interval):
505                     try:
506                         props[key] = date.Interval(value)
507                     except ValueError, message:
508                         raise UsageError, '"%s": %s'%(value, message)
509                 elif isinstance(proptype, hyperdb.Link):
510                     props[key] = value
511                 elif isinstance(proptype, hyperdb.Boolean):
512                     props[key] = value.lower() in ('yes', 'true', 'on', '1')
513                 elif isinstance(proptype, hyperdb.Number):
514                     props[key] = float(value)
516             # try the set
517             try:
518                 apply(cl.set, (itemid, ), props)
519             except (TypeError, IndexError, ValueError), message:
520                 import traceback; traceback.print_exc()
521                 raise UsageError, message
522         return 0
524     def do_find(self, args):
525         '''Usage: find classname propname=value ...
526         Find the nodes of the given class with a given link property value.
528         Find the nodes of the given class with a given link property value. The
529         value may be either the nodeid of the linked node, or its key value.
530         '''
531         if len(args) < 1:
532             raise UsageError, _('Not enough arguments supplied')
533         classname = args[0]
534         # get the class
535         cl = self.get_class(classname)
537         # handle the propname=value argument
538         props = self.props_from_args(args[1:])
540         # if the value isn't a number, look up the linked class to get the
541         # number
542         for propname, value in props.items():
543             num_re = re.compile('^\d+$')
544             if value == '-1':
545                 props[propname] = None
546             elif not num_re.match(value):
547                 # get the property
548                 try:
549                     property = cl.properties[propname]
550                 except KeyError:
551                     raise UsageError, _('%(classname)s has no property '
552                         '"%(propname)s"')%locals()
554                 # make sure it's a link
555                 if (not isinstance(property, hyperdb.Link) and not
556                         isinstance(property, hyperdb.Multilink)):
557                     raise UsageError, _('You may only "find" link properties')
559                 # get the linked-to class and look up the key property
560                 link_class = self.db.getclass(property.classname)
561                 try:
562                     props[propname] = link_class.lookup(value)
563                 except TypeError:
564                     raise UsageError, _('%(classname)s has no key property"')%{
565                         'classname': link_class.classname}
567         # now do the find 
568         try:
569             if self.comma_sep:
570                 print ','.join(apply(cl.find, (), props))
571             else:
572                 print apply(cl.find, (), props)
573         except KeyError:
574             raise UsageError, _('%(classname)s has no property '
575                 '"%(propname)s"')%locals()
576         except (ValueError, TypeError), message:
577             raise UsageError, message
578         return 0
580     def do_specification(self, args):
581         '''Usage: specification classname
582         Show the properties for a classname.
584         This lists the properties for a given class.
585         '''
586         if len(args) < 1:
587             raise UsageError, _('Not enough arguments supplied')
588         classname = args[0]
589         # get the class
590         cl = self.get_class(classname)
592         # get the key property
593         keyprop = cl.getkey()
594         for key, value in cl.properties.items():
595             if keyprop == key:
596                 print _('%(key)s: %(value)s (key property)')%locals()
597             else:
598                 print _('%(key)s: %(value)s')%locals()
600     def do_display(self, args):
601         '''Usage: display designator
602         Show the property values for the given node.
604         This lists the properties and their associated values for the given
605         node.
606         '''
607         if len(args) < 1:
608             raise UsageError, _('Not enough arguments supplied')
610         # decode the node designator
611         try:
612             classname, nodeid = hyperdb.splitDesignator(args[0])
613         except hyperdb.DesignatorError, message:
614             raise UsageError, message
616         # get the class
617         cl = self.get_class(classname)
619         # display the values
620         for key in cl.properties.keys():
621             value = cl.get(nodeid, key)
622             print _('%(key)s: %(value)s')%locals()
624     def do_create(self, args, pwre = re.compile(r'{(\w+)}(.+)')):
625         '''Usage: create classname property=value ...
626         Create a new entry of a given class.
628         This creates a new entry of the given class using the property
629         name=value arguments provided on the command line after the "create"
630         command.
631         '''
632         if len(args) < 1:
633             raise UsageError, _('Not enough arguments supplied')
634         from roundup import hyperdb
636         classname = args[0]
638         # get the class
639         cl = self.get_class(classname)
641         # now do a create
642         props = {}
643         properties = cl.getprops(protected = 0)
644         if len(args) == 1:
645             # ask for the properties
646             for key, value in properties.items():
647                 if key == 'id': continue
648                 name = value.__class__.__name__
649                 if isinstance(value , hyperdb.Password):
650                     again = None
651                     while value != again:
652                         value = getpass.getpass(_('%(propname)s (Password): ')%{
653                             'propname': key.capitalize()})
654                         again = getpass.getpass(_('   %(propname)s (Again): ')%{
655                             'propname': key.capitalize()})
656                         if value != again: print _('Sorry, try again...')
657                     if value:
658                         props[key] = value
659                 else:
660                     value = raw_input(_('%(propname)s (%(proptype)s): ')%{
661                         'propname': key.capitalize(), 'proptype': name})
662                     if value:
663                         props[key] = value
664         else:
665             props = self.props_from_args(args[1:])
667         # convert types
668         for propname, value in props.items():
669             # get the property
670             try:
671                 proptype = properties[propname]
672             except KeyError:
673                 raise UsageError, _('%(classname)s has no property '
674                     '"%(propname)s"')%locals()
676             if isinstance(proptype, hyperdb.Date):
677                 try:
678                     props[propname] = date.Date(value)
679                 except ValueError, message:
680                     raise UsageError, _('"%(value)s": %(message)s')%locals()
681             elif isinstance(proptype, hyperdb.Interval):
682                 try:
683                     props[propname] = date.Interval(value)
684                 except ValueError, message:
685                     raise UsageError, _('"%(value)s": %(message)s')%locals()
686             elif isinstance(proptype, hyperdb.Password):
687                 m = pwre.match(value)
688                 if m:
689                     # password is being given to us encrypted
690                     p = password.Password()
691                     p.scheme = m.group(1)
692                     p.password = m.group(2)
693                     props[propname] = p
694                 else:
695                     props[propname] = password.Password(value)
696             elif isinstance(proptype, hyperdb.Multilink):
697                 props[propname] = value.split(',')
698             elif isinstance(proptype, hyperdb.Boolean):
699                 props[propname] = value.lower() in ('yes', 'true', 'on', '1')
700             elif isinstance(proptype, hyperdb.Number):
701                 props[propname] = float(value)
703         # check for the key property
704         propname = cl.getkey()
705         if propname and not props.has_key(propname):
706             raise UsageError, _('you must provide the "%(propname)s" '
707                 'property.')%locals()
709         # do the actual create
710         try:
711             print apply(cl.create, (), props)
712         except (TypeError, IndexError, ValueError), message:
713             raise UsageError, message
714         return 0
716     def do_list(self, args):
717         '''Usage: list classname [property]
718         List the instances of a class.
720         Lists all instances of the given class. If the property is not
721         specified, the  "label" property is used. The label property is tried
722         in order: the key, "name", "title" and then the first property,
723         alphabetically.
724         '''
725         if len(args) < 1:
726             raise UsageError, _('Not enough arguments supplied')
727         classname = args[0]
729         # get the class
730         cl = self.get_class(classname)
732         # figure the property
733         if len(args) > 1:
734             propname = args[1]
735         else:
736             propname = cl.labelprop()
738         if self.comma_sep:
739             print ','.join(cl.list())
740         else:
741             for nodeid in cl.list():
742                 try:
743                     value = cl.get(nodeid, propname)
744                 except KeyError:
745                     raise UsageError, _('%(classname)s has no property '
746                         '"%(propname)s"')%locals()
747                 print _('%(nodeid)4s: %(value)s')%locals()
748         return 0
750     def do_table(self, args):
751         '''Usage: table classname [property[,property]*]
752         List the instances of a class in tabular form.
754         Lists all instances of the given class. If the properties are not
755         specified, all properties are displayed. By default, the column widths
756         are the width of the largest value. The width may be explicitly defined
757         by defining the property as "name:width". For example::
758           roundup> table priority id,name:10
759           Id Name
760           1  fatal-bug 
761           2  bug       
762           3  usability 
763           4  feature   
765         Also to make the width of the column the width of the label,
766         leave a trailing : without a width on the property. E.G.
767           roundup> table priority id,name:
768           Id Name
769           1  fata
770           2  bug       
771           3  usab
772           4  feat
774         will result in a the 4 character wide "Name" column.
775         '''
776         if len(args) < 1:
777             raise UsageError, _('Not enough arguments supplied')
778         classname = args[0]
780         # get the class
781         cl = self.get_class(classname)
783         # figure the property names to display
784         if len(args) > 1:
785             prop_names = args[1].split(',')
786             all_props = cl.getprops()
787             for spec in prop_names:
788                 if ':' in spec:
789                     try:
790                         propname, width = spec.split(':')
791                     except (ValueError, TypeError):
792                         raise UsageError, _('"%(spec)s" not name:width')%locals()
793                 else:
794                     propname = spec
795                 if not all_props.has_key(propname):
796                     raise UsageError, _('%(classname)s has no property '
797                         '"%(propname)s"')%locals()
798         else:
799             prop_names = cl.getprops().keys()
801         # now figure column widths
802         props = []
803         for spec in prop_names:
804             if ':' in spec:
805                 name, width = spec.split(':')
806                 if width == '':
807                     props.append((name, len(spec)))
808                 else:
809                     props.append((name, int(width)))
810             else:
811                # this is going to be slow
812                maxlen = len(spec)
813                for nodeid in cl.list():
814                    curlen = len(str(cl.get(nodeid, spec)))
815                    if curlen > maxlen:
816                        maxlen = curlen
817                props.append((spec, maxlen))
818                
819         # now display the heading
820         print ' '.join([name.capitalize().ljust(width) for name,width in props])
822         # and the table data
823         for nodeid in cl.list():
824             l = []
825             for name, width in props:
826                 if name != 'id':
827                     try:
828                         value = str(cl.get(nodeid, name))
829                     except KeyError:
830                         # we already checked if the property is valid - a
831                         # KeyError here means the node just doesn't have a
832                         # value for it
833                         value = ''
834                 else:
835                     value = str(nodeid)
836                 f = '%%-%ds'%width
837                 l.append(f%value[:width])
838             print ' '.join(l)
839         return 0
841     def do_history(self, args):
842         '''Usage: history designator
843         Show the history entries of a designator.
845         Lists the journal entries for the node identified by the designator.
846         '''
847         if len(args) < 1:
848             raise UsageError, _('Not enough arguments supplied')
849         try:
850             classname, nodeid = hyperdb.splitDesignator(args[0])
851         except hyperdb.DesignatorError, message:
852             raise UsageError, message
854         try:
855             print self.db.getclass(classname).history(nodeid)
856         except KeyError:
857             raise UsageError, _('no such class "%(classname)s"')%locals()
858         except IndexError:
859             raise UsageError, _('no such %(classname)s node "%(nodeid)s"')%locals()
860         return 0
862     def do_commit(self, args):
863         '''Usage: commit
864         Commit all changes made to the database.
866         The changes made during an interactive session are not
867         automatically written to the database - they must be committed
868         using this command.
870         One-off commands on the command-line are automatically committed if
871         they are successful.
872         '''
873         self.db.commit()
874         return 0
876     def do_rollback(self, args):
877         '''Usage: rollback
878         Undo all changes that are pending commit to the database.
880         The changes made during an interactive session are not
881         automatically written to the database - they must be committed
882         manually. This command undoes all those changes, so a commit
883         immediately after would make no changes to the database.
884         '''
885         self.db.rollback()
886         return 0
888     def do_retire(self, args):
889         '''Usage: retire designator[,designator]*
890         Retire the node specified by designator.
892         This action indicates that a particular node is not to be retrieved by
893         the list or find commands, and its key value may be re-used.
894         '''
895         if len(args) < 1:
896             raise UsageError, _('Not enough arguments supplied')
897         designators = args[0].split(',')
898         for designator in designators:
899             try:
900                 classname, nodeid = hyperdb.splitDesignator(designator)
901             except hyperdb.DesignatorError, message:
902                 raise UsageError, message
903             try:
904                 self.db.getclass(classname).retire(nodeid)
905             except KeyError:
906                 raise UsageError, _('no such class "%(classname)s"')%locals()
907             except IndexError:
908                 raise UsageError, _('no such %(classname)s node "%(nodeid)s"')%locals()
909         return 0
911     def do_restore(self, args):
912         '''Usage: restore designator[,designator]*
913         Restore the retired node specified by designator.
915         The given nodes will become available for users again.
916         '''
917         if len(args) < 1:
918             raise UsageError, _('Not enough arguments supplied')
919         designators = args[0].split(',')
920         for designator in designators:
921             try:
922                 classname, nodeid = hyperdb.splitDesignator(designator)
923             except hyperdb.DesignatorError, message:
924                 raise UsageError, message
925             try:
926                 self.db.getclass(classname).restore(nodeid)
927             except KeyError:
928                 raise UsageError, _('no such class "%(classname)s"')%locals()
929             except IndexError:
930                 raise UsageError, _('no such %(classname)s node "%(nodeid)s"')%locals()
931         return 0
933     def do_export(self, args):
934         '''Usage: export [class[,class]] export_dir
935         Export the database to colon-separated-value files.
937         This action exports the current data from the database into
938         colon-separated-value files that are placed in the nominated
939         destination directory. The journals are not exported.
940         '''
941         # we need the CSV module
942         if csv is None:
943             raise UsageError, \
944                 _('Sorry, you need the csv module to use this function.\n'
945                 'Get it from: http://www.object-craft.com.au/projects/csv/')
947         # grab the directory to export to
948         if len(args) < 1:
949             raise UsageError, _('Not enough arguments supplied')
950         dir = args[-1]
952         # get the list of classes to export
953         if len(args) == 2:
954             classes = args[0].split(',')
955         else:
956             classes = self.db.classes.keys()
958         # use the csv parser if we can - it's faster
959         p = csv.parser(field_sep=':')
961         # do all the classes specified
962         for classname in classes:
963             cl = self.get_class(classname)
964             f = open(os.path.join(dir, classname+'.csv'), 'w')
965             properties = cl.getprops()
966             propnames = properties.keys()
967             propnames.sort()
968             l = propnames[:]
969             l.append('is retired')
970             print >> f, p.join(l)
972             # all nodes for this class (not using list() 'cos it doesn't
973             # include retired nodes)
975             for nodeid in self.db.getclass(classname).getnodeids():
976                 # get the regular props
977                 print >>f, p.join(cl.export_list(propnames, nodeid))
979             # close this file
980             f.close()
981         return 0
983     def do_import(self, args):
984         '''Usage: import import_dir
985         Import a database from the directory containing CSV files, one per
986         class to import.
988         The files must define the same properties as the class (including having
989         a "header" line with those property names.)
991         The imported nodes will have the same nodeid as defined in the
992         import file, thus replacing any existing content.
994         The new nodes are added to the existing database - if you want to
995         create a new database using the imported data, then create a new
996         database (or, tediously, retire all the old data.)
997         '''
998         if len(args) < 1:
999             raise UsageError, _('Not enough arguments supplied')
1000         if csv is None:
1001             raise UsageError, \
1002                 _('Sorry, you need the csv module to use this function.\n'
1003                 'Get it from: http://www.object-craft.com.au/projects/csv/')
1005         from roundup import hyperdb
1007         for file in os.listdir(args[0]):
1008             # we only care about CSV files
1009             if not file.endswith('.csv'):
1010                 continue
1012             f = open(os.path.join(args[0], file))
1014             # get the classname
1015             classname = os.path.splitext(file)[0]
1017             # ensure that the properties and the CSV file headings match
1018             cl = self.get_class(classname)
1019             p = csv.parser(field_sep=':')
1020             file_props = p.parse(f.readline())
1022 # XXX we don't _really_ need to do this...
1023 #            properties = cl.getprops()
1024 #            propnames = properties.keys()
1025 #            propnames.sort()
1026 #            m = file_props[:]
1027 #            m.sort()
1028 #            if m != propnames:
1029 #                raise UsageError, _('Import file doesn\'t define the same '
1030 #                    'properties as "%(arg0)s".')%{'arg0': args[0]}
1032             # loop through the file and create a node for each entry
1033             maxid = 1
1034             while 1:
1035                 line = f.readline()
1036                 if not line: break
1038                 # parse lines until we get a complete entry
1039                 while 1:
1040                     l = p.parse(line)
1041                     if l: break
1042                     line = f.readline()
1043                     if not line:
1044                         raise ValueError, "Unexpected EOF during CSV parse"
1046                 # do the import and figure the current highest nodeid
1047                 maxid = max(maxid, int(cl.import_list(file_props, l)))
1049             print 'setting', classname, maxid+1
1050             self.db.setid(classname, str(maxid+1))
1051         return 0
1053     def do_pack(self, args):
1054         '''Usage: pack period | date
1056 Remove journal entries older than a period of time specified or
1057 before a certain date.
1059 A period is specified using the suffixes "y", "m", and "d". The
1060 suffix "w" (for "week") means 7 days.
1062       "3y" means three years
1063       "2y 1m" means two years and one month
1064       "1m 25d" means one month and 25 days
1065       "2w 3d" means two weeks and three days
1067 Date format is "YYYY-MM-DD" eg:
1068     2001-01-01
1069     
1070         '''
1071         if len(args) <> 1:
1072             raise UsageError, _('Not enough arguments supplied')
1073         
1074         # are we dealing with a period or a date
1075         value = args[0]
1076         date_re = re.compile(r'''
1077               (?P<date>\d\d\d\d-\d\d?-\d\d?)? # yyyy-mm-dd
1078               (?P<period>(\d+y\s*)?(\d+m\s*)?(\d+d\s*)?)?
1079               ''', re.VERBOSE)
1080         m = date_re.match(value)
1081         if not m:
1082             raise ValueError, _('Invalid format')
1083         m = m.groupdict()
1084         if m['period']:
1085             pack_before = date.Date(". - %s"%value)
1086         elif m['date']:
1087             pack_before = date.Date(value)
1088         self.db.pack(pack_before)
1089         return 0
1091     def do_reindex(self, args):
1092         '''Usage: reindex
1093         Re-generate a tracker's search indexes.
1095         This will re-generate the search indexes for a tracker. This will
1096         typically happen automatically.
1097         '''
1098         self.db.indexer.force_reindex()
1099         self.db.reindex()
1100         return 0
1102     def do_security(self, args):
1103         '''Usage: security [Role name]
1104         Display the Permissions available to one or all Roles.
1105         '''
1106         if len(args) == 1:
1107             role = args[0]
1108             try:
1109                 roles = [(args[0], self.db.security.role[args[0]])]
1110             except KeyError:
1111                 print _('No such Role "%(role)s"')%locals()
1112                 return 1
1113         else:
1114             roles = self.db.security.role.items()
1115             role = self.db.config.NEW_WEB_USER_ROLES
1116             if ',' in role:
1117                 print _('New Web users get the Roles "%(role)s"')%locals()
1118             else:
1119                 print _('New Web users get the Role "%(role)s"')%locals()
1120             role = self.db.config.NEW_EMAIL_USER_ROLES
1121             if ',' in role:
1122                 print _('New Email users get the Roles "%(role)s"')%locals()
1123             else:
1124                 print _('New Email users get the Role "%(role)s"')%locals()
1125         roles.sort()
1126         for rolename, role in roles:
1127             print _('Role "%(name)s":')%role.__dict__
1128             for permission in role.permissions:
1129                 if permission.klass:
1130                     print _(' %(description)s (%(name)s for "%(klass)s" '
1131                         'only)')%permission.__dict__
1132                 else:
1133                     print _(' %(description)s (%(name)s)')%permission.__dict__
1134         return 0
1136     def run_command(self, args):
1137         '''Run a single command
1138         '''
1139         command = args[0]
1141         # handle help now
1142         if command == 'help':
1143             if len(args)>1:
1144                 self.do_help(args[1:])
1145                 return 0
1146             self.do_help(['help'])
1147             return 0
1148         if command == 'morehelp':
1149             self.do_help(['help'])
1150             self.help_commands()
1151             self.help_all()
1152             return 0
1154         # figure what the command is
1155         try:
1156             functions = self.commands.get(command)
1157         except KeyError:
1158             # not a valid command
1159             print _('Unknown command "%(command)s" ("help commands" for a '
1160                 'list)')%locals()
1161             return 1
1163         # check for multiple matches
1164         if len(functions) > 1:
1165             print _('Multiple commands match "%(command)s": %(list)s')%{'command':
1166                 command, 'list': ', '.join([i[0] for i in functions])}
1167             return 1
1168         command, function = functions[0]
1170         # make sure we have a tracker_home
1171         while not self.tracker_home:
1172             self.tracker_home = raw_input(_('Enter tracker home: ')).strip()
1174         # before we open the db, we may be doing an install or init
1175         if command == 'initialise':
1176             try:
1177                 return self.do_initialise(self.tracker_home, args)
1178             except UsageError, message:
1179                 print _('Error: %(message)s')%locals()
1180                 return 1
1181         elif command == 'install':
1182             try:
1183                 return self.do_install(self.tracker_home, args)
1184             except UsageError, message:
1185                 print _('Error: %(message)s')%locals()
1186                 return 1
1188         # get the tracker
1189         try:
1190             tracker = roundup.instance.open(self.tracker_home)
1191         except ValueError, message:
1192             self.tracker_home = ''
1193             print _("Error: Couldn't open tracker: %(message)s")%locals()
1194             return 1
1196         # only open the database once!
1197         if not self.db:
1198             self.db = tracker.open('admin')
1200         # do the command
1201         ret = 0
1202         try:
1203             ret = function(args[1:])
1204         except UsageError, message:
1205             print _('Error: %(message)s')%locals()
1206             print
1207             print function.__doc__
1208             ret = 1
1209         except:
1210             import traceback
1211             traceback.print_exc()
1212             ret = 1
1213         return ret
1215     def interactive(self):
1216         '''Run in an interactive mode
1217         '''
1218         print _('Roundup %s ready for input.'%roundup_version)
1219         print _('Type "help" for help.')
1220         try:
1221             import readline
1222         except ImportError:
1223             print _('Note: command history and editing not available')
1225         while 1:
1226             try:
1227                 command = raw_input(_('roundup> '))
1228             except EOFError:
1229                 print _('exit...')
1230                 break
1231             if not command: continue
1232             args = token.token_split(command)
1233             if not args: continue
1234             if args[0] in ('quit', 'exit'): break
1235             self.run_command(args)
1237         # exit.. check for transactions
1238         if self.db and self.db.transactions:
1239             commit = raw_input(_('There are unsaved changes. Commit them (y/N)? '))
1240             if commit and commit[0].lower() == 'y':
1241                 self.db.commit()
1242         return 0
1244     def main(self):
1245         try:
1246             opts, args = getopt.getopt(sys.argv[1:], 'i:u:hc')
1247         except getopt.GetoptError, e:
1248             self.usage(str(e))
1249             return 1
1251         # handle command-line args
1252         self.tracker_home = os.environ.get('TRACKER_HOME', '')
1253         # TODO: reinstate the user/password stuff (-u arg too)
1254         name = password = ''
1255         if os.environ.has_key('ROUNDUP_LOGIN'):
1256             l = os.environ['ROUNDUP_LOGIN'].split(':')
1257             name = l[0]
1258             if len(l) > 1:
1259                 password = l[1]
1260         self.comma_sep = 0
1261         for opt, arg in opts:
1262             if opt == '-h':
1263                 self.usage()
1264                 return 0
1265             if opt == '-i':
1266                 self.tracker_home = arg
1267             if opt == '-c':
1268                 self.comma_sep = 1
1270         # if no command - go interactive
1271         # wrap in a try/finally so we always close off the db
1272         ret = 0
1273         try:
1274             if not args:
1275                 self.interactive()
1276             else:
1277                 ret = self.run_command(args)
1278                 if self.db: self.db.commit()
1279             return ret
1280         finally:
1281             if self.db:
1282                 self.db.close()
1284 if __name__ == '__main__':
1285     tool = AdminTool()
1286     sys.exit(tool.main())
1288 # vim: set filetype=python ts=4 sw=4 et si