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.52 2003-04-17 07:33:08 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 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_ /usr/lib/python2.2/site-packages/roundup/admin.py, and
293 # we're interested in where the "lib" directory is - ie. the /usr/
294 # part
295 path = __file__
296 for i in range(5):
297 path = os.path.dirname(path)
298 tdir = os.path.join(path, 'share', 'roundup', 'templates')
299 if os.path.isdir(tdir):
300 templates = listTemplates(tdir)
301 else:
302 templates = {}
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 # we need the CSV module
1063 if csv is None:
1064 raise UsageError, \
1065 _('Sorry, you need the csv module to use this function.\n'
1066 'Get it from: http://www.object-craft.com.au/projects/csv/')
1068 # grab the directory to export to
1069 if len(args) < 1:
1070 raise UsageError, _('Not enough arguments supplied')
1071 dir = args[-1]
1073 # get the list of classes to export
1074 if len(args) == 2:
1075 classes = args[0].split(',')
1076 else:
1077 classes = self.db.classes.keys()
1079 # use the csv parser if we can - it's faster
1080 p = csv.parser(field_sep=':')
1082 # do all the classes specified
1083 for classname in classes:
1084 cl = self.get_class(classname)
1085 f = open(os.path.join(dir, classname+'.csv'), 'w')
1086 properties = cl.getprops()
1087 propnames = properties.keys()
1088 propnames.sort()
1089 l = propnames[:]
1090 l.append('is retired')
1091 print >> f, p.join(l)
1093 # all nodes for this class (not using list() 'cos it doesn't
1094 # include retired nodes)
1096 for nodeid in self.db.getclass(classname).getnodeids():
1097 # get the regular props
1098 print >>f, p.join(cl.export_list(propnames, nodeid))
1100 # close this file
1101 f.close()
1102 return 0
1104 def do_import(self, args):
1105 '''Usage: import import_dir
1106 Import a database from the directory containing CSV files, one per
1107 class to import.
1109 The files must define the same properties as the class (including having
1110 a "header" line with those property names.)
1112 The imported nodes will have the same nodeid as defined in the
1113 import file, thus replacing any existing content.
1115 The new nodes are added to the existing database - if you want to
1116 create a new database using the imported data, then create a new
1117 database (or, tediously, retire all the old data.)
1118 '''
1119 if len(args) < 1:
1120 raise UsageError, _('Not enough arguments supplied')
1121 if csv is None:
1122 raise UsageError, \
1123 _('Sorry, you need the csv module to use this function.\n'
1124 'Get it from: http://www.object-craft.com.au/projects/csv/')
1126 from roundup import hyperdb
1128 for file in os.listdir(args[0]):
1129 # we only care about CSV files
1130 if not file.endswith('.csv'):
1131 continue
1133 f = open(os.path.join(args[0], file))
1135 # get the classname
1136 classname = os.path.splitext(file)[0]
1138 # ensure that the properties and the CSV file headings match
1139 cl = self.get_class(classname)
1140 p = csv.parser(field_sep=':')
1141 file_props = p.parse(f.readline())
1143 # XXX we don't _really_ need to do this...
1144 # properties = cl.getprops()
1145 # propnames = properties.keys()
1146 # propnames.sort()
1147 # m = file_props[:]
1148 # m.sort()
1149 # if m != propnames:
1150 # raise UsageError, _('Import file doesn\'t define the same '
1151 # 'properties as "%(arg0)s".')%{'arg0': args[0]}
1153 # loop through the file and create a node for each entry
1154 maxid = 1
1155 while 1:
1156 line = f.readline()
1157 if not line: break
1159 # parse lines until we get a complete entry
1160 while 1:
1161 l = p.parse(line)
1162 if l: break
1163 line = f.readline()
1164 if not line:
1165 raise ValueError, "Unexpected EOF during CSV parse"
1167 # do the import and figure the current highest nodeid
1168 maxid = max(maxid, int(cl.import_list(file_props, l)))
1170 print 'setting', classname, maxid+1
1171 self.db.setid(classname, str(maxid+1))
1172 return 0
1174 def do_pack(self, args):
1175 '''Usage: pack period | date
1177 Remove journal entries older than a period of time specified or
1178 before a certain date.
1180 A period is specified using the suffixes "y", "m", and "d". The
1181 suffix "w" (for "week") means 7 days.
1183 "3y" means three years
1184 "2y 1m" means two years and one month
1185 "1m 25d" means one month and 25 days
1186 "2w 3d" means two weeks and three days
1188 Date format is "YYYY-MM-DD" eg:
1189 2001-01-01
1191 '''
1192 if len(args) <> 1:
1193 raise UsageError, _('Not enough arguments supplied')
1195 # are we dealing with a period or a date
1196 value = args[0]
1197 date_re = re.compile(r'''
1198 (?P<date>\d\d\d\d-\d\d?-\d\d?)? # yyyy-mm-dd
1199 (?P<period>(\d+y\s*)?(\d+m\s*)?(\d+d\s*)?)?
1200 ''', re.VERBOSE)
1201 m = date_re.match(value)
1202 if not m:
1203 raise ValueError, _('Invalid format')
1204 m = m.groupdict()
1205 if m['period']:
1206 pack_before = date.Date(". - %s"%value)
1207 elif m['date']:
1208 pack_before = date.Date(value)
1209 self.db.pack(pack_before)
1210 return 0
1212 def do_reindex(self, args):
1213 '''Usage: reindex
1214 Re-generate a tracker's search indexes.
1216 This will re-generate the search indexes for a tracker. This will
1217 typically happen automatically.
1218 '''
1219 self.db.indexer.force_reindex()
1220 self.db.reindex()
1221 return 0
1223 def do_security(self, args):
1224 '''Usage: security [Role name]
1225 Display the Permissions available to one or all Roles.
1226 '''
1227 if len(args) == 1:
1228 role = args[0]
1229 try:
1230 roles = [(args[0], self.db.security.role[args[0]])]
1231 except KeyError:
1232 print _('No such Role "%(role)s"')%locals()
1233 return 1
1234 else:
1235 roles = self.db.security.role.items()
1236 role = self.db.config.NEW_WEB_USER_ROLES
1237 if ',' in role:
1238 print _('New Web users get the Roles "%(role)s"')%locals()
1239 else:
1240 print _('New Web users get the Role "%(role)s"')%locals()
1241 role = self.db.config.NEW_EMAIL_USER_ROLES
1242 if ',' in role:
1243 print _('New Email users get the Roles "%(role)s"')%locals()
1244 else:
1245 print _('New Email users get the Role "%(role)s"')%locals()
1246 roles.sort()
1247 for rolename, role in roles:
1248 print _('Role "%(name)s":')%role.__dict__
1249 for permission in role.permissions:
1250 if permission.klass:
1251 print _(' %(description)s (%(name)s for "%(klass)s" '
1252 'only)')%permission.__dict__
1253 else:
1254 print _(' %(description)s (%(name)s)')%permission.__dict__
1255 return 0
1257 def run_command(self, args):
1258 '''Run a single command
1259 '''
1260 command = args[0]
1262 # handle help now
1263 if command == 'help':
1264 if len(args)>1:
1265 self.do_help(args[1:])
1266 return 0
1267 self.do_help(['help'])
1268 return 0
1269 if command == 'morehelp':
1270 self.do_help(['help'])
1271 self.help_commands()
1272 self.help_all()
1273 return 0
1275 # figure what the command is
1276 try:
1277 functions = self.commands.get(command)
1278 except KeyError:
1279 # not a valid command
1280 print _('Unknown command "%(command)s" ("help commands" for a '
1281 'list)')%locals()
1282 return 1
1284 # check for multiple matches
1285 if len(functions) > 1:
1286 print _('Multiple commands match "%(command)s": %(list)s')%{'command':
1287 command, 'list': ', '.join([i[0] for i in functions])}
1288 return 1
1289 command, function = functions[0]
1291 # make sure we have a tracker_home
1292 while not self.tracker_home:
1293 self.tracker_home = raw_input(_('Enter tracker home: ')).strip()
1295 # before we open the db, we may be doing an install or init
1296 if command == 'initialise':
1297 try:
1298 return self.do_initialise(self.tracker_home, args)
1299 except UsageError, message:
1300 print _('Error: %(message)s')%locals()
1301 return 1
1302 elif command == 'install':
1303 try:
1304 return self.do_install(self.tracker_home, args)
1305 except UsageError, message:
1306 print _('Error: %(message)s')%locals()
1307 return 1
1309 # get the tracker
1310 try:
1311 tracker = roundup.instance.open(self.tracker_home)
1312 except ValueError, message:
1313 self.tracker_home = ''
1314 print _("Error: Couldn't open tracker: %(message)s")%locals()
1315 return 1
1317 # only open the database once!
1318 if not self.db:
1319 self.db = tracker.open('admin')
1321 # do the command
1322 ret = 0
1323 try:
1324 ret = function(args[1:])
1325 except UsageError, message:
1326 print _('Error: %(message)s')%locals()
1327 print
1328 print function.__doc__
1329 ret = 1
1330 except:
1331 import traceback
1332 traceback.print_exc()
1333 ret = 1
1334 return ret
1336 def interactive(self):
1337 '''Run in an interactive mode
1338 '''
1339 print _('Roundup %s ready for input.'%roundup_version)
1340 print _('Type "help" for help.')
1341 try:
1342 import readline
1343 except ImportError:
1344 print _('Note: command history and editing not available')
1346 while 1:
1347 try:
1348 command = raw_input(_('roundup> '))
1349 except EOFError:
1350 print _('exit...')
1351 break
1352 if not command: continue
1353 args = token.token_split(command)
1354 if not args: continue
1355 if args[0] in ('quit', 'exit'): break
1356 self.run_command(args)
1358 # exit.. check for transactions
1359 if self.db and self.db.transactions:
1360 commit = raw_input(_('There are unsaved changes. Commit them (y/N)? '))
1361 if commit and commit[0].lower() == 'y':
1362 self.db.commit()
1363 return 0
1365 def main(self):
1366 try:
1367 opts, args = getopt.getopt(sys.argv[1:], 'i:u:hcdsS:')
1368 except getopt.GetoptError, e:
1369 self.usage(str(e))
1370 return 1
1372 # handle command-line args
1373 self.tracker_home = os.environ.get('TRACKER_HOME', '')
1374 # TODO: reinstate the user/password stuff (-u arg too)
1375 name = password = ''
1376 if os.environ.has_key('ROUNDUP_LOGIN'):
1377 l = os.environ['ROUNDUP_LOGIN'].split(':')
1378 name = l[0]
1379 if len(l) > 1:
1380 password = l[1]
1381 self.separator = None
1382 self.print_designator = 0
1383 for opt, arg in opts:
1384 if opt == '-h':
1385 self.usage()
1386 return 0
1387 if opt == '-i':
1388 self.tracker_home = arg
1389 if opt == '-c':
1390 if self.separator != None:
1391 self.usage('Only one of -c, -S and -s may be specified')
1392 return 1
1393 self.separator = ','
1394 if opt == '-S':
1395 if self.separator != None:
1396 self.usage('Only one of -c, -S and -s may be specified')
1397 return 1
1398 self.separator = arg
1399 if opt == '-s':
1400 if self.separator != None:
1401 self.usage('Only one of -c, -S and -s may be specified')
1402 return 1
1403 self.separator = ' '
1404 if opt == '-d':
1405 self.print_designator = 1
1407 # if no command - go interactive
1408 # wrap in a try/finally so we always close off the db
1409 ret = 0
1410 try:
1411 if not args:
1412 self.interactive()
1413 else:
1414 ret = self.run_command(args)
1415 if self.db: self.db.commit()
1416 return ret
1417 finally:
1418 if self.db:
1419 self.db.close()
1422 def listTemplates(dir):
1423 ''' List all the Roundup template directories in a given directory.
1425 Find all the dirs that contain a TEMPLATE-INFO.txt and parse it.
1427 Return a list of dicts of info about the templates.
1428 '''
1429 ret = {}
1430 for idir in os.listdir(dir):
1431 idir = os.path.join(dir, idir)
1432 ti = loadTemplate(idir)
1433 if ti:
1434 ret[ti['name']] = ti
1435 return ret
1437 def loadTemplate(dir):
1438 ''' Attempt to load a Roundup template from the indicated directory.
1440 Return None if there's no template, otherwise a template info
1441 dictionary.
1442 '''
1443 ti = os.path.join(dir, 'TEMPLATE-INFO.txt')
1444 if not os.path.exists(ti):
1445 return None
1447 # load up the template's information
1448 m = rfc822.Message(open(ti))
1449 ti = {}
1450 ti['name'] = m['name']
1451 ti['description'] = m['description']
1452 ti['intended-for'] = m['intended-for']
1453 ti['path'] = dir
1454 return ti
1456 if __name__ == '__main__':
1457 tool = AdminTool()
1458 sys.exit(tool.main())
1460 # vim: set filetype=python ts=4 sw=4 et si