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.48 2003-03-26 10:43:58 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 -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. If the property is a multilink, you specify the linked ids
449 for the multilink as comma-separated numbers (ie "1,2,3").
450 '''
451 if len(args) < 2:
452 raise UsageError, _('Not enough arguments supplied')
453 from roundup import hyperdb
455 designators = args[0].split(',')
456 if len(designators) == 1:
457 designator = designators[0]
458 try:
459 designator = hyperdb.splitDesignator(designator)
460 designators = [designator]
461 except hyperdb.DesignatorError:
462 cl = self.get_class(designator)
463 designators = [(designator, x) for x in cl.list()]
464 else:
465 try:
466 designators = [hyperdb.splitDesignator(x) for x in designators]
467 except hyperdb.DesignatorError, message:
468 raise UsageError, message
470 # get the props from the args
471 props = self.props_from_args(args[1:])
473 # now do the set for all the nodes
474 for classname, itemid in designators:
475 cl = self.get_class(classname)
477 properties = cl.getprops()
478 for key, value in props.items():
479 proptype = properties[key]
480 if isinstance(proptype, hyperdb.Multilink):
481 if value is None:
482 props[key] = []
483 else:
484 props[key] = value.split(',')
485 elif value is None:
486 continue
487 elif isinstance(proptype, hyperdb.String):
488 continue
489 elif isinstance(proptype, hyperdb.Password):
490 m = pwre.match(value)
491 if m:
492 # password is being given to us encrypted
493 p = password.Password()
494 p.scheme = m.group(1)
495 p.password = m.group(2)
496 props[key] = p
497 else:
498 props[key] = password.Password(value)
499 elif isinstance(proptype, hyperdb.Date):
500 try:
501 props[key] = date.Date(value)
502 except ValueError, message:
503 raise UsageError, '"%s": %s'%(value, message)
504 elif isinstance(proptype, hyperdb.Interval):
505 try:
506 props[key] = date.Interval(value)
507 except ValueError, message:
508 raise UsageError, '"%s": %s'%(value, message)
509 elif isinstance(proptype, hyperdb.Link):
510 props[key] = value
511 elif isinstance(proptype, hyperdb.Boolean):
512 props[key] = value.lower() in ('yes', 'true', 'on', '1')
513 elif isinstance(proptype, hyperdb.Number):
514 props[key] = float(value)
516 # try the set
517 try:
518 apply(cl.set, (itemid, ), props)
519 except (TypeError, IndexError, ValueError), message:
520 import traceback; traceback.print_exc()
521 raise UsageError, message
522 return 0
524 def do_find(self, args):
525 '''Usage: find classname propname=value ...
526 Find the nodes of the given class with a given link property value.
528 Find the nodes of the given class with a given link property value. The
529 value may be either the nodeid of the linked node, or its key value.
530 '''
531 if len(args) < 1:
532 raise UsageError, _('Not enough arguments supplied')
533 classname = args[0]
534 # get the class
535 cl = self.get_class(classname)
537 # handle the propname=value argument
538 props = self.props_from_args(args[1:])
540 # if the value isn't a number, look up the linked class to get the
541 # number
542 for propname, value in props.items():
543 num_re = re.compile('^\d+$')
544 if value == '-1':
545 props[propname] = None
546 elif not num_re.match(value):
547 # get the property
548 try:
549 property = cl.properties[propname]
550 except KeyError:
551 raise UsageError, _('%(classname)s has no property '
552 '"%(propname)s"')%locals()
554 # make sure it's a link
555 if (not isinstance(property, hyperdb.Link) and not
556 isinstance(property, hyperdb.Multilink)):
557 raise UsageError, _('You may only "find" link properties')
559 # get the linked-to class and look up the key property
560 link_class = self.db.getclass(property.classname)
561 try:
562 props[propname] = link_class.lookup(value)
563 except TypeError:
564 raise UsageError, _('%(classname)s has no key property"')%{
565 'classname': link_class.classname}
567 # now do the find
568 try:
569 if self.comma_sep:
570 print ','.join(apply(cl.find, (), props))
571 else:
572 print apply(cl.find, (), props)
573 except KeyError:
574 raise UsageError, _('%(classname)s has no property '
575 '"%(propname)s"')%locals()
576 except (ValueError, TypeError), message:
577 raise UsageError, message
578 return 0
580 def do_specification(self, args):
581 '''Usage: specification classname
582 Show the properties for a classname.
584 This lists the properties for a given class.
585 '''
586 if len(args) < 1:
587 raise UsageError, _('Not enough arguments supplied')
588 classname = args[0]
589 # get the class
590 cl = self.get_class(classname)
592 # get the key property
593 keyprop = cl.getkey()
594 for key, value in cl.properties.items():
595 if keyprop == key:
596 print _('%(key)s: %(value)s (key property)')%locals()
597 else:
598 print _('%(key)s: %(value)s')%locals()
600 def do_display(self, args):
601 '''Usage: display designator
602 Show the property values for the given node.
604 This lists the properties and their associated values for the given
605 node.
606 '''
607 if len(args) < 1:
608 raise UsageError, _('Not enough arguments supplied')
610 # decode the node designator
611 try:
612 classname, nodeid = hyperdb.splitDesignator(args[0])
613 except hyperdb.DesignatorError, message:
614 raise UsageError, message
616 # get the class
617 cl = self.get_class(classname)
619 # display the values
620 for key in cl.properties.keys():
621 value = cl.get(nodeid, key)
622 print _('%(key)s: %(value)s')%locals()
624 def do_create(self, args, pwre = re.compile(r'{(\w+)}(.+)')):
625 '''Usage: create classname property=value ...
626 Create a new entry of a given class.
628 This creates a new entry of the given class using the property
629 name=value arguments provided on the command line after the "create"
630 command.
631 '''
632 if len(args) < 1:
633 raise UsageError, _('Not enough arguments supplied')
634 from roundup import hyperdb
636 classname = args[0]
638 # get the class
639 cl = self.get_class(classname)
641 # now do a create
642 props = {}
643 properties = cl.getprops(protected = 0)
644 if len(args) == 1:
645 # ask for the properties
646 for key, value in properties.items():
647 if key == 'id': continue
648 name = value.__class__.__name__
649 if isinstance(value , hyperdb.Password):
650 again = None
651 while value != again:
652 value = getpass.getpass(_('%(propname)s (Password): ')%{
653 'propname': key.capitalize()})
654 again = getpass.getpass(_(' %(propname)s (Again): ')%{
655 'propname': key.capitalize()})
656 if value != again: print _('Sorry, try again...')
657 if value:
658 props[key] = value
659 else:
660 value = raw_input(_('%(propname)s (%(proptype)s): ')%{
661 'propname': key.capitalize(), 'proptype': name})
662 if value:
663 props[key] = value
664 else:
665 props = self.props_from_args(args[1:])
667 # convert types
668 for propname, value in props.items():
669 # get the property
670 try:
671 proptype = properties[propname]
672 except KeyError:
673 raise UsageError, _('%(classname)s has no property '
674 '"%(propname)s"')%locals()
676 if isinstance(proptype, hyperdb.Date):
677 try:
678 props[propname] = date.Date(value)
679 except ValueError, message:
680 raise UsageError, _('"%(value)s": %(message)s')%locals()
681 elif isinstance(proptype, hyperdb.Interval):
682 try:
683 props[propname] = date.Interval(value)
684 except ValueError, message:
685 raise UsageError, _('"%(value)s": %(message)s')%locals()
686 elif isinstance(proptype, hyperdb.Password):
687 m = pwre.match(value)
688 if m:
689 # password is being given to us encrypted
690 p = password.Password()
691 p.scheme = m.group(1)
692 p.password = m.group(2)
693 props[propname] = p
694 else:
695 props[propname] = password.Password(value)
696 elif isinstance(proptype, hyperdb.Multilink):
697 props[propname] = value.split(',')
698 elif isinstance(proptype, hyperdb.Boolean):
699 props[propname] = value.lower() in ('yes', 'true', 'on', '1')
700 elif isinstance(proptype, hyperdb.Number):
701 props[propname] = float(value)
703 # check for the key property
704 propname = cl.getkey()
705 if propname and not props.has_key(propname):
706 raise UsageError, _('you must provide the "%(propname)s" '
707 'property.')%locals()
709 # do the actual create
710 try:
711 print apply(cl.create, (), props)
712 except (TypeError, IndexError, ValueError), message:
713 raise UsageError, message
714 return 0
716 def do_list(self, args):
717 '''Usage: list classname [property]
718 List the instances of a class.
720 Lists all instances of the given class. If the property is not
721 specified, the "label" property is used. The label property is tried
722 in order: the key, "name", "title" and then the first property,
723 alphabetically.
724 '''
725 if len(args) < 1:
726 raise UsageError, _('Not enough arguments supplied')
727 classname = args[0]
729 # get the class
730 cl = self.get_class(classname)
732 # figure the property
733 if len(args) > 1:
734 propname = args[1]
735 else:
736 propname = cl.labelprop()
738 if self.comma_sep:
739 print ','.join(cl.list())
740 else:
741 for nodeid in cl.list():
742 try:
743 value = cl.get(nodeid, propname)
744 except KeyError:
745 raise UsageError, _('%(classname)s has no property '
746 '"%(propname)s"')%locals()
747 print _('%(nodeid)4s: %(value)s')%locals()
748 return 0
750 def do_table(self, args):
751 '''Usage: table classname [property[,property]*]
752 List the instances of a class in tabular form.
754 Lists all instances of the given class. If the properties are not
755 specified, all properties are displayed. By default, the column widths
756 are the width of the largest value. The width may be explicitly defined
757 by defining the property as "name:width". For example::
758 roundup> table priority id,name:10
759 Id Name
760 1 fatal-bug
761 2 bug
762 3 usability
763 4 feature
765 Also to make the width of the column the width of the label,
766 leave a trailing : without a width on the property. E.G.
767 roundup> table priority id,name:
768 Id Name
769 1 fata
770 2 bug
771 3 usab
772 4 feat
774 will result in a the 4 character wide "Name" column.
775 '''
776 if len(args) < 1:
777 raise UsageError, _('Not enough arguments supplied')
778 classname = args[0]
780 # get the class
781 cl = self.get_class(classname)
783 # figure the property names to display
784 if len(args) > 1:
785 prop_names = args[1].split(',')
786 all_props = cl.getprops()
787 for spec in prop_names:
788 if ':' in spec:
789 try:
790 propname, width = spec.split(':')
791 except (ValueError, TypeError):
792 raise UsageError, _('"%(spec)s" not name:width')%locals()
793 else:
794 propname = spec
795 if not all_props.has_key(propname):
796 raise UsageError, _('%(classname)s has no property '
797 '"%(propname)s"')%locals()
798 else:
799 prop_names = cl.getprops().keys()
801 # now figure column widths
802 props = []
803 for spec in prop_names:
804 if ':' in spec:
805 name, width = spec.split(':')
806 if width == '':
807 props.append((name, len(spec)))
808 else:
809 props.append((name, int(width)))
810 else:
811 # this is going to be slow
812 maxlen = len(spec)
813 for nodeid in cl.list():
814 curlen = len(str(cl.get(nodeid, spec)))
815 if curlen > maxlen:
816 maxlen = curlen
817 props.append((spec, maxlen))
819 # now display the heading
820 print ' '.join([name.capitalize().ljust(width) for name,width in props])
822 # and the table data
823 for nodeid in cl.list():
824 l = []
825 for name, width in props:
826 if name != 'id':
827 try:
828 value = str(cl.get(nodeid, name))
829 except KeyError:
830 # we already checked if the property is valid - a
831 # KeyError here means the node just doesn't have a
832 # value for it
833 value = ''
834 else:
835 value = str(nodeid)
836 f = '%%-%ds'%width
837 l.append(f%value[:width])
838 print ' '.join(l)
839 return 0
841 def do_history(self, args):
842 '''Usage: history designator
843 Show the history entries of a designator.
845 Lists the journal entries for the node identified by the designator.
846 '''
847 if len(args) < 1:
848 raise UsageError, _('Not enough arguments supplied')
849 try:
850 classname, nodeid = hyperdb.splitDesignator(args[0])
851 except hyperdb.DesignatorError, message:
852 raise UsageError, message
854 try:
855 print self.db.getclass(classname).history(nodeid)
856 except KeyError:
857 raise UsageError, _('no such class "%(classname)s"')%locals()
858 except IndexError:
859 raise UsageError, _('no such %(classname)s node "%(nodeid)s"')%locals()
860 return 0
862 def do_commit(self, args):
863 '''Usage: commit
864 Commit all changes made to the database.
866 The changes made during an interactive session are not
867 automatically written to the database - they must be committed
868 using this command.
870 One-off commands on the command-line are automatically committed if
871 they are successful.
872 '''
873 self.db.commit()
874 return 0
876 def do_rollback(self, args):
877 '''Usage: rollback
878 Undo all changes that are pending commit to the database.
880 The changes made during an interactive session are not
881 automatically written to the database - they must be committed
882 manually. This command undoes all those changes, so a commit
883 immediately after would make no changes to the database.
884 '''
885 self.db.rollback()
886 return 0
888 def do_retire(self, args):
889 '''Usage: retire designator[,designator]*
890 Retire the node specified by designator.
892 This action indicates that a particular node is not to be retrieved by
893 the list or find commands, and its key value may be re-used.
894 '''
895 if len(args) < 1:
896 raise UsageError, _('Not enough arguments supplied')
897 designators = args[0].split(',')
898 for designator in designators:
899 try:
900 classname, nodeid = hyperdb.splitDesignator(designator)
901 except hyperdb.DesignatorError, message:
902 raise UsageError, message
903 try:
904 self.db.getclass(classname).retire(nodeid)
905 except KeyError:
906 raise UsageError, _('no such class "%(classname)s"')%locals()
907 except IndexError:
908 raise UsageError, _('no such %(classname)s node "%(nodeid)s"')%locals()
909 return 0
911 def do_restore(self, args):
912 '''Usage: restore designator[,designator]*
913 Restore the retired node specified by designator.
915 The given nodes will become available for users again.
916 '''
917 if len(args) < 1:
918 raise UsageError, _('Not enough arguments supplied')
919 designators = args[0].split(',')
920 for designator in designators:
921 try:
922 classname, nodeid = hyperdb.splitDesignator(designator)
923 except hyperdb.DesignatorError, message:
924 raise UsageError, message
925 try:
926 self.db.getclass(classname).restore(nodeid)
927 except KeyError:
928 raise UsageError, _('no such class "%(classname)s"')%locals()
929 except IndexError:
930 raise UsageError, _('no such %(classname)s node "%(nodeid)s"')%locals()
931 return 0
933 def do_export(self, args):
934 '''Usage: export [class[,class]] export_dir
935 Export the database to colon-separated-value files.
937 This action exports the current data from the database into
938 colon-separated-value files that are placed in the nominated
939 destination directory. The journals are not exported.
940 '''
941 # we need the CSV module
942 if csv is None:
943 raise UsageError, \
944 _('Sorry, you need the csv module to use this function.\n'
945 'Get it from: http://www.object-craft.com.au/projects/csv/')
947 # grab the directory to export to
948 if len(args) < 1:
949 raise UsageError, _('Not enough arguments supplied')
950 dir = args[-1]
952 # get the list of classes to export
953 if len(args) == 2:
954 classes = args[0].split(',')
955 else:
956 classes = self.db.classes.keys()
958 # use the csv parser if we can - it's faster
959 p = csv.parser(field_sep=':')
961 # do all the classes specified
962 for classname in classes:
963 cl = self.get_class(classname)
964 f = open(os.path.join(dir, classname+'.csv'), 'w')
965 properties = cl.getprops()
966 propnames = properties.keys()
967 propnames.sort()
968 l = propnames[:]
969 l.append('is retired')
970 print >> f, p.join(l)
972 # all nodes for this class (not using list() 'cos it doesn't
973 # include retired nodes)
975 for nodeid in self.db.getclass(classname).getnodeids():
976 # get the regular props
977 print >>f, p.join(cl.export_list(propnames, nodeid))
979 # close this file
980 f.close()
981 return 0
983 def do_import(self, args):
984 '''Usage: import import_dir
985 Import a database from the directory containing CSV files, one per
986 class to import.
988 The files must define the same properties as the class (including having
989 a "header" line with those property names.)
991 The imported nodes will have the same nodeid as defined in the
992 import file, thus replacing any existing content.
994 The new nodes are added to the existing database - if you want to
995 create a new database using the imported data, then create a new
996 database (or, tediously, retire all the old data.)
997 '''
998 if len(args) < 1:
999 raise UsageError, _('Not enough arguments supplied')
1000 if csv is None:
1001 raise UsageError, \
1002 _('Sorry, you need the csv module to use this function.\n'
1003 'Get it from: http://www.object-craft.com.au/projects/csv/')
1005 from roundup import hyperdb
1007 for file in os.listdir(args[0]):
1008 # we only care about CSV files
1009 if not file.endswith('.csv'):
1010 continue
1012 f = open(os.path.join(args[0], file))
1014 # get the classname
1015 classname = os.path.splitext(file)[0]
1017 # ensure that the properties and the CSV file headings match
1018 cl = self.get_class(classname)
1019 p = csv.parser(field_sep=':')
1020 file_props = p.parse(f.readline())
1022 # XXX we don't _really_ need to do this...
1023 # properties = cl.getprops()
1024 # propnames = properties.keys()
1025 # propnames.sort()
1026 # m = file_props[:]
1027 # m.sort()
1028 # if m != propnames:
1029 # raise UsageError, _('Import file doesn\'t define the same '
1030 # 'properties as "%(arg0)s".')%{'arg0': args[0]}
1032 # loop through the file and create a node for each entry
1033 maxid = 1
1034 while 1:
1035 line = f.readline()
1036 if not line: break
1038 # parse lines until we get a complete entry
1039 while 1:
1040 l = p.parse(line)
1041 if l: break
1042 line = f.readline()
1043 if not line:
1044 raise ValueError, "Unexpected EOF during CSV parse"
1046 # do the import and figure the current highest nodeid
1047 maxid = max(maxid, int(cl.import_list(file_props, l)))
1049 print 'setting', classname, maxid+1
1050 self.db.setid(classname, str(maxid+1))
1051 return 0
1053 def do_pack(self, args):
1054 '''Usage: pack period | date
1056 Remove journal entries older than a period of time specified or
1057 before a certain date.
1059 A period is specified using the suffixes "y", "m", and "d". The
1060 suffix "w" (for "week") means 7 days.
1062 "3y" means three years
1063 "2y 1m" means two years and one month
1064 "1m 25d" means one month and 25 days
1065 "2w 3d" means two weeks and three days
1067 Date format is "YYYY-MM-DD" eg:
1068 2001-01-01
1070 '''
1071 if len(args) <> 1:
1072 raise UsageError, _('Not enough arguments supplied')
1074 # are we dealing with a period or a date
1075 value = args[0]
1076 date_re = re.compile(r'''
1077 (?P<date>\d\d\d\d-\d\d?-\d\d?)? # yyyy-mm-dd
1078 (?P<period>(\d+y\s*)?(\d+m\s*)?(\d+d\s*)?)?
1079 ''', re.VERBOSE)
1080 m = date_re.match(value)
1081 if not m:
1082 raise ValueError, _('Invalid format')
1083 m = m.groupdict()
1084 if m['period']:
1085 pack_before = date.Date(". - %s"%value)
1086 elif m['date']:
1087 pack_before = date.Date(value)
1088 self.db.pack(pack_before)
1089 return 0
1091 def do_reindex(self, args):
1092 '''Usage: reindex
1093 Re-generate a tracker's search indexes.
1095 This will re-generate the search indexes for a tracker. This will
1096 typically happen automatically.
1097 '''
1098 self.db.indexer.force_reindex()
1099 self.db.reindex()
1100 return 0
1102 def do_security(self, args):
1103 '''Usage: security [Role name]
1104 Display the Permissions available to one or all Roles.
1105 '''
1106 if len(args) == 1:
1107 role = args[0]
1108 try:
1109 roles = [(args[0], self.db.security.role[args[0]])]
1110 except KeyError:
1111 print _('No such Role "%(role)s"')%locals()
1112 return 1
1113 else:
1114 roles = self.db.security.role.items()
1115 role = self.db.config.NEW_WEB_USER_ROLES
1116 if ',' in role:
1117 print _('New Web users get the Roles "%(role)s"')%locals()
1118 else:
1119 print _('New Web users get the Role "%(role)s"')%locals()
1120 role = self.db.config.NEW_EMAIL_USER_ROLES
1121 if ',' in role:
1122 print _('New Email users get the Roles "%(role)s"')%locals()
1123 else:
1124 print _('New Email users get the Role "%(role)s"')%locals()
1125 roles.sort()
1126 for rolename, role in roles:
1127 print _('Role "%(name)s":')%role.__dict__
1128 for permission in role.permissions:
1129 if permission.klass:
1130 print _(' %(description)s (%(name)s for "%(klass)s" '
1131 'only)')%permission.__dict__
1132 else:
1133 print _(' %(description)s (%(name)s)')%permission.__dict__
1134 return 0
1136 def run_command(self, args):
1137 '''Run a single command
1138 '''
1139 command = args[0]
1141 # handle help now
1142 if command == 'help':
1143 if len(args)>1:
1144 self.do_help(args[1:])
1145 return 0
1146 self.do_help(['help'])
1147 return 0
1148 if command == 'morehelp':
1149 self.do_help(['help'])
1150 self.help_commands()
1151 self.help_all()
1152 return 0
1154 # figure what the command is
1155 try:
1156 functions = self.commands.get(command)
1157 except KeyError:
1158 # not a valid command
1159 print _('Unknown command "%(command)s" ("help commands" for a '
1160 'list)')%locals()
1161 return 1
1163 # check for multiple matches
1164 if len(functions) > 1:
1165 print _('Multiple commands match "%(command)s": %(list)s')%{'command':
1166 command, 'list': ', '.join([i[0] for i in functions])}
1167 return 1
1168 command, function = functions[0]
1170 # make sure we have a tracker_home
1171 while not self.tracker_home:
1172 self.tracker_home = raw_input(_('Enter tracker home: ')).strip()
1174 # before we open the db, we may be doing an install or init
1175 if command == 'initialise':
1176 try:
1177 return self.do_initialise(self.tracker_home, args)
1178 except UsageError, message:
1179 print _('Error: %(message)s')%locals()
1180 return 1
1181 elif command == 'install':
1182 try:
1183 return self.do_install(self.tracker_home, args)
1184 except UsageError, message:
1185 print _('Error: %(message)s')%locals()
1186 return 1
1188 # get the tracker
1189 try:
1190 tracker = roundup.instance.open(self.tracker_home)
1191 except ValueError, message:
1192 self.tracker_home = ''
1193 print _("Error: Couldn't open tracker: %(message)s")%locals()
1194 return 1
1196 # only open the database once!
1197 if not self.db:
1198 self.db = tracker.open('admin')
1200 # do the command
1201 ret = 0
1202 try:
1203 ret = function(args[1:])
1204 except UsageError, message:
1205 print _('Error: %(message)s')%locals()
1206 print
1207 print function.__doc__
1208 ret = 1
1209 except:
1210 import traceback
1211 traceback.print_exc()
1212 ret = 1
1213 return ret
1215 def interactive(self):
1216 '''Run in an interactive mode
1217 '''
1218 print _('Roundup %s ready for input.'%roundup_version)
1219 print _('Type "help" for help.')
1220 try:
1221 import readline
1222 except ImportError:
1223 print _('Note: command history and editing not available')
1225 while 1:
1226 try:
1227 command = raw_input(_('roundup> '))
1228 except EOFError:
1229 print _('exit...')
1230 break
1231 if not command: continue
1232 args = token.token_split(command)
1233 if not args: continue
1234 if args[0] in ('quit', 'exit'): break
1235 self.run_command(args)
1237 # exit.. check for transactions
1238 if self.db and self.db.transactions:
1239 commit = raw_input(_('There are unsaved changes. Commit them (y/N)? '))
1240 if commit and commit[0].lower() == 'y':
1241 self.db.commit()
1242 return 0
1244 def main(self):
1245 try:
1246 opts, args = getopt.getopt(sys.argv[1:], 'i:u:hc')
1247 except getopt.GetoptError, e:
1248 self.usage(str(e))
1249 return 1
1251 # handle command-line args
1252 self.tracker_home = os.environ.get('TRACKER_HOME', '')
1253 # TODO: reinstate the user/password stuff (-u arg too)
1254 name = password = ''
1255 if os.environ.has_key('ROUNDUP_LOGIN'):
1256 l = os.environ['ROUNDUP_LOGIN'].split(':')
1257 name = l[0]
1258 if len(l) > 1:
1259 password = l[1]
1260 self.comma_sep = 0
1261 for opt, arg in opts:
1262 if opt == '-h':
1263 self.usage()
1264 return 0
1265 if opt == '-i':
1266 self.tracker_home = arg
1267 if opt == '-c':
1268 self.comma_sep = 1
1270 # if no command - go interactive
1271 # wrap in a try/finally so we always close off the db
1272 ret = 0
1273 try:
1274 if not args:
1275 self.interactive()
1276 else:
1277 ret = self.run_command(args)
1278 if self.db: self.db.commit()
1279 return ret
1280 finally:
1281 if self.db:
1282 self.db.close()
1284 if __name__ == '__main__':
1285 tool = AdminTool()
1286 sys.exit(tool.main())
1288 # vim: set filetype=python ts=4 sw=4 et si