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