c4cc737beee8634f8742bdca973e806cc1d6c770
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.58 2003-08-29 12:43:03 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 three places:
281 <prefix>/share/roundup/templates/*
282 <__file__>/../templates/*
283 current dir/*
284 current dir as a template
285 '''
286 # OK, try <prefix>/share/roundup/templates
287 # -- this module (roundup.admin) will be installed in something
288 # like:
289 # /usr/lib/python2.2/site-packages/roundup/admin.py (5 dirs up)
290 # c:\python22\lib\site-packages\roundup\admin.py (4 dirs up)
291 # we're interested in where the "lib" directory is - ie. the /usr/
292 # part
293 templates = {}
294 for N in 4, 5:
295 path = __file__
296 # move up N elements in the path
297 for i in range(N):
298 path = os.path.dirname(path)
299 tdir = os.path.join(path, 'share', 'roundup', 'templates')
300 if os.path.isdir(tdir):
301 templates = listTemplates(tdir)
302 break
304 # OK, now try as if we're in the roundup source distribution
305 # directory, so this module will be in .../roundup-*/roundup/admin.py
306 # and we're interested in the .../roundup-*/ part.
307 path = __file__
308 for i in range(2):
309 path = os.path.dirname(path)
310 tdir = os.path.join(path, 'templates')
311 if os.path.isdir(tdir):
312 templates.update(listTemplates(tdir))
314 # Try subdirs of the current dir
315 templates.update(listTemplates(os.getcwd()))
317 # Finally, try the current directory as a template
318 template = loadTemplate(os.getcwd())
319 if template:
320 templates[template['name']] = template
322 return templates
324 def help_initopts(self):
325 templates = self.listTemplates()
326 print _('Templates:'), ', '.join(templates.keys())
327 import roundup.backends
328 backends = roundup.backends.__all__
329 print _('Back ends:'), ', '.join(backends)
331 def do_install(self, tracker_home, args):
332 '''Usage: install [template [backend [admin password]]]
333 Install a new Roundup tracker.
335 The command will prompt for the tracker home directory (if not supplied
336 through TRACKER_HOME or the -i option). The template, backend and admin
337 password may be specified on the command-line as arguments, in that
338 order.
340 The initialise command must be called after this command in order
341 to initialise the tracker's database. You may edit the tracker's
342 initial database contents before running that command by editing
343 the tracker's dbinit.py module init() function.
345 See also initopts help.
346 '''
347 if len(args) < 1:
348 raise UsageError, _('Not enough arguments supplied')
350 # make sure the tracker home can be created
351 parent = os.path.split(tracker_home)[0]
352 if not os.path.exists(parent):
353 raise UsageError, _('Instance home parent directory "%(parent)s"'
354 ' does not exist')%locals()
356 # select template
357 templates = self.listTemplates()
358 template = len(args) > 1 and args[1] or ''
359 if not templates.has_key(template):
360 print _('Templates:'), ', '.join(templates.keys())
361 while not templates.has_key(template):
362 template = raw_input(_('Select template [classic]: ')).strip()
363 if not template:
364 template = 'classic'
366 # select hyperdb backend
367 import roundup.backends
368 backends = roundup.backends.__all__
369 backend = len(args) > 2 and args[2] or ''
370 if backend not in backends:
371 print _('Back ends:'), ', '.join(backends)
372 while backend not in backends:
373 backend = raw_input(_('Select backend [anydbm]: ')).strip()
374 if not backend:
375 backend = 'anydbm'
376 # XXX perform a unit test based on the user's selections
378 # install!
379 init.install(tracker_home, templates[template]['path'])
380 init.write_select_db(tracker_home, backend)
382 print _('''
383 You should now edit the tracker configuration file:
384 %(config_file)s
385 ... at a minimum, you must set MAILHOST, TRACKER_WEB, MAIL_DOMAIN and
386 ADMIN_EMAIL.
388 If you wish to modify the default schema, you should also edit the database
389 initialisation file:
390 %(database_config_file)s
391 ... see the documentation on customizing for more information.
392 ''')%{
393 'config_file': os.path.join(tracker_home, 'config.py'),
394 'database_config_file': os.path.join(tracker_home, 'dbinit.py')
395 }
396 return 0
399 def do_initialise(self, tracker_home, args):
400 '''Usage: initialise [adminpw]
401 Initialise a new Roundup tracker.
403 The administrator details will be set at this step.
405 Execute the tracker's initialisation function dbinit.init()
406 '''
407 # password
408 if len(args) > 1:
409 adminpw = args[1]
410 else:
411 adminpw = ''
412 confirm = 'x'
413 while adminpw != confirm:
414 adminpw = getpass.getpass(_('Admin Password: '))
415 confirm = getpass.getpass(_(' Confirm: '))
417 # make sure the tracker home is installed
418 if not os.path.exists(tracker_home):
419 raise UsageError, _('Instance home does not exist')%locals()
420 try:
421 tracker = roundup.instance.open(tracker_home)
422 except roundup.instance.TrackerError:
423 raise UsageError, _('Instance has not been installed')%locals()
425 # is there already a database?
426 try:
427 db_exists = tracker.select_db.Database.exists(tracker.config)
428 except AttributeError:
429 # TODO: move this code to exists() static method in every backend
430 db_exists = os.path.exists(os.path.join(tracker_home, 'db'))
431 if db_exists:
432 print _('WARNING: The database is already initialised!')
433 print _('If you re-initialise it, you will lose all the data!')
434 ok = raw_input(_('Erase it? Y/[N]: ')).strip()
435 if ok.lower() != 'y':
436 return 0
438 # Get a database backend in use by tracker
439 try:
440 # nuke it
441 tracker.select_db.Database.nuke(tracker.config)
442 except AttributeError:
443 # TODO: move this code to nuke() static method in every backend
444 shutil.rmtree(os.path.join(tracker_home, 'db'))
446 # GO
447 init.initialise(tracker_home, adminpw)
449 return 0
452 def do_get(self, args):
453 '''Usage: get property designator[,designator]*
454 Get the given property of one or more designator(s).
456 Retrieves the property value of the nodes specified by the designators.
457 '''
458 if len(args) < 2:
459 raise UsageError, _('Not enough arguments supplied')
460 propname = args[0]
461 designators = args[1].split(',')
462 l = []
463 for designator in designators:
464 # decode the node designator
465 try:
466 classname, nodeid = hyperdb.splitDesignator(designator)
467 except hyperdb.DesignatorError, message:
468 raise UsageError, message
470 # get the class
471 cl = self.get_class(classname)
472 try:
473 id=[]
474 if self.separator:
475 if self.print_designator:
476 # see if property is a link or multilink for
477 # which getting a desginator make sense.
478 # Algorithm: Get the properties of the
479 # current designator's class. (cl.getprops)
480 # get the property object for the property the
481 # user requested (properties[propname])
482 # verify its type (isinstance...)
483 # raise error if not link/multilink
484 # get class name for link/multilink property
485 # do the get on the designators
486 # append the new designators
487 # print
488 properties = cl.getprops()
489 property = properties[propname]
490 if not (isinstance(property, hyperdb.Multilink) or
491 isinstance(property, hyperdb.Link)):
492 raise UsageError, _('property %s is not of type Multilink or Link so -d flag does not apply.')%propname
493 propclassname = self.db.getclass(property.classname).classname
494 id = cl.get(nodeid, propname)
495 for i in id:
496 l.append(propclassname + i)
497 else:
498 id = cl.get(nodeid, propname)
499 for i in id:
500 l.append(i)
501 else:
502 if self.print_designator:
503 properties = cl.getprops()
504 property = properties[propname]
505 if not (isinstance(property, hyperdb.Multilink) or
506 isinstance(property, hyperdb.Link)):
507 raise UsageError, _('property %s is not of type Multilink or Link so -d flag does not apply.')%propname
508 propclassname = self.db.getclass(property.classname).classname
509 id = cl.get(nodeid, propname)
510 for i in id:
511 print propclassname + i
512 else:
513 print cl.get(nodeid, propname)
514 except IndexError:
515 raise UsageError, _('no such %(classname)s node "%(nodeid)s"')%locals()
516 except KeyError:
517 raise UsageError, _('no such %(classname)s property '
518 '"%(propname)s"')%locals()
519 if self.separator:
520 print self.separator.join(l)
522 return 0
525 def do_set(self, args, pwre = re.compile(r'{(\w+)}(.+)')):
526 '''Usage: set items property=value property=value ...
527 Set the given properties of one or more items(s).
529 The items are specified as a class or as a comma-separated
530 list of item designators (ie "designator[,designator,...]").
532 This command sets the properties to the values for all designators
533 given. If the value is missing (ie. "property=") then the property is
534 un-set. If the property is a multilink, you specify the linked ids
535 for the multilink as comma-separated numbers (ie "1,2,3").
536 '''
537 if len(args) < 2:
538 raise UsageError, _('Not enough arguments supplied')
539 from roundup import hyperdb
541 designators = args[0].split(',')
542 if len(designators) == 1:
543 designator = designators[0]
544 try:
545 designator = hyperdb.splitDesignator(designator)
546 designators = [designator]
547 except hyperdb.DesignatorError:
548 cl = self.get_class(designator)
549 designators = [(designator, x) for x in cl.list()]
550 else:
551 try:
552 designators = [hyperdb.splitDesignator(x) for x in designators]
553 except hyperdb.DesignatorError, message:
554 raise UsageError, message
556 # get the props from the args
557 props = self.props_from_args(args[1:])
559 # now do the set for all the nodes
560 for classname, itemid in designators:
561 cl = self.get_class(classname)
563 properties = cl.getprops()
564 for key, value in props.items():
565 proptype = properties[key]
566 if isinstance(proptype, hyperdb.Multilink):
567 if value is None:
568 props[key] = []
569 else:
570 props[key] = value.split(',')
571 elif value is None:
572 continue
573 elif isinstance(proptype, hyperdb.String):
574 continue
575 elif isinstance(proptype, hyperdb.Password):
576 m = pwre.match(value)
577 if m:
578 # password is being given to us encrypted
579 p = password.Password()
580 p.scheme = m.group(1)
581 p.password = m.group(2)
582 props[key] = p
583 else:
584 props[key] = password.Password(value)
585 elif isinstance(proptype, hyperdb.Date):
586 try:
587 props[key] = date.Date(value)
588 except ValueError, message:
589 raise UsageError, '"%s": %s'%(value, message)
590 elif isinstance(proptype, hyperdb.Interval):
591 try:
592 props[key] = date.Interval(value)
593 except ValueError, message:
594 raise UsageError, '"%s": %s'%(value, message)
595 elif isinstance(proptype, hyperdb.Link):
596 props[key] = value
597 elif isinstance(proptype, hyperdb.Boolean):
598 props[key] = value.lower() in ('yes', 'true', 'on', '1')
599 elif isinstance(proptype, hyperdb.Number):
600 props[key] = float(value)
602 # try the set
603 try:
604 apply(cl.set, (itemid, ), props)
605 except (TypeError, IndexError, ValueError), message:
606 import traceback; traceback.print_exc()
607 raise UsageError, message
608 return 0
610 def do_find(self, args):
611 '''Usage: find classname propname=value ...
612 Find the nodes of the given class with a given link property value.
614 Find the nodes of the given class with a given link property value. The
615 value may be either the nodeid of the linked node, or its key value.
616 '''
617 if len(args) < 1:
618 raise UsageError, _('Not enough arguments supplied')
619 classname = args[0]
620 # get the class
621 cl = self.get_class(classname)
623 # handle the propname=value argument
624 props = self.props_from_args(args[1:])
626 # if the value isn't a number, look up the linked class to get the
627 # number
628 for propname, value in props.items():
629 num_re = re.compile('^\d+$')
630 if value == '-1':
631 props[propname] = None
632 elif not num_re.match(value):
633 # get the property
634 try:
635 property = cl.properties[propname]
636 except KeyError:
637 raise UsageError, _('%(classname)s has no property '
638 '"%(propname)s"')%locals()
640 # make sure it's a link
641 if (not isinstance(property, hyperdb.Link) and not
642 isinstance(property, hyperdb.Multilink)):
643 raise UsageError, _('You may only "find" link properties')
645 # get the linked-to class and look up the key property
646 link_class = self.db.getclass(property.classname)
647 try:
648 props[propname] = link_class.lookup(value)
649 except TypeError:
650 raise UsageError, _('%(classname)s has no key property"')%{
651 'classname': link_class.classname}
653 # now do the find
654 try:
655 id = []
656 designator = []
657 if self.separator:
658 if self.print_designator:
659 id=apply(cl.find, (), props)
660 for i in id:
661 designator.append(classname + i)
662 print self.separator.join(designator)
663 else:
664 print self.separator.join(apply(cl.find, (), props))
666 else:
667 if self.print_designator:
668 id=apply(cl.find, (), props)
669 for i in id:
670 designator.append(classname + i)
671 print designator
672 else:
673 print apply(cl.find, (), props)
674 except KeyError:
675 raise UsageError, _('%(classname)s has no property '
676 '"%(propname)s"')%locals()
677 except (ValueError, TypeError), message:
678 raise UsageError, message
679 return 0
681 def do_specification(self, args):
682 '''Usage: specification classname
683 Show the properties for a classname.
685 This lists the properties for a given class.
686 '''
687 if len(args) < 1:
688 raise UsageError, _('Not enough arguments supplied')
689 classname = args[0]
690 # get the class
691 cl = self.get_class(classname)
693 # get the key property
694 keyprop = cl.getkey()
695 for key, value in cl.properties.items():
696 if keyprop == key:
697 print _('%(key)s: %(value)s (key property)')%locals()
698 else:
699 print _('%(key)s: %(value)s')%locals()
701 def do_display(self, args):
702 '''Usage: display designator[,designator]*
703 Show the property values for the given node(s).
705 This lists the properties and their associated values for the given
706 node.
707 '''
708 if len(args) < 1:
709 raise UsageError, _('Not enough arguments supplied')
711 # decode the node designator
712 for designator in args[0].split(','):
713 try:
714 classname, nodeid = hyperdb.splitDesignator(designator)
715 except hyperdb.DesignatorError, message:
716 raise UsageError, message
718 # get the class
719 cl = self.get_class(classname)
721 # display the values
722 keys = cl.properties.keys()
723 keys.sort()
724 for key in keys:
725 value = cl.get(nodeid, key)
726 print _('%(key)s: %(value)s')%locals()
728 def do_create(self, args, pwre = re.compile(r'{(\w+)}(.+)')):
729 '''Usage: create classname property=value ...
730 Create a new entry of a given class.
732 This creates a new entry of the given class using the property
733 name=value arguments provided on the command line after the "create"
734 command.
735 '''
736 if len(args) < 1:
737 raise UsageError, _('Not enough arguments supplied')
738 from roundup import hyperdb
740 classname = args[0]
742 # get the class
743 cl = self.get_class(classname)
745 # now do a create
746 props = {}
747 properties = cl.getprops(protected = 0)
748 if len(args) == 1:
749 # ask for the properties
750 for key, value in properties.items():
751 if key == 'id': continue
752 name = value.__class__.__name__
753 if isinstance(value , hyperdb.Password):
754 again = None
755 while value != again:
756 value = getpass.getpass(_('%(propname)s (Password): ')%{
757 'propname': key.capitalize()})
758 again = getpass.getpass(_(' %(propname)s (Again): ')%{
759 'propname': key.capitalize()})
760 if value != again: print _('Sorry, try again...')
761 if value:
762 props[key] = value
763 else:
764 value = raw_input(_('%(propname)s (%(proptype)s): ')%{
765 'propname': key.capitalize(), 'proptype': name})
766 if value:
767 props[key] = value
768 else:
769 props = self.props_from_args(args[1:])
771 # convert types
772 for propname, value in props.items():
773 # get the property
774 try:
775 proptype = properties[propname]
776 except KeyError:
777 raise UsageError, _('%(classname)s has no property '
778 '"%(propname)s"')%locals()
780 if isinstance(proptype, hyperdb.Date):
781 try:
782 props[propname] = date.Date(value)
783 except ValueError, message:
784 raise UsageError, _('"%(value)s": %(message)s')%locals()
785 elif isinstance(proptype, hyperdb.Interval):
786 try:
787 props[propname] = date.Interval(value)
788 except ValueError, message:
789 raise UsageError, _('"%(value)s": %(message)s')%locals()
790 elif isinstance(proptype, hyperdb.Password):
791 m = pwre.match(value)
792 if m:
793 # password is being given to us encrypted
794 p = password.Password()
795 p.scheme = m.group(1)
796 p.password = m.group(2)
797 props[propname] = p
798 else:
799 props[propname] = password.Password(value)
800 elif isinstance(proptype, hyperdb.Multilink):
801 props[propname] = value.split(',')
802 elif isinstance(proptype, hyperdb.Boolean):
803 props[propname] = value.lower() in ('yes', 'true', 'on', '1')
804 elif isinstance(proptype, hyperdb.Number):
805 props[propname] = float(value)
807 # check for the key property
808 propname = cl.getkey()
809 if propname and not props.has_key(propname):
810 raise UsageError, _('you must provide the "%(propname)s" '
811 'property.')%locals()
813 # do the actual create
814 try:
815 print apply(cl.create, (), props)
816 except (TypeError, IndexError, ValueError), message:
817 raise UsageError, message
818 return 0
820 def do_list(self, args):
821 '''Usage: list classname [property]
822 List the instances of a class.
824 Lists all instances of the given class. If the property is not
825 specified, the "label" property is used. The label property is tried
826 in order: the key, "name", "title" and then the first property,
827 alphabetically.
829 With -c, -S or -s print a list of item id's if no property specified.
830 If property specified, print list of that property for every class
831 instance.
832 '''
833 if len(args) > 2:
834 raise UsageError, _('Too many arguments supplied')
835 if len(args) < 1:
836 raise UsageError, _('Not enough arguments supplied')
837 classname = args[0]
839 # get the class
840 cl = self.get_class(classname)
842 # figure the property
843 if len(args) > 1:
844 propname = args[1]
845 else:
846 propname = cl.labelprop()
848 if self.separator:
849 if len(args) == 2:
850 # create a list of propnames since user specified propname
851 proplist=[]
852 for nodeid in cl.list():
853 try:
854 proplist.append(cl.get(nodeid, propname))
855 except KeyError:
856 raise UsageError, _('%(classname)s has no property '
857 '"%(propname)s"')%locals()
858 print self.separator.join(proplist)
859 else:
860 # create a list of index id's since user didn't specify
861 # otherwise
862 print self.separator.join(cl.list())
863 else:
864 for nodeid in cl.list():
865 try:
866 value = cl.get(nodeid, propname)
867 except KeyError:
868 raise UsageError, _('%(classname)s has no property '
869 '"%(propname)s"')%locals()
870 print _('%(nodeid)4s: %(value)s')%locals()
871 return 0
873 def do_table(self, args):
874 '''Usage: table classname [property[,property]*]
875 List the instances of a class in tabular form.
877 Lists all instances of the given class. If the properties are not
878 specified, all properties are displayed. By default, the column widths
879 are the width of the largest value. The width may be explicitly defined
880 by defining the property as "name:width". For example::
881 roundup> table priority id,name:10
882 Id Name
883 1 fatal-bug
884 2 bug
885 3 usability
886 4 feature
888 Also to make the width of the column the width of the label,
889 leave a trailing : without a width on the property. E.G.
890 roundup> table priority id,name:
891 Id Name
892 1 fata
893 2 bug
894 3 usab
895 4 feat
897 will result in a the 4 character wide "Name" column.
898 '''
899 if len(args) < 1:
900 raise UsageError, _('Not enough arguments supplied')
901 classname = args[0]
903 # get the class
904 cl = self.get_class(classname)
906 # figure the property names to display
907 if len(args) > 1:
908 prop_names = args[1].split(',')
909 all_props = cl.getprops()
910 for spec in prop_names:
911 if ':' in spec:
912 try:
913 propname, width = spec.split(':')
914 except (ValueError, TypeError):
915 raise UsageError, _('"%(spec)s" not name:width')%locals()
916 else:
917 propname = spec
918 if not all_props.has_key(propname):
919 raise UsageError, _('%(classname)s has no property '
920 '"%(propname)s"')%locals()
921 else:
922 prop_names = cl.getprops().keys()
924 # now figure column widths
925 props = []
926 for spec in prop_names:
927 if ':' in spec:
928 name, width = spec.split(':')
929 if width == '':
930 props.append((name, len(spec)))
931 else:
932 props.append((name, int(width)))
933 else:
934 # this is going to be slow
935 maxlen = len(spec)
936 for nodeid in cl.list():
937 curlen = len(str(cl.get(nodeid, spec)))
938 if curlen > maxlen:
939 maxlen = curlen
940 props.append((spec, maxlen))
942 # now display the heading
943 print ' '.join([name.capitalize().ljust(width) for name,width in props])
945 # and the table data
946 for nodeid in cl.list():
947 l = []
948 for name, width in props:
949 if name != 'id':
950 try:
951 value = str(cl.get(nodeid, name))
952 except KeyError:
953 # we already checked if the property is valid - a
954 # KeyError here means the node just doesn't have a
955 # value for it
956 value = ''
957 else:
958 value = str(nodeid)
959 f = '%%-%ds'%width
960 l.append(f%value[:width])
961 print ' '.join(l)
962 return 0
964 def do_history(self, args):
965 '''Usage: history designator
966 Show the history entries of a designator.
968 Lists the journal entries for the node identified by the designator.
969 '''
970 if len(args) < 1:
971 raise UsageError, _('Not enough arguments supplied')
972 try:
973 classname, nodeid = hyperdb.splitDesignator(args[0])
974 except hyperdb.DesignatorError, message:
975 raise UsageError, message
977 try:
978 print self.db.getclass(classname).history(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_commit(self, args):
986 '''Usage: commit
987 Commit all changes made to the database.
989 The changes made during an interactive session are not
990 automatically written to the database - they must be committed
991 using this command.
993 One-off commands on the command-line are automatically committed if
994 they are successful.
995 '''
996 self.db.commit()
997 return 0
999 def do_rollback(self, args):
1000 '''Usage: rollback
1001 Undo all changes that are pending commit to the database.
1003 The changes made during an interactive session are not
1004 automatically written to the database - they must be committed
1005 manually. This command undoes all those changes, so a commit
1006 immediately after would make no changes to the database.
1007 '''
1008 self.db.rollback()
1009 return 0
1011 def do_retire(self, args):
1012 '''Usage: retire designator[,designator]*
1013 Retire the node specified by designator.
1015 This action indicates that a particular node is not to be retrieved by
1016 the list or find commands, and its key value may be re-used.
1017 '''
1018 if len(args) < 1:
1019 raise UsageError, _('Not enough arguments supplied')
1020 designators = args[0].split(',')
1021 for designator in designators:
1022 try:
1023 classname, nodeid = hyperdb.splitDesignator(designator)
1024 except hyperdb.DesignatorError, message:
1025 raise UsageError, message
1026 try:
1027 self.db.getclass(classname).retire(nodeid)
1028 except KeyError:
1029 raise UsageError, _('no such class "%(classname)s"')%locals()
1030 except IndexError:
1031 raise UsageError, _('no such %(classname)s node "%(nodeid)s"')%locals()
1032 return 0
1034 def do_restore(self, args):
1035 '''Usage: restore designator[,designator]*
1036 Restore the retired node specified by designator.
1038 The given nodes will become available for users again.
1039 '''
1040 if len(args) < 1:
1041 raise UsageError, _('Not enough arguments supplied')
1042 designators = args[0].split(',')
1043 for designator in designators:
1044 try:
1045 classname, nodeid = hyperdb.splitDesignator(designator)
1046 except hyperdb.DesignatorError, message:
1047 raise UsageError, message
1048 try:
1049 self.db.getclass(classname).restore(nodeid)
1050 except KeyError:
1051 raise UsageError, _('no such class "%(classname)s"')%locals()
1052 except IndexError:
1053 raise UsageError, _('no such %(classname)s node "%(nodeid)s"')%locals()
1054 return 0
1056 def do_export(self, args):
1057 '''Usage: export [class[,class]] export_dir
1058 Export the database to colon-separated-value files.
1060 This action exports the current data from the database into
1061 colon-separated-value files that are placed in the nominated
1062 destination directory. The journals are not exported.
1063 '''
1064 # grab the directory to export to
1065 if len(args) < 1:
1066 raise UsageError, _('Not enough arguments supplied')
1067 if rcsv.error:
1068 raise UsageError, _(rcsv.error)
1070 dir = args[-1]
1072 # get the list of classes to export
1073 if len(args) == 2:
1074 classes = args[0].split(',')
1075 else:
1076 classes = self.db.classes.keys()
1078 # do all the classes specified
1079 for classname in classes:
1080 cl = self.get_class(classname)
1081 f = open(os.path.join(dir, classname+'.csv'), 'w')
1082 writer = rcsv.writer(f, rcsv.colon_separated)
1083 properties = cl.getprops()
1084 propnames = properties.keys()
1085 propnames.sort()
1086 fields = propnames[:]
1087 fields.append('is retired')
1088 writer.writerow(fields)
1090 # all nodes for this class (not using list() 'cos it doesn't
1091 # include retired nodes)
1093 for nodeid in self.db.getclass(classname).getnodeids():
1094 # get the regular props
1095 writer.writerow (cl.export_list(propnames, nodeid))
1097 # close this file
1098 f.close()
1099 return 0
1101 def do_import(self, args):
1102 '''Usage: import import_dir
1103 Import a database from the directory containing CSV files, one per
1104 class to import.
1106 The files must define the same properties as the class (including having
1107 a "header" line with those property names.)
1109 The imported nodes will have the same nodeid as defined in the
1110 import file, thus replacing any existing content.
1112 The new nodes are added to the existing database - if you want to
1113 create a new database using the imported data, then create a new
1114 database (or, tediously, retire all the old data.)
1115 '''
1116 if len(args) < 1:
1117 raise UsageError, _('Not enough arguments supplied')
1118 if rcsv.error:
1119 raise UsageError, _(rcsv.error)
1120 from roundup import hyperdb
1122 for file in os.listdir(args[0]):
1123 # we only care about CSV files
1124 if not file.endswith('.csv'):
1125 continue
1127 f = open(os.path.join(args[0], file))
1129 # get the classname
1130 classname = os.path.splitext(file)[0]
1132 # ensure that the properties and the CSV file headings match
1133 cl = self.get_class(classname)
1134 reader = rcsv.reader(f, rcsv.colon_separated)
1135 file_props = None
1136 maxid = 1
1138 # loop through the file and create a node for each entry
1139 for r in reader:
1140 if file_props is None:
1141 file_props = r
1142 continue
1144 # do the import and figure the current highest nodeid
1145 maxid = max(maxid, int(cl.import_list(file_props, r)))
1147 # set the id counter
1148 print 'setting', classname, maxid+1
1149 self.db.setid(classname, str(maxid+1))
1150 return 0
1152 def do_pack(self, args):
1153 '''Usage: pack period | date
1155 Remove journal entries older than a period of time specified or
1156 before a certain date.
1158 A period is specified using the suffixes "y", "m", and "d". The
1159 suffix "w" (for "week") means 7 days.
1161 "3y" means three years
1162 "2y 1m" means two years and one month
1163 "1m 25d" means one month and 25 days
1164 "2w 3d" means two weeks and three days
1166 Date format is "YYYY-MM-DD" eg:
1167 2001-01-01
1169 '''
1170 if len(args) <> 1:
1171 raise UsageError, _('Not enough arguments supplied')
1173 # are we dealing with a period or a date
1174 value = args[0]
1175 date_re = re.compile(r'''
1176 (?P<date>\d\d\d\d-\d\d?-\d\d?)? # yyyy-mm-dd
1177 (?P<period>(\d+y\s*)?(\d+m\s*)?(\d+d\s*)?)?
1178 ''', re.VERBOSE)
1179 m = date_re.match(value)
1180 if not m:
1181 raise ValueError, _('Invalid format')
1182 m = m.groupdict()
1183 if m['period']:
1184 pack_before = date.Date(". - %s"%value)
1185 elif m['date']:
1186 pack_before = date.Date(value)
1187 self.db.pack(pack_before)
1188 return 0
1190 def do_reindex(self, args):
1191 '''Usage: reindex
1192 Re-generate a tracker's search indexes.
1194 This will re-generate the search indexes for a tracker. This will
1195 typically happen automatically.
1196 '''
1197 self.db.indexer.force_reindex()
1198 self.db.reindex()
1199 return 0
1201 def do_security(self, args):
1202 '''Usage: security [Role name]
1203 Display the Permissions available to one or all Roles.
1204 '''
1205 if len(args) == 1:
1206 role = args[0]
1207 try:
1208 roles = [(args[0], self.db.security.role[args[0]])]
1209 except KeyError:
1210 print _('No such Role "%(role)s"')%locals()
1211 return 1
1212 else:
1213 roles = self.db.security.role.items()
1214 role = self.db.config.NEW_WEB_USER_ROLES
1215 if ',' in role:
1216 print _('New Web users get the Roles "%(role)s"')%locals()
1217 else:
1218 print _('New Web users get the Role "%(role)s"')%locals()
1219 role = self.db.config.NEW_EMAIL_USER_ROLES
1220 if ',' in role:
1221 print _('New Email users get the Roles "%(role)s"')%locals()
1222 else:
1223 print _('New Email users get the Role "%(role)s"')%locals()
1224 roles.sort()
1225 for rolename, role in roles:
1226 print _('Role "%(name)s":')%role.__dict__
1227 for permission in role.permissions:
1228 if permission.klass:
1229 print _(' %(description)s (%(name)s for "%(klass)s" '
1230 'only)')%permission.__dict__
1231 else:
1232 print _(' %(description)s (%(name)s)')%permission.__dict__
1233 return 0
1235 def run_command(self, args):
1236 '''Run a single command
1237 '''
1238 command = args[0]
1240 # handle help now
1241 if command == 'help':
1242 if len(args)>1:
1243 self.do_help(args[1:])
1244 return 0
1245 self.do_help(['help'])
1246 return 0
1247 if command == 'morehelp':
1248 self.do_help(['help'])
1249 self.help_commands()
1250 self.help_all()
1251 return 0
1253 # figure what the command is
1254 try:
1255 functions = self.commands.get(command)
1256 except KeyError:
1257 # not a valid command
1258 print _('Unknown command "%(command)s" ("help commands" for a '
1259 'list)')%locals()
1260 return 1
1262 # check for multiple matches
1263 if len(functions) > 1:
1264 print _('Multiple commands match "%(command)s": %(list)s')%{'command':
1265 command, 'list': ', '.join([i[0] for i in functions])}
1266 return 1
1267 command, function = functions[0]
1269 # make sure we have a tracker_home
1270 while not self.tracker_home:
1271 self.tracker_home = raw_input(_('Enter tracker home: ')).strip()
1273 # before we open the db, we may be doing an install or init
1274 if command == 'initialise':
1275 try:
1276 return self.do_initialise(self.tracker_home, args)
1277 except UsageError, message:
1278 print _('Error: %(message)s')%locals()
1279 return 1
1280 elif command == 'install':
1281 try:
1282 return self.do_install(self.tracker_home, args)
1283 except UsageError, message:
1284 print _('Error: %(message)s')%locals()
1285 return 1
1287 # get the tracker
1288 try:
1289 tracker = roundup.instance.open(self.tracker_home)
1290 except ValueError, message:
1291 self.tracker_home = ''
1292 print _("Error: Couldn't open tracker: %(message)s")%locals()
1293 return 1
1295 # only open the database once!
1296 if not self.db:
1297 self.db = tracker.open('admin')
1299 # do the command
1300 ret = 0
1301 try:
1302 ret = function(args[1:])
1303 except UsageError, message:
1304 print _('Error: %(message)s')%locals()
1305 print
1306 print function.__doc__
1307 ret = 1
1308 except:
1309 import traceback
1310 traceback.print_exc()
1311 ret = 1
1312 return ret
1314 def interactive(self):
1315 '''Run in an interactive mode
1316 '''
1317 print _('Roundup %s ready for input.'%roundup_version)
1318 print _('Type "help" for help.')
1319 try:
1320 import readline
1321 except ImportError:
1322 print _('Note: command history and editing not available')
1324 while 1:
1325 try:
1326 command = raw_input(_('roundup> '))
1327 except EOFError:
1328 print _('exit...')
1329 break
1330 if not command: continue
1331 args = token.token_split(command)
1332 if not args: continue
1333 if args[0] in ('quit', 'exit'): break
1334 self.run_command(args)
1336 # exit.. check for transactions
1337 if self.db and self.db.transactions:
1338 commit = raw_input(_('There are unsaved changes. Commit them (y/N)? '))
1339 if commit and commit[0].lower() == 'y':
1340 self.db.commit()
1341 return 0
1343 def main(self):
1344 try:
1345 opts, args = getopt.getopt(sys.argv[1:], 'i:u:hcdsS:')
1346 except getopt.GetoptError, e:
1347 self.usage(str(e))
1348 return 1
1350 # handle command-line args
1351 self.tracker_home = os.environ.get('TRACKER_HOME', '')
1352 # TODO: reinstate the user/password stuff (-u arg too)
1353 name = password = ''
1354 if os.environ.has_key('ROUNDUP_LOGIN'):
1355 l = os.environ['ROUNDUP_LOGIN'].split(':')
1356 name = l[0]
1357 if len(l) > 1:
1358 password = l[1]
1359 self.separator = None
1360 self.print_designator = 0
1361 for opt, arg in opts:
1362 if opt == '-h':
1363 self.usage()
1364 return 0
1365 if opt == '-i':
1366 self.tracker_home = arg
1367 if opt == '-c':
1368 if self.separator != None:
1369 self.usage('Only one of -c, -S and -s may be specified')
1370 return 1
1371 self.separator = ','
1372 if opt == '-S':
1373 if self.separator != None:
1374 self.usage('Only one of -c, -S and -s may be specified')
1375 return 1
1376 self.separator = arg
1377 if opt == '-s':
1378 if self.separator != None:
1379 self.usage('Only one of -c, -S and -s may be specified')
1380 return 1
1381 self.separator = ' '
1382 if opt == '-d':
1383 self.print_designator = 1
1385 # if no command - go interactive
1386 # wrap in a try/finally so we always close off the db
1387 ret = 0
1388 try:
1389 if not args:
1390 self.interactive()
1391 else:
1392 ret = self.run_command(args)
1393 if self.db: self.db.commit()
1394 return ret
1395 finally:
1396 if self.db:
1397 self.db.close()
1400 def listTemplates(dir):
1401 ''' List all the Roundup template directories in a given directory.
1403 Find all the dirs that contain a TEMPLATE-INFO.txt and parse it.
1405 Return a list of dicts of info about the templates.
1406 '''
1407 ret = {}
1408 for idir in os.listdir(dir):
1409 idir = os.path.join(dir, idir)
1410 ti = loadTemplate(idir)
1411 if ti:
1412 ret[ti['name']] = ti
1413 return ret
1415 def loadTemplate(dir):
1416 ''' Attempt to load a Roundup template from the indicated directory.
1418 Return None if there's no template, otherwise a template info
1419 dictionary.
1420 '''
1421 ti = os.path.join(dir, 'TEMPLATE-INFO.txt')
1422 if not os.path.exists(ti):
1423 return None
1425 # load up the template's information
1426 m = rfc822.Message(open(ti))
1427 ti = {}
1428 ti['name'] = m['name']
1429 ti['description'] = m['description']
1430 ti['intended-for'] = m['intended-for']
1431 ti['path'] = dir
1432 return ti
1434 if __name__ == '__main__':
1435 tool = AdminTool()
1436 sys.exit(tool.main())
1438 # vim: set filetype=python ts=4 sw=4 et si