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.63 2004-03-21 23:39:08 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 parent = os.path.split(tracker_home)[0]
360 if not os.path.exists(parent):
361 raise UsageError, _('Instance home parent directory "%(parent)s"'
362 ' does not exist')%locals()
364 # select template
365 templates = self.listTemplates()
366 template = len(args) > 1 and args[1] or ''
367 if not templates.has_key(template):
368 print _('Templates:'), ', '.join(templates.keys())
369 while not templates.has_key(template):
370 template = raw_input(_('Select template [classic]: ')).strip()
371 if not template:
372 template = 'classic'
374 # select hyperdb backend
375 import roundup.backends
376 backends = roundup.backends.__all__
377 backend = len(args) > 2 and args[2] or ''
378 if backend not in backends:
379 print _('Back ends:'), ', '.join(backends)
380 while backend not in backends:
381 backend = raw_input(_('Select backend [anydbm]: ')).strip()
382 if not backend:
383 backend = 'anydbm'
384 # XXX perform a unit test based on the user's selections
386 # install!
387 init.install(tracker_home, templates[template]['path'])
388 init.write_select_db(tracker_home, backend)
390 print _('''
391 You should now edit the tracker configuration file:
392 %(config_file)s
393 ... at a minimum, you must set MAILHOST, TRACKER_WEB, MAIL_DOMAIN and
394 ADMIN_EMAIL.
396 If you wish to modify the default schema, you should also edit the database
397 initialisation file:
398 %(database_config_file)s
399 ... see the documentation on customizing for more information.
400 ''')%{
401 'config_file': os.path.join(tracker_home, 'config.py'),
402 'database_config_file': os.path.join(tracker_home, 'dbinit.py')
403 }
404 return 0
407 def do_initialise(self, tracker_home, args):
408 '''Usage: initialise [adminpw]
409 Initialise a new Roundup tracker.
411 The administrator details will be set at this step.
413 Execute the tracker's initialisation function dbinit.init()
414 '''
415 # password
416 if len(args) > 1:
417 adminpw = args[1]
418 else:
419 adminpw = ''
420 confirm = 'x'
421 while adminpw != confirm:
422 adminpw = getpass.getpass(_('Admin Password: '))
423 confirm = getpass.getpass(_(' Confirm: '))
425 # make sure the tracker home is installed
426 if not os.path.exists(tracker_home):
427 raise UsageError, _('Instance home does not exist')%locals()
428 try:
429 tracker = roundup.instance.open(tracker_home)
430 except roundup.instance.TrackerError:
431 raise UsageError, _('Instance has not been installed')%locals()
433 # is there already a database?
434 try:
435 db_exists = tracker.select_db.Database.exists(tracker.config)
436 except AttributeError:
437 # TODO: move this code to exists() static method in every backend
438 db_exists = os.path.exists(os.path.join(tracker_home, 'db'))
439 if db_exists:
440 print _('WARNING: The database is already initialised!')
441 print _('If you re-initialise it, you will lose all the data!')
442 ok = raw_input(_('Erase it? Y/[N]: ')).strip()
443 if ok.lower() != 'y':
444 return 0
446 # Get a database backend in use by tracker
447 try:
448 # nuke it
449 tracker.select_db.Database.nuke(tracker.config)
450 except AttributeError:
451 # TODO: move this code to nuke() static method in every backend
452 shutil.rmtree(os.path.join(tracker_home, 'db'))
454 # GO
455 init.initialise(tracker_home, adminpw)
457 return 0
460 def do_get(self, args):
461 '''Usage: get property designator[,designator]*
462 Get the given property of one or more designator(s).
464 Retrieves the property value of the nodes specified by the designators.
465 '''
466 if len(args) < 2:
467 raise UsageError, _('Not enough arguments supplied')
468 propname = args[0]
469 designators = args[1].split(',')
470 l = []
471 for designator in designators:
472 # decode the node designator
473 try:
474 classname, nodeid = hyperdb.splitDesignator(designator)
475 except hyperdb.DesignatorError, message:
476 raise UsageError, message
478 # get the class
479 cl = self.get_class(classname)
480 try:
481 id=[]
482 if self.separator:
483 if self.print_designator:
484 # see if property is a link or multilink for
485 # which getting a desginator make sense.
486 # Algorithm: Get the properties of the
487 # current designator's class. (cl.getprops)
488 # get the property object for the property the
489 # user requested (properties[propname])
490 # verify its type (isinstance...)
491 # raise error if not link/multilink
492 # get class name for link/multilink property
493 # do the get on the designators
494 # append the new designators
495 # print
496 properties = cl.getprops()
497 property = properties[propname]
498 if not (isinstance(property, hyperdb.Multilink) or
499 isinstance(property, hyperdb.Link)):
500 raise UsageError, _('property %s is not of type Multilink or Link so -d flag does not apply.')%propname
501 propclassname = self.db.getclass(property.classname).classname
502 id = cl.get(nodeid, propname)
503 for i in id:
504 l.append(propclassname + i)
505 else:
506 id = cl.get(nodeid, propname)
507 for i in id:
508 l.append(i)
509 else:
510 if self.print_designator:
511 properties = cl.getprops()
512 property = properties[propname]
513 if not (isinstance(property, hyperdb.Multilink) or
514 isinstance(property, hyperdb.Link)):
515 raise UsageError, _('property %s is not of type Multilink or Link so -d flag does not apply.')%propname
516 propclassname = self.db.getclass(property.classname).classname
517 id = cl.get(nodeid, propname)
518 for i in id:
519 print propclassname + i
520 else:
521 print cl.get(nodeid, propname)
522 except IndexError:
523 raise UsageError, _('no such %(classname)s node "%(nodeid)s"')%locals()
524 except KeyError:
525 raise UsageError, _('no such %(classname)s property '
526 '"%(propname)s"')%locals()
527 if self.separator:
528 print self.separator.join(l)
530 return 0
533 def do_set(self, args, pwre = re.compile(r'{(\w+)}(.+)')):
534 '''Usage: set items property=value property=value ...
535 Set the given properties of one or more items(s).
537 The items are specified as a class or as a comma-separated
538 list of item designators (ie "designator[,designator,...]").
540 This command sets the properties to the values for all designators
541 given. If the value is missing (ie. "property=") then the property is
542 un-set. If the property is a multilink, you specify the linked ids
543 for the multilink as comma-separated numbers (ie "1,2,3").
544 '''
545 if len(args) < 2:
546 raise UsageError, _('Not enough arguments supplied')
547 from roundup import hyperdb
549 designators = args[0].split(',')
550 if len(designators) == 1:
551 designator = designators[0]
552 try:
553 designator = hyperdb.splitDesignator(designator)
554 designators = [designator]
555 except hyperdb.DesignatorError:
556 cl = self.get_class(designator)
557 designators = [(designator, x) for x in cl.list()]
558 else:
559 try:
560 designators = [hyperdb.splitDesignator(x) for x in designators]
561 except hyperdb.DesignatorError, message:
562 raise UsageError, message
564 # get the props from the args
565 props = self.props_from_args(args[1:])
567 # now do the set for all the nodes
568 for classname, itemid in designators:
569 cl = self.get_class(classname)
571 properties = cl.getprops()
572 for key, value in props.items():
573 try:
574 props[key] = hyperdb.rawToHyperdb(self.db, cl, itemid,
575 key, value)
576 except hyperdb.HyperdbValueError, message:
577 raise UsageError, message
579 # try the set
580 try:
581 apply(cl.set, (itemid, ), props)
582 except (TypeError, IndexError, ValueError), message:
583 import traceback; traceback.print_exc()
584 raise UsageError, message
585 return 0
587 def do_find(self, args):
588 '''Usage: find classname propname=value ...
589 Find the nodes of the given class with a given link property value.
591 Find the nodes of the given class with a given link property value. The
592 value may be either the nodeid of the linked node, or its key value.
593 '''
594 if len(args) < 1:
595 raise UsageError, _('Not enough arguments supplied')
596 classname = args[0]
597 # get the class
598 cl = self.get_class(classname)
600 # handle the propname=value argument
601 props = self.props_from_args(args[1:])
603 # if the value isn't a number, look up the linked class to get the
604 # number
605 for propname, value in props.items():
606 num_re = re.compile('^\d+$')
607 if value == '-1':
608 props[propname] = None
609 elif not num_re.match(value):
610 # get the property
611 try:
612 property = cl.properties[propname]
613 except KeyError:
614 raise UsageError, _('%(classname)s has no property '
615 '"%(propname)s"')%locals()
617 # make sure it's a link
618 if (not isinstance(property, hyperdb.Link) and not
619 isinstance(property, hyperdb.Multilink)):
620 raise UsageError, _('You may only "find" link properties')
622 # get the linked-to class and look up the key property
623 link_class = self.db.getclass(property.classname)
624 try:
625 props[propname] = link_class.lookup(value)
626 except TypeError:
627 raise UsageError, _('%(classname)s has no key property"')%{
628 'classname': link_class.classname}
630 # now do the find
631 try:
632 id = []
633 designator = []
634 if self.separator:
635 if self.print_designator:
636 id=apply(cl.find, (), props)
637 for i in id:
638 designator.append(classname + i)
639 print self.separator.join(designator)
640 else:
641 print self.separator.join(apply(cl.find, (), props))
643 else:
644 if self.print_designator:
645 id=apply(cl.find, (), props)
646 for i in id:
647 designator.append(classname + i)
648 print designator
649 else:
650 print apply(cl.find, (), props)
651 except KeyError:
652 raise UsageError, _('%(classname)s has no property '
653 '"%(propname)s"')%locals()
654 except (ValueError, TypeError), message:
655 raise UsageError, message
656 return 0
658 def do_specification(self, args):
659 '''Usage: specification classname
660 Show the properties for a classname.
662 This lists the properties for a given class.
663 '''
664 if len(args) < 1:
665 raise UsageError, _('Not enough arguments supplied')
666 classname = args[0]
667 # get the class
668 cl = self.get_class(classname)
670 # get the key property
671 keyprop = cl.getkey()
672 for key, value in cl.properties.items():
673 if keyprop == key:
674 print _('%(key)s: %(value)s (key property)')%locals()
675 else:
676 print _('%(key)s: %(value)s')%locals()
678 def do_display(self, args):
679 '''Usage: display designator[,designator]*
680 Show the property values for the given node(s).
682 This lists the properties and their associated values for the given
683 node.
684 '''
685 if len(args) < 1:
686 raise UsageError, _('Not enough arguments supplied')
688 # decode the node designator
689 for designator in args[0].split(','):
690 try:
691 classname, nodeid = hyperdb.splitDesignator(designator)
692 except hyperdb.DesignatorError, message:
693 raise UsageError, message
695 # get the class
696 cl = self.get_class(classname)
698 # display the values
699 keys = cl.properties.keys()
700 keys.sort()
701 for key in keys:
702 value = cl.get(nodeid, key)
703 print _('%(key)s: %(value)s')%locals()
705 def do_create(self, args, pwre = re.compile(r'{(\w+)}(.+)')):
706 '''Usage: create classname property=value ...
707 Create a new entry of a given class.
709 This creates a new entry of the given class using the property
710 name=value arguments provided on the command line after the "create"
711 command.
712 '''
713 if len(args) < 1:
714 raise UsageError, _('Not enough arguments supplied')
715 from roundup import hyperdb
717 classname = args[0]
719 # get the class
720 cl = self.get_class(classname)
722 # now do a create
723 props = {}
724 properties = cl.getprops(protected = 0)
725 if len(args) == 1:
726 # ask for the properties
727 for key, value in properties.items():
728 if key == 'id': continue
729 name = value.__class__.__name__
730 if isinstance(value , hyperdb.Password):
731 again = None
732 while value != again:
733 value = getpass.getpass(_('%(propname)s (Password): ')%{
734 'propname': key.capitalize()})
735 again = getpass.getpass(_(' %(propname)s (Again): ')%{
736 'propname': key.capitalize()})
737 if value != again: print _('Sorry, try again...')
738 if value:
739 props[key] = value
740 else:
741 value = raw_input(_('%(propname)s (%(proptype)s): ')%{
742 'propname': key.capitalize(), 'proptype': name})
743 if value:
744 props[key] = value
745 else:
746 props = self.props_from_args(args[1:])
748 # convert types
749 for propname, value in props.items():
750 try:
751 props[propname] = hyperdb.rawToHyperdb(self.db, cl, None,
752 propname, value)
753 except hyperdb.HyperdbValueError, message:
754 raise UsageError, message
756 # check for the key property
757 propname = cl.getkey()
758 if propname and not props.has_key(propname):
759 raise UsageError, _('you must provide the "%(propname)s" '
760 'property.')%locals()
762 # do the actual create
763 try:
764 print apply(cl.create, (), props)
765 except (TypeError, IndexError, ValueError), message:
766 raise UsageError, message
767 return 0
769 def do_list(self, args):
770 '''Usage: list classname [property]
771 List the instances of a class.
773 Lists all instances of the given class. If the property is not
774 specified, the "label" property is used. The label property is tried
775 in order: the key, "name", "title" and then the first property,
776 alphabetically.
778 With -c, -S or -s print a list of item id's if no property specified.
779 If property specified, print list of that property for every class
780 instance.
781 '''
782 if len(args) > 2:
783 raise UsageError, _('Too many arguments supplied')
784 if len(args) < 1:
785 raise UsageError, _('Not enough arguments supplied')
786 classname = args[0]
788 # get the class
789 cl = self.get_class(classname)
791 # figure the property
792 if len(args) > 1:
793 propname = args[1]
794 else:
795 propname = cl.labelprop()
797 if self.separator:
798 if len(args) == 2:
799 # create a list of propnames since user specified propname
800 proplist=[]
801 for nodeid in cl.list():
802 try:
803 proplist.append(cl.get(nodeid, propname))
804 except KeyError:
805 raise UsageError, _('%(classname)s has no property '
806 '"%(propname)s"')%locals()
807 print self.separator.join(proplist)
808 else:
809 # create a list of index id's since user didn't specify
810 # otherwise
811 print self.separator.join(cl.list())
812 else:
813 for nodeid in cl.list():
814 try:
815 value = cl.get(nodeid, propname)
816 except KeyError:
817 raise UsageError, _('%(classname)s has no property '
818 '"%(propname)s"')%locals()
819 print _('%(nodeid)4s: %(value)s')%locals()
820 return 0
822 def do_table(self, args):
823 '''Usage: table classname [property[,property]*]
824 List the instances of a class in tabular form.
826 Lists all instances of the given class. If the properties are not
827 specified, all properties are displayed. By default, the column widths
828 are the width of the largest value. The width may be explicitly defined
829 by defining the property as "name:width". For example::
831 roundup> table priority id,name:10
832 Id Name
833 1 fatal-bug
834 2 bug
835 3 usability
836 4 feature
838 Also to make the width of the column the width of the label,
839 leave a trailing : without a width on the property. For example::
841 roundup> table priority id,name:
842 Id Name
843 1 fata
844 2 bug
845 3 usab
846 4 feat
848 will result in a the 4 character wide "Name" column.
849 '''
850 if len(args) < 1:
851 raise UsageError, _('Not enough arguments supplied')
852 classname = args[0]
854 # get the class
855 cl = self.get_class(classname)
857 # figure the property names to display
858 if len(args) > 1:
859 prop_names = args[1].split(',')
860 all_props = cl.getprops()
861 for spec in prop_names:
862 if ':' in spec:
863 try:
864 propname, width = spec.split(':')
865 except (ValueError, TypeError):
866 raise UsageError, _('"%(spec)s" not name:width')%locals()
867 else:
868 propname = spec
869 if not all_props.has_key(propname):
870 raise UsageError, _('%(classname)s has no property '
871 '"%(propname)s"')%locals()
872 else:
873 prop_names = cl.getprops().keys()
875 # now figure column widths
876 props = []
877 for spec in prop_names:
878 if ':' in spec:
879 name, width = spec.split(':')
880 if width == '':
881 props.append((name, len(spec)))
882 else:
883 props.append((name, int(width)))
884 else:
885 # this is going to be slow
886 maxlen = len(spec)
887 for nodeid in cl.list():
888 curlen = len(str(cl.get(nodeid, spec)))
889 if curlen > maxlen:
890 maxlen = curlen
891 props.append((spec, maxlen))
893 # now display the heading
894 print ' '.join([name.capitalize().ljust(width) for name,width in props])
896 # and the table data
897 for nodeid in cl.list():
898 l = []
899 for name, width in props:
900 if name != 'id':
901 try:
902 value = str(cl.get(nodeid, name))
903 except KeyError:
904 # we already checked if the property is valid - a
905 # KeyError here means the node just doesn't have a
906 # value for it
907 value = ''
908 else:
909 value = str(nodeid)
910 f = '%%-%ds'%width
911 l.append(f%value[:width])
912 print ' '.join(l)
913 return 0
915 def do_history(self, args):
916 '''Usage: history designator
917 Show the history entries of a designator.
919 Lists the journal entries for the node identified by the designator.
920 '''
921 if len(args) < 1:
922 raise UsageError, _('Not enough arguments supplied')
923 try:
924 classname, nodeid = hyperdb.splitDesignator(args[0])
925 except hyperdb.DesignatorError, message:
926 raise UsageError, message
928 try:
929 print self.db.getclass(classname).history(nodeid)
930 except KeyError:
931 raise UsageError, _('no such class "%(classname)s"')%locals()
932 except IndexError:
933 raise UsageError, _('no such %(classname)s node "%(nodeid)s"')%locals()
934 return 0
936 def do_commit(self, args):
937 '''Usage: commit
938 Commit all changes made to the database.
940 The changes made during an interactive session are not
941 automatically written to the database - they must be committed
942 using this command.
944 One-off commands on the command-line are automatically committed if
945 they are successful.
946 '''
947 self.db.commit()
948 return 0
950 def do_rollback(self, args):
951 '''Usage: rollback
952 Undo all changes that are pending commit to the database.
954 The changes made during an interactive session are not
955 automatically written to the database - they must be committed
956 manually. This command undoes all those changes, so a commit
957 immediately after would make no changes to the database.
958 '''
959 self.db.rollback()
960 return 0
962 def do_retire(self, args):
963 '''Usage: retire designator[,designator]*
964 Retire the node specified by designator.
966 This action indicates that a particular node is not to be retrieved by
967 the list or find commands, and its key value may be re-used.
968 '''
969 if len(args) < 1:
970 raise UsageError, _('Not enough arguments supplied')
971 designators = args[0].split(',')
972 for designator in designators:
973 try:
974 classname, nodeid = hyperdb.splitDesignator(designator)
975 except hyperdb.DesignatorError, message:
976 raise UsageError, message
977 try:
978 self.db.getclass(classname).retire(nodeid)
979 except KeyError:
980 raise UsageError, _('no such class "%(classname)s"')%locals()
981 except IndexError:
982 raise UsageError, _('no such %(classname)s node "%(nodeid)s"')%locals()
983 return 0
985 def do_restore(self, args):
986 '''Usage: restore designator[,designator]*
987 Restore the retired node specified by designator.
989 The given nodes will become available for users again.
990 '''
991 if len(args) < 1:
992 raise UsageError, _('Not enough arguments supplied')
993 designators = args[0].split(',')
994 for designator in designators:
995 try:
996 classname, nodeid = hyperdb.splitDesignator(designator)
997 except hyperdb.DesignatorError, message:
998 raise UsageError, message
999 try:
1000 self.db.getclass(classname).restore(nodeid)
1001 except KeyError:
1002 raise UsageError, _('no such class "%(classname)s"')%locals()
1003 except IndexError:
1004 raise UsageError, _('no such %(classname)s node "%(nodeid)s"')%locals()
1005 return 0
1007 def do_export(self, args):
1008 '''Usage: export [class[,class]] export_dir
1009 Export the database to colon-separated-value files.
1011 This action exports the current data from the database into
1012 colon-separated-value files that are placed in the nominated
1013 destination directory. The journals are not exported.
1014 '''
1015 # grab the directory to export to
1016 if len(args) < 1:
1017 raise UsageError, _('Not enough arguments supplied')
1018 if rcsv.error:
1019 raise UsageError, _(rcsv.error)
1021 dir = args[-1]
1023 # get the list of classes to export
1024 if len(args) == 2:
1025 classes = args[0].split(',')
1026 else:
1027 classes = self.db.classes.keys()
1029 # do all the classes specified
1030 for classname in classes:
1031 cl = self.get_class(classname)
1032 f = open(os.path.join(dir, classname+'.csv'), 'w')
1033 writer = rcsv.writer(f, rcsv.colon_separated)
1034 properties = cl.getprops()
1035 propnames = properties.keys()
1036 propnames.sort()
1037 fields = propnames[:]
1038 fields.append('is retired')
1039 writer.writerow(fields)
1041 # all nodes for this class (not using list() 'cos it doesn't
1042 # include retired nodes)
1044 for nodeid in self.db.getclass(classname).getnodeids():
1045 # get the regular props
1046 writer.writerow (cl.export_list(propnames, nodeid))
1048 # close this file
1049 f.close()
1050 return 0
1052 def do_import(self, args):
1053 '''Usage: import import_dir
1054 Import a database from the directory containing CSV files, one per
1055 class to import.
1057 The files must define the same properties as the class (including having
1058 a "header" line with those property names.)
1060 The imported nodes will have the same nodeid as defined in the
1061 import file, thus replacing any existing content.
1063 The new nodes are added to the existing database - if you want to
1064 create a new database using the imported data, then create a new
1065 database (or, tediously, retire all the old data.)
1066 '''
1067 if len(args) < 1:
1068 raise UsageError, _('Not enough arguments supplied')
1069 if rcsv.error:
1070 raise UsageError, _(rcsv.error)
1071 from roundup import hyperdb
1073 for file in os.listdir(args[0]):
1074 # we only care about CSV files
1075 if not file.endswith('.csv'):
1076 continue
1078 f = open(os.path.join(args[0], file))
1080 # get the classname
1081 classname = os.path.splitext(file)[0]
1083 # ensure that the properties and the CSV file headings match
1084 cl = self.get_class(classname)
1085 reader = rcsv.reader(f, rcsv.colon_separated)
1086 file_props = None
1087 maxid = 1
1089 # loop through the file and create a node for each entry
1090 for r in reader:
1091 if file_props is None:
1092 file_props = r
1093 continue
1095 # do the import and figure the current highest nodeid
1096 maxid = max(maxid, int(cl.import_list(file_props, r)))
1098 # set the id counter
1099 print 'setting', classname, maxid+1
1100 self.db.setid(classname, str(maxid+1))
1101 return 0
1103 def do_pack(self, args):
1104 '''Usage: pack period | date
1106 Remove journal entries older than a period of time specified or
1107 before a certain date.
1109 A period is specified using the suffixes "y", "m", and "d". The
1110 suffix "w" (for "week") means 7 days.
1112 "3y" means three years
1113 "2y 1m" means two years and one month
1114 "1m 25d" means one month and 25 days
1115 "2w 3d" means two weeks and three days
1117 Date format is "YYYY-MM-DD" eg:
1118 2001-01-01
1120 '''
1121 if len(args) <> 1:
1122 raise UsageError, _('Not enough arguments supplied')
1124 # are we dealing with a period or a date
1125 value = args[0]
1126 date_re = re.compile(r'''
1127 (?P<date>\d\d\d\d-\d\d?-\d\d?)? # yyyy-mm-dd
1128 (?P<period>(\d+y\s*)?(\d+m\s*)?(\d+d\s*)?)?
1129 ''', re.VERBOSE)
1130 m = date_re.match(value)
1131 if not m:
1132 raise ValueError, _('Invalid format')
1133 m = m.groupdict()
1134 if m['period']:
1135 pack_before = date.Date(". - %s"%value)
1136 elif m['date']:
1137 pack_before = date.Date(value)
1138 self.db.pack(pack_before)
1139 return 0
1141 def do_reindex(self, args):
1142 '''Usage: reindex
1143 Re-generate a tracker's search indexes.
1145 This will re-generate the search indexes for a tracker. This will
1146 typically happen automatically.
1147 '''
1148 self.db.indexer.force_reindex()
1149 self.db.reindex()
1150 return 0
1152 def do_security(self, args):
1153 '''Usage: security [Role name]
1154 Display the Permissions available to one or all Roles.
1155 '''
1156 if len(args) == 1:
1157 role = args[0]
1158 try:
1159 roles = [(args[0], self.db.security.role[args[0]])]
1160 except KeyError:
1161 print _('No such Role "%(role)s"')%locals()
1162 return 1
1163 else:
1164 roles = self.db.security.role.items()
1165 role = self.db.config.NEW_WEB_USER_ROLES
1166 if ',' in role:
1167 print _('New Web users get the Roles "%(role)s"')%locals()
1168 else:
1169 print _('New Web users get the Role "%(role)s"')%locals()
1170 role = self.db.config.NEW_EMAIL_USER_ROLES
1171 if ',' in role:
1172 print _('New Email users get the Roles "%(role)s"')%locals()
1173 else:
1174 print _('New Email users get the Role "%(role)s"')%locals()
1175 roles.sort()
1176 for rolename, role in roles:
1177 print _('Role "%(name)s":')%role.__dict__
1178 for permission in role.permissions:
1179 if permission.klass:
1180 print _(' %(description)s (%(name)s for "%(klass)s" '
1181 'only)')%permission.__dict__
1182 else:
1183 print _(' %(description)s (%(name)s)')%permission.__dict__
1184 return 0
1186 def run_command(self, args):
1187 '''Run a single command
1188 '''
1189 command = args[0]
1191 # handle help now
1192 if command == 'help':
1193 if len(args)>1:
1194 self.do_help(args[1:])
1195 return 0
1196 self.do_help(['help'])
1197 return 0
1198 if command == 'morehelp':
1199 self.do_help(['help'])
1200 self.help_commands()
1201 self.help_all()
1202 return 0
1204 # figure what the command is
1205 try:
1206 functions = self.commands.get(command)
1207 except KeyError:
1208 # not a valid command
1209 print _('Unknown command "%(command)s" ("help commands" for a '
1210 'list)')%locals()
1211 return 1
1213 # check for multiple matches
1214 if len(functions) > 1:
1215 print _('Multiple commands match "%(command)s": %(list)s')%{'command':
1216 command, 'list': ', '.join([i[0] for i in functions])}
1217 return 1
1218 command, function = functions[0]
1220 # make sure we have a tracker_home
1221 while not self.tracker_home:
1222 self.tracker_home = raw_input(_('Enter tracker home: ')).strip()
1224 # before we open the db, we may be doing an install or init
1225 if command == 'initialise':
1226 try:
1227 return self.do_initialise(self.tracker_home, args)
1228 except UsageError, message:
1229 print _('Error: %(message)s')%locals()
1230 return 1
1231 elif command == 'install':
1232 try:
1233 return self.do_install(self.tracker_home, args)
1234 except UsageError, message:
1235 print _('Error: %(message)s')%locals()
1236 return 1
1238 # get the tracker
1239 try:
1240 tracker = roundup.instance.open(self.tracker_home)
1241 except ValueError, message:
1242 self.tracker_home = ''
1243 print _("Error: Couldn't open tracker: %(message)s")%locals()
1244 return 1
1246 # only open the database once!
1247 if not self.db:
1248 self.db = tracker.open('admin')
1250 # do the command
1251 ret = 0
1252 try:
1253 ret = function(args[1:])
1254 except UsageError, message:
1255 print _('Error: %(message)s')%locals()
1256 print
1257 print function.__doc__
1258 ret = 1
1259 except:
1260 import traceback
1261 traceback.print_exc()
1262 ret = 1
1263 return ret
1265 def interactive(self):
1266 '''Run in an interactive mode
1267 '''
1268 print _('Roundup %s ready for input.'%roundup_version)
1269 print _('Type "help" for help.')
1270 try:
1271 import readline
1272 except ImportError:
1273 print _('Note: command history and editing not available')
1275 while 1:
1276 try:
1277 command = raw_input(_('roundup> '))
1278 except EOFError:
1279 print _('exit...')
1280 break
1281 if not command: continue
1282 args = token.token_split(command)
1283 if not args: continue
1284 if args[0] in ('quit', 'exit'): break
1285 self.run_command(args)
1287 # exit.. check for transactions
1288 if self.db and self.db.transactions:
1289 commit = raw_input(_('There are unsaved changes. Commit them (y/N)? '))
1290 if commit and commit[0].lower() == 'y':
1291 self.db.commit()
1292 return 0
1294 def main(self):
1295 try:
1296 opts, args = getopt.getopt(sys.argv[1:], 'i:u:hcdsS:')
1297 except getopt.GetoptError, e:
1298 self.usage(str(e))
1299 return 1
1301 # handle command-line args
1302 self.tracker_home = os.environ.get('TRACKER_HOME', '')
1303 # TODO: reinstate the user/password stuff (-u arg too)
1304 name = password = ''
1305 if os.environ.has_key('ROUNDUP_LOGIN'):
1306 l = os.environ['ROUNDUP_LOGIN'].split(':')
1307 name = l[0]
1308 if len(l) > 1:
1309 password = l[1]
1310 self.separator = None
1311 self.print_designator = 0
1312 for opt, arg in opts:
1313 if opt == '-h':
1314 self.usage()
1315 return 0
1316 if opt == '-i':
1317 self.tracker_home = arg
1318 if opt == '-c':
1319 if self.separator != None:
1320 self.usage('Only one of -c, -S and -s may be specified')
1321 return 1
1322 self.separator = ','
1323 if opt == '-S':
1324 if self.separator != None:
1325 self.usage('Only one of -c, -S and -s may be specified')
1326 return 1
1327 self.separator = arg
1328 if opt == '-s':
1329 if self.separator != None:
1330 self.usage('Only one of -c, -S and -s may be specified')
1331 return 1
1332 self.separator = ' '
1333 if opt == '-d':
1334 self.print_designator = 1
1336 # if no command - go interactive
1337 # wrap in a try/finally so we always close off the db
1338 ret = 0
1339 try:
1340 if not args:
1341 self.interactive()
1342 else:
1343 ret = self.run_command(args)
1344 if self.db: self.db.commit()
1345 return ret
1346 finally:
1347 if self.db:
1348 self.db.close()
1350 if __name__ == '__main__':
1351 tool = AdminTool()
1352 sys.exit(tool.main())
1354 # vim: set filetype=python ts=4 sw=4 et si