Code

c4cc737beee8634f8742bdca973e806cc1d6c770
[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.58 2003-08-29 12:43:03 richard Exp $
21 '''Administration commands for maintaining Roundup trackers.
22 '''
24 import sys, os, getpass, getopt, re, UserDict, shutil, rfc822
25 from roundup import date, hyperdb, roundupdb, init, password, token, rcsv
26 from roundup import __version__ as roundup_version
27 import roundup.instance
28 from roundup.i18n import _
30 class CommandDict(UserDict.UserDict):
31     '''Simple dictionary that lets us do lookups using partial keys.
33     Original code submitted by Engelbert Gruber.
34     '''
35     _marker = []
36     def get(self, key, default=_marker):
37         if self.data.has_key(key):
38             return [(key, self.data[key])]
39         keylist = self.data.keys()
40         keylist.sort()
41         l = []
42         for ki in keylist:
43             if ki.startswith(key):
44                 l.append((ki, self.data[ki]))
45         if not l and default is self._marker:
46             raise KeyError, key
47         return l
49 class UsageError(ValueError):
50     pass
52 class AdminTool:
53     ''' A collection of methods used in maintaining Roundup trackers.
55         Typically these methods are accessed through the roundup-admin
56         script. The main() method provided on this class gives the main
57         loop for the roundup-admin script.
59         Actions are defined by do_*() methods, with help for the action
60         given in the method docstring.
62         Additional help may be supplied by help_*() methods.
63     '''
64     def __init__(self):
65         self.commands = CommandDict()
66         for k in AdminTool.__dict__.keys():
67             if k[:3] == 'do_':
68                 self.commands[k[3:]] = getattr(self, k)
69         self.help = {}
70         for k in AdminTool.__dict__.keys():
71             if k[:5] == 'help_':
72                 self.help[k[5:]] = getattr(self, k)
73         self.tracker_home = ''
74         self.db = None
76     def get_class(self, classname):
77         '''Get the class - raise an exception if it doesn't exist.
78         '''
79         try:
80             return self.db.getclass(classname)
81         except KeyError:
82             raise UsageError, _('no such class "%(classname)s"')%locals()
84     def props_from_args(self, args):
85         ''' Produce a dictionary of prop: value from the args list.
87             The args list is specified as ``prop=value prop=value ...``.
88         '''
89         props = {}
90         for arg in args:
91             if arg.find('=') == -1:
92                 raise UsageError, _('argument "%(arg)s" not propname=value'
93                     )%locals()
94             l = arg.split('=')
95             if len(l) < 2:
96                 raise UsageError, _('argument "%(arg)s" not propname=value'
97                     )%locals()
98             key, value = l[0], '='.join(l[1:])
99             if value:
100                 props[key] = value
101             else:
102                 props[key] = None
103         return props
105     def usage(self, message=''):
106         ''' Display a simple usage message.
107         '''
108         if message:
109             message = _('Problem: %(message)s\n\n')%locals()
110         print _('''%(message)sUsage: roundup-admin [options] [<command> <arguments>]
112 Options:
113  -i instance home  -- specify the issue tracker "home directory" to administer
114  -u                -- the user[:password] to use for commands
115  -d                -- print full designators not just class id numbers
116  -c                -- when outputting lists of data, comma-separate them.
117                       Same as '-S ","'.
118  -S <string>       -- when outputting lists of data, string-separate them
119  -s                -- when outputting lists of data, space-separate them.
120                       Same as '-S " "'.
122  Only one of -s, -c or -S can be specified.
124 Help:
125  roundup-admin -h
126  roundup-admin help                       -- this help
127  roundup-admin help <command>             -- command-specific help
128  roundup-admin help all                   -- all available help
129 ''')%locals()
130         self.help_commands()
132     def help_commands(self):
133         ''' List the commands available with their precis help.
134         '''
135         print _('Commands:'),
136         commands = ['']
137         for command in self.commands.values():
138             h = command.__doc__.split('\n')[0]
139             commands.append(' '+h[7:])
140         commands.sort()
141         commands.append(_('Commands may be abbreviated as long as the abbreviation matches only one'))
142         commands.append(_('command, e.g. l == li == lis == list.'))
143         print '\n'.join(commands)
144         print
146     def help_commands_html(self, indent_re=re.compile(r'^(\s+)\S+')):
147         ''' Produce an HTML command list.
148         '''
149         commands = self.commands.values()
150         def sortfun(a, b):
151             return cmp(a.__name__, b.__name__)
152         commands.sort(sortfun)
153         for command in commands:
154             h = command.__doc__.split('\n')
155             name = command.__name__[3:]
156             usage = h[0]
157             print _('''
158 <tr><td valign=top><strong>%(name)s</strong></td>
159     <td><tt>%(usage)s</tt><p>
160 <pre>''')%locals()
161             indent = indent_re.match(h[3])
162             if indent: indent = len(indent.group(1))
163             for line in h[3:]:
164                 if indent:
165                     print line[indent:]
166                 else:
167                     print line
168             print _('</pre></td></tr>\n')
170     def help_all(self):
171         print _('''
172 All commands (except help) require a tracker specifier. This is just the path
173 to the roundup tracker you're working with. A roundup tracker is where 
174 roundup keeps the database and configuration file that defines an issue
175 tracker. It may be thought of as the issue tracker's "home directory". It may
176 be specified in the environment variable TRACKER_HOME or on the command
177 line as "-i tracker".
179 A designator is a classname and a nodeid concatenated, eg. bug1, user10, ...
181 Property values are represented as strings in command arguments and in the
182 printed results:
183  . Strings are, well, strings.
184  . Date values are printed in the full date format in the local time zone, and
185    accepted in the full format or any of the partial formats explained below.
186  . Link values are printed as node designators. When given as an argument,
187    node designators and key strings are both accepted.
188  . Multilink values are printed as lists of node designators joined by commas.
189    When given as an argument, node designators and key strings are both
190    accepted; an empty string, a single node, or a list of nodes joined by
191    commas is accepted.
193 When property values must contain spaces, just surround the value with
194 quotes, either ' or ". A single space may also be backslash-quoted. If a
195 valuu must contain a quote character, it must be backslash-quoted or inside
196 quotes. Examples:
197            hello world      (2 tokens: hello, world)
198            "hello world"    (1 token: hello world)
199            "Roch'e" Compaan (2 tokens: Roch'e Compaan)
200            Roch\'e Compaan  (2 tokens: Roch'e Compaan)
201            address="1 2 3"  (1 token: address=1 2 3)
202            \\               (1 token: \)
203            \n\r\t           (1 token: a newline, carriage-return and tab)
205 When multiple nodes are specified to the roundup get or roundup set
206 commands, the specified properties are retrieved or set on all the listed
207 nodes. 
209 When multiple results are returned by the roundup get or roundup find
210 commands, they are printed one per line (default) or joined by commas (with
211 the -c) option. 
213 Where the command changes data, a login name/password is required. The
214 login may be specified as either "name" or "name:password".
215  . ROUNDUP_LOGIN environment variable
216  . the -u command-line option
217 If either the name or password is not supplied, they are obtained from the
218 command-line. 
220 Date format examples:
221   "2000-04-17.03:45" means <Date 2000-04-17.08:45:00>
222   "2000-04-17" means <Date 2000-04-17.00:00:00>
223   "01-25" means <Date yyyy-01-25.00:00:00>
224   "08-13.22:13" means <Date yyyy-08-14.03:13:00>
225   "11-07.09:32:43" means <Date yyyy-11-07.14:32:43>
226   "14:25" means <Date yyyy-mm-dd.19:25:00>
227   "8:47:11" means <Date yyyy-mm-dd.13:47:11>
228   "." means "right now"
230 Command help:
231 ''')
232         for name, command in self.commands.items():
233             print _('%s:')%name
234             print _('   '), command.__doc__
236     def do_help(self, args, nl_re=re.compile('[\r\n]'),
237             indent_re=re.compile(r'^(\s+)\S+')):
238         '''Usage: help topic
239         Give help about topic.
241         commands  -- list commands
242         <command> -- help specific to a command
243         initopts  -- init command options
244         all       -- all available help
245         '''
246         if len(args)>0:
247             topic = args[0]
248         else:
249             topic = 'help'
250  
252         # try help_ methods
253         if self.help.has_key(topic):
254             self.help[topic]()
255             return 0
257         # try command docstrings
258         try:
259             l = self.commands.get(topic)
260         except KeyError:
261             print _('Sorry, no help for "%(topic)s"')%locals()
262             return 1
264         # display the help for each match, removing the docsring indent
265         for name, help in l:
266             lines = nl_re.split(help.__doc__)
267             print lines[0]
268             indent = indent_re.match(lines[1])
269             if indent: indent = len(indent.group(1))
270             for line in lines[1:]:
271                 if indent:
272                     print line[indent:]
273                 else:
274                     print line
275         return 0
277     def listTemplates(self):
278         ''' List all the available templates.
280             Look in three places:
281                 <prefix>/share/roundup/templates/*
282                 <__file__>/../templates/*
283                 current dir/*
284                 current dir as a template
285         '''
286         # OK, try <prefix>/share/roundup/templates
287         # -- this module (roundup.admin) will be installed in something
288         # like:
289         #    /usr/lib/python2.2/site-packages/roundup/admin.py  (5 dirs up)
290         #    c:\python22\lib\site-packages\roundup\admin.py     (4 dirs up)
291         # we're interested in where the "lib" directory is - ie. the /usr/
292         # part
293         templates = {}
294         for N in 4, 5:
295             path = __file__
296             # move up N elements in the path
297             for i in range(N):
298                 path = os.path.dirname(path)
299             tdir = os.path.join(path, 'share', 'roundup', 'templates')
300             if os.path.isdir(tdir):
301                 templates = listTemplates(tdir)
302                 break
304         # OK, now try as if we're in the roundup source distribution
305         # directory, so this module will be in .../roundup-*/roundup/admin.py
306         # and we're interested in the .../roundup-*/ part.
307         path = __file__
308         for i in range(2):
309             path = os.path.dirname(path)
310         tdir = os.path.join(path, 'templates')
311         if os.path.isdir(tdir):
312             templates.update(listTemplates(tdir))
314         # Try subdirs of the current dir
315         templates.update(listTemplates(os.getcwd()))
317         # Finally, try the current directory as a template
318         template = loadTemplate(os.getcwd())
319         if template:
320             templates[template['name']] = template
322         return templates
324     def help_initopts(self):
325         templates = self.listTemplates()
326         print _('Templates:'), ', '.join(templates.keys())
327         import roundup.backends
328         backends = roundup.backends.__all__
329         print _('Back ends:'), ', '.join(backends)
331     def do_install(self, tracker_home, args):
332         '''Usage: install [template [backend [admin password]]]
333         Install a new Roundup tracker.
335         The command will prompt for the tracker home directory (if not supplied
336         through TRACKER_HOME or the -i option). The template, backend and admin
337         password may be specified on the command-line as arguments, in that
338         order.
340         The initialise command must be called after this command in order
341         to initialise the tracker's database. You may edit the tracker's
342         initial database contents before running that command by editing
343         the tracker's dbinit.py module init() function.
345         See also initopts help.
346         '''
347         if len(args) < 1:
348             raise UsageError, _('Not enough arguments supplied')
350         # make sure the tracker home can be created
351         parent = os.path.split(tracker_home)[0]
352         if not os.path.exists(parent):
353             raise UsageError, _('Instance home parent directory "%(parent)s"'
354                 ' does not exist')%locals()
356         # select template
357         templates = self.listTemplates()
358         template = len(args) > 1 and args[1] or ''
359         if not templates.has_key(template):
360             print _('Templates:'), ', '.join(templates.keys())
361         while not templates.has_key(template):
362             template = raw_input(_('Select template [classic]: ')).strip()
363             if not template:
364                 template = 'classic'
366         # select hyperdb backend
367         import roundup.backends
368         backends = roundup.backends.__all__
369         backend = len(args) > 2 and args[2] or ''
370         if backend not in backends:
371             print _('Back ends:'), ', '.join(backends)
372         while backend not in backends:
373             backend = raw_input(_('Select backend [anydbm]: ')).strip()
374             if not backend:
375                 backend = 'anydbm'
376         # XXX perform a unit test based on the user's selections
378         # install!
379         init.install(tracker_home, templates[template]['path'])
380         init.write_select_db(tracker_home, backend)
382         print _('''
383  You should now edit the tracker configuration file:
384    %(config_file)s
385  ... at a minimum, you must set MAILHOST, TRACKER_WEB, MAIL_DOMAIN and
386  ADMIN_EMAIL.
388  If you wish to modify the default schema, you should also edit the database
389  initialisation file:
390    %(database_config_file)s
391  ... see the documentation on customizing for more information.
392 ''')%{
393     'config_file': os.path.join(tracker_home, 'config.py'),
394     'database_config_file': os.path.join(tracker_home, 'dbinit.py')
396         return 0
399     def do_initialise(self, tracker_home, args):
400         '''Usage: initialise [adminpw]
401         Initialise a new Roundup tracker.
403         The administrator details will be set at this step.
405         Execute the tracker's initialisation function dbinit.init()
406         '''
407         # password
408         if len(args) > 1:
409             adminpw = args[1]
410         else:
411             adminpw = ''
412             confirm = 'x'
413             while adminpw != confirm:
414                 adminpw = getpass.getpass(_('Admin Password: '))
415                 confirm = getpass.getpass(_('       Confirm: '))
417         # make sure the tracker home is installed
418         if not os.path.exists(tracker_home):
419             raise UsageError, _('Instance home does not exist')%locals()
420         try:
421             tracker = roundup.instance.open(tracker_home)
422         except roundup.instance.TrackerError:
423             raise UsageError, _('Instance has not been installed')%locals()
425         # is there already a database?
426         try:
427             db_exists = tracker.select_db.Database.exists(tracker.config)
428         except AttributeError:
429             # TODO: move this code to exists() static method in every backend
430             db_exists = os.path.exists(os.path.join(tracker_home, 'db'))
431         if db_exists:
432             print _('WARNING: The database is already initialised!')
433             print _('If you re-initialise it, you will lose all the data!')
434             ok = raw_input(_('Erase it? Y/[N]: ')).strip()
435             if ok.lower() != 'y':
436                 return 0
438             # Get a database backend in use by tracker
439             try:
440                 # nuke it
441                 tracker.select_db.Database.nuke(tracker.config)
442             except AttributeError:
443                 # TODO: move this code to nuke() static method in every backend
444                 shutil.rmtree(os.path.join(tracker_home, 'db'))
446         # GO
447         init.initialise(tracker_home, adminpw)
449         return 0
452     def do_get(self, args):
453         '''Usage: get property designator[,designator]*
454         Get the given property of one or more designator(s).
456         Retrieves the property value of the nodes specified by the designators.
457         '''
458         if len(args) < 2:
459             raise UsageError, _('Not enough arguments supplied')
460         propname = args[0]
461         designators = args[1].split(',')
462         l = []
463         for designator in designators:
464             # decode the node designator
465             try:
466                 classname, nodeid = hyperdb.splitDesignator(designator)
467             except hyperdb.DesignatorError, message:
468                 raise UsageError, message
470             # get the class
471             cl = self.get_class(classname)
472             try:
473                 id=[]
474                 if self.separator:
475                     if self.print_designator:
476                         # see if property is a link or multilink for
477                         # which getting a desginator make sense.
478                         # Algorithm: Get the properties of the
479                         #     current designator's class. (cl.getprops)
480                         # get the property object for the property the
481                         #     user requested (properties[propname])
482                         # verify its type (isinstance...)
483                         # raise error if not link/multilink
484                         # get class name for link/multilink property
485                         # do the get on the designators
486                         # append the new designators
487                         # print
488                         properties = cl.getprops()
489                         property = properties[propname]
490                         if not (isinstance(property, hyperdb.Multilink) or
491                           isinstance(property, hyperdb.Link)):
492                             raise UsageError, _('property %s is not of type Multilink or Link so -d flag does not apply.')%propname
493                         propclassname = self.db.getclass(property.classname).classname
494                         id = cl.get(nodeid, propname)
495                         for i in id:
496                             l.append(propclassname + i)
497                     else:
498                         id = cl.get(nodeid, propname)
499                         for i in id:
500                             l.append(i)
501                 else:
502                     if self.print_designator:
503                         properties = cl.getprops()
504                         property = properties[propname]
505                         if not (isinstance(property, hyperdb.Multilink) or
506                           isinstance(property, hyperdb.Link)):
507                             raise UsageError, _('property %s is not of type Multilink or Link so -d flag does not apply.')%propname
508                         propclassname = self.db.getclass(property.classname).classname
509                         id = cl.get(nodeid, propname)
510                         for i in id:
511                             print propclassname + i
512                     else:
513                         print cl.get(nodeid, propname)
514             except IndexError:
515                 raise UsageError, _('no such %(classname)s node "%(nodeid)s"')%locals()
516             except KeyError:
517                 raise UsageError, _('no such %(classname)s property '
518                     '"%(propname)s"')%locals()
519         if self.separator:
520             print self.separator.join(l)
522         return 0
525     def do_set(self, args, pwre = re.compile(r'{(\w+)}(.+)')):
526         '''Usage: set items property=value property=value ...
527         Set the given properties of one or more items(s).
529         The items are specified as a class or as a comma-separated
530         list of item designators (ie "designator[,designator,...]").
532         This command sets the properties to the values for all designators
533         given. If the value is missing (ie. "property=") then the property is
534         un-set. If the property is a multilink, you specify the linked ids
535         for the multilink as comma-separated numbers (ie "1,2,3").
536         '''
537         if len(args) < 2:
538             raise UsageError, _('Not enough arguments supplied')
539         from roundup import hyperdb
541         designators = args[0].split(',')
542         if len(designators) == 1:
543             designator = designators[0]
544             try:
545                 designator = hyperdb.splitDesignator(designator)
546                 designators = [designator]
547             except hyperdb.DesignatorError:
548                 cl = self.get_class(designator)
549                 designators = [(designator, x) for x in cl.list()]
550         else:
551             try:
552                 designators = [hyperdb.splitDesignator(x) for x in designators]
553             except hyperdb.DesignatorError, message:
554                 raise UsageError, message
556         # get the props from the args
557         props = self.props_from_args(args[1:])
559         # now do the set for all the nodes
560         for classname, itemid in designators:
561             cl = self.get_class(classname)
563             properties = cl.getprops()
564             for key, value in props.items():
565                 proptype =  properties[key]
566                 if isinstance(proptype, hyperdb.Multilink):
567                     if value is None:
568                         props[key] = []
569                     else:
570                         props[key] = value.split(',')
571                 elif value is None:
572                     continue
573                 elif isinstance(proptype, hyperdb.String):
574                     continue
575                 elif isinstance(proptype, hyperdb.Password):
576                     m = pwre.match(value)
577                     if m:
578                         # password is being given to us encrypted
579                         p = password.Password()
580                         p.scheme = m.group(1)
581                         p.password = m.group(2)
582                         props[key] = p
583                     else:
584                         props[key] = password.Password(value)
585                 elif isinstance(proptype, hyperdb.Date):
586                     try:
587                         props[key] = date.Date(value)
588                     except ValueError, message:
589                         raise UsageError, '"%s": %s'%(value, message)
590                 elif isinstance(proptype, hyperdb.Interval):
591                     try:
592                         props[key] = date.Interval(value)
593                     except ValueError, message:
594                         raise UsageError, '"%s": %s'%(value, message)
595                 elif isinstance(proptype, hyperdb.Link):
596                     props[key] = value
597                 elif isinstance(proptype, hyperdb.Boolean):
598                     props[key] = value.lower() in ('yes', 'true', 'on', '1')
599                 elif isinstance(proptype, hyperdb.Number):
600                     props[key] = float(value)
602             # try the set
603             try:
604                 apply(cl.set, (itemid, ), props)
605             except (TypeError, IndexError, ValueError), message:
606                 import traceback; traceback.print_exc()
607                 raise UsageError, message
608         return 0
610     def do_find(self, args):
611         '''Usage: find classname propname=value ...
612         Find the nodes of the given class with a given link property value.
614         Find the nodes of the given class with a given link property value. The
615         value may be either the nodeid of the linked node, or its key value.
616         '''
617         if len(args) < 1:
618             raise UsageError, _('Not enough arguments supplied')
619         classname = args[0]
620         # get the class
621         cl = self.get_class(classname)
623         # handle the propname=value argument
624         props = self.props_from_args(args[1:])
626         # if the value isn't a number, look up the linked class to get the
627         # number
628         for propname, value in props.items():
629             num_re = re.compile('^\d+$')
630             if value == '-1':
631                 props[propname] = None
632             elif not num_re.match(value):
633                 # get the property
634                 try:
635                     property = cl.properties[propname]
636                 except KeyError:
637                     raise UsageError, _('%(classname)s has no property '
638                         '"%(propname)s"')%locals()
640                 # make sure it's a link
641                 if (not isinstance(property, hyperdb.Link) and not
642                         isinstance(property, hyperdb.Multilink)):
643                     raise UsageError, _('You may only "find" link properties')
645                 # get the linked-to class and look up the key property
646                 link_class = self.db.getclass(property.classname)
647                 try:
648                     props[propname] = link_class.lookup(value)
649                 except TypeError:
650                     raise UsageError, _('%(classname)s has no key property"')%{
651                         'classname': link_class.classname}
653         # now do the find 
654         try:
655             id = []
656             designator = []
657             if self.separator:
658                 if self.print_designator:
659                     id=apply(cl.find, (), props)
660                     for i in id:
661                         designator.append(classname + i)
662                     print self.separator.join(designator)
663                 else:
664                     print self.separator.join(apply(cl.find, (), props))
666             else:
667                 if self.print_designator:
668                     id=apply(cl.find, (), props)
669                     for i in id:
670                         designator.append(classname + i)
671                     print designator
672                 else:
673                     print apply(cl.find, (), props)
674         except KeyError:
675             raise UsageError, _('%(classname)s has no property '
676                 '"%(propname)s"')%locals()
677         except (ValueError, TypeError), message:
678             raise UsageError, message
679         return 0
681     def do_specification(self, args):
682         '''Usage: specification classname
683         Show the properties for a classname.
685         This lists the properties for a given class.
686         '''
687         if len(args) < 1:
688             raise UsageError, _('Not enough arguments supplied')
689         classname = args[0]
690         # get the class
691         cl = self.get_class(classname)
693         # get the key property
694         keyprop = cl.getkey()
695         for key, value in cl.properties.items():
696             if keyprop == key:
697                 print _('%(key)s: %(value)s (key property)')%locals()
698             else:
699                 print _('%(key)s: %(value)s')%locals()
701     def do_display(self, args):
702         '''Usage: display designator[,designator]*
703         Show the property values for the given node(s).
705         This lists the properties and their associated values for the given
706         node.
707         '''
708         if len(args) < 1:
709             raise UsageError, _('Not enough arguments supplied')
711         # decode the node designator
712         for designator in args[0].split(','):
713             try:
714                 classname, nodeid = hyperdb.splitDesignator(designator)
715             except hyperdb.DesignatorError, message:
716                 raise UsageError, message
718             # get the class
719             cl = self.get_class(classname)
721             # display the values
722             keys = cl.properties.keys()
723             keys.sort()
724             for key in keys:
725                 value = cl.get(nodeid, key)
726                 print _('%(key)s: %(value)s')%locals()
728     def do_create(self, args, pwre = re.compile(r'{(\w+)}(.+)')):
729         '''Usage: create classname property=value ...
730         Create a new entry of a given class.
732         This creates a new entry of the given class using the property
733         name=value arguments provided on the command line after the "create"
734         command.
735         '''
736         if len(args) < 1:
737             raise UsageError, _('Not enough arguments supplied')
738         from roundup import hyperdb
740         classname = args[0]
742         # get the class
743         cl = self.get_class(classname)
745         # now do a create
746         props = {}
747         properties = cl.getprops(protected = 0)
748         if len(args) == 1:
749             # ask for the properties
750             for key, value in properties.items():
751                 if key == 'id': continue
752                 name = value.__class__.__name__
753                 if isinstance(value , hyperdb.Password):
754                     again = None
755                     while value != again:
756                         value = getpass.getpass(_('%(propname)s (Password): ')%{
757                             'propname': key.capitalize()})
758                         again = getpass.getpass(_('   %(propname)s (Again): ')%{
759                             'propname': key.capitalize()})
760                         if value != again: print _('Sorry, try again...')
761                     if value:
762                         props[key] = value
763                 else:
764                     value = raw_input(_('%(propname)s (%(proptype)s): ')%{
765                         'propname': key.capitalize(), 'proptype': name})
766                     if value:
767                         props[key] = value
768         else:
769             props = self.props_from_args(args[1:])
771         # convert types
772         for propname, value in props.items():
773             # get the property
774             try:
775                 proptype = properties[propname]
776             except KeyError:
777                 raise UsageError, _('%(classname)s has no property '
778                     '"%(propname)s"')%locals()
780             if isinstance(proptype, hyperdb.Date):
781                 try:
782                     props[propname] = date.Date(value)
783                 except ValueError, message:
784                     raise UsageError, _('"%(value)s": %(message)s')%locals()
785             elif isinstance(proptype, hyperdb.Interval):
786                 try:
787                     props[propname] = date.Interval(value)
788                 except ValueError, message:
789                     raise UsageError, _('"%(value)s": %(message)s')%locals()
790             elif isinstance(proptype, hyperdb.Password):
791                 m = pwre.match(value)
792                 if m:
793                     # password is being given to us encrypted
794                     p = password.Password()
795                     p.scheme = m.group(1)
796                     p.password = m.group(2)
797                     props[propname] = p
798                 else:
799                     props[propname] = password.Password(value)
800             elif isinstance(proptype, hyperdb.Multilink):
801                 props[propname] = value.split(',')
802             elif isinstance(proptype, hyperdb.Boolean):
803                 props[propname] = value.lower() in ('yes', 'true', 'on', '1')
804             elif isinstance(proptype, hyperdb.Number):
805                 props[propname] = float(value)
807         # check for the key property
808         propname = cl.getkey()
809         if propname and not props.has_key(propname):
810             raise UsageError, _('you must provide the "%(propname)s" '
811                 'property.')%locals()
813         # do the actual create
814         try:
815             print apply(cl.create, (), props)
816         except (TypeError, IndexError, ValueError), message:
817             raise UsageError, message
818         return 0
820     def do_list(self, args):
821         '''Usage: list classname [property]
822         List the instances of a class.
824         Lists all instances of the given class. If the property is not
825         specified, the  "label" property is used. The label property is tried
826         in order: the key, "name", "title" and then the first property,
827         alphabetically.
829         With -c, -S or -s print a list of item id's if no property specified.
830         If property specified, print list of that property for every class
831         instance.
832         '''
833         if len(args) > 2:
834             raise UsageError, _('Too many arguments supplied')
835         if len(args) < 1:
836             raise UsageError, _('Not enough arguments supplied')
837         classname = args[0]
839         # get the class
840         cl = self.get_class(classname)
842         # figure the property
843         if len(args) > 1:
844             propname = args[1]
845         else:
846             propname = cl.labelprop()
848         if self.separator:
849             if len(args) == 2:
850                # create a list of propnames since user specified propname
851                 proplist=[]
852                 for nodeid in cl.list():
853                     try:
854                         proplist.append(cl.get(nodeid, propname))
855                     except KeyError:
856                         raise UsageError, _('%(classname)s has no property '
857                             '"%(propname)s"')%locals()
858                 print self.separator.join(proplist)
859             else:
860                 # create a list of index id's since user didn't specify
861                 # otherwise
862                 print self.separator.join(cl.list())
863         else:
864             for nodeid in cl.list():
865                 try:
866                     value = cl.get(nodeid, propname)
867                 except KeyError:
868                     raise UsageError, _('%(classname)s has no property '
869                         '"%(propname)s"')%locals()
870                 print _('%(nodeid)4s: %(value)s')%locals()
871         return 0
873     def do_table(self, args):
874         '''Usage: table classname [property[,property]*]
875         List the instances of a class in tabular form.
877         Lists all instances of the given class. If the properties are not
878         specified, all properties are displayed. By default, the column widths
879         are the width of the largest value. The width may be explicitly defined
880         by defining the property as "name:width". For example::
881           roundup> table priority id,name:10
882           Id Name
883           1  fatal-bug 
884           2  bug       
885           3  usability 
886           4  feature   
888         Also to make the width of the column the width of the label,
889         leave a trailing : without a width on the property. E.G.
890           roundup> table priority id,name:
891           Id Name
892           1  fata
893           2  bug       
894           3  usab
895           4  feat
897         will result in a the 4 character wide "Name" column.
898         '''
899         if len(args) < 1:
900             raise UsageError, _('Not enough arguments supplied')
901         classname = args[0]
903         # get the class
904         cl = self.get_class(classname)
906         # figure the property names to display
907         if len(args) > 1:
908             prop_names = args[1].split(',')
909             all_props = cl.getprops()
910             for spec in prop_names:
911                 if ':' in spec:
912                     try:
913                         propname, width = spec.split(':')
914                     except (ValueError, TypeError):
915                         raise UsageError, _('"%(spec)s" not name:width')%locals()
916                 else:
917                     propname = spec
918                 if not all_props.has_key(propname):
919                     raise UsageError, _('%(classname)s has no property '
920                         '"%(propname)s"')%locals()
921         else:
922             prop_names = cl.getprops().keys()
924         # now figure column widths
925         props = []
926         for spec in prop_names:
927             if ':' in spec:
928                 name, width = spec.split(':')
929                 if width == '':
930                     props.append((name, len(spec)))
931                 else:
932                     props.append((name, int(width)))
933             else:
934                # this is going to be slow
935                maxlen = len(spec)
936                for nodeid in cl.list():
937                    curlen = len(str(cl.get(nodeid, spec)))
938                    if curlen > maxlen:
939                        maxlen = curlen
940                props.append((spec, maxlen))
941                
942         # now display the heading
943         print ' '.join([name.capitalize().ljust(width) for name,width in props])
945         # and the table data
946         for nodeid in cl.list():
947             l = []
948             for name, width in props:
949                 if name != 'id':
950                     try:
951                         value = str(cl.get(nodeid, name))
952                     except KeyError:
953                         # we already checked if the property is valid - a
954                         # KeyError here means the node just doesn't have a
955                         # value for it
956                         value = ''
957                 else:
958                     value = str(nodeid)
959                 f = '%%-%ds'%width
960                 l.append(f%value[:width])
961             print ' '.join(l)
962         return 0
964     def do_history(self, args):
965         '''Usage: history designator
966         Show the history entries of a designator.
968         Lists the journal entries for the node identified by the designator.
969         '''
970         if len(args) < 1:
971             raise UsageError, _('Not enough arguments supplied')
972         try:
973             classname, nodeid = hyperdb.splitDesignator(args[0])
974         except hyperdb.DesignatorError, message:
975             raise UsageError, message
977         try:
978             print self.db.getclass(classname).history(nodeid)
979         except KeyError:
980             raise UsageError, _('no such class "%(classname)s"')%locals()
981         except IndexError:
982             raise UsageError, _('no such %(classname)s node "%(nodeid)s"')%locals()
983         return 0
985     def do_commit(self, args):
986         '''Usage: commit
987         Commit all changes made to the database.
989         The changes made during an interactive session are not
990         automatically written to the database - they must be committed
991         using this command.
993         One-off commands on the command-line are automatically committed if
994         they are successful.
995         '''
996         self.db.commit()
997         return 0
999     def do_rollback(self, args):
1000         '''Usage: rollback
1001         Undo all changes that are pending commit to the database.
1003         The changes made during an interactive session are not
1004         automatically written to the database - they must be committed
1005         manually. This command undoes all those changes, so a commit
1006         immediately after would make no changes to the database.
1007         '''
1008         self.db.rollback()
1009         return 0
1011     def do_retire(self, args):
1012         '''Usage: retire designator[,designator]*
1013         Retire the node specified by designator.
1015         This action indicates that a particular node is not to be retrieved by
1016         the list or find commands, and its key value may be re-used.
1017         '''
1018         if len(args) < 1:
1019             raise UsageError, _('Not enough arguments supplied')
1020         designators = args[0].split(',')
1021         for designator in designators:
1022             try:
1023                 classname, nodeid = hyperdb.splitDesignator(designator)
1024             except hyperdb.DesignatorError, message:
1025                 raise UsageError, message
1026             try:
1027                 self.db.getclass(classname).retire(nodeid)
1028             except KeyError:
1029                 raise UsageError, _('no such class "%(classname)s"')%locals()
1030             except IndexError:
1031                 raise UsageError, _('no such %(classname)s node "%(nodeid)s"')%locals()
1032         return 0
1034     def do_restore(self, args):
1035         '''Usage: restore designator[,designator]*
1036         Restore the retired node specified by designator.
1038         The given nodes will become available for users again.
1039         '''
1040         if len(args) < 1:
1041             raise UsageError, _('Not enough arguments supplied')
1042         designators = args[0].split(',')
1043         for designator in designators:
1044             try:
1045                 classname, nodeid = hyperdb.splitDesignator(designator)
1046             except hyperdb.DesignatorError, message:
1047                 raise UsageError, message
1048             try:
1049                 self.db.getclass(classname).restore(nodeid)
1050             except KeyError:
1051                 raise UsageError, _('no such class "%(classname)s"')%locals()
1052             except IndexError:
1053                 raise UsageError, _('no such %(classname)s node "%(nodeid)s"')%locals()
1054         return 0
1056     def do_export(self, args):
1057         '''Usage: export [class[,class]] export_dir
1058         Export the database to colon-separated-value files.
1060         This action exports the current data from the database into
1061         colon-separated-value files that are placed in the nominated
1062         destination directory. The journals are not exported.
1063         '''
1064         # grab the directory to export to
1065         if len(args) < 1:
1066             raise UsageError, _('Not enough arguments supplied')
1067         if rcsv.error:
1068             raise UsageError, _(rcsv.error)
1070         dir = args[-1]
1072         # get the list of classes to export
1073         if len(args) == 2:
1074             classes = args[0].split(',')
1075         else:
1076             classes = self.db.classes.keys()
1078         # do all the classes specified
1079         for classname in classes:
1080             cl = self.get_class(classname)
1081             f = open(os.path.join(dir, classname+'.csv'), 'w')
1082             writer = rcsv.writer(f, rcsv.colon_separated)
1083             properties = cl.getprops()
1084             propnames = properties.keys()
1085             propnames.sort()
1086             fields = propnames[:]
1087             fields.append('is retired')
1088             writer.writerow(fields)
1090             # all nodes for this class (not using list() 'cos it doesn't
1091             # include retired nodes)
1093             for nodeid in self.db.getclass(classname).getnodeids():
1094                 # get the regular props
1095                 writer.writerow (cl.export_list(propnames, nodeid))
1097             # close this file
1098             f.close()
1099         return 0
1101     def do_import(self, args):
1102         '''Usage: import import_dir
1103         Import a database from the directory containing CSV files, one per
1104         class to import.
1106         The files must define the same properties as the class (including having
1107         a "header" line with those property names.)
1109         The imported nodes will have the same nodeid as defined in the
1110         import file, thus replacing any existing content.
1112         The new nodes are added to the existing database - if you want to
1113         create a new database using the imported data, then create a new
1114         database (or, tediously, retire all the old data.)
1115         '''
1116         if len(args) < 1:
1117             raise UsageError, _('Not enough arguments supplied')
1118         if rcsv.error:
1119             raise UsageError, _(rcsv.error)
1120         from roundup import hyperdb
1122         for file in os.listdir(args[0]):
1123             # we only care about CSV files
1124             if not file.endswith('.csv'):
1125                 continue
1127             f = open(os.path.join(args[0], file))
1129             # get the classname
1130             classname = os.path.splitext(file)[0]
1132             # ensure that the properties and the CSV file headings match
1133             cl = self.get_class(classname)
1134             reader = rcsv.reader(f, rcsv.colon_separated)
1135             file_props = None
1136             maxid = 1
1138             # loop through the file and create a node for each entry
1139             for r in reader:
1140                 if file_props is None:
1141                     file_props = r
1142                     continue
1144                 # do the import and figure the current highest nodeid
1145                 maxid = max(maxid, int(cl.import_list(file_props, r)))
1147             # set the id counter
1148             print 'setting', classname, maxid+1
1149             self.db.setid(classname, str(maxid+1))
1150         return 0
1152     def do_pack(self, args):
1153         '''Usage: pack period | date
1155 Remove journal entries older than a period of time specified or
1156 before a certain date.
1158 A period is specified using the suffixes "y", "m", and "d". The
1159 suffix "w" (for "week") means 7 days.
1161       "3y" means three years
1162       "2y 1m" means two years and one month
1163       "1m 25d" means one month and 25 days
1164       "2w 3d" means two weeks and three days
1166 Date format is "YYYY-MM-DD" eg:
1167     2001-01-01
1168     
1169         '''
1170         if len(args) <> 1:
1171             raise UsageError, _('Not enough arguments supplied')
1172         
1173         # are we dealing with a period or a date
1174         value = args[0]
1175         date_re = re.compile(r'''
1176               (?P<date>\d\d\d\d-\d\d?-\d\d?)? # yyyy-mm-dd
1177               (?P<period>(\d+y\s*)?(\d+m\s*)?(\d+d\s*)?)?
1178               ''', re.VERBOSE)
1179         m = date_re.match(value)
1180         if not m:
1181             raise ValueError, _('Invalid format')
1182         m = m.groupdict()
1183         if m['period']:
1184             pack_before = date.Date(". - %s"%value)
1185         elif m['date']:
1186             pack_before = date.Date(value)
1187         self.db.pack(pack_before)
1188         return 0
1190     def do_reindex(self, args):
1191         '''Usage: reindex
1192         Re-generate a tracker's search indexes.
1194         This will re-generate the search indexes for a tracker. This will
1195         typically happen automatically.
1196         '''
1197         self.db.indexer.force_reindex()
1198         self.db.reindex()
1199         return 0
1201     def do_security(self, args):
1202         '''Usage: security [Role name]
1203         Display the Permissions available to one or all Roles.
1204         '''
1205         if len(args) == 1:
1206             role = args[0]
1207             try:
1208                 roles = [(args[0], self.db.security.role[args[0]])]
1209             except KeyError:
1210                 print _('No such Role "%(role)s"')%locals()
1211                 return 1
1212         else:
1213             roles = self.db.security.role.items()
1214             role = self.db.config.NEW_WEB_USER_ROLES
1215             if ',' in role:
1216                 print _('New Web users get the Roles "%(role)s"')%locals()
1217             else:
1218                 print _('New Web users get the Role "%(role)s"')%locals()
1219             role = self.db.config.NEW_EMAIL_USER_ROLES
1220             if ',' in role:
1221                 print _('New Email users get the Roles "%(role)s"')%locals()
1222             else:
1223                 print _('New Email users get the Role "%(role)s"')%locals()
1224         roles.sort()
1225         for rolename, role in roles:
1226             print _('Role "%(name)s":')%role.__dict__
1227             for permission in role.permissions:
1228                 if permission.klass:
1229                     print _(' %(description)s (%(name)s for "%(klass)s" '
1230                         'only)')%permission.__dict__
1231                 else:
1232                     print _(' %(description)s (%(name)s)')%permission.__dict__
1233         return 0
1235     def run_command(self, args):
1236         '''Run a single command
1237         '''
1238         command = args[0]
1240         # handle help now
1241         if command == 'help':
1242             if len(args)>1:
1243                 self.do_help(args[1:])
1244                 return 0
1245             self.do_help(['help'])
1246             return 0
1247         if command == 'morehelp':
1248             self.do_help(['help'])
1249             self.help_commands()
1250             self.help_all()
1251             return 0
1253         # figure what the command is
1254         try:
1255             functions = self.commands.get(command)
1256         except KeyError:
1257             # not a valid command
1258             print _('Unknown command "%(command)s" ("help commands" for a '
1259                 'list)')%locals()
1260             return 1
1262         # check for multiple matches
1263         if len(functions) > 1:
1264             print _('Multiple commands match "%(command)s": %(list)s')%{'command':
1265                 command, 'list': ', '.join([i[0] for i in functions])}
1266             return 1
1267         command, function = functions[0]
1269         # make sure we have a tracker_home
1270         while not self.tracker_home:
1271             self.tracker_home = raw_input(_('Enter tracker home: ')).strip()
1273         # before we open the db, we may be doing an install or init
1274         if command == 'initialise':
1275             try:
1276                 return self.do_initialise(self.tracker_home, args)
1277             except UsageError, message:
1278                 print _('Error: %(message)s')%locals()
1279                 return 1
1280         elif command == 'install':
1281             try:
1282                 return self.do_install(self.tracker_home, args)
1283             except UsageError, message:
1284                 print _('Error: %(message)s')%locals()
1285                 return 1
1287         # get the tracker
1288         try:
1289             tracker = roundup.instance.open(self.tracker_home)
1290         except ValueError, message:
1291             self.tracker_home = ''
1292             print _("Error: Couldn't open tracker: %(message)s")%locals()
1293             return 1
1295         # only open the database once!
1296         if not self.db:
1297             self.db = tracker.open('admin')
1299         # do the command
1300         ret = 0
1301         try:
1302             ret = function(args[1:])
1303         except UsageError, message:
1304             print _('Error: %(message)s')%locals()
1305             print
1306             print function.__doc__
1307             ret = 1
1308         except:
1309             import traceback
1310             traceback.print_exc()
1311             ret = 1
1312         return ret
1314     def interactive(self):
1315         '''Run in an interactive mode
1316         '''
1317         print _('Roundup %s ready for input.'%roundup_version)
1318         print _('Type "help" for help.')
1319         try:
1320             import readline
1321         except ImportError:
1322             print _('Note: command history and editing not available')
1324         while 1:
1325             try:
1326                 command = raw_input(_('roundup> '))
1327             except EOFError:
1328                 print _('exit...')
1329                 break
1330             if not command: continue
1331             args = token.token_split(command)
1332             if not args: continue
1333             if args[0] in ('quit', 'exit'): break
1334             self.run_command(args)
1336         # exit.. check for transactions
1337         if self.db and self.db.transactions:
1338             commit = raw_input(_('There are unsaved changes. Commit them (y/N)? '))
1339             if commit and commit[0].lower() == 'y':
1340                 self.db.commit()
1341         return 0
1343     def main(self):
1344         try:
1345             opts, args = getopt.getopt(sys.argv[1:], 'i:u:hcdsS:')
1346         except getopt.GetoptError, e:
1347             self.usage(str(e))
1348             return 1
1350         # handle command-line args
1351         self.tracker_home = os.environ.get('TRACKER_HOME', '')
1352         # TODO: reinstate the user/password stuff (-u arg too)
1353         name = password = ''
1354         if os.environ.has_key('ROUNDUP_LOGIN'):
1355             l = os.environ['ROUNDUP_LOGIN'].split(':')
1356             name = l[0]
1357             if len(l) > 1:
1358                 password = l[1]
1359         self.separator = None
1360         self.print_designator = 0
1361         for opt, arg in opts:
1362             if opt == '-h':
1363                 self.usage()
1364                 return 0
1365             if opt == '-i':
1366                 self.tracker_home = arg
1367             if opt == '-c':
1368                 if self.separator != None:
1369                     self.usage('Only one of -c, -S and -s may be specified')
1370                     return 1
1371                 self.separator = ','
1372             if opt == '-S':
1373                 if self.separator != None:
1374                     self.usage('Only one of -c, -S and -s may be specified')
1375                     return 1
1376                 self.separator = arg
1377             if opt == '-s':
1378                 if self.separator != None:
1379                     self.usage('Only one of -c, -S and -s may be specified')
1380                     return 1
1381                 self.separator = ' '
1382             if opt == '-d':
1383                 self.print_designator = 1
1385         # if no command - go interactive
1386         # wrap in a try/finally so we always close off the db
1387         ret = 0
1388         try:
1389             if not args:
1390                 self.interactive()
1391             else:
1392                 ret = self.run_command(args)
1393                 if self.db: self.db.commit()
1394             return ret
1395         finally:
1396             if self.db:
1397                 self.db.close()
1400 def listTemplates(dir):
1401     ''' List all the Roundup template directories in a given directory.
1403         Find all the dirs that contain a TEMPLATE-INFO.txt and parse it.
1405         Return a list of dicts of info about the templates.
1406     '''
1407     ret = {}
1408     for idir in os.listdir(dir):
1409         idir = os.path.join(dir, idir)
1410         ti = loadTemplate(idir)
1411         if ti:
1412             ret[ti['name']] = ti
1413     return ret
1415 def loadTemplate(dir):
1416     ''' Attempt to load a Roundup template from the indicated directory.
1418         Return None if there's no template, otherwise a template info
1419         dictionary.
1420     '''
1421     ti = os.path.join(dir, 'TEMPLATE-INFO.txt')
1422     if not os.path.exists(ti):
1423         return None
1425     # load up the template's information
1426     m = rfc822.Message(open(ti))
1427     ti = {}
1428     ti['name'] = m['name']
1429     ti['description'] = m['description']
1430     ti['intended-for'] = m['intended-for']
1431     ti['path'] = dir
1432     return ti
1434 if __name__ == '__main__':
1435     tool = AdminTool()
1436     sys.exit(tool.main())
1438 # vim: set filetype=python ts=4 sw=4 et si