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.35 2002-10-03 06:56:28 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 try:
99 key, value = arg.split('=')
100 except ValueError:
101 raise UsageError, _('argument "%(arg)s" not propname=value'
102 )%locals()
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 -c -- when outputting lists of data, just comma-separate them
121 Help:
122 roundup-admin -h
123 roundup-admin help -- this help
124 roundup-admin help <command> -- command-specific help
125 roundup-admin help all -- all available help
126 ''')%locals()
127 self.help_commands()
129 def help_commands(self):
130 ''' List the commands available with their precis help.
131 '''
132 print _('Commands:'),
133 commands = ['']
134 for command in self.commands.values():
135 h = command.__doc__.split('\n')[0]
136 commands.append(' '+h[7:])
137 commands.sort()
138 commands.append(_('Commands may be abbreviated as long as the abbreviation matches only one'))
139 commands.append(_('command, e.g. l == li == lis == list.'))
140 print '\n'.join(commands)
141 print
143 def help_commands_html(self, indent_re=re.compile(r'^(\s+)\S+')):
144 ''' Produce an HTML command list.
145 '''
146 commands = self.commands.values()
147 def sortfun(a, b):
148 return cmp(a.__name__, b.__name__)
149 commands.sort(sortfun)
150 for command in commands:
151 h = command.__doc__.split('\n')
152 name = command.__name__[3:]
153 usage = h[0]
154 print _('''
155 <tr><td valign=top><strong>%(name)s</strong></td>
156 <td><tt>%(usage)s</tt><p>
157 <pre>''')%locals()
158 indent = indent_re.match(h[3])
159 if indent: indent = len(indent.group(1))
160 for line in h[3:]:
161 if indent:
162 print line[indent:]
163 else:
164 print line
165 print _('</pre></td></tr>\n')
167 def help_all(self):
168 print _('''
169 All commands (except help) require a tracker specifier. This is just the path
170 to the roundup tracker you're working with. A roundup tracker is where
171 roundup keeps the database and configuration file that defines an issue
172 tracker. It may be thought of as the issue tracker's "home directory". It may
173 be specified in the environment variable TRACKER_HOME or on the command
174 line as "-i tracker".
176 A designator is a classname and a nodeid concatenated, eg. bug1, user10, ...
178 Property values are represented as strings in command arguments and in the
179 printed results:
180 . Strings are, well, strings.
181 . Date values are printed in the full date format in the local time zone, and
182 accepted in the full format or any of the partial formats explained below.
183 . Link values are printed as node designators. When given as an argument,
184 node designators and key strings are both accepted.
185 . Multilink values are printed as lists of node designators joined by commas.
186 When given as an argument, node designators and key strings are both
187 accepted; an empty string, a single node, or a list of nodes joined by
188 commas is accepted.
190 When property values must contain spaces, just surround the value with
191 quotes, either ' or ". A single space may also be backslash-quoted. If a
192 valuu must contain a quote character, it must be backslash-quoted or inside
193 quotes. Examples:
194 hello world (2 tokens: hello, world)
195 "hello world" (1 token: hello world)
196 "Roch'e" Compaan (2 tokens: Roch'e Compaan)
197 Roch\'e Compaan (2 tokens: Roch'e Compaan)
198 address="1 2 3" (1 token: address=1 2 3)
199 \\ (1 token: \)
200 \n\r\t (1 token: a newline, carriage-return and tab)
202 When multiple nodes are specified to the roundup get or roundup set
203 commands, the specified properties are retrieved or set on all the listed
204 nodes.
206 When multiple results are returned by the roundup get or roundup find
207 commands, they are printed one per line (default) or joined by commas (with
208 the -c) option.
210 Where the command changes data, a login name/password is required. The
211 login may be specified as either "name" or "name:password".
212 . ROUNDUP_LOGIN environment variable
213 . the -u command-line option
214 If either the name or password is not supplied, they are obtained from the
215 command-line.
217 Date format examples:
218 "2000-04-17.03:45" means <Date 2000-04-17.08:45:00>
219 "2000-04-17" means <Date 2000-04-17.00:00:00>
220 "01-25" means <Date yyyy-01-25.00:00:00>
221 "08-13.22:13" means <Date yyyy-08-14.03:13:00>
222 "11-07.09:32:43" means <Date yyyy-11-07.14:32:43>
223 "14:25" means <Date yyyy-mm-dd.19:25:00>
224 "8:47:11" means <Date yyyy-mm-dd.13:47:11>
225 "." means "right now"
227 Command help:
228 ''')
229 for name, command in self.commands.items():
230 print _('%s:')%name
231 print _(' '), command.__doc__
233 def do_help(self, args, nl_re=re.compile('[\r\n]'),
234 indent_re=re.compile(r'^(\s+)\S+')):
235 '''Usage: help topic
236 Give help about topic.
238 commands -- list commands
239 <command> -- help specific to a command
240 initopts -- init command options
241 all -- all available help
242 '''
243 if len(args)>0:
244 topic = args[0]
245 else:
246 topic = 'help'
249 # try help_ methods
250 if self.help.has_key(topic):
251 self.help[topic]()
252 return 0
254 # try command docstrings
255 try:
256 l = self.commands.get(topic)
257 except KeyError:
258 print _('Sorry, no help for "%(topic)s"')%locals()
259 return 1
261 # display the help for each match, removing the docsring indent
262 for name, help in l:
263 lines = nl_re.split(help.__doc__)
264 print lines[0]
265 indent = indent_re.match(lines[1])
266 if indent: indent = len(indent.group(1))
267 for line in lines[1:]:
268 if indent:
269 print line[indent:]
270 else:
271 print line
272 return 0
274 def help_initopts(self):
275 import roundup.templates
276 templates = roundup.templates.listTemplates()
277 print _('Templates:'), ', '.join(templates)
278 import roundup.backends
279 backends = roundup.backends.__all__
280 print _('Back ends:'), ', '.join(backends)
282 def do_install(self, tracker_home, args):
283 '''Usage: install [template [backend [admin password]]]
284 Install a new Roundup tracker.
286 The command will prompt for the tracker home directory (if not supplied
287 through TRACKER_HOME or the -i option). The template, backend and admin
288 password may be specified on the command-line as arguments, in that
289 order.
291 The initialise command must be called after this command in order
292 to initialise the tracker's database. You may edit the tracker's
293 initial database contents before running that command by editing
294 the tracker's dbinit.py module init() function.
296 See also initopts help.
297 '''
298 if len(args) < 1:
299 raise UsageError, _('Not enough arguments supplied')
301 # make sure the tracker home can be created
302 parent = os.path.split(tracker_home)[0]
303 if not os.path.exists(parent):
304 raise UsageError, _('Instance home parent directory "%(parent)s"'
305 ' does not exist')%locals()
307 # select template
308 import roundup.templates
309 templates = roundup.templates.listTemplates()
310 template = len(args) > 1 and args[1] or ''
311 if template not in templates:
312 print _('Templates:'), ', '.join(templates)
313 while template not in templates:
314 template = raw_input(_('Select template [classic]: ')).strip()
315 if not template:
316 template = 'classic'
318 # select hyperdb backend
319 import roundup.backends
320 backends = roundup.backends.__all__
321 backend = len(args) > 2 and args[2] or ''
322 if backend not in backends:
323 print _('Back ends:'), ', '.join(backends)
324 while backend not in backends:
325 backend = raw_input(_('Select backend [anydbm]: ')).strip()
326 if not backend:
327 backend = 'anydbm'
328 # XXX perform a unit test based on the user's selections
330 # install!
331 init.install(tracker_home, template, backend)
333 print _('''
334 You should now edit the tracker configuration file:
335 %(config_file)s
336 ... at a minimum, you must set MAILHOST, TRACKER_WEB, MAIL_DOMAIN and
337 ADMIN_EMAIL.
339 If you wish to modify the default schema, you should also edit the database
340 initialisation file:
341 %(database_config_file)s
342 ... see the documentation on customizing for more information.
343 ''')%{
344 'config_file': os.path.join(tracker_home, 'config.py'),
345 'database_config_file': os.path.join(tracker_home, 'dbinit.py')
346 }
347 return 0
350 def do_initialise(self, tracker_home, args):
351 '''Usage: initialise [adminpw]
352 Initialise a new Roundup tracker.
354 The administrator details will be set at this step.
356 Execute the tracker's initialisation function dbinit.init()
357 '''
358 # password
359 if len(args) > 1:
360 adminpw = args[1]
361 else:
362 adminpw = ''
363 confirm = 'x'
364 while adminpw != confirm:
365 adminpw = getpass.getpass(_('Admin Password: '))
366 confirm = getpass.getpass(_(' Confirm: '))
368 # make sure the tracker home is installed
369 if not os.path.exists(tracker_home):
370 raise UsageError, _('Instance home does not exist')%locals()
371 if not os.path.exists(os.path.join(tracker_home, 'html')):
372 raise UsageError, _('Instance has not been installed')%locals()
374 # is there already a database?
375 if os.path.exists(os.path.join(tracker_home, 'db')):
376 print _('WARNING: The database is already initialised!')
377 print _('If you re-initialise it, you will lose all the data!')
378 ok = raw_input(_('Erase it? Y/[N]: ')).strip()
379 if ok.lower() != 'y':
380 return 0
382 # nuke it
383 shutil.rmtree(os.path.join(tracker_home, 'db'))
385 # GO
386 init.initialise(tracker_home, adminpw)
388 return 0
391 def do_get(self, args):
392 '''Usage: get property designator[,designator]*
393 Get the given property of one or more designator(s).
395 Retrieves the property value of the nodes specified by the designators.
396 '''
397 if len(args) < 2:
398 raise UsageError, _('Not enough arguments supplied')
399 propname = args[0]
400 designators = args[1].split(',')
401 l = []
402 for designator in designators:
403 # decode the node designator
404 try:
405 classname, nodeid = hyperdb.splitDesignator(designator)
406 except hyperdb.DesignatorError, message:
407 raise UsageError, message
409 # get the class
410 cl = self.get_class(classname)
411 try:
412 if self.comma_sep:
413 l.append(cl.get(nodeid, propname))
414 else:
415 print cl.get(nodeid, propname)
416 except IndexError:
417 raise UsageError, _('no such %(classname)s node "%(nodeid)s"')%locals()
418 except KeyError:
419 raise UsageError, _('no such %(classname)s property '
420 '"%(propname)s"')%locals()
421 if self.comma_sep:
422 print ','.join(l)
423 return 0
426 def do_set(self, args, pwre = re.compile(r'{(\w+)}(.+)')):
427 '''Usage: set [items] property=value property=value ...
428 Set the given properties of one or more items(s).
430 The items may be specified as a class or as a comma-separeted
431 list of item designators (ie "designator[,designator,...]").
433 This command sets the properties to the values for all designators
434 given. If the value is missing (ie. "property=") then the property is
435 un-set.
436 '''
437 if len(args) < 2:
438 raise UsageError, _('Not enough arguments supplied')
439 from roundup import hyperdb
441 designators = args[0].split(',')
442 if len(designators) == 1:
443 designator = designators[0]
444 try:
445 designator = hyperdb.splitDesignator(designator)
446 designators = [designator]
447 except hyperdb.DesignatorError:
448 cl = self.get_class(designator)
449 designators = [(designator, x) for x in cl.list()]
450 else:
451 try:
452 designators = [hyperdb.splitDesignator(x) for x in designators]
453 except hyperdb.DesignatorError, message:
454 raise UsageError, message
456 # get the props from the args
457 props = self.props_from_args(args[1:])
459 # now do the set for all the nodes
460 for classname, itemid in designators:
461 cl = self.get_class(classname)
463 properties = cl.getprops()
464 for key, value in props.items():
465 proptype = properties[key]
466 if isinstance(proptype, hyperdb.Multilink):
467 if value is None:
468 props[key] = []
469 else:
470 props[key] = value.split(',')
471 elif value is None:
472 continue
473 elif isinstance(proptype, hyperdb.String):
474 continue
475 elif isinstance(proptype, hyperdb.Password):
476 m = pwre.match(value)
477 if m:
478 # password is being given to us encrypted
479 p = password.Password()
480 p.scheme = m.group(1)
481 p.password = m.group(2)
482 props[key] = p
483 else:
484 props[key] = password.Password(value)
485 elif isinstance(proptype, hyperdb.Date):
486 try:
487 props[key] = date.Date(value)
488 except ValueError, message:
489 raise UsageError, '"%s": %s'%(value, message)
490 elif isinstance(proptype, hyperdb.Interval):
491 try:
492 props[key] = date.Interval(value)
493 except ValueError, message:
494 raise UsageError, '"%s": %s'%(value, message)
495 elif isinstance(proptype, hyperdb.Link):
496 props[key] = value
497 elif isinstance(proptype, hyperdb.Boolean):
498 props[key] = value.lower() in ('yes', 'true', 'on', '1')
499 elif isinstance(proptype, hyperdb.Number):
500 props[key] = int(value)
502 # try the set
503 try:
504 apply(cl.set, (itemid, ), props)
505 except (TypeError, IndexError, ValueError), message:
506 import traceback; traceback.print_exc()
507 raise UsageError, message
508 return 0
510 def do_find(self, args):
511 '''Usage: find classname propname=value ...
512 Find the nodes of the given class with a given link property value.
514 Find the nodes of the given class with a given link property value. The
515 value may be either the nodeid of the linked node, or its key value.
516 '''
517 if len(args) < 1:
518 raise UsageError, _('Not enough arguments supplied')
519 classname = args[0]
520 # get the class
521 cl = self.get_class(classname)
523 # handle the propname=value argument
524 props = self.props_from_args(args[1:])
526 # if the value isn't a number, look up the linked class to get the
527 # number
528 for propname, value in props.items():
529 num_re = re.compile('^\d+$')
530 if not num_re.match(value):
531 # get the property
532 try:
533 property = cl.properties[propname]
534 except KeyError:
535 raise UsageError, _('%(classname)s has no property '
536 '"%(propname)s"')%locals()
538 # make sure it's a link
539 if (not isinstance(property, hyperdb.Link) and not
540 isinstance(property, hyperdb.Multilink)):
541 raise UsageError, _('You may only "find" link properties')
543 # get the linked-to class and look up the key property
544 link_class = self.db.getclass(property.classname)
545 try:
546 props[propname] = link_class.lookup(value)
547 except TypeError:
548 raise UsageError, _('%(classname)s has no key property"')%{
549 'classname': link_class.classname}
551 # now do the find
552 try:
553 if self.comma_sep:
554 print ','.join(apply(cl.find, (), props))
555 else:
556 print apply(cl.find, (), props)
557 except KeyError:
558 raise UsageError, _('%(classname)s has no property '
559 '"%(propname)s"')%locals()
560 except (ValueError, TypeError), message:
561 raise UsageError, message
562 return 0
564 def do_specification(self, args):
565 '''Usage: specification classname
566 Show the properties for a classname.
568 This lists the properties for a given class.
569 '''
570 if len(args) < 1:
571 raise UsageError, _('Not enough arguments supplied')
572 classname = args[0]
573 # get the class
574 cl = self.get_class(classname)
576 # get the key property
577 keyprop = cl.getkey()
578 for key, value in cl.properties.items():
579 if keyprop == key:
580 print _('%(key)s: %(value)s (key property)')%locals()
581 else:
582 print _('%(key)s: %(value)s')%locals()
584 def do_display(self, args):
585 '''Usage: display designator
586 Show the property values for the given node.
588 This lists the properties and their associated values for the given
589 node.
590 '''
591 if len(args) < 1:
592 raise UsageError, _('Not enough arguments supplied')
594 # decode the node designator
595 try:
596 classname, nodeid = hyperdb.splitDesignator(args[0])
597 except hyperdb.DesignatorError, message:
598 raise UsageError, message
600 # get the class
601 cl = self.get_class(classname)
603 # display the values
604 for key in cl.properties.keys():
605 value = cl.get(nodeid, key)
606 print _('%(key)s: %(value)s')%locals()
608 def do_create(self, args, pwre = re.compile(r'{(\w+)}(.+)')):
609 '''Usage: create classname property=value ...
610 Create a new entry of a given class.
612 This creates a new entry of the given class using the property
613 name=value arguments provided on the command line after the "create"
614 command.
615 '''
616 if len(args) < 1:
617 raise UsageError, _('Not enough arguments supplied')
618 from roundup import hyperdb
620 classname = args[0]
622 # get the class
623 cl = self.get_class(classname)
625 # now do a create
626 props = {}
627 properties = cl.getprops(protected = 0)
628 if len(args) == 1:
629 # ask for the properties
630 for key, value in properties.items():
631 if key == 'id': continue
632 name = value.__class__.__name__
633 if isinstance(value , hyperdb.Password):
634 again = None
635 while value != again:
636 value = getpass.getpass(_('%(propname)s (Password): ')%{
637 'propname': key.capitalize()})
638 again = getpass.getpass(_(' %(propname)s (Again): ')%{
639 'propname': key.capitalize()})
640 if value != again: print _('Sorry, try again...')
641 if value:
642 props[key] = value
643 else:
644 value = raw_input(_('%(propname)s (%(proptype)s): ')%{
645 'propname': key.capitalize(), 'proptype': name})
646 if value:
647 props[key] = value
648 else:
649 props = self.props_from_args(args[1:])
651 # convert types
652 for propname, value in props.items():
653 # get the property
654 try:
655 proptype = properties[propname]
656 except KeyError:
657 raise UsageError, _('%(classname)s has no property '
658 '"%(propname)s"')%locals()
660 if isinstance(proptype, hyperdb.Date):
661 try:
662 props[propname] = date.Date(value)
663 except ValueError, message:
664 raise UsageError, _('"%(value)s": %(message)s')%locals()
665 elif isinstance(proptype, hyperdb.Interval):
666 try:
667 props[propname] = date.Interval(value)
668 except ValueError, message:
669 raise UsageError, _('"%(value)s": %(message)s')%locals()
670 elif isinstance(proptype, hyperdb.Password):
671 m = pwre.match(value)
672 if m:
673 # password is being given to us encrypted
674 p = password.Password()
675 p.scheme = m.group(1)
676 p.password = m.group(2)
677 props[propname] = p
678 else:
679 props[propname] = password.Password(value)
680 elif isinstance(proptype, hyperdb.Multilink):
681 props[propname] = value.split(',')
682 elif isinstance(proptype, hyperdb.Boolean):
683 props[propname] = value.lower() in ('yes', 'true', 'on', '1')
684 elif isinstance(proptype, hyperdb.Number):
685 props[propname] = int(value)
687 # check for the key property
688 propname = cl.getkey()
689 if propname and not props.has_key(propname):
690 raise UsageError, _('you must provide the "%(propname)s" '
691 'property.')%locals()
693 # do the actual create
694 try:
695 print apply(cl.create, (), props)
696 except (TypeError, IndexError, ValueError), message:
697 raise UsageError, message
698 return 0
700 def do_list(self, args):
701 '''Usage: list classname [property]
702 List the instances of a class.
704 Lists all instances of the given class. If the property is not
705 specified, the "label" property is used. The label property is tried
706 in order: the key, "name", "title" and then the first property,
707 alphabetically.
708 '''
709 if len(args) < 1:
710 raise UsageError, _('Not enough arguments supplied')
711 classname = args[0]
713 # get the class
714 cl = self.get_class(classname)
716 # figure the property
717 if len(args) > 1:
718 propname = args[1]
719 else:
720 propname = cl.labelprop()
722 if self.comma_sep:
723 print ','.join(cl.list())
724 else:
725 for nodeid in cl.list():
726 try:
727 value = cl.get(nodeid, propname)
728 except KeyError:
729 raise UsageError, _('%(classname)s has no property '
730 '"%(propname)s"')%locals()
731 print _('%(nodeid)4s: %(value)s')%locals()
732 return 0
734 def do_table(self, args):
735 '''Usage: table classname [property[,property]*]
736 List the instances of a class in tabular form.
738 Lists all instances of the given class. If the properties are not
739 specified, all properties are displayed. By default, the column widths
740 are the width of the property names. The width may be explicitly defined
741 by defining the property as "name:width". For example::
742 roundup> table priority id,name:10
743 Id Name
744 1 fatal-bug
745 2 bug
746 3 usability
747 4 feature
748 '''
749 if len(args) < 1:
750 raise UsageError, _('Not enough arguments supplied')
751 classname = args[0]
753 # get the class
754 cl = self.get_class(classname)
756 # figure the property names to display
757 if len(args) > 1:
758 prop_names = args[1].split(',')
759 all_props = cl.getprops()
760 for spec in prop_names:
761 if ':' in spec:
762 try:
763 propname, width = spec.split(':')
764 except (ValueError, TypeError):
765 raise UsageError, _('"%(spec)s" not name:width')%locals()
766 else:
767 propname = spec
768 if not all_props.has_key(propname):
769 raise UsageError, _('%(classname)s has no property '
770 '"%(propname)s"')%locals()
771 else:
772 prop_names = cl.getprops().keys()
774 # now figure column widths
775 props = []
776 for spec in prop_names:
777 if ':' in spec:
778 name, width = spec.split(':')
779 props.append((name, int(width)))
780 else:
781 props.append((spec, len(spec)))
783 # now display the heading
784 print ' '.join([name.capitalize().ljust(width) for name,width in props])
786 # and the table data
787 for nodeid in cl.list():
788 l = []
789 for name, width in props:
790 if name != 'id':
791 try:
792 value = str(cl.get(nodeid, name))
793 except KeyError:
794 # we already checked if the property is valid - a
795 # KeyError here means the node just doesn't have a
796 # value for it
797 value = ''
798 else:
799 value = str(nodeid)
800 f = '%%-%ds'%width
801 l.append(f%value[:width])
802 print ' '.join(l)
803 return 0
805 def do_history(self, args):
806 '''Usage: history designator
807 Show the history entries of a designator.
809 Lists the journal entries for the node identified by the designator.
810 '''
811 if len(args) < 1:
812 raise UsageError, _('Not enough arguments supplied')
813 try:
814 classname, nodeid = hyperdb.splitDesignator(args[0])
815 except hyperdb.DesignatorError, message:
816 raise UsageError, message
818 try:
819 print self.db.getclass(classname).history(nodeid)
820 except KeyError:
821 raise UsageError, _('no such class "%(classname)s"')%locals()
822 except IndexError:
823 raise UsageError, _('no such %(classname)s node "%(nodeid)s"')%locals()
824 return 0
826 def do_commit(self, args):
827 '''Usage: commit
828 Commit all changes made to the database.
830 The changes made during an interactive session are not
831 automatically written to the database - they must be committed
832 using this command.
834 One-off commands on the command-line are automatically committed if
835 they are successful.
836 '''
837 self.db.commit()
838 return 0
840 def do_rollback(self, args):
841 '''Usage: rollback
842 Undo all changes that are pending commit to the database.
844 The changes made during an interactive session are not
845 automatically written to the database - they must be committed
846 manually. This command undoes all those changes, so a commit
847 immediately after would make no changes to the database.
848 '''
849 self.db.rollback()
850 return 0
852 def do_retire(self, args):
853 '''Usage: retire designator[,designator]*
854 Retire the node specified by designator.
856 This action indicates that a particular node is not to be retrieved by
857 the list or find commands, and its key value may be re-used.
858 '''
859 if len(args) < 1:
860 raise UsageError, _('Not enough arguments supplied')
861 designators = args[0].split(',')
862 for designator in designators:
863 try:
864 classname, nodeid = hyperdb.splitDesignator(designator)
865 except hyperdb.DesignatorError, message:
866 raise UsageError, message
867 try:
868 self.db.getclass(classname).retire(nodeid)
869 except KeyError:
870 raise UsageError, _('no such class "%(classname)s"')%locals()
871 except IndexError:
872 raise UsageError, _('no such %(classname)s node "%(nodeid)s"')%locals()
873 return 0
875 def do_export(self, args):
876 '''Usage: export [class[,class]] export_dir
877 Export the database to colon-separated-value files.
879 This action exports the current data from the database into
880 colon-separated-value files that are placed in the nominated
881 destination directory. The journals are not exported.
882 '''
883 # we need the CSV module
884 if csv is None:
885 raise UsageError, \
886 _('Sorry, you need the csv module to use this function.\n'
887 'Get it from: http://www.object-craft.com.au/projects/csv/')
889 # grab the directory to export to
890 if len(args) < 1:
891 raise UsageError, _('Not enough arguments supplied')
892 dir = args[-1]
894 # get the list of classes to export
895 if len(args) == 2:
896 classes = args[0].split(',')
897 else:
898 classes = self.db.classes.keys()
900 # use the csv parser if we can - it's faster
901 p = csv.parser(field_sep=':')
903 # do all the classes specified
904 for classname in classes:
905 cl = self.get_class(classname)
906 f = open(os.path.join(dir, classname+'.csv'), 'w')
907 properties = cl.getprops()
908 propnames = properties.keys()
909 propnames.sort()
910 print >> f, p.join(propnames)
912 # all nodes for this class
913 for nodeid in cl.list():
914 print >>f, p.join(cl.export_list(propnames, nodeid))
915 return 0
917 def do_import(self, args):
918 '''Usage: import import_dir
919 Import a database from the directory containing CSV files, one per
920 class to import.
922 The files must define the same properties as the class (including having
923 a "header" line with those property names.)
925 The imported nodes will have the same nodeid as defined in the
926 import file, thus replacing any existing content.
928 The new nodes are added to the existing database - if you want to
929 create a new database using the imported data, then create a new
930 database (or, tediously, retire all the old data.)
931 '''
932 if len(args) < 1:
933 raise UsageError, _('Not enough arguments supplied')
934 if csv is None:
935 raise UsageError, \
936 _('Sorry, you need the csv module to use this function.\n'
937 'Get it from: http://www.object-craft.com.au/projects/csv/')
939 from roundup import hyperdb
941 for file in os.listdir(args[0]):
942 f = open(os.path.join(args[0], file))
944 # get the classname
945 classname = os.path.splitext(file)[0]
947 # ensure that the properties and the CSV file headings match
948 cl = self.get_class(classname)
949 p = csv.parser(field_sep=':')
950 file_props = p.parse(f.readline())
951 properties = cl.getprops()
952 propnames = properties.keys()
953 propnames.sort()
954 m = file_props[:]
955 m.sort()
956 if m != propnames:
957 raise UsageError, _('Import file doesn\'t define the same '
958 'properties as "%(arg0)s".')%{'arg0': args[0]}
960 # loop through the file and create a node for each entry
961 maxid = 1
962 while 1:
963 line = f.readline()
964 if not line: break
966 # parse lines until we get a complete entry
967 while 1:
968 l = p.parse(line)
969 if l: break
970 line = f.readline()
971 if not line:
972 raise ValueError, "Unexpected EOF during CSV parse"
974 # do the import and figure the current highest nodeid
975 maxid = max(maxid, int(cl.import_list(propnames, l)))
977 print 'setting', classname, maxid+1
978 self.db.setid(classname, str(maxid+1))
979 return 0
981 def do_pack(self, args):
982 '''Usage: pack period | date
984 Remove journal entries older than a period of time specified or
985 before a certain date.
987 A period is specified using the suffixes "y", "m", and "d". The
988 suffix "w" (for "week") means 7 days.
990 "3y" means three years
991 "2y 1m" means two years and one month
992 "1m 25d" means one month and 25 days
993 "2w 3d" means two weeks and three days
995 Date format is "YYYY-MM-DD" eg:
996 2001-01-01
998 '''
999 if len(args) <> 1:
1000 raise UsageError, _('Not enough arguments supplied')
1002 # are we dealing with a period or a date
1003 value = args[0]
1004 date_re = re.compile(r'''
1005 (?P<date>\d\d\d\d-\d\d?-\d\d?)? # yyyy-mm-dd
1006 (?P<period>(\d+y\s*)?(\d+m\s*)?(\d+d\s*)?)?
1007 ''', re.VERBOSE)
1008 m = date_re.match(value)
1009 if not m:
1010 raise ValueError, _('Invalid format')
1011 m = m.groupdict()
1012 if m['period']:
1013 pack_before = date.Date(". - %s"%value)
1014 elif m['date']:
1015 pack_before = date.Date(value)
1016 self.db.pack(pack_before)
1017 return 0
1019 def do_reindex(self, args):
1020 '''Usage: reindex
1021 Re-generate a tracker's search indexes.
1023 This will re-generate the search indexes for a tracker. This will
1024 typically happen automatically.
1025 '''
1026 self.db.indexer.force_reindex()
1027 self.db.reindex()
1028 return 0
1030 def do_security(self, args):
1031 '''Usage: security [Role name]
1032 Display the Permissions available to one or all Roles.
1033 '''
1034 if len(args) == 1:
1035 role = args[0]
1036 try:
1037 roles = [(args[0], self.db.security.role[args[0]])]
1038 except KeyError:
1039 print _('No such Role "%(role)s"')%locals()
1040 return 1
1041 else:
1042 roles = self.db.security.role.items()
1043 role = self.db.config.NEW_WEB_USER_ROLES
1044 if ',' in role:
1045 print _('New Web users get the Roles "%(role)s"')%locals()
1046 else:
1047 print _('New Web users get the Role "%(role)s"')%locals()
1048 role = self.db.config.NEW_EMAIL_USER_ROLES
1049 if ',' in role:
1050 print _('New Email users get the Roles "%(role)s"')%locals()
1051 else:
1052 print _('New Email users get the Role "%(role)s"')%locals()
1053 roles.sort()
1054 for rolename, role in roles:
1055 print _('Role "%(name)s":')%role.__dict__
1056 for permission in role.permissions:
1057 if permission.klass:
1058 print _(' %(description)s (%(name)s for "%(klass)s" '
1059 'only)')%permission.__dict__
1060 else:
1061 print _(' %(description)s (%(name)s)')%permission.__dict__
1062 return 0
1064 def run_command(self, args):
1065 '''Run a single command
1066 '''
1067 command = args[0]
1069 # handle help now
1070 if command == 'help':
1071 if len(args)>1:
1072 self.do_help(args[1:])
1073 return 0
1074 self.do_help(['help'])
1075 return 0
1076 if command == 'morehelp':
1077 self.do_help(['help'])
1078 self.help_commands()
1079 self.help_all()
1080 return 0
1082 # figure what the command is
1083 try:
1084 functions = self.commands.get(command)
1085 except KeyError:
1086 # not a valid command
1087 print _('Unknown command "%(command)s" ("help commands" for a '
1088 'list)')%locals()
1089 return 1
1091 # check for multiple matches
1092 if len(functions) > 1:
1093 print _('Multiple commands match "%(command)s": %(list)s')%{'command':
1094 command, 'list': ', '.join([i[0] for i in functions])}
1095 return 1
1096 command, function = functions[0]
1098 # make sure we have a tracker_home
1099 while not self.tracker_home:
1100 self.tracker_home = raw_input(_('Enter tracker home: ')).strip()
1102 # before we open the db, we may be doing an install or init
1103 if command == 'initialise':
1104 try:
1105 return self.do_initialise(self.tracker_home, args)
1106 except UsageError, message:
1107 print _('Error: %(message)s')%locals()
1108 return 1
1109 elif command == 'install':
1110 try:
1111 return self.do_install(self.tracker_home, args)
1112 except UsageError, message:
1113 print _('Error: %(message)s')%locals()
1114 return 1
1116 # get the tracker
1117 try:
1118 tracker = roundup.instance.open(self.tracker_home)
1119 except ValueError, message:
1120 self.tracker_home = ''
1121 print _("Error: Couldn't open tracker: %(message)s")%locals()
1122 return 1
1124 # only open the database once!
1125 if not self.db:
1126 self.db = tracker.open('admin')
1128 # do the command
1129 ret = 0
1130 try:
1131 ret = function(args[1:])
1132 except UsageError, message:
1133 print _('Error: %(message)s')%locals()
1134 print
1135 print function.__doc__
1136 ret = 1
1137 except:
1138 import traceback
1139 traceback.print_exc()
1140 ret = 1
1141 return ret
1143 def interactive(self):
1144 '''Run in an interactive mode
1145 '''
1146 print _('Roundup %s ready for input.'%roundup_version)
1147 print _('Type "help" for help.')
1148 try:
1149 import readline
1150 except ImportError:
1151 print _('Note: command history and editing not available')
1153 while 1:
1154 try:
1155 command = raw_input(_('roundup> '))
1156 except EOFError:
1157 print _('exit...')
1158 break
1159 if not command: continue
1160 args = token.token_split(command)
1161 if not args: continue
1162 if args[0] in ('quit', 'exit'): break
1163 self.run_command(args)
1165 # exit.. check for transactions
1166 if self.db and self.db.transactions:
1167 commit = raw_input(_('There are unsaved changes. Commit them (y/N)? '))
1168 if commit and commit[0].lower() == 'y':
1169 self.db.commit()
1170 return 0
1172 def main(self):
1173 try:
1174 opts, args = getopt.getopt(sys.argv[1:], 'i:u:hc')
1175 except getopt.GetoptError, e:
1176 self.usage(str(e))
1177 return 1
1179 # handle command-line args
1180 self.tracker_home = os.environ.get('TRACKER_HOME', '')
1181 # TODO: reinstate the user/password stuff (-u arg too)
1182 name = password = ''
1183 if os.environ.has_key('ROUNDUP_LOGIN'):
1184 l = os.environ['ROUNDUP_LOGIN'].split(':')
1185 name = l[0]
1186 if len(l) > 1:
1187 password = l[1]
1188 self.comma_sep = 0
1189 for opt, arg in opts:
1190 if opt == '-h':
1191 self.usage()
1192 return 0
1193 if opt == '-i':
1194 self.tracker_home = arg
1195 if opt == '-c':
1196 self.comma_sep = 1
1198 # if no command - go interactive
1199 # wrap in a try/finally so we always close off the db
1200 ret = 0
1201 try:
1202 if not args:
1203 self.interactive()
1204 else:
1205 ret = self.run_command(args)
1206 if self.db: self.db.commit()
1207 return ret
1208 finally:
1209 if self.db:
1210 self.db.close()
1212 if __name__ == '__main__':
1213 tool = AdminTool()
1214 sys.exit(tool.main())
1216 # vim: set filetype=python ts=4 sw=4 et si