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.68 2004-04-17 01:47:37 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 Optionally limit the export to just the names classes.
1025 This action exports the current data from the database into
1026 colon-separated-value files that are placed in the nominated
1027 destination directory.
1028 '''
1029 # grab the directory to export to
1030 if len(args) < 1:
1031 raise UsageError, _('Not enough arguments supplied')
1032 if rcsv.error:
1033 raise UsageError, _(rcsv.error)
1035 dir = args[-1]
1037 # get the list of classes to export
1038 if len(args) == 2:
1039 classes = args[0].split(',')
1040 else:
1041 classes = self.db.classes.keys()
1043 # do all the classes specified
1044 for classname in classes:
1045 cl = self.get_class(classname)
1047 f = open(os.path.join(dir, classname+'.csv'), 'w')
1048 writer = rcsv.writer(f, rcsv.colon_separated)
1050 properties = cl.getprops()
1051 propnames = properties.keys()
1052 propnames.sort()
1053 fields = propnames[:]
1054 fields.append('is retired')
1055 writer.writerow(fields)
1057 # all nodes for this class
1058 for nodeid in cl.getnodeids():
1059 writer.writerow(cl.export_list(propnames, nodeid))
1061 # close this file
1062 f.close()
1064 # export the journals
1065 jf = open(os.path.join(dir, classname+'-journals.csv'), 'w')
1066 journals = rcsv.writer(jf, rcsv.colon_separated)
1067 map(journals.writerow, cl.export_journals())
1068 jf.close()
1069 return 0
1071 def do_import(self, args):
1072 '''Usage: import import_dir
1073 Import a database from the directory containing CSV files, two per
1074 class to import.
1076 The files used in the import are:
1078 <class>.csv
1079 This must define the same properties as the class (including
1080 having a "header" line with those property names.)
1081 <class>-journals.csv
1082 This defines the journals for the items being imported.
1084 The imported nodes will have the same nodeid as defined in the
1085 import file, thus replacing any existing content.
1087 The new nodes are added to the existing database - if you want to
1088 create a new database using the imported data, then create a new
1089 database (or, tediously, retire all the old data.)
1090 '''
1091 if len(args) < 1:
1092 raise UsageError, _('Not enough arguments supplied')
1093 if rcsv.error:
1094 raise UsageError, _(rcsv.error)
1095 from roundup import hyperdb
1097 for file in os.listdir(args[0]):
1098 classname, ext = os.path.splitext(file)
1099 # we only care about CSV files
1100 if ext != '.csv' or classname.endswith('-journals'):
1101 continue
1103 cl = self.get_class(classname)
1105 # ensure that the properties and the CSV file headings match
1106 f = open(os.path.join(args[0], file))
1107 reader = rcsv.reader(f, rcsv.colon_separated)
1108 file_props = None
1109 maxid = 1
1110 # loop through the file and create a node for each entry
1111 for r in reader:
1112 if file_props is None:
1113 file_props = r
1114 continue
1115 # do the import and figure the current highest nodeid
1116 maxid = max(maxid, int(cl.import_list(file_props, r)))
1117 f.close()
1119 # import the journals
1120 f = open(os.path.join(args[0], classname + '-journals.csv'))
1121 reader = rcsv.reader(f, rcsv.colon_separated)
1122 cl.import_journals(reader)
1123 f.close()
1125 # set the id counter
1126 print 'setting', classname, maxid+1
1127 self.db.setid(classname, str(maxid+1))
1129 return 0
1131 def do_pack(self, args):
1132 '''Usage: pack period | date
1134 Remove journal entries older than a period of time specified or
1135 before a certain date.
1137 A period is specified using the suffixes "y", "m", and "d". The
1138 suffix "w" (for "week") means 7 days.
1140 "3y" means three years
1141 "2y 1m" means two years and one month
1142 "1m 25d" means one month and 25 days
1143 "2w 3d" means two weeks and three days
1145 Date format is "YYYY-MM-DD" eg:
1146 2001-01-01
1148 '''
1149 if len(args) <> 1:
1150 raise UsageError, _('Not enough arguments supplied')
1152 # are we dealing with a period or a date
1153 value = args[0]
1154 date_re = re.compile(r'''
1155 (?P<date>\d\d\d\d-\d\d?-\d\d?)? # yyyy-mm-dd
1156 (?P<period>(\d+y\s*)?(\d+m\s*)?(\d+d\s*)?)?
1157 ''', re.VERBOSE)
1158 m = date_re.match(value)
1159 if not m:
1160 raise ValueError, _('Invalid format')
1161 m = m.groupdict()
1162 if m['period']:
1163 pack_before = date.Date(". - %s"%value)
1164 elif m['date']:
1165 pack_before = date.Date(value)
1166 self.db.pack(pack_before)
1167 return 0
1169 def do_reindex(self, args):
1170 '''Usage: reindex
1171 Re-generate a tracker's search indexes.
1173 This will re-generate the search indexes for a tracker. This will
1174 typically happen automatically.
1175 '''
1176 self.db.indexer.force_reindex()
1177 self.db.reindex()
1178 return 0
1180 def do_security(self, args):
1181 '''Usage: security [Role name]
1182 Display the Permissions available to one or all Roles.
1183 '''
1184 if len(args) == 1:
1185 role = args[0]
1186 try:
1187 roles = [(args[0], self.db.security.role[args[0]])]
1188 except KeyError:
1189 print _('No such Role "%(role)s"')%locals()
1190 return 1
1191 else:
1192 roles = self.db.security.role.items()
1193 role = self.db.config.NEW_WEB_USER_ROLES
1194 if ',' in role:
1195 print _('New Web users get the Roles "%(role)s"')%locals()
1196 else:
1197 print _('New Web users get the Role "%(role)s"')%locals()
1198 role = self.db.config.NEW_EMAIL_USER_ROLES
1199 if ',' in role:
1200 print _('New Email users get the Roles "%(role)s"')%locals()
1201 else:
1202 print _('New Email users get the Role "%(role)s"')%locals()
1203 roles.sort()
1204 for rolename, role in roles:
1205 print _('Role "%(name)s":')%role.__dict__
1206 for permission in role.permissions:
1207 if permission.klass:
1208 print _(' %(description)s (%(name)s for "%(klass)s" '
1209 'only)')%permission.__dict__
1210 else:
1211 print _(' %(description)s (%(name)s)')%permission.__dict__
1212 return 0
1214 def run_command(self, args):
1215 '''Run a single command
1216 '''
1217 command = args[0]
1219 # handle help now
1220 if command == 'help':
1221 if len(args)>1:
1222 self.do_help(args[1:])
1223 return 0
1224 self.do_help(['help'])
1225 return 0
1226 if command == 'morehelp':
1227 self.do_help(['help'])
1228 self.help_commands()
1229 self.help_all()
1230 return 0
1232 # figure what the command is
1233 try:
1234 functions = self.commands.get(command)
1235 except KeyError:
1236 # not a valid command
1237 print _('Unknown command "%(command)s" ("help commands" for a '
1238 'list)')%locals()
1239 return 1
1241 # check for multiple matches
1242 if len(functions) > 1:
1243 print _('Multiple commands match "%(command)s": %(list)s')%{'command':
1244 command, 'list': ', '.join([i[0] for i in functions])}
1245 return 1
1246 command, function = functions[0]
1248 # make sure we have a tracker_home
1249 while not self.tracker_home:
1250 self.tracker_home = raw_input(_('Enter tracker home: ')).strip()
1252 # before we open the db, we may be doing an install or init
1253 if command == 'initialise':
1254 try:
1255 return self.do_initialise(self.tracker_home, args)
1256 except UsageError, message:
1257 print _('Error: %(message)s')%locals()
1258 return 1
1259 elif command == 'install':
1260 try:
1261 return self.do_install(self.tracker_home, args)
1262 except UsageError, message:
1263 print _('Error: %(message)s')%locals()
1264 return 1
1266 # get the tracker
1267 try:
1268 tracker = roundup.instance.open(self.tracker_home)
1269 except ValueError, message:
1270 self.tracker_home = ''
1271 print _("Error: Couldn't open tracker: %(message)s")%locals()
1272 return 1
1274 # only open the database once!
1275 if not self.db:
1276 self.db = tracker.open('admin')
1278 # do the command
1279 ret = 0
1280 try:
1281 ret = function(args[1:])
1282 except UsageError, message:
1283 print _('Error: %(message)s')%locals()
1284 print
1285 print function.__doc__
1286 ret = 1
1287 except:
1288 import traceback
1289 traceback.print_exc()
1290 ret = 1
1291 return ret
1293 def interactive(self):
1294 '''Run in an interactive mode
1295 '''
1296 print _('Roundup %s ready for input.'%roundup_version)
1297 print _('Type "help" for help.')
1298 try:
1299 import readline
1300 except ImportError:
1301 print _('Note: command history and editing not available')
1303 while 1:
1304 try:
1305 command = raw_input(_('roundup> '))
1306 except EOFError:
1307 print _('exit...')
1308 break
1309 if not command: continue
1310 args = token.token_split(command)
1311 if not args: continue
1312 if args[0] in ('quit', 'exit'): break
1313 self.run_command(args)
1315 # exit.. check for transactions
1316 if self.db and self.db.transactions:
1317 commit = raw_input(_('There are unsaved changes. Commit them (y/N)? '))
1318 if commit and commit[0].lower() == 'y':
1319 self.db.commit()
1320 return 0
1322 def main(self):
1323 try:
1324 opts, args = getopt.getopt(sys.argv[1:], 'i:u:hcdsS:v')
1325 except getopt.GetoptError, e:
1326 self.usage(str(e))
1327 return 1
1329 # handle command-line args
1330 self.tracker_home = os.environ.get('TRACKER_HOME', '')
1331 # TODO: reinstate the user/password stuff (-u arg too)
1332 name = password = ''
1333 if os.environ.has_key('ROUNDUP_LOGIN'):
1334 l = os.environ['ROUNDUP_LOGIN'].split(':')
1335 name = l[0]
1336 if len(l) > 1:
1337 password = l[1]
1338 self.separator = None
1339 self.print_designator = 0
1340 for opt, arg in opts:
1341 if opt == '-h':
1342 self.usage()
1343 return 0
1344 if opt == '-v':
1345 print '%s (python %s)'%(roundup_version, sys.version.split()[0])
1346 return 0
1347 if opt == '-i':
1348 self.tracker_home = arg
1349 if opt == '-c':
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 == '-S':
1355 if self.separator != None:
1356 self.usage('Only one of -c, -S and -s may be specified')
1357 return 1
1358 self.separator = arg
1359 if opt == '-s':
1360 if self.separator != None:
1361 self.usage('Only one of -c, -S and -s may be specified')
1362 return 1
1363 self.separator = ' '
1364 if opt == '-d':
1365 self.print_designator = 1
1367 # if no command - go interactive
1368 # wrap in a try/finally so we always close off the db
1369 ret = 0
1370 try:
1371 if not args:
1372 self.interactive()
1373 else:
1374 ret = self.run_command(args)
1375 if self.db: self.db.commit()
1376 return ret
1377 finally:
1378 if self.db:
1379 self.db.close()
1381 if __name__ == '__main__':
1382 tool = AdminTool()
1383 sys.exit(tool.main())
1385 # vim: set filetype=python ts=4 sw=4 et si