Code

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