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