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.38 2003-02-25 10:19:31 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)
332 init.write_select_db(tracker_home, backend)
334 print _('''
335 You should now edit the tracker configuration file:
336 %(config_file)s
337 ... at a minimum, you must set MAILHOST, TRACKER_WEB, MAIL_DOMAIN and
338 ADMIN_EMAIL.
340 If you wish to modify the default schema, you should also edit the database
341 initialisation file:
342 %(database_config_file)s
343 ... see the documentation on customizing for more information.
344 ''')%{
345 'config_file': os.path.join(tracker_home, 'config.py'),
346 'database_config_file': os.path.join(tracker_home, 'dbinit.py')
347 }
348 return 0
351 def do_initialise(self, tracker_home, args):
352 '''Usage: initialise [adminpw]
353 Initialise a new Roundup tracker.
355 The administrator details will be set at this step.
357 Execute the tracker's initialisation function dbinit.init()
358 '''
359 # password
360 if len(args) > 1:
361 adminpw = args[1]
362 else:
363 adminpw = ''
364 confirm = 'x'
365 while adminpw != confirm:
366 adminpw = getpass.getpass(_('Admin Password: '))
367 confirm = getpass.getpass(_(' Confirm: '))
369 # make sure the tracker home is installed
370 if not os.path.exists(tracker_home):
371 raise UsageError, _('Instance home does not exist')%locals()
372 try:
373 tracker = roundup.instance.open(tracker_home)
374 except roundup.instance.TrackerError:
375 raise UsageError, _('Instance has not been installed')%locals()
377 # is there already a database?
378 try:
379 db_exists = tracker.select_db.Database.exists(tracker.config)
380 except AttributeError:
381 # TODO: move this code to exists() static method in every backend
382 db_exists = os.path.exists(os.path.join(tracker_home, 'db'))
383 if db_exists:
384 print _('WARNING: The database is already initialised!')
385 print _('If you re-initialise it, you will lose all the data!')
386 ok = raw_input(_('Erase it? Y/[N]: ')).strip()
387 if ok.lower() != 'y':
388 return 0
390 # Get a database backend in use by tracker
391 try:
392 # nuke it
393 tracker.select_db.Database.nuke(tracker.config)
394 except AttributeError:
395 # TODO: move this code to nuke() static method in every backend
396 shutil.rmtree(os.path.join(tracker_home, 'db'))
398 # GO
399 init.initialise(tracker_home, adminpw)
401 return 0
404 def do_get(self, args):
405 '''Usage: get property designator[,designator]*
406 Get the given property of one or more designator(s).
408 Retrieves the property value of the nodes specified by the designators.
409 '''
410 if len(args) < 2:
411 raise UsageError, _('Not enough arguments supplied')
412 propname = args[0]
413 designators = args[1].split(',')
414 l = []
415 for designator in designators:
416 # decode the node designator
417 try:
418 classname, nodeid = hyperdb.splitDesignator(designator)
419 except hyperdb.DesignatorError, message:
420 raise UsageError, message
422 # get the class
423 cl = self.get_class(classname)
424 try:
425 if self.comma_sep:
426 l.append(cl.get(nodeid, propname))
427 else:
428 print cl.get(nodeid, propname)
429 except IndexError:
430 raise UsageError, _('no such %(classname)s node "%(nodeid)s"')%locals()
431 except KeyError:
432 raise UsageError, _('no such %(classname)s property '
433 '"%(propname)s"')%locals()
434 if self.comma_sep:
435 print ','.join(l)
436 return 0
439 def do_set(self, args, pwre = re.compile(r'{(\w+)}(.+)')):
440 '''Usage: set [items] property=value property=value ...
441 Set the given properties of one or more items(s).
443 The items may be specified as a class or as a comma-separeted
444 list of item designators (ie "designator[,designator,...]").
446 This command sets the properties to the values for all designators
447 given. If the value is missing (ie. "property=") then the property is
448 un-set.
449 '''
450 if len(args) < 2:
451 raise UsageError, _('Not enough arguments supplied')
452 from roundup import hyperdb
454 designators = args[0].split(',')
455 if len(designators) == 1:
456 designator = designators[0]
457 try:
458 designator = hyperdb.splitDesignator(designator)
459 designators = [designator]
460 except hyperdb.DesignatorError:
461 cl = self.get_class(designator)
462 designators = [(designator, x) for x in cl.list()]
463 else:
464 try:
465 designators = [hyperdb.splitDesignator(x) for x in designators]
466 except hyperdb.DesignatorError, message:
467 raise UsageError, message
469 # get the props from the args
470 props = self.props_from_args(args[1:])
472 # now do the set for all the nodes
473 for classname, itemid in designators:
474 cl = self.get_class(classname)
476 properties = cl.getprops()
477 for key, value in props.items():
478 proptype = properties[key]
479 if isinstance(proptype, hyperdb.Multilink):
480 if value is None:
481 props[key] = []
482 else:
483 props[key] = value.split(',')
484 elif value is None:
485 continue
486 elif isinstance(proptype, hyperdb.String):
487 continue
488 elif isinstance(proptype, hyperdb.Password):
489 m = pwre.match(value)
490 if m:
491 # password is being given to us encrypted
492 p = password.Password()
493 p.scheme = m.group(1)
494 p.password = m.group(2)
495 props[key] = p
496 else:
497 props[key] = password.Password(value)
498 elif isinstance(proptype, hyperdb.Date):
499 try:
500 props[key] = date.Date(value)
501 except ValueError, message:
502 raise UsageError, '"%s": %s'%(value, message)
503 elif isinstance(proptype, hyperdb.Interval):
504 try:
505 props[key] = date.Interval(value)
506 except ValueError, message:
507 raise UsageError, '"%s": %s'%(value, message)
508 elif isinstance(proptype, hyperdb.Link):
509 props[key] = value
510 elif isinstance(proptype, hyperdb.Boolean):
511 props[key] = value.lower() in ('yes', 'true', 'on', '1')
512 elif isinstance(proptype, hyperdb.Number):
513 props[key] = float(value)
515 # try the set
516 try:
517 apply(cl.set, (itemid, ), props)
518 except (TypeError, IndexError, ValueError), message:
519 import traceback; traceback.print_exc()
520 raise UsageError, message
521 return 0
523 def do_find(self, args):
524 '''Usage: find classname propname=value ...
525 Find the nodes of the given class with a given link property value.
527 Find the nodes of the given class with a given link property value. The
528 value may be either the nodeid of the linked node, or its key value.
529 '''
530 if len(args) < 1:
531 raise UsageError, _('Not enough arguments supplied')
532 classname = args[0]
533 # get the class
534 cl = self.get_class(classname)
536 # handle the propname=value argument
537 props = self.props_from_args(args[1:])
539 # if the value isn't a number, look up the linked class to get the
540 # number
541 for propname, value in props.items():
542 num_re = re.compile('^\d+$')
543 if not num_re.match(value):
544 # get the property
545 try:
546 property = cl.properties[propname]
547 except KeyError:
548 raise UsageError, _('%(classname)s has no property '
549 '"%(propname)s"')%locals()
551 # make sure it's a link
552 if (not isinstance(property, hyperdb.Link) and not
553 isinstance(property, hyperdb.Multilink)):
554 raise UsageError, _('You may only "find" link properties')
556 # get the linked-to class and look up the key property
557 link_class = self.db.getclass(property.classname)
558 try:
559 props[propname] = link_class.lookup(value)
560 except TypeError:
561 raise UsageError, _('%(classname)s has no key property"')%{
562 'classname': link_class.classname}
564 # now do the find
565 try:
566 if self.comma_sep:
567 print ','.join(apply(cl.find, (), props))
568 else:
569 print apply(cl.find, (), props)
570 except KeyError:
571 raise UsageError, _('%(classname)s has no property '
572 '"%(propname)s"')%locals()
573 except (ValueError, TypeError), message:
574 raise UsageError, message
575 return 0
577 def do_specification(self, args):
578 '''Usage: specification classname
579 Show the properties for a classname.
581 This lists the properties for a given class.
582 '''
583 if len(args) < 1:
584 raise UsageError, _('Not enough arguments supplied')
585 classname = args[0]
586 # get the class
587 cl = self.get_class(classname)
589 # get the key property
590 keyprop = cl.getkey()
591 for key, value in cl.properties.items():
592 if keyprop == key:
593 print _('%(key)s: %(value)s (key property)')%locals()
594 else:
595 print _('%(key)s: %(value)s')%locals()
597 def do_display(self, args):
598 '''Usage: display designator
599 Show the property values for the given node.
601 This lists the properties and their associated values for the given
602 node.
603 '''
604 if len(args) < 1:
605 raise UsageError, _('Not enough arguments supplied')
607 # decode the node designator
608 try:
609 classname, nodeid = hyperdb.splitDesignator(args[0])
610 except hyperdb.DesignatorError, message:
611 raise UsageError, message
613 # get the class
614 cl = self.get_class(classname)
616 # display the values
617 for key in cl.properties.keys():
618 value = cl.get(nodeid, key)
619 print _('%(key)s: %(value)s')%locals()
621 def do_create(self, args, pwre = re.compile(r'{(\w+)}(.+)')):
622 '''Usage: create classname property=value ...
623 Create a new entry of a given class.
625 This creates a new entry of the given class using the property
626 name=value arguments provided on the command line after the "create"
627 command.
628 '''
629 if len(args) < 1:
630 raise UsageError, _('Not enough arguments supplied')
631 from roundup import hyperdb
633 classname = args[0]
635 # get the class
636 cl = self.get_class(classname)
638 # now do a create
639 props = {}
640 properties = cl.getprops(protected = 0)
641 if len(args) == 1:
642 # ask for the properties
643 for key, value in properties.items():
644 if key == 'id': continue
645 name = value.__class__.__name__
646 if isinstance(value , hyperdb.Password):
647 again = None
648 while value != again:
649 value = getpass.getpass(_('%(propname)s (Password): ')%{
650 'propname': key.capitalize()})
651 again = getpass.getpass(_(' %(propname)s (Again): ')%{
652 'propname': key.capitalize()})
653 if value != again: print _('Sorry, try again...')
654 if value:
655 props[key] = value
656 else:
657 value = raw_input(_('%(propname)s (%(proptype)s): ')%{
658 'propname': key.capitalize(), 'proptype': name})
659 if value:
660 props[key] = value
661 else:
662 props = self.props_from_args(args[1:])
664 # convert types
665 for propname, value in props.items():
666 # get the property
667 try:
668 proptype = properties[propname]
669 except KeyError:
670 raise UsageError, _('%(classname)s has no property '
671 '"%(propname)s"')%locals()
673 if isinstance(proptype, hyperdb.Date):
674 try:
675 props[propname] = date.Date(value)
676 except ValueError, message:
677 raise UsageError, _('"%(value)s": %(message)s')%locals()
678 elif isinstance(proptype, hyperdb.Interval):
679 try:
680 props[propname] = date.Interval(value)
681 except ValueError, message:
682 raise UsageError, _('"%(value)s": %(message)s')%locals()
683 elif isinstance(proptype, hyperdb.Password):
684 m = pwre.match(value)
685 if m:
686 # password is being given to us encrypted
687 p = password.Password()
688 p.scheme = m.group(1)
689 p.password = m.group(2)
690 props[propname] = p
691 else:
692 props[propname] = password.Password(value)
693 elif isinstance(proptype, hyperdb.Multilink):
694 props[propname] = value.split(',')
695 elif isinstance(proptype, hyperdb.Boolean):
696 props[propname] = value.lower() in ('yes', 'true', 'on', '1')
697 elif isinstance(proptype, hyperdb.Number):
698 props[propname] = float(value)
700 # check for the key property
701 propname = cl.getkey()
702 if propname and not props.has_key(propname):
703 raise UsageError, _('you must provide the "%(propname)s" '
704 'property.')%locals()
706 # do the actual create
707 try:
708 print apply(cl.create, (), props)
709 except (TypeError, IndexError, ValueError), message:
710 raise UsageError, message
711 return 0
713 def do_list(self, args):
714 '''Usage: list classname [property]
715 List the instances of a class.
717 Lists all instances of the given class. If the property is not
718 specified, the "label" property is used. The label property is tried
719 in order: the key, "name", "title" and then the first property,
720 alphabetically.
721 '''
722 if len(args) < 1:
723 raise UsageError, _('Not enough arguments supplied')
724 classname = args[0]
726 # get the class
727 cl = self.get_class(classname)
729 # figure the property
730 if len(args) > 1:
731 propname = args[1]
732 else:
733 propname = cl.labelprop()
735 if self.comma_sep:
736 print ','.join(cl.list())
737 else:
738 for nodeid in cl.list():
739 try:
740 value = cl.get(nodeid, propname)
741 except KeyError:
742 raise UsageError, _('%(classname)s has no property '
743 '"%(propname)s"')%locals()
744 print _('%(nodeid)4s: %(value)s')%locals()
745 return 0
747 def do_table(self, args):
748 '''Usage: table classname [property[,property]*]
749 List the instances of a class in tabular form.
751 Lists all instances of the given class. If the properties are not
752 specified, all properties are displayed. By default, the column widths
753 are the width of the property names. The width may be explicitly defined
754 by defining the property as "name:width". For example::
755 roundup> table priority id,name:10
756 Id Name
757 1 fatal-bug
758 2 bug
759 3 usability
760 4 feature
761 '''
762 if len(args) < 1:
763 raise UsageError, _('Not enough arguments supplied')
764 classname = args[0]
766 # get the class
767 cl = self.get_class(classname)
769 # figure the property names to display
770 if len(args) > 1:
771 prop_names = args[1].split(',')
772 all_props = cl.getprops()
773 for spec in prop_names:
774 if ':' in spec:
775 try:
776 propname, width = spec.split(':')
777 except (ValueError, TypeError):
778 raise UsageError, _('"%(spec)s" not name:width')%locals()
779 else:
780 propname = spec
781 if not all_props.has_key(propname):
782 raise UsageError, _('%(classname)s has no property '
783 '"%(propname)s"')%locals()
784 else:
785 prop_names = cl.getprops().keys()
787 # now figure column widths
788 props = []
789 for spec in prop_names:
790 if ':' in spec:
791 name, width = spec.split(':')
792 props.append((name, int(width)))
793 else:
794 props.append((spec, len(spec)))
796 # now display the heading
797 print ' '.join([name.capitalize().ljust(width) for name,width in props])
799 # and the table data
800 for nodeid in cl.list():
801 l = []
802 for name, width in props:
803 if name != 'id':
804 try:
805 value = str(cl.get(nodeid, name))
806 except KeyError:
807 # we already checked if the property is valid - a
808 # KeyError here means the node just doesn't have a
809 # value for it
810 value = ''
811 else:
812 value = str(nodeid)
813 f = '%%-%ds'%width
814 l.append(f%value[:width])
815 print ' '.join(l)
816 return 0
818 def do_history(self, args):
819 '''Usage: history designator
820 Show the history entries of a designator.
822 Lists the journal entries for the node identified by the designator.
823 '''
824 if len(args) < 1:
825 raise UsageError, _('Not enough arguments supplied')
826 try:
827 classname, nodeid = hyperdb.splitDesignator(args[0])
828 except hyperdb.DesignatorError, message:
829 raise UsageError, message
831 try:
832 print self.db.getclass(classname).history(nodeid)
833 except KeyError:
834 raise UsageError, _('no such class "%(classname)s"')%locals()
835 except IndexError:
836 raise UsageError, _('no such %(classname)s node "%(nodeid)s"')%locals()
837 return 0
839 def do_commit(self, args):
840 '''Usage: commit
841 Commit all changes made to the database.
843 The changes made during an interactive session are not
844 automatically written to the database - they must be committed
845 using this command.
847 One-off commands on the command-line are automatically committed if
848 they are successful.
849 '''
850 self.db.commit()
851 return 0
853 def do_rollback(self, args):
854 '''Usage: rollback
855 Undo all changes that are pending commit to the database.
857 The changes made during an interactive session are not
858 automatically written to the database - they must be committed
859 manually. This command undoes all those changes, so a commit
860 immediately after would make no changes to the database.
861 '''
862 self.db.rollback()
863 return 0
865 def do_retire(self, args):
866 '''Usage: retire designator[,designator]*
867 Retire the node specified by designator.
869 This action indicates that a particular node is not to be retrieved by
870 the list or find commands, and its key value may be re-used.
871 '''
872 if len(args) < 1:
873 raise UsageError, _('Not enough arguments supplied')
874 designators = args[0].split(',')
875 for designator in designators:
876 try:
877 classname, nodeid = hyperdb.splitDesignator(designator)
878 except hyperdb.DesignatorError, message:
879 raise UsageError, message
880 try:
881 self.db.getclass(classname).retire(nodeid)
882 except KeyError:
883 raise UsageError, _('no such class "%(classname)s"')%locals()
884 except IndexError:
885 raise UsageError, _('no such %(classname)s node "%(nodeid)s"')%locals()
886 return 0
888 def do_export(self, args):
889 '''Usage: export [class[,class]] export_dir
890 Export the database to colon-separated-value files.
892 This action exports the current data from the database into
893 colon-separated-value files that are placed in the nominated
894 destination directory. The journals are not exported.
895 '''
896 # we need the CSV module
897 if csv is None:
898 raise UsageError, \
899 _('Sorry, you need the csv module to use this function.\n'
900 'Get it from: http://www.object-craft.com.au/projects/csv/')
902 # grab the directory to export to
903 if len(args) < 1:
904 raise UsageError, _('Not enough arguments supplied')
905 dir = args[-1]
907 # get the list of classes to export
908 if len(args) == 2:
909 classes = args[0].split(',')
910 else:
911 classes = self.db.classes.keys()
913 # use the csv parser if we can - it's faster
914 p = csv.parser(field_sep=':')
916 # do all the classes specified
917 for classname in classes:
918 cl = self.get_class(classname)
919 f = open(os.path.join(dir, classname+'.csv'), 'w')
920 properties = cl.getprops()
921 propnames = properties.keys()
922 propnames.sort()
923 print >> f, p.join(propnames)
925 # all nodes for this class
926 for nodeid in cl.list():
927 print >>f, p.join(cl.export_list(propnames, nodeid))
928 return 0
930 def do_import(self, args):
931 '''Usage: import import_dir
932 Import a database from the directory containing CSV files, one per
933 class to import.
935 The files must define the same properties as the class (including having
936 a "header" line with those property names.)
938 The imported nodes will have the same nodeid as defined in the
939 import file, thus replacing any existing content.
941 The new nodes are added to the existing database - if you want to
942 create a new database using the imported data, then create a new
943 database (or, tediously, retire all the old data.)
944 '''
945 if len(args) < 1:
946 raise UsageError, _('Not enough arguments supplied')
947 if csv is None:
948 raise UsageError, \
949 _('Sorry, you need the csv module to use this function.\n'
950 'Get it from: http://www.object-craft.com.au/projects/csv/')
952 from roundup import hyperdb
954 for file in os.listdir(args[0]):
955 f = open(os.path.join(args[0], file))
957 # get the classname
958 classname = os.path.splitext(file)[0]
960 # ensure that the properties and the CSV file headings match
961 cl = self.get_class(classname)
962 p = csv.parser(field_sep=':')
963 file_props = p.parse(f.readline())
964 properties = cl.getprops()
965 propnames = properties.keys()
966 propnames.sort()
967 m = file_props[:]
968 m.sort()
969 if m != propnames:
970 raise UsageError, _('Import file doesn\'t define the same '
971 'properties as "%(arg0)s".')%{'arg0': args[0]}
973 # loop through the file and create a node for each entry
974 maxid = 1
975 while 1:
976 line = f.readline()
977 if not line: break
979 # parse lines until we get a complete entry
980 while 1:
981 l = p.parse(line)
982 if l: break
983 line = f.readline()
984 if not line:
985 raise ValueError, "Unexpected EOF during CSV parse"
987 # do the import and figure the current highest nodeid
988 maxid = max(maxid, int(cl.import_list(propnames, l)))
990 print 'setting', classname, maxid+1
991 self.db.setid(classname, str(maxid+1))
992 return 0
994 def do_pack(self, args):
995 '''Usage: pack period | date
997 Remove journal entries older than a period of time specified or
998 before a certain date.
1000 A period is specified using the suffixes "y", "m", and "d". The
1001 suffix "w" (for "week") means 7 days.
1003 "3y" means three years
1004 "2y 1m" means two years and one month
1005 "1m 25d" means one month and 25 days
1006 "2w 3d" means two weeks and three days
1008 Date format is "YYYY-MM-DD" eg:
1009 2001-01-01
1011 '''
1012 if len(args) <> 1:
1013 raise UsageError, _('Not enough arguments supplied')
1015 # are we dealing with a period or a date
1016 value = args[0]
1017 date_re = re.compile(r'''
1018 (?P<date>\d\d\d\d-\d\d?-\d\d?)? # yyyy-mm-dd
1019 (?P<period>(\d+y\s*)?(\d+m\s*)?(\d+d\s*)?)?
1020 ''', re.VERBOSE)
1021 m = date_re.match(value)
1022 if not m:
1023 raise ValueError, _('Invalid format')
1024 m = m.groupdict()
1025 if m['period']:
1026 pack_before = date.Date(". - %s"%value)
1027 elif m['date']:
1028 pack_before = date.Date(value)
1029 self.db.pack(pack_before)
1030 return 0
1032 def do_reindex(self, args):
1033 '''Usage: reindex
1034 Re-generate a tracker's search indexes.
1036 This will re-generate the search indexes for a tracker. This will
1037 typically happen automatically.
1038 '''
1039 self.db.indexer.force_reindex()
1040 self.db.reindex()
1041 return 0
1043 def do_security(self, args):
1044 '''Usage: security [Role name]
1045 Display the Permissions available to one or all Roles.
1046 '''
1047 if len(args) == 1:
1048 role = args[0]
1049 try:
1050 roles = [(args[0], self.db.security.role[args[0]])]
1051 except KeyError:
1052 print _('No such Role "%(role)s"')%locals()
1053 return 1
1054 else:
1055 roles = self.db.security.role.items()
1056 role = self.db.config.NEW_WEB_USER_ROLES
1057 if ',' in role:
1058 print _('New Web users get the Roles "%(role)s"')%locals()
1059 else:
1060 print _('New Web users get the Role "%(role)s"')%locals()
1061 role = self.db.config.NEW_EMAIL_USER_ROLES
1062 if ',' in role:
1063 print _('New Email users get the Roles "%(role)s"')%locals()
1064 else:
1065 print _('New Email users get the Role "%(role)s"')%locals()
1066 roles.sort()
1067 for rolename, role in roles:
1068 print _('Role "%(name)s":')%role.__dict__
1069 for permission in role.permissions:
1070 if permission.klass:
1071 print _(' %(description)s (%(name)s for "%(klass)s" '
1072 'only)')%permission.__dict__
1073 else:
1074 print _(' %(description)s (%(name)s)')%permission.__dict__
1075 return 0
1077 def run_command(self, args):
1078 '''Run a single command
1079 '''
1080 command = args[0]
1082 # handle help now
1083 if command == 'help':
1084 if len(args)>1:
1085 self.do_help(args[1:])
1086 return 0
1087 self.do_help(['help'])
1088 return 0
1089 if command == 'morehelp':
1090 self.do_help(['help'])
1091 self.help_commands()
1092 self.help_all()
1093 return 0
1095 # figure what the command is
1096 try:
1097 functions = self.commands.get(command)
1098 except KeyError:
1099 # not a valid command
1100 print _('Unknown command "%(command)s" ("help commands" for a '
1101 'list)')%locals()
1102 return 1
1104 # check for multiple matches
1105 if len(functions) > 1:
1106 print _('Multiple commands match "%(command)s": %(list)s')%{'command':
1107 command, 'list': ', '.join([i[0] for i in functions])}
1108 return 1
1109 command, function = functions[0]
1111 # make sure we have a tracker_home
1112 while not self.tracker_home:
1113 self.tracker_home = raw_input(_('Enter tracker home: ')).strip()
1115 # before we open the db, we may be doing an install or init
1116 if command == 'initialise':
1117 try:
1118 return self.do_initialise(self.tracker_home, args)
1119 except UsageError, message:
1120 print _('Error: %(message)s')%locals()
1121 return 1
1122 elif command == 'install':
1123 try:
1124 return self.do_install(self.tracker_home, args)
1125 except UsageError, message:
1126 print _('Error: %(message)s')%locals()
1127 return 1
1129 # get the tracker
1130 try:
1131 tracker = roundup.instance.open(self.tracker_home)
1132 except ValueError, message:
1133 self.tracker_home = ''
1134 print _("Error: Couldn't open tracker: %(message)s")%locals()
1135 return 1
1137 # only open the database once!
1138 if not self.db:
1139 self.db = tracker.open('admin')
1141 # do the command
1142 ret = 0
1143 try:
1144 ret = function(args[1:])
1145 except UsageError, message:
1146 print _('Error: %(message)s')%locals()
1147 print
1148 print function.__doc__
1149 ret = 1
1150 except:
1151 import traceback
1152 traceback.print_exc()
1153 ret = 1
1154 return ret
1156 def interactive(self):
1157 '''Run in an interactive mode
1158 '''
1159 print _('Roundup %s ready for input.'%roundup_version)
1160 print _('Type "help" for help.')
1161 try:
1162 import readline
1163 except ImportError:
1164 print _('Note: command history and editing not available')
1166 while 1:
1167 try:
1168 command = raw_input(_('roundup> '))
1169 except EOFError:
1170 print _('exit...')
1171 break
1172 if not command: continue
1173 args = token.token_split(command)
1174 if not args: continue
1175 if args[0] in ('quit', 'exit'): break
1176 self.run_command(args)
1178 # exit.. check for transactions
1179 if self.db and self.db.transactions:
1180 commit = raw_input(_('There are unsaved changes. Commit them (y/N)? '))
1181 if commit and commit[0].lower() == 'y':
1182 self.db.commit()
1183 return 0
1185 def main(self):
1186 try:
1187 opts, args = getopt.getopt(sys.argv[1:], 'i:u:hc')
1188 except getopt.GetoptError, e:
1189 self.usage(str(e))
1190 return 1
1192 # handle command-line args
1193 self.tracker_home = os.environ.get('TRACKER_HOME', '')
1194 # TODO: reinstate the user/password stuff (-u arg too)
1195 name = password = ''
1196 if os.environ.has_key('ROUNDUP_LOGIN'):
1197 l = os.environ['ROUNDUP_LOGIN'].split(':')
1198 name = l[0]
1199 if len(l) > 1:
1200 password = l[1]
1201 self.comma_sep = 0
1202 for opt, arg in opts:
1203 if opt == '-h':
1204 self.usage()
1205 return 0
1206 if opt == '-i':
1207 self.tracker_home = arg
1208 if opt == '-c':
1209 self.comma_sep = 1
1211 # if no command - go interactive
1212 # wrap in a try/finally so we always close off the db
1213 ret = 0
1214 try:
1215 if not args:
1216 self.interactive()
1217 else:
1218 ret = self.run_command(args)
1219 if self.db: self.db.commit()
1220 return ret
1221 finally:
1222 if self.db:
1223 self.db.close()
1225 if __name__ == '__main__':
1226 tool = AdminTool()
1227 sys.exit(tool.main())
1229 # vim: set filetype=python ts=4 sw=4 et si