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