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