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