Code

extended date syntax to make range searches even more useful
[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.52 2003-04-17 07:33:08 richard Exp $
21 '''Administration commands for maintaining Roundup trackers.
22 '''
24 import sys, os, getpass, getopt, re, UserDict, shlex, 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_ /usr/lib/python2.2/site-packages/roundup/admin.py, and
293         # we're interested in where the "lib" directory is - ie. the /usr/
294         # part
295         path = __file__
296         for i in range(5):
297             path = os.path.dirname(path)
298         tdir = os.path.join(path, 'share', 'roundup', 'templates')
299         if os.path.isdir(tdir):
300             templates = listTemplates(tdir)
301         else:
302             templates = {}
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             for key in cl.properties.keys():
723                 value = cl.get(nodeid, key)
724                 print _('%(key)s: %(value)s')%locals()
726     def do_create(self, args, pwre = re.compile(r'{(\w+)}(.+)')):
727         '''Usage: create classname property=value ...
728         Create a new entry of a given class.
730         This creates a new entry of the given class using the property
731         name=value arguments provided on the command line after the "create"
732         command.
733         '''
734         if len(args) < 1:
735             raise UsageError, _('Not enough arguments supplied')
736         from roundup import hyperdb
738         classname = args[0]
740         # get the class
741         cl = self.get_class(classname)
743         # now do a create
744         props = {}
745         properties = cl.getprops(protected = 0)
746         if len(args) == 1:
747             # ask for the properties
748             for key, value in properties.items():
749                 if key == 'id': continue
750                 name = value.__class__.__name__
751                 if isinstance(value , hyperdb.Password):
752                     again = None
753                     while value != again:
754                         value = getpass.getpass(_('%(propname)s (Password): ')%{
755                             'propname': key.capitalize()})
756                         again = getpass.getpass(_('   %(propname)s (Again): ')%{
757                             'propname': key.capitalize()})
758                         if value != again: print _('Sorry, try again...')
759                     if value:
760                         props[key] = value
761                 else:
762                     value = raw_input(_('%(propname)s (%(proptype)s): ')%{
763                         'propname': key.capitalize(), 'proptype': name})
764                     if value:
765                         props[key] = value
766         else:
767             props = self.props_from_args(args[1:])
769         # convert types
770         for propname, value in props.items():
771             # get the property
772             try:
773                 proptype = properties[propname]
774             except KeyError:
775                 raise UsageError, _('%(classname)s has no property '
776                     '"%(propname)s"')%locals()
778             if isinstance(proptype, hyperdb.Date):
779                 try:
780                     props[propname] = date.Date(value)
781                 except ValueError, message:
782                     raise UsageError, _('"%(value)s": %(message)s')%locals()
783             elif isinstance(proptype, hyperdb.Interval):
784                 try:
785                     props[propname] = date.Interval(value)
786                 except ValueError, message:
787                     raise UsageError, _('"%(value)s": %(message)s')%locals()
788             elif isinstance(proptype, hyperdb.Password):
789                 m = pwre.match(value)
790                 if m:
791                     # password is being given to us encrypted
792                     p = password.Password()
793                     p.scheme = m.group(1)
794                     p.password = m.group(2)
795                     props[propname] = p
796                 else:
797                     props[propname] = password.Password(value)
798             elif isinstance(proptype, hyperdb.Multilink):
799                 props[propname] = value.split(',')
800             elif isinstance(proptype, hyperdb.Boolean):
801                 props[propname] = value.lower() in ('yes', 'true', 'on', '1')
802             elif isinstance(proptype, hyperdb.Number):
803                 props[propname] = float(value)
805         # check for the key property
806         propname = cl.getkey()
807         if propname and not props.has_key(propname):
808             raise UsageError, _('you must provide the "%(propname)s" '
809                 'property.')%locals()
811         # do the actual create
812         try:
813             print apply(cl.create, (), props)
814         except (TypeError, IndexError, ValueError), message:
815             raise UsageError, message
816         return 0
818     def do_list(self, args):
819         '''Usage: list classname [property]
820         List the instances of a class.
822         Lists all instances of the given class. If the property is not
823         specified, the  "label" property is used. The label property is tried
824         in order: the key, "name", "title" and then the first property,
825         alphabetically.
827         With -c, -S or -s print a list of item id's if no property specified.
828         If property specified, print list of that property for every class
829         instance.
830         '''
831         if len(args) > 2:
832             raise UsageError, _('Too many arguments supplied')
833         if len(args) < 1:
834             raise UsageError, _('Not enough arguments supplied')
835         classname = args[0]
837         # get the class
838         cl = self.get_class(classname)
840         # figure the property
841         if len(args) > 1:
842             propname = args[1]
843         else:
844             propname = cl.labelprop()
846         if self.separator:
847             if len(args) == 2:
848                # create a list of propnames since user specified propname
849                 proplist=[]
850                 for nodeid in cl.list():
851                     try:
852                         proplist.append(cl.get(nodeid, propname))
853                     except KeyError:
854                         raise UsageError, _('%(classname)s has no property '
855                             '"%(propname)s"')%locals()
856                 print self.separator.join(proplist)
857             else:
858                 # create a list of index id's since user didn't specify
859                 # otherwise
860                 print self.separator.join(cl.list())
861         else:
862             for nodeid in cl.list():
863                 try:
864                     value = cl.get(nodeid, propname)
865                 except KeyError:
866                     raise UsageError, _('%(classname)s has no property '
867                         '"%(propname)s"')%locals()
868                 print _('%(nodeid)4s: %(value)s')%locals()
869         return 0
871     def do_table(self, args):
872         '''Usage: table classname [property[,property]*]
873         List the instances of a class in tabular form.
875         Lists all instances of the given class. If the properties are not
876         specified, all properties are displayed. By default, the column widths
877         are the width of the largest value. The width may be explicitly defined
878         by defining the property as "name:width". For example::
879           roundup> table priority id,name:10
880           Id Name
881           1  fatal-bug 
882           2  bug       
883           3  usability 
884           4  feature   
886         Also to make the width of the column the width of the label,
887         leave a trailing : without a width on the property. E.G.
888           roundup> table priority id,name:
889           Id Name
890           1  fata
891           2  bug       
892           3  usab
893           4  feat
895         will result in a the 4 character wide "Name" column.
896         '''
897         if len(args) < 1:
898             raise UsageError, _('Not enough arguments supplied')
899         classname = args[0]
901         # get the class
902         cl = self.get_class(classname)
904         # figure the property names to display
905         if len(args) > 1:
906             prop_names = args[1].split(',')
907             all_props = cl.getprops()
908             for spec in prop_names:
909                 if ':' in spec:
910                     try:
911                         propname, width = spec.split(':')
912                     except (ValueError, TypeError):
913                         raise UsageError, _('"%(spec)s" not name:width')%locals()
914                 else:
915                     propname = spec
916                 if not all_props.has_key(propname):
917                     raise UsageError, _('%(classname)s has no property '
918                         '"%(propname)s"')%locals()
919         else:
920             prop_names = cl.getprops().keys()
922         # now figure column widths
923         props = []
924         for spec in prop_names:
925             if ':' in spec:
926                 name, width = spec.split(':')
927                 if width == '':
928                     props.append((name, len(spec)))
929                 else:
930                     props.append((name, int(width)))
931             else:
932                # this is going to be slow
933                maxlen = len(spec)
934                for nodeid in cl.list():
935                    curlen = len(str(cl.get(nodeid, spec)))
936                    if curlen > maxlen:
937                        maxlen = curlen
938                props.append((spec, maxlen))
939                
940         # now display the heading
941         print ' '.join([name.capitalize().ljust(width) for name,width in props])
943         # and the table data
944         for nodeid in cl.list():
945             l = []
946             for name, width in props:
947                 if name != 'id':
948                     try:
949                         value = str(cl.get(nodeid, name))
950                     except KeyError:
951                         # we already checked if the property is valid - a
952                         # KeyError here means the node just doesn't have a
953                         # value for it
954                         value = ''
955                 else:
956                     value = str(nodeid)
957                 f = '%%-%ds'%width
958                 l.append(f%value[:width])
959             print ' '.join(l)
960         return 0
962     def do_history(self, args):
963         '''Usage: history designator
964         Show the history entries of a designator.
966         Lists the journal entries for the node identified by the designator.
967         '''
968         if len(args) < 1:
969             raise UsageError, _('Not enough arguments supplied')
970         try:
971             classname, nodeid = hyperdb.splitDesignator(args[0])
972         except hyperdb.DesignatorError, message:
973             raise UsageError, message
975         try:
976             print self.db.getclass(classname).history(nodeid)
977         except KeyError:
978             raise UsageError, _('no such class "%(classname)s"')%locals()
979         except IndexError:
980             raise UsageError, _('no such %(classname)s node "%(nodeid)s"')%locals()
981         return 0
983     def do_commit(self, args):
984         '''Usage: commit
985         Commit all changes made to the database.
987         The changes made during an interactive session are not
988         automatically written to the database - they must be committed
989         using this command.
991         One-off commands on the command-line are automatically committed if
992         they are successful.
993         '''
994         self.db.commit()
995         return 0
997     def do_rollback(self, args):
998         '''Usage: rollback
999         Undo all changes that are pending commit to the database.
1001         The changes made during an interactive session are not
1002         automatically written to the database - they must be committed
1003         manually. This command undoes all those changes, so a commit
1004         immediately after would make no changes to the database.
1005         '''
1006         self.db.rollback()
1007         return 0
1009     def do_retire(self, args):
1010         '''Usage: retire designator[,designator]*
1011         Retire the node specified by designator.
1013         This action indicates that a particular node is not to be retrieved by
1014         the list or find commands, and its key value may be re-used.
1015         '''
1016         if len(args) < 1:
1017             raise UsageError, _('Not enough arguments supplied')
1018         designators = args[0].split(',')
1019         for designator in designators:
1020             try:
1021                 classname, nodeid = hyperdb.splitDesignator(designator)
1022             except hyperdb.DesignatorError, message:
1023                 raise UsageError, message
1024             try:
1025                 self.db.getclass(classname).retire(nodeid)
1026             except KeyError:
1027                 raise UsageError, _('no such class "%(classname)s"')%locals()
1028             except IndexError:
1029                 raise UsageError, _('no such %(classname)s node "%(nodeid)s"')%locals()
1030         return 0
1032     def do_restore(self, args):
1033         '''Usage: restore designator[,designator]*
1034         Restore the retired node specified by designator.
1036         The given nodes will become available for users again.
1037         '''
1038         if len(args) < 1:
1039             raise UsageError, _('Not enough arguments supplied')
1040         designators = args[0].split(',')
1041         for designator in designators:
1042             try:
1043                 classname, nodeid = hyperdb.splitDesignator(designator)
1044             except hyperdb.DesignatorError, message:
1045                 raise UsageError, message
1046             try:
1047                 self.db.getclass(classname).restore(nodeid)
1048             except KeyError:
1049                 raise UsageError, _('no such class "%(classname)s"')%locals()
1050             except IndexError:
1051                 raise UsageError, _('no such %(classname)s node "%(nodeid)s"')%locals()
1052         return 0
1054     def do_export(self, args):
1055         '''Usage: export [class[,class]] export_dir
1056         Export the database to colon-separated-value files.
1058         This action exports the current data from the database into
1059         colon-separated-value files that are placed in the nominated
1060         destination directory. The journals are not exported.
1061         '''
1062         # we need the CSV module
1063         if csv is None:
1064             raise UsageError, \
1065                 _('Sorry, you need the csv module to use this function.\n'
1066                 'Get it from: http://www.object-craft.com.au/projects/csv/')
1068         # grab the directory to export to
1069         if len(args) < 1:
1070             raise UsageError, _('Not enough arguments supplied')
1071         dir = args[-1]
1073         # get the list of classes to export
1074         if len(args) == 2:
1075             classes = args[0].split(',')
1076         else:
1077             classes = self.db.classes.keys()
1079         # use the csv parser if we can - it's faster
1080         p = csv.parser(field_sep=':')
1082         # do all the classes specified
1083         for classname in classes:
1084             cl = self.get_class(classname)
1085             f = open(os.path.join(dir, classname+'.csv'), 'w')
1086             properties = cl.getprops()
1087             propnames = properties.keys()
1088             propnames.sort()
1089             l = propnames[:]
1090             l.append('is retired')
1091             print >> f, p.join(l)
1093             # all nodes for this class (not using list() 'cos it doesn't
1094             # include retired nodes)
1096             for nodeid in self.db.getclass(classname).getnodeids():
1097                 # get the regular props
1098                 print >>f, p.join(cl.export_list(propnames, nodeid))
1100             # close this file
1101             f.close()
1102         return 0
1104     def do_import(self, args):
1105         '''Usage: import import_dir
1106         Import a database from the directory containing CSV files, one per
1107         class to import.
1109         The files must define the same properties as the class (including having
1110         a "header" line with those property names.)
1112         The imported nodes will have the same nodeid as defined in the
1113         import file, thus replacing any existing content.
1115         The new nodes are added to the existing database - if you want to
1116         create a new database using the imported data, then create a new
1117         database (or, tediously, retire all the old data.)
1118         '''
1119         if len(args) < 1:
1120             raise UsageError, _('Not enough arguments supplied')
1121         if csv is None:
1122             raise UsageError, \
1123                 _('Sorry, you need the csv module to use this function.\n'
1124                 'Get it from: http://www.object-craft.com.au/projects/csv/')
1126         from roundup import hyperdb
1128         for file in os.listdir(args[0]):
1129             # we only care about CSV files
1130             if not file.endswith('.csv'):
1131                 continue
1133             f = open(os.path.join(args[0], file))
1135             # get the classname
1136             classname = os.path.splitext(file)[0]
1138             # ensure that the properties and the CSV file headings match
1139             cl = self.get_class(classname)
1140             p = csv.parser(field_sep=':')
1141             file_props = p.parse(f.readline())
1143 # XXX we don't _really_ need to do this...
1144 #            properties = cl.getprops()
1145 #            propnames = properties.keys()
1146 #            propnames.sort()
1147 #            m = file_props[:]
1148 #            m.sort()
1149 #            if m != propnames:
1150 #                raise UsageError, _('Import file doesn\'t define the same '
1151 #                    'properties as "%(arg0)s".')%{'arg0': args[0]}
1153             # loop through the file and create a node for each entry
1154             maxid = 1
1155             while 1:
1156                 line = f.readline()
1157                 if not line: break
1159                 # parse lines until we get a complete entry
1160                 while 1:
1161                     l = p.parse(line)
1162                     if l: break
1163                     line = f.readline()
1164                     if not line:
1165                         raise ValueError, "Unexpected EOF during CSV parse"
1167                 # do the import and figure the current highest nodeid
1168                 maxid = max(maxid, int(cl.import_list(file_props, l)))
1170             print 'setting', classname, maxid+1
1171             self.db.setid(classname, str(maxid+1))
1172         return 0
1174     def do_pack(self, args):
1175         '''Usage: pack period | date
1177 Remove journal entries older than a period of time specified or
1178 before a certain date.
1180 A period is specified using the suffixes "y", "m", and "d". The
1181 suffix "w" (for "week") means 7 days.
1183       "3y" means three years
1184       "2y 1m" means two years and one month
1185       "1m 25d" means one month and 25 days
1186       "2w 3d" means two weeks and three days
1188 Date format is "YYYY-MM-DD" eg:
1189     2001-01-01
1190     
1191         '''
1192         if len(args) <> 1:
1193             raise UsageError, _('Not enough arguments supplied')
1194         
1195         # are we dealing with a period or a date
1196         value = args[0]
1197         date_re = re.compile(r'''
1198               (?P<date>\d\d\d\d-\d\d?-\d\d?)? # yyyy-mm-dd
1199               (?P<period>(\d+y\s*)?(\d+m\s*)?(\d+d\s*)?)?
1200               ''', re.VERBOSE)
1201         m = date_re.match(value)
1202         if not m:
1203             raise ValueError, _('Invalid format')
1204         m = m.groupdict()
1205         if m['period']:
1206             pack_before = date.Date(". - %s"%value)
1207         elif m['date']:
1208             pack_before = date.Date(value)
1209         self.db.pack(pack_before)
1210         return 0
1212     def do_reindex(self, args):
1213         '''Usage: reindex
1214         Re-generate a tracker's search indexes.
1216         This will re-generate the search indexes for a tracker. This will
1217         typically happen automatically.
1218         '''
1219         self.db.indexer.force_reindex()
1220         self.db.reindex()
1221         return 0
1223     def do_security(self, args):
1224         '''Usage: security [Role name]
1225         Display the Permissions available to one or all Roles.
1226         '''
1227         if len(args) == 1:
1228             role = args[0]
1229             try:
1230                 roles = [(args[0], self.db.security.role[args[0]])]
1231             except KeyError:
1232                 print _('No such Role "%(role)s"')%locals()
1233                 return 1
1234         else:
1235             roles = self.db.security.role.items()
1236             role = self.db.config.NEW_WEB_USER_ROLES
1237             if ',' in role:
1238                 print _('New Web users get the Roles "%(role)s"')%locals()
1239             else:
1240                 print _('New Web users get the Role "%(role)s"')%locals()
1241             role = self.db.config.NEW_EMAIL_USER_ROLES
1242             if ',' in role:
1243                 print _('New Email users get the Roles "%(role)s"')%locals()
1244             else:
1245                 print _('New Email users get the Role "%(role)s"')%locals()
1246         roles.sort()
1247         for rolename, role in roles:
1248             print _('Role "%(name)s":')%role.__dict__
1249             for permission in role.permissions:
1250                 if permission.klass:
1251                     print _(' %(description)s (%(name)s for "%(klass)s" '
1252                         'only)')%permission.__dict__
1253                 else:
1254                     print _(' %(description)s (%(name)s)')%permission.__dict__
1255         return 0
1257     def run_command(self, args):
1258         '''Run a single command
1259         '''
1260         command = args[0]
1262         # handle help now
1263         if command == 'help':
1264             if len(args)>1:
1265                 self.do_help(args[1:])
1266                 return 0
1267             self.do_help(['help'])
1268             return 0
1269         if command == 'morehelp':
1270             self.do_help(['help'])
1271             self.help_commands()
1272             self.help_all()
1273             return 0
1275         # figure what the command is
1276         try:
1277             functions = self.commands.get(command)
1278         except KeyError:
1279             # not a valid command
1280             print _('Unknown command "%(command)s" ("help commands" for a '
1281                 'list)')%locals()
1282             return 1
1284         # check for multiple matches
1285         if len(functions) > 1:
1286             print _('Multiple commands match "%(command)s": %(list)s')%{'command':
1287                 command, 'list': ', '.join([i[0] for i in functions])}
1288             return 1
1289         command, function = functions[0]
1291         # make sure we have a tracker_home
1292         while not self.tracker_home:
1293             self.tracker_home = raw_input(_('Enter tracker home: ')).strip()
1295         # before we open the db, we may be doing an install or init
1296         if command == 'initialise':
1297             try:
1298                 return self.do_initialise(self.tracker_home, args)
1299             except UsageError, message:
1300                 print _('Error: %(message)s')%locals()
1301                 return 1
1302         elif command == 'install':
1303             try:
1304                 return self.do_install(self.tracker_home, args)
1305             except UsageError, message:
1306                 print _('Error: %(message)s')%locals()
1307                 return 1
1309         # get the tracker
1310         try:
1311             tracker = roundup.instance.open(self.tracker_home)
1312         except ValueError, message:
1313             self.tracker_home = ''
1314             print _("Error: Couldn't open tracker: %(message)s")%locals()
1315             return 1
1317         # only open the database once!
1318         if not self.db:
1319             self.db = tracker.open('admin')
1321         # do the command
1322         ret = 0
1323         try:
1324             ret = function(args[1:])
1325         except UsageError, message:
1326             print _('Error: %(message)s')%locals()
1327             print
1328             print function.__doc__
1329             ret = 1
1330         except:
1331             import traceback
1332             traceback.print_exc()
1333             ret = 1
1334         return ret
1336     def interactive(self):
1337         '''Run in an interactive mode
1338         '''
1339         print _('Roundup %s ready for input.'%roundup_version)
1340         print _('Type "help" for help.')
1341         try:
1342             import readline
1343         except ImportError:
1344             print _('Note: command history and editing not available')
1346         while 1:
1347             try:
1348                 command = raw_input(_('roundup> '))
1349             except EOFError:
1350                 print _('exit...')
1351                 break
1352             if not command: continue
1353             args = token.token_split(command)
1354             if not args: continue
1355             if args[0] in ('quit', 'exit'): break
1356             self.run_command(args)
1358         # exit.. check for transactions
1359         if self.db and self.db.transactions:
1360             commit = raw_input(_('There are unsaved changes. Commit them (y/N)? '))
1361             if commit and commit[0].lower() == 'y':
1362                 self.db.commit()
1363         return 0
1365     def main(self):
1366         try:
1367             opts, args = getopt.getopt(sys.argv[1:], 'i:u:hcdsS:')
1368         except getopt.GetoptError, e:
1369             self.usage(str(e))
1370             return 1
1372         # handle command-line args
1373         self.tracker_home = os.environ.get('TRACKER_HOME', '')
1374         # TODO: reinstate the user/password stuff (-u arg too)
1375         name = password = ''
1376         if os.environ.has_key('ROUNDUP_LOGIN'):
1377             l = os.environ['ROUNDUP_LOGIN'].split(':')
1378             name = l[0]
1379             if len(l) > 1:
1380                 password = l[1]
1381         self.separator = None
1382         self.print_designator = 0
1383         for opt, arg in opts:
1384             if opt == '-h':
1385                 self.usage()
1386                 return 0
1387             if opt == '-i':
1388                 self.tracker_home = arg
1389             if opt == '-c':
1390                 if self.separator != None:
1391                     self.usage('Only one of -c, -S and -s may be specified')
1392                     return 1
1393                 self.separator = ','
1394             if opt == '-S':
1395                 if self.separator != None:
1396                     self.usage('Only one of -c, -S and -s may be specified')
1397                     return 1
1398                 self.separator = arg
1399             if opt == '-s':
1400                 if self.separator != None:
1401                     self.usage('Only one of -c, -S and -s may be specified')
1402                     return 1
1403                 self.separator = ' '
1404             if opt == '-d':
1405                 self.print_designator = 1
1407         # if no command - go interactive
1408         # wrap in a try/finally so we always close off the db
1409         ret = 0
1410         try:
1411             if not args:
1412                 self.interactive()
1413             else:
1414                 ret = self.run_command(args)
1415                 if self.db: self.db.commit()
1416             return ret
1417         finally:
1418             if self.db:
1419                 self.db.close()
1422 def listTemplates(dir):
1423     ''' List all the Roundup template directories in a given directory.
1425         Find all the dirs that contain a TEMPLATE-INFO.txt and parse it.
1427         Return a list of dicts of info about the templates.
1428     '''
1429     ret = {}
1430     for idir in os.listdir(dir):
1431         idir = os.path.join(dir, idir)
1432         ti = loadTemplate(idir)
1433         if ti:
1434             ret[ti['name']] = ti
1435     return ret
1437 def loadTemplate(dir):
1438     ''' Attempt to load a Roundup template from the indicated directory.
1440         Return None if there's no template, otherwise a template info
1441         dictionary.
1442     '''
1443     ti = os.path.join(dir, 'TEMPLATE-INFO.txt')
1444     if not os.path.exists(ti):
1445         return None
1447     # load up the template's information
1448     m = rfc822.Message(open(ti))
1449     ti = {}
1450     ti['name'] = m['name']
1451     ti['description'] = m['description']
1452     ti['intended-for'] = m['intended-for']
1453     ti['path'] = dir
1454     return ti
1456 if __name__ == '__main__':
1457     tool = AdminTool()
1458     sys.exit(tool.main())
1460 # vim: set filetype=python ts=4 sw=4 et si