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