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.57 2003-08-28 04:46:39 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 for key in cl.properties.keys():
723 value = cl.get(nodeid, key)
724 print _('%(key)s: %(value)s')%locals()
726 def do_create(self, args, pwre = re.compile(r'{(\w+)}(.+)')):
727 '''Usage: create classname property=value ...
728 Create a new entry of a given class.
730 This creates a new entry of the given class using the property
731 name=value arguments provided on the command line after the "create"
732 command.
733 '''
734 if len(args) < 1:
735 raise UsageError, _('Not enough arguments supplied')
736 from roundup import hyperdb
738 classname = args[0]
740 # get the class
741 cl = self.get_class(classname)
743 # now do a create
744 props = {}
745 properties = cl.getprops(protected = 0)
746 if len(args) == 1:
747 # ask for the properties
748 for key, value in properties.items():
749 if key == 'id': continue
750 name = value.__class__.__name__
751 if isinstance(value , hyperdb.Password):
752 again = None
753 while value != again:
754 value = getpass.getpass(_('%(propname)s (Password): ')%{
755 'propname': key.capitalize()})
756 again = getpass.getpass(_(' %(propname)s (Again): ')%{
757 'propname': key.capitalize()})
758 if value != again: print _('Sorry, try again...')
759 if value:
760 props[key] = value
761 else:
762 value = raw_input(_('%(propname)s (%(proptype)s): ')%{
763 'propname': key.capitalize(), 'proptype': name})
764 if value:
765 props[key] = value
766 else:
767 props = self.props_from_args(args[1:])
769 # convert types
770 for propname, value in props.items():
771 # get the property
772 try:
773 proptype = properties[propname]
774 except KeyError:
775 raise UsageError, _('%(classname)s has no property '
776 '"%(propname)s"')%locals()
778 if isinstance(proptype, hyperdb.Date):
779 try:
780 props[propname] = date.Date(value)
781 except ValueError, message:
782 raise UsageError, _('"%(value)s": %(message)s')%locals()
783 elif isinstance(proptype, hyperdb.Interval):
784 try:
785 props[propname] = date.Interval(value)
786 except ValueError, message:
787 raise UsageError, _('"%(value)s": %(message)s')%locals()
788 elif isinstance(proptype, hyperdb.Password):
789 m = pwre.match(value)
790 if m:
791 # password is being given to us encrypted
792 p = password.Password()
793 p.scheme = m.group(1)
794 p.password = m.group(2)
795 props[propname] = p
796 else:
797 props[propname] = password.Password(value)
798 elif isinstance(proptype, hyperdb.Multilink):
799 props[propname] = value.split(',')
800 elif isinstance(proptype, hyperdb.Boolean):
801 props[propname] = value.lower() in ('yes', 'true', 'on', '1')
802 elif isinstance(proptype, hyperdb.Number):
803 props[propname] = float(value)
805 # check for the key property
806 propname = cl.getkey()
807 if propname and not props.has_key(propname):
808 raise UsageError, _('you must provide the "%(propname)s" '
809 'property.')%locals()
811 # do the actual create
812 try:
813 print apply(cl.create, (), props)
814 except (TypeError, IndexError, ValueError), message:
815 raise UsageError, message
816 return 0
818 def do_list(self, args):
819 '''Usage: list classname [property]
820 List the instances of a class.
822 Lists all instances of the given class. If the property is not
823 specified, the "label" property is used. The label property is tried
824 in order: the key, "name", "title" and then the first property,
825 alphabetically.
827 With -c, -S or -s print a list of item id's if no property specified.
828 If property specified, print list of that property for every class
829 instance.
830 '''
831 if len(args) > 2:
832 raise UsageError, _('Too many arguments supplied')
833 if len(args) < 1:
834 raise UsageError, _('Not enough arguments supplied')
835 classname = args[0]
837 # get the class
838 cl = self.get_class(classname)
840 # figure the property
841 if len(args) > 1:
842 propname = args[1]
843 else:
844 propname = cl.labelprop()
846 if self.separator:
847 if len(args) == 2:
848 # create a list of propnames since user specified propname
849 proplist=[]
850 for nodeid in cl.list():
851 try:
852 proplist.append(cl.get(nodeid, propname))
853 except KeyError:
854 raise UsageError, _('%(classname)s has no property '
855 '"%(propname)s"')%locals()
856 print self.separator.join(proplist)
857 else:
858 # create a list of index id's since user didn't specify
859 # otherwise
860 print self.separator.join(cl.list())
861 else:
862 for nodeid in cl.list():
863 try:
864 value = cl.get(nodeid, propname)
865 except KeyError:
866 raise UsageError, _('%(classname)s has no property '
867 '"%(propname)s"')%locals()
868 print _('%(nodeid)4s: %(value)s')%locals()
869 return 0
871 def do_table(self, args):
872 '''Usage: table classname [property[,property]*]
873 List the instances of a class in tabular form.
875 Lists all instances of the given class. If the properties are not
876 specified, all properties are displayed. By default, the column widths
877 are the width of the largest value. The width may be explicitly defined
878 by defining the property as "name:width". For example::
879 roundup> table priority id,name:10
880 Id Name
881 1 fatal-bug
882 2 bug
883 3 usability
884 4 feature
886 Also to make the width of the column the width of the label,
887 leave a trailing : without a width on the property. E.G.
888 roundup> table priority id,name:
889 Id Name
890 1 fata
891 2 bug
892 3 usab
893 4 feat
895 will result in a the 4 character wide "Name" column.
896 '''
897 if len(args) < 1:
898 raise UsageError, _('Not enough arguments supplied')
899 classname = args[0]
901 # get the class
902 cl = self.get_class(classname)
904 # figure the property names to display
905 if len(args) > 1:
906 prop_names = args[1].split(',')
907 all_props = cl.getprops()
908 for spec in prop_names:
909 if ':' in spec:
910 try:
911 propname, width = spec.split(':')
912 except (ValueError, TypeError):
913 raise UsageError, _('"%(spec)s" not name:width')%locals()
914 else:
915 propname = spec
916 if not all_props.has_key(propname):
917 raise UsageError, _('%(classname)s has no property '
918 '"%(propname)s"')%locals()
919 else:
920 prop_names = cl.getprops().keys()
922 # now figure column widths
923 props = []
924 for spec in prop_names:
925 if ':' in spec:
926 name, width = spec.split(':')
927 if width == '':
928 props.append((name, len(spec)))
929 else:
930 props.append((name, int(width)))
931 else:
932 # this is going to be slow
933 maxlen = len(spec)
934 for nodeid in cl.list():
935 curlen = len(str(cl.get(nodeid, spec)))
936 if curlen > maxlen:
937 maxlen = curlen
938 props.append((spec, maxlen))
940 # now display the heading
941 print ' '.join([name.capitalize().ljust(width) for name,width in props])
943 # and the table data
944 for nodeid in cl.list():
945 l = []
946 for name, width in props:
947 if name != 'id':
948 try:
949 value = str(cl.get(nodeid, name))
950 except KeyError:
951 # we already checked if the property is valid - a
952 # KeyError here means the node just doesn't have a
953 # value for it
954 value = ''
955 else:
956 value = str(nodeid)
957 f = '%%-%ds'%width
958 l.append(f%value[:width])
959 print ' '.join(l)
960 return 0
962 def do_history(self, args):
963 '''Usage: history designator
964 Show the history entries of a designator.
966 Lists the journal entries for the node identified by the designator.
967 '''
968 if len(args) < 1:
969 raise UsageError, _('Not enough arguments supplied')
970 try:
971 classname, nodeid = hyperdb.splitDesignator(args[0])
972 except hyperdb.DesignatorError, message:
973 raise UsageError, message
975 try:
976 print self.db.getclass(classname).history(nodeid)
977 except KeyError:
978 raise UsageError, _('no such class "%(classname)s"')%locals()
979 except IndexError:
980 raise UsageError, _('no such %(classname)s node "%(nodeid)s"')%locals()
981 return 0
983 def do_commit(self, args):
984 '''Usage: commit
985 Commit all changes made to the database.
987 The changes made during an interactive session are not
988 automatically written to the database - they must be committed
989 using this command.
991 One-off commands on the command-line are automatically committed if
992 they are successful.
993 '''
994 self.db.commit()
995 return 0
997 def do_rollback(self, args):
998 '''Usage: rollback
999 Undo all changes that are pending commit to the database.
1001 The changes made during an interactive session are not
1002 automatically written to the database - they must be committed
1003 manually. This command undoes all those changes, so a commit
1004 immediately after would make no changes to the database.
1005 '''
1006 self.db.rollback()
1007 return 0
1009 def do_retire(self, args):
1010 '''Usage: retire designator[,designator]*
1011 Retire the node specified by designator.
1013 This action indicates that a particular node is not to be retrieved by
1014 the list or find commands, and its key value may be re-used.
1015 '''
1016 if len(args) < 1:
1017 raise UsageError, _('Not enough arguments supplied')
1018 designators = args[0].split(',')
1019 for designator in designators:
1020 try:
1021 classname, nodeid = hyperdb.splitDesignator(designator)
1022 except hyperdb.DesignatorError, message:
1023 raise UsageError, message
1024 try:
1025 self.db.getclass(classname).retire(nodeid)
1026 except KeyError:
1027 raise UsageError, _('no such class "%(classname)s"')%locals()
1028 except IndexError:
1029 raise UsageError, _('no such %(classname)s node "%(nodeid)s"')%locals()
1030 return 0
1032 def do_restore(self, args):
1033 '''Usage: restore designator[,designator]*
1034 Restore the retired node specified by designator.
1036 The given nodes will become available for users again.
1037 '''
1038 if len(args) < 1:
1039 raise UsageError, _('Not enough arguments supplied')
1040 designators = args[0].split(',')
1041 for designator in designators:
1042 try:
1043 classname, nodeid = hyperdb.splitDesignator(designator)
1044 except hyperdb.DesignatorError, message:
1045 raise UsageError, message
1046 try:
1047 self.db.getclass(classname).restore(nodeid)
1048 except KeyError:
1049 raise UsageError, _('no such class "%(classname)s"')%locals()
1050 except IndexError:
1051 raise UsageError, _('no such %(classname)s node "%(nodeid)s"')%locals()
1052 return 0
1054 def do_export(self, args):
1055 '''Usage: export [class[,class]] export_dir
1056 Export the database to colon-separated-value files.
1058 This action exports the current data from the database into
1059 colon-separated-value files that are placed in the nominated
1060 destination directory. The journals are not exported.
1061 '''
1062 # grab the directory to export to
1063 if len(args) < 1:
1064 raise UsageError, _('Not enough arguments supplied')
1065 if rcsv.error:
1066 raise UsageError, _(rcsv.error)
1068 dir = args[-1]
1070 # get the list of classes to export
1071 if len(args) == 2:
1072 classes = args[0].split(',')
1073 else:
1074 classes = self.db.classes.keys()
1076 # do all the classes specified
1077 for classname in classes:
1078 cl = self.get_class(classname)
1079 f = open(os.path.join(dir, classname+'.csv'), 'w')
1080 writer = rcsv.writer(f, rcsv.colon_separated)
1081 properties = cl.getprops()
1082 propnames = properties.keys()
1083 propnames.sort()
1084 fields = propnames[:]
1085 fields.append('is retired')
1086 writer.writerow(fields)
1088 # all nodes for this class (not using list() 'cos it doesn't
1089 # include retired nodes)
1091 for nodeid in self.db.getclass(classname).getnodeids():
1092 # get the regular props
1093 writer.writerow (cl.export_list(propnames, nodeid))
1095 # close this file
1096 f.close()
1097 return 0
1099 def do_import(self, args):
1100 '''Usage: import import_dir
1101 Import a database from the directory containing CSV files, one per
1102 class to import.
1104 The files must define the same properties as the class (including having
1105 a "header" line with those property names.)
1107 The imported nodes will have the same nodeid as defined in the
1108 import file, thus replacing any existing content.
1110 The new nodes are added to the existing database - if you want to
1111 create a new database using the imported data, then create a new
1112 database (or, tediously, retire all the old data.)
1113 '''
1114 if len(args) < 1:
1115 raise UsageError, _('Not enough arguments supplied')
1116 if rcsv.error:
1117 raise UsageError, _(rcsv.error)
1118 from roundup import hyperdb
1120 for file in os.listdir(args[0]):
1121 # we only care about CSV files
1122 if not file.endswith('.csv'):
1123 continue
1125 f = open(os.path.join(args[0], file))
1127 # get the classname
1128 classname = os.path.splitext(file)[0]
1130 # ensure that the properties and the CSV file headings match
1131 cl = self.get_class(classname)
1132 reader = rcsv.reader(f, rcsv.colon_separated)
1133 file_props = None
1134 maxid = 1
1136 # loop through the file and create a node for each entry
1137 for r in reader:
1138 if file_props is None:
1139 file_props = r
1140 continue
1142 # do the import and figure the current highest nodeid
1143 maxid = max(maxid, int(cl.import_list(file_props, r)))
1145 # set the id counter
1146 print 'setting', classname, maxid+1
1147 self.db.setid(classname, str(maxid+1))
1148 return 0
1150 def do_pack(self, args):
1151 '''Usage: pack period | date
1153 Remove journal entries older than a period of time specified or
1154 before a certain date.
1156 A period is specified using the suffixes "y", "m", and "d". The
1157 suffix "w" (for "week") means 7 days.
1159 "3y" means three years
1160 "2y 1m" means two years and one month
1161 "1m 25d" means one month and 25 days
1162 "2w 3d" means two weeks and three days
1164 Date format is "YYYY-MM-DD" eg:
1165 2001-01-01
1167 '''
1168 if len(args) <> 1:
1169 raise UsageError, _('Not enough arguments supplied')
1171 # are we dealing with a period or a date
1172 value = args[0]
1173 date_re = re.compile(r'''
1174 (?P<date>\d\d\d\d-\d\d?-\d\d?)? # yyyy-mm-dd
1175 (?P<period>(\d+y\s*)?(\d+m\s*)?(\d+d\s*)?)?
1176 ''', re.VERBOSE)
1177 m = date_re.match(value)
1178 if not m:
1179 raise ValueError, _('Invalid format')
1180 m = m.groupdict()
1181 if m['period']:
1182 pack_before = date.Date(". - %s"%value)
1183 elif m['date']:
1184 pack_before = date.Date(value)
1185 self.db.pack(pack_before)
1186 return 0
1188 def do_reindex(self, args):
1189 '''Usage: reindex
1190 Re-generate a tracker's search indexes.
1192 This will re-generate the search indexes for a tracker. This will
1193 typically happen automatically.
1194 '''
1195 self.db.indexer.force_reindex()
1196 self.db.reindex()
1197 return 0
1199 def do_security(self, args):
1200 '''Usage: security [Role name]
1201 Display the Permissions available to one or all Roles.
1202 '''
1203 if len(args) == 1:
1204 role = args[0]
1205 try:
1206 roles = [(args[0], self.db.security.role[args[0]])]
1207 except KeyError:
1208 print _('No such Role "%(role)s"')%locals()
1209 return 1
1210 else:
1211 roles = self.db.security.role.items()
1212 role = self.db.config.NEW_WEB_USER_ROLES
1213 if ',' in role:
1214 print _('New Web users get the Roles "%(role)s"')%locals()
1215 else:
1216 print _('New Web users get the Role "%(role)s"')%locals()
1217 role = self.db.config.NEW_EMAIL_USER_ROLES
1218 if ',' in role:
1219 print _('New Email users get the Roles "%(role)s"')%locals()
1220 else:
1221 print _('New Email users get the Role "%(role)s"')%locals()
1222 roles.sort()
1223 for rolename, role in roles:
1224 print _('Role "%(name)s":')%role.__dict__
1225 for permission in role.permissions:
1226 if permission.klass:
1227 print _(' %(description)s (%(name)s for "%(klass)s" '
1228 'only)')%permission.__dict__
1229 else:
1230 print _(' %(description)s (%(name)s)')%permission.__dict__
1231 return 0
1233 def run_command(self, args):
1234 '''Run a single command
1235 '''
1236 command = args[0]
1238 # handle help now
1239 if command == 'help':
1240 if len(args)>1:
1241 self.do_help(args[1:])
1242 return 0
1243 self.do_help(['help'])
1244 return 0
1245 if command == 'morehelp':
1246 self.do_help(['help'])
1247 self.help_commands()
1248 self.help_all()
1249 return 0
1251 # figure what the command is
1252 try:
1253 functions = self.commands.get(command)
1254 except KeyError:
1255 # not a valid command
1256 print _('Unknown command "%(command)s" ("help commands" for a '
1257 'list)')%locals()
1258 return 1
1260 # check for multiple matches
1261 if len(functions) > 1:
1262 print _('Multiple commands match "%(command)s": %(list)s')%{'command':
1263 command, 'list': ', '.join([i[0] for i in functions])}
1264 return 1
1265 command, function = functions[0]
1267 # make sure we have a tracker_home
1268 while not self.tracker_home:
1269 self.tracker_home = raw_input(_('Enter tracker home: ')).strip()
1271 # before we open the db, we may be doing an install or init
1272 if command == 'initialise':
1273 try:
1274 return self.do_initialise(self.tracker_home, args)
1275 except UsageError, message:
1276 print _('Error: %(message)s')%locals()
1277 return 1
1278 elif command == 'install':
1279 try:
1280 return self.do_install(self.tracker_home, args)
1281 except UsageError, message:
1282 print _('Error: %(message)s')%locals()
1283 return 1
1285 # get the tracker
1286 try:
1287 tracker = roundup.instance.open(self.tracker_home)
1288 except ValueError, message:
1289 self.tracker_home = ''
1290 print _("Error: Couldn't open tracker: %(message)s")%locals()
1291 return 1
1293 # only open the database once!
1294 if not self.db:
1295 self.db = tracker.open('admin')
1297 # do the command
1298 ret = 0
1299 try:
1300 ret = function(args[1:])
1301 except UsageError, message:
1302 print _('Error: %(message)s')%locals()
1303 print
1304 print function.__doc__
1305 ret = 1
1306 except:
1307 import traceback
1308 traceback.print_exc()
1309 ret = 1
1310 return ret
1312 def interactive(self):
1313 '''Run in an interactive mode
1314 '''
1315 print _('Roundup %s ready for input.'%roundup_version)
1316 print _('Type "help" for help.')
1317 try:
1318 import readline
1319 except ImportError:
1320 print _('Note: command history and editing not available')
1322 while 1:
1323 try:
1324 command = raw_input(_('roundup> '))
1325 except EOFError:
1326 print _('exit...')
1327 break
1328 if not command: continue
1329 args = token.token_split(command)
1330 if not args: continue
1331 if args[0] in ('quit', 'exit'): break
1332 self.run_command(args)
1334 # exit.. check for transactions
1335 if self.db and self.db.transactions:
1336 commit = raw_input(_('There are unsaved changes. Commit them (y/N)? '))
1337 if commit and commit[0].lower() == 'y':
1338 self.db.commit()
1339 return 0
1341 def main(self):
1342 try:
1343 opts, args = getopt.getopt(sys.argv[1:], 'i:u:hcdsS:')
1344 except getopt.GetoptError, e:
1345 self.usage(str(e))
1346 return 1
1348 # handle command-line args
1349 self.tracker_home = os.environ.get('TRACKER_HOME', '')
1350 # TODO: reinstate the user/password stuff (-u arg too)
1351 name = password = ''
1352 if os.environ.has_key('ROUNDUP_LOGIN'):
1353 l = os.environ['ROUNDUP_LOGIN'].split(':')
1354 name = l[0]
1355 if len(l) > 1:
1356 password = l[1]
1357 self.separator = None
1358 self.print_designator = 0
1359 for opt, arg in opts:
1360 if opt == '-h':
1361 self.usage()
1362 return 0
1363 if opt == '-i':
1364 self.tracker_home = arg
1365 if opt == '-c':
1366 if self.separator != None:
1367 self.usage('Only one of -c, -S and -s may be specified')
1368 return 1
1369 self.separator = ','
1370 if opt == '-S':
1371 if self.separator != None:
1372 self.usage('Only one of -c, -S and -s may be specified')
1373 return 1
1374 self.separator = arg
1375 if opt == '-s':
1376 if self.separator != None:
1377 self.usage('Only one of -c, -S and -s may be specified')
1378 return 1
1379 self.separator = ' '
1380 if opt == '-d':
1381 self.print_designator = 1
1383 # if no command - go interactive
1384 # wrap in a try/finally so we always close off the db
1385 ret = 0
1386 try:
1387 if not args:
1388 self.interactive()
1389 else:
1390 ret = self.run_command(args)
1391 if self.db: self.db.commit()
1392 return ret
1393 finally:
1394 if self.db:
1395 self.db.close()
1398 def listTemplates(dir):
1399 ''' List all the Roundup template directories in a given directory.
1401 Find all the dirs that contain a TEMPLATE-INFO.txt and parse it.
1403 Return a list of dicts of info about the templates.
1404 '''
1405 ret = {}
1406 for idir in os.listdir(dir):
1407 idir = os.path.join(dir, idir)
1408 ti = loadTemplate(idir)
1409 if ti:
1410 ret[ti['name']] = ti
1411 return ret
1413 def loadTemplate(dir):
1414 ''' Attempt to load a Roundup template from the indicated directory.
1416 Return None if there's no template, otherwise a template info
1417 dictionary.
1418 '''
1419 ti = os.path.join(dir, 'TEMPLATE-INFO.txt')
1420 if not os.path.exists(ti):
1421 return None
1423 # load up the template's information
1424 m = rfc822.Message(open(ti))
1425 ti = {}
1426 ti['name'] = m['name']
1427 ti['description'] = m['description']
1428 ti['intended-for'] = m['intended-for']
1429 ti['path'] = dir
1430 return ti
1432 if __name__ == '__main__':
1433 tool = AdminTool()
1434 sys.exit(tool.main())
1436 # vim: set filetype=python ts=4 sw=4 et si