Code

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