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