51695e2bd46b44789f4bb88ba23532d631070ab5
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.110 2008-02-07 03:28:33 richard Exp $
21 '''Administration commands for maintaining Roundup trackers.
22 '''
23 __docformat__ = 'restructuredtext'
25 import csv, getopt, getpass, os, re, shutil, sys, UserDict
27 from roundup import date, hyperdb, roundupdb, init, password, token
28 from roundup import __version__ as roundup_version
29 import roundup.instance
30 from roundup.configuration import CoreConfig
31 from roundup.i18n import _
33 class CommandDict(UserDict.UserDict):
34 '''Simple dictionary that lets us do lookups using partial keys.
36 Original code submitted by Engelbert Gruber.
37 '''
38 _marker = []
39 def get(self, key, default=_marker):
40 if self.data.has_key(key):
41 return [(key, self.data[key])]
42 keylist = self.data.keys()
43 keylist.sort()
44 l = []
45 for ki in keylist:
46 if ki.startswith(key):
47 l.append((ki, self.data[ki]))
48 if not l and default is self._marker:
49 raise KeyError, key
50 return l
52 class UsageError(ValueError):
53 pass
55 class AdminTool:
56 ''' A collection of methods used in maintaining Roundup trackers.
58 Typically these methods are accessed through the roundup-admin
59 script. The main() method provided on this class gives the main
60 loop for the roundup-admin script.
62 Actions are defined by do_*() methods, with help for the action
63 given in the method docstring.
65 Additional help may be supplied by help_*() methods.
66 '''
67 def __init__(self):
68 self.commands = CommandDict()
69 for k in AdminTool.__dict__.keys():
70 if k[:3] == 'do_':
71 self.commands[k[3:]] = getattr(self, k)
72 self.help = {}
73 for k in AdminTool.__dict__.keys():
74 if k[:5] == 'help_':
75 self.help[k[5:]] = getattr(self, k)
76 self.tracker_home = ''
77 self.db = None
78 self.db_uncommitted = False
80 def get_class(self, classname):
81 '''Get the class - raise an exception if it doesn't exist.
82 '''
83 try:
84 return self.db.getclass(classname)
85 except KeyError:
86 raise UsageError, _('no such class "%(classname)s"')%locals()
88 def props_from_args(self, args):
89 ''' Produce a dictionary of prop: value from the args list.
91 The args list is specified as ``prop=value prop=value ...``.
92 '''
93 props = {}
94 for arg in args:
95 if arg.find('=') == -1:
96 raise UsageError, _('argument "%(arg)s" not propname=value'
97 )%locals()
98 l = arg.split('=')
99 if len(l) < 2:
100 raise UsageError, _('argument "%(arg)s" not propname=value'
101 )%locals()
102 key, value = l[0], '='.join(l[1:])
103 if value:
104 props[key] = value
105 else:
106 props[key] = None
107 return props
109 def usage(self, message=''):
110 ''' Display a simple usage message.
111 '''
112 if message:
113 message = _('Problem: %(message)s\n\n')%locals()
114 print _('''%(message)sUsage: roundup-admin [options] [<command> <arguments>]
116 Options:
117 -i instance home -- specify the issue tracker "home directory" to administer
118 -u -- the user[:password] to use for commands
119 -d -- print full designators not just class id numbers
120 -c -- when outputting lists of data, comma-separate them.
121 Same as '-S ","'.
122 -S <string> -- when outputting lists of data, string-separate them
123 -s -- when outputting lists of data, space-separate them.
124 Same as '-S " "'.
125 -V -- be verbose when importing
126 -v -- report Roundup and Python versions (and quit)
128 Only one of -s, -c or -S can be specified.
130 Help:
131 roundup-admin -h
132 roundup-admin help -- this help
133 roundup-admin help <command> -- command-specific help
134 roundup-admin help all -- all available help
135 ''')%locals()
136 self.help_commands()
138 def help_commands(self):
139 ''' List the commands available with their help summary.
140 '''
141 print _('Commands:'),
142 commands = ['']
143 for command in self.commands.values():
144 h = _(command.__doc__).split('\n')[0]
145 commands.append(' '+h[7:])
146 commands.sort()
147 commands.append(_(
148 """Commands may be abbreviated as long as the abbreviation
149 matches only one command, e.g. l == li == lis == list."""))
150 print '\n'.join(commands)
151 print
153 def help_commands_html(self, indent_re=re.compile(r'^(\s+)\S+')):
154 ''' Produce an HTML command list.
155 '''
156 commands = self.commands.values()
157 def sortfun(a, b):
158 return cmp(a.__name__, b.__name__)
159 commands.sort(sortfun)
160 for command in commands:
161 h = _(command.__doc__).split('\n')
162 name = command.__name__[3:]
163 usage = h[0]
164 print '''
165 <tr><td valign=top><strong>%(name)s</strong></td>
166 <td><tt>%(usage)s</tt><p>
167 <pre>''' % locals()
168 indent = indent_re.match(h[3])
169 if indent: indent = len(indent.group(1))
170 for line in h[3:]:
171 if indent:
172 print line[indent:]
173 else:
174 print line
175 print '</pre></td></tr>\n'
177 def help_all(self):
178 print _('''
179 All commands (except help) require a tracker specifier. This is just
180 the path to the roundup tracker you're working with. A roundup tracker
181 is where roundup keeps the database and configuration file that defines
182 an issue tracker. It may be thought of as the issue tracker's "home
183 directory". It may be specified in the environment variable TRACKER_HOME
184 or on the command line as "-i tracker".
186 A designator is a classname and a nodeid concatenated, eg. bug1, user10, ...
188 Property values are represented as strings in command arguments and in the
189 printed results:
190 . Strings are, well, strings.
191 . Date values are printed in the full date format in the local time zone,
192 and accepted in the full format or any of the partial formats explained
193 below.
194 . Link values are printed as node designators. When given as an argument,
195 node designators and key strings are both accepted.
196 . Multilink values are printed as lists of node designators joined
197 by commas. When given as an argument, node designators and key
198 strings are both accepted; an empty string, a single node, or a list
199 of nodes joined by commas is accepted.
201 When property values must contain spaces, just surround the value with
202 quotes, either ' or ". A single space may also be backslash-quoted. If a
203 value must contain a quote character, it must be backslash-quoted or inside
204 quotes. Examples:
205 hello world (2 tokens: hello, world)
206 "hello world" (1 token: hello world)
207 "Roch'e" Compaan (2 tokens: Roch'e Compaan)
208 Roch\\'e Compaan (2 tokens: Roch'e Compaan)
209 address="1 2 3" (1 token: address=1 2 3)
210 \\\\ (1 token: \\)
211 \\n\\r\\t (1 token: a newline, carriage-return and tab)
213 When multiple nodes are specified to the roundup get or roundup set
214 commands, the specified properties are retrieved or set on all the listed
215 nodes.
217 When multiple results are returned by the roundup get or roundup find
218 commands, they are printed one per line (default) or joined by commas (with
219 the -c) option.
221 Where the command changes data, a login name/password is required. The
222 login may be specified as either "name" or "name:password".
223 . ROUNDUP_LOGIN environment variable
224 . the -u command-line option
225 If either the name or password is not supplied, they are obtained from the
226 command-line.
228 Date format examples:
229 "2000-04-17.03:45" means <Date 2000-04-17.08:45:00>
230 "2000-04-17" means <Date 2000-04-17.00:00:00>
231 "01-25" means <Date yyyy-01-25.00:00:00>
232 "08-13.22:13" means <Date yyyy-08-14.03:13:00>
233 "11-07.09:32:43" means <Date yyyy-11-07.14:32:43>
234 "14:25" means <Date yyyy-mm-dd.19:25:00>
235 "8:47:11" means <Date yyyy-mm-dd.13:47:11>
236 "." means "right now"
238 Command help:
239 ''')
240 for name, command in self.commands.items():
241 print _('%s:')%name
242 print ' ', _(command.__doc__)
244 def do_help(self, args, nl_re=re.compile('[\r\n]'),
245 indent_re=re.compile(r'^(\s+)\S+')):
246 ""'''Usage: help topic
247 Give help about topic.
249 commands -- list commands
250 <command> -- help specific to a command
251 initopts -- init command options
252 all -- all available help
253 '''
254 if len(args)>0:
255 topic = args[0]
256 else:
257 topic = 'help'
260 # try help_ methods
261 if self.help.has_key(topic):
262 self.help[topic]()
263 return 0
265 # try command docstrings
266 try:
267 l = self.commands.get(topic)
268 except KeyError:
269 print _('Sorry, no help for "%(topic)s"')%locals()
270 return 1
272 # display the help for each match, removing the docsring indent
273 for name, help in l:
274 lines = nl_re.split(_(help.__doc__))
275 print lines[0]
276 indent = indent_re.match(lines[1])
277 if indent: indent = len(indent.group(1))
278 for line in lines[1:]:
279 if indent:
280 print line[indent:]
281 else:
282 print line
283 return 0
285 def listTemplates(self):
286 ''' List all the available templates.
288 Look in the following places, where the later rules take precedence:
290 1. <roundup.admin.__file__>/../../share/roundup/templates/*
291 this is where they will be if we installed an egg via easy_install
292 2. <prefix>/share/roundup/templates/*
293 this should be the standard place to find them when Roundup is
294 installed
295 3. <roundup.admin.__file__>/../templates/*
296 this will be used if Roundup's run in the distro (aka. source)
297 directory
298 4. <current working dir>/*
299 this is for when someone unpacks a 3rd-party template
300 5. <current working dir>
301 this is for someone who "cd"s to the 3rd-party template dir
302 '''
303 # OK, try <prefix>/share/roundup/templates
304 # and <egg-directory>/share/roundup/templates
305 # -- this module (roundup.admin) will be installed in something
306 # like:
307 # /usr/lib/python2.5/site-packages/roundup/admin.py (5 dirs up)
308 # c:\python25\lib\site-packages\roundup\admin.py (4 dirs up)
309 # /usr/lib/python2.5/site-packages/roundup-1.3.3-py2.5-egg/roundup/admin.py
310 # (2 dirs up)
311 #
312 # we're interested in where the directory containing "share" is
313 templates = {}
314 for N in 2, 4, 5:
315 path = __file__
316 # move up N elements in the path
317 for i in range(N):
318 path = os.path.dirname(path)
319 tdir = os.path.join(path, 'share', 'roundup', 'templates')
320 if os.path.isdir(tdir):
321 templates = init.listTemplates(tdir)
322 break
324 # OK, now try as if we're in the roundup source distribution
325 # directory, so this module will be in .../roundup-*/roundup/admin.py
326 # and we're interested in the .../roundup-*/ part.
327 path = __file__
328 for i in range(2):
329 path = os.path.dirname(path)
330 tdir = os.path.join(path, 'templates')
331 if os.path.isdir(tdir):
332 templates.update(init.listTemplates(tdir))
334 # Try subdirs of the current dir
335 templates.update(init.listTemplates(os.getcwd()))
337 # Finally, try the current directory as a template
338 template = init.loadTemplateInfo(os.getcwd())
339 if template:
340 templates[template['name']] = template
342 return templates
344 def help_initopts(self):
345 templates = self.listTemplates()
346 print _('Templates:'), ', '.join(templates.keys())
347 import roundup.backends
348 backends = roundup.backends.list_backends()
349 print _('Back ends:'), ', '.join(backends)
351 def do_install(self, tracker_home, args):
352 ""'''Usage: install [template [backend [key=val[,key=val]]]]
353 Install a new Roundup tracker.
355 The command will prompt for the tracker home directory
356 (if not supplied through TRACKER_HOME or the -i option).
357 The template and backend may be specified on the command-line
358 as arguments, in that order.
360 Command line arguments following the backend allows you to
361 pass initial values for config options. For example, passing
362 "web_http_auth=no,rdbms_user=dinsdale" will override defaults
363 for options http_auth in section [web] and user in section [rdbms].
364 Please be careful to not use spaces in this argument! (Enclose
365 whole argument in quotes if you need spaces in option value).
367 The initialise command must be called after this command in order
368 to initialise the tracker's database. You may edit the tracker's
369 initial database contents before running that command by editing
370 the tracker's dbinit.py module init() function.
372 See also initopts help.
373 '''
374 if len(args) < 1:
375 raise UsageError, _('Not enough arguments supplied')
377 # make sure the tracker home can be created
378 tracker_home = os.path.abspath(tracker_home)
379 parent = os.path.split(tracker_home)[0]
380 if not os.path.exists(parent):
381 raise UsageError, _('Instance home parent directory "%(parent)s"'
382 ' does not exist')%locals()
384 config_ini_file = os.path.join(tracker_home, CoreConfig.INI_FILE)
385 # check for both old- and new-style configs
386 if filter(os.path.exists, [config_ini_file,
387 os.path.join(tracker_home, 'config.py')]):
388 ok = raw_input(_(
389 """WARNING: There appears to be a tracker in "%(tracker_home)s"!
390 If you re-install it, you will lose all the data!
391 Erase it? Y/N: """) % locals())
392 if ok.strip().lower() != 'y':
393 return 0
395 # clear it out so the install isn't confused
396 shutil.rmtree(tracker_home)
398 # select template
399 templates = self.listTemplates()
400 template = len(args) > 1 and args[1] or ''
401 if not templates.has_key(template):
402 print _('Templates:'), ', '.join(templates.keys())
403 while not templates.has_key(template):
404 template = raw_input(_('Select template [classic]: ')).strip()
405 if not template:
406 template = 'classic'
408 # select hyperdb backend
409 import roundup.backends
410 backends = roundup.backends.list_backends()
411 backend = len(args) > 2 and args[2] or ''
412 if backend not in backends:
413 print _('Back ends:'), ', '.join(backends)
414 while backend not in backends:
415 backend = raw_input(_('Select backend [anydbm]: ')).strip()
416 if not backend:
417 backend = 'anydbm'
418 # XXX perform a unit test based on the user's selections
420 # Process configuration file definitions
421 if len(args) > 3:
422 try:
423 defns = dict([item.split("=") for item in args[3].split(",")])
424 except:
425 print _('Error in configuration settings: "%s"') % args[3]
426 raise
427 else:
428 defns = {}
430 # install!
431 init.install(tracker_home, templates[template]['path'], settings=defns)
432 init.write_select_db(tracker_home, backend)
434 print _("""
435 ---------------------------------------------------------------------------
436 You should now edit the tracker configuration file:
437 %(config_file)s""") % {"config_file": config_ini_file}
439 # find list of options that need manual adjustments
440 # XXX config._get_unset_options() is marked as private
441 # (leading underscore). make it public or don't care?
442 need_set = CoreConfig(tracker_home)._get_unset_options()
443 if need_set:
444 print _(" ... at a minimum, you must set following options:")
445 for section, options in need_set.items():
446 print " [%s]: %s" % (section, ", ".join(options))
448 # note about schema modifications
449 print _("""
450 If you wish to modify the database schema,
451 you should also edit the schema file:
452 %(database_config_file)s
453 You may also change the database initialisation file:
454 %(database_init_file)s
455 ... see the documentation on customizing for more information.
457 You MUST run the "roundup-admin initialise" command once you've performed
458 the above steps.
459 ---------------------------------------------------------------------------
460 """) % {
461 'database_config_file': os.path.join(tracker_home, 'schema.py'),
462 'database_init_file': os.path.join(tracker_home, 'initial_data.py'),
463 }
464 return 0
466 def do_genconfig(self, args):
467 ""'''Usage: genconfig <filename>
468 Generate a new tracker config file (ini style) with default values
469 in <filename>.
470 '''
471 if len(args) < 1:
472 raise UsageError, _('Not enough arguments supplied')
473 config = CoreConfig()
474 config.save(args[0])
476 def do_initialise(self, tracker_home, args):
477 ""'''Usage: initialise [adminpw]
478 Initialise a new Roundup tracker.
480 The administrator details will be set at this step.
482 Execute the tracker's initialisation function dbinit.init()
483 '''
484 # password
485 if len(args) > 1:
486 adminpw = args[1]
487 else:
488 adminpw = ''
489 confirm = 'x'
490 while adminpw != confirm:
491 adminpw = getpass.getpass(_('Admin Password: '))
492 confirm = getpass.getpass(_(' Confirm: '))
494 # make sure the tracker home is installed
495 if not os.path.exists(tracker_home):
496 raise UsageError, _('Instance home does not exist')%locals()
497 try:
498 tracker = roundup.instance.open(tracker_home)
499 except roundup.instance.TrackerError:
500 raise UsageError, _('Instance has not been installed')%locals()
502 # is there already a database?
503 if tracker.exists():
504 ok = raw_input(_(
505 """WARNING: The database is already initialised!
506 If you re-initialise it, you will lose all the data!
507 Erase it? Y/N: """))
508 if ok.strip().lower() != 'y':
509 return 0
511 backend = tracker.get_backend_name()
513 # nuke it
514 tracker.nuke()
516 # re-write the backend select file
517 init.write_select_db(tracker_home, backend)
519 # GO
520 tracker.init(password.Password(adminpw))
522 return 0
525 def do_get(self, args):
526 ""'''Usage: get property designator[,designator]*
527 Get the given property of one or more designator(s).
529 Retrieves the property value of the nodes specified
530 by the designators.
531 '''
532 if len(args) < 2:
533 raise UsageError, _('Not enough arguments supplied')
534 propname = args[0]
535 designators = args[1].split(',')
536 l = []
537 for designator in designators:
538 # decode the node designator
539 try:
540 classname, nodeid = hyperdb.splitDesignator(designator)
541 except hyperdb.DesignatorError, message:
542 raise UsageError, message
544 # get the class
545 cl = self.get_class(classname)
546 try:
547 id=[]
548 if self.separator:
549 if self.print_designator:
550 # see if property is a link or multilink for
551 # which getting a desginator make sense.
552 # Algorithm: Get the properties of the
553 # current designator's class. (cl.getprops)
554 # get the property object for the property the
555 # user requested (properties[propname])
556 # verify its type (isinstance...)
557 # raise error if not link/multilink
558 # get class name for link/multilink property
559 # do the get on the designators
560 # append the new designators
561 # print
562 properties = cl.getprops()
563 property = properties[propname]
564 if not (isinstance(property, hyperdb.Multilink) or
565 isinstance(property, hyperdb.Link)):
566 raise UsageError, _('property %s is not of type Multilink or Link so -d flag does not apply.')%propname
567 propclassname = self.db.getclass(property.classname).classname
568 id = cl.get(nodeid, propname)
569 for i in id:
570 l.append(propclassname + i)
571 else:
572 id = cl.get(nodeid, propname)
573 for i in id:
574 l.append(i)
575 else:
576 if self.print_designator:
577 properties = cl.getprops()
578 property = properties[propname]
579 if not (isinstance(property, hyperdb.Multilink) or
580 isinstance(property, hyperdb.Link)):
581 raise UsageError, _('property %s is not of type Multilink or Link so -d flag does not apply.')%propname
582 propclassname = self.db.getclass(property.classname).classname
583 id = cl.get(nodeid, propname)
584 for i in id:
585 print propclassname + i
586 else:
587 print cl.get(nodeid, propname)
588 except IndexError:
589 raise UsageError, _('no such %(classname)s node "%(nodeid)s"')%locals()
590 except KeyError:
591 raise UsageError, _('no such %(classname)s property '
592 '"%(propname)s"')%locals()
593 if self.separator:
594 print self.separator.join(l)
596 return 0
599 def do_set(self, args):
600 ""'''Usage: set items property=value property=value ...
601 Set the given properties of one or more items(s).
603 The items are specified as a class or as a comma-separated
604 list of item designators (ie "designator[,designator,...]").
606 This command sets the properties to the values for all designators
607 given. If the value is missing (ie. "property=") then the property
608 is un-set. If the property is a multilink, you specify the linked
609 ids for the multilink as comma-separated numbers (ie "1,2,3").
610 '''
611 if len(args) < 2:
612 raise UsageError, _('Not enough arguments supplied')
613 from roundup import hyperdb
615 designators = args[0].split(',')
616 if len(designators) == 1:
617 designator = designators[0]
618 try:
619 designator = hyperdb.splitDesignator(designator)
620 designators = [designator]
621 except hyperdb.DesignatorError:
622 cl = self.get_class(designator)
623 designators = [(designator, x) for x in cl.list()]
624 else:
625 try:
626 designators = [hyperdb.splitDesignator(x) for x in designators]
627 except hyperdb.DesignatorError, message:
628 raise UsageError, message
630 # get the props from the args
631 props = self.props_from_args(args[1:])
633 # now do the set for all the nodes
634 for classname, itemid in designators:
635 cl = self.get_class(classname)
637 properties = cl.getprops()
638 for key, value in props.items():
639 try:
640 props[key] = hyperdb.rawToHyperdb(self.db, cl, itemid,
641 key, value)
642 except hyperdb.HyperdbValueError, message:
643 raise UsageError, message
645 # try the set
646 try:
647 apply(cl.set, (itemid, ), props)
648 except (TypeError, IndexError, ValueError), message:
649 import traceback; traceback.print_exc()
650 raise UsageError, message
651 self.db_uncommitted = True
652 return 0
654 def do_find(self, args):
655 ""'''Usage: find classname propname=value ...
656 Find the nodes of the given class with a given link property value.
658 Find the nodes of the given class with a given link property value.
659 The value may be either the nodeid of the linked node, or its key
660 value.
661 '''
662 if len(args) < 1:
663 raise UsageError, _('Not enough arguments supplied')
664 classname = args[0]
665 # get the class
666 cl = self.get_class(classname)
668 # handle the propname=value argument
669 props = self.props_from_args(args[1:])
671 # convert the user-input value to a value used for find()
672 for propname, value in props.items():
673 if ',' in value:
674 values = value.split(',')
675 else:
676 values = [value]
677 d = props[propname] = {}
678 for value in values:
679 value = hyperdb.rawToHyperdb(self.db, cl, None, propname, value)
680 if isinstance(value, list):
681 for entry in value:
682 d[entry] = 1
683 else:
684 d[value] = 1
686 # now do the find
687 try:
688 id = []
689 designator = []
690 if self.separator:
691 if self.print_designator:
692 id=apply(cl.find, (), props)
693 for i in id:
694 designator.append(classname + i)
695 print self.separator.join(designator)
696 else:
697 print self.separator.join(apply(cl.find, (), props))
699 else:
700 if self.print_designator:
701 id=apply(cl.find, (), props)
702 for i in id:
703 designator.append(classname + i)
704 print designator
705 else:
706 print apply(cl.find, (), props)
707 except KeyError:
708 raise UsageError, _('%(classname)s has no property '
709 '"%(propname)s"')%locals()
710 except (ValueError, TypeError), message:
711 raise UsageError, message
712 return 0
714 def do_specification(self, args):
715 ""'''Usage: specification classname
716 Show the properties for a classname.
718 This lists the properties for a given class.
719 '''
720 if len(args) < 1:
721 raise UsageError, _('Not enough arguments supplied')
722 classname = args[0]
723 # get the class
724 cl = self.get_class(classname)
726 # get the key property
727 keyprop = cl.getkey()
728 for key, value in cl.properties.items():
729 if keyprop == key:
730 print _('%(key)s: %(value)s (key property)')%locals()
731 else:
732 print _('%(key)s: %(value)s')%locals()
734 def do_display(self, args):
735 ""'''Usage: display designator[,designator]*
736 Show the property values for the given node(s).
738 This lists the properties and their associated values for the given
739 node.
740 '''
741 if len(args) < 1:
742 raise UsageError, _('Not enough arguments supplied')
744 # decode the node designator
745 for designator in args[0].split(','):
746 try:
747 classname, nodeid = hyperdb.splitDesignator(designator)
748 except hyperdb.DesignatorError, message:
749 raise UsageError, message
751 # get the class
752 cl = self.get_class(classname)
754 # display the values
755 keys = cl.properties.keys()
756 keys.sort()
757 for key in keys:
758 value = cl.get(nodeid, key)
759 print _('%(key)s: %(value)s')%locals()
761 def do_create(self, args):
762 ""'''Usage: create classname property=value ...
763 Create a new entry of a given class.
765 This creates a new entry of the given class using the property
766 name=value arguments provided on the command line after the "create"
767 command.
768 '''
769 if len(args) < 1:
770 raise UsageError, _('Not enough arguments supplied')
771 from roundup import hyperdb
773 classname = args[0]
775 # get the class
776 cl = self.get_class(classname)
778 # now do a create
779 props = {}
780 properties = cl.getprops(protected = 0)
781 if len(args) == 1:
782 # ask for the properties
783 for key, value in properties.items():
784 if key == 'id': continue
785 name = value.__class__.__name__
786 if isinstance(value , hyperdb.Password):
787 again = None
788 while value != again:
789 value = getpass.getpass(_('%(propname)s (Password): ')%{
790 'propname': key.capitalize()})
791 again = getpass.getpass(_(' %(propname)s (Again): ')%{
792 'propname': key.capitalize()})
793 if value != again: print _('Sorry, try again...')
794 if value:
795 props[key] = value
796 else:
797 value = raw_input(_('%(propname)s (%(proptype)s): ')%{
798 'propname': key.capitalize(), 'proptype': name})
799 if value:
800 props[key] = value
801 else:
802 props = self.props_from_args(args[1:])
804 # convert types
805 for propname, value in props.items():
806 try:
807 props[propname] = hyperdb.rawToHyperdb(self.db, cl, None,
808 propname, value)
809 except hyperdb.HyperdbValueError, message:
810 raise UsageError, message
812 # check for the key property
813 propname = cl.getkey()
814 if propname and not props.has_key(propname):
815 raise UsageError, _('you must provide the "%(propname)s" '
816 'property.')%locals()
818 # do the actual create
819 try:
820 print apply(cl.create, (), props)
821 except (TypeError, IndexError, ValueError), message:
822 raise UsageError, message
823 self.db_uncommitted = True
824 return 0
826 def do_list(self, args):
827 ""'''Usage: list classname [property]
828 List the instances of a class.
830 Lists all instances of the given class. If the property is not
831 specified, the "label" property is used. The label property is
832 tried in order: the key, "name", "title" and then the first
833 property, alphabetically.
835 With -c, -S or -s print a list of item id's if no property
836 specified. If property specified, print list of that property
837 for every class instance.
838 '''
839 if len(args) > 2:
840 raise UsageError, _('Too many arguments supplied')
841 if len(args) < 1:
842 raise UsageError, _('Not enough arguments supplied')
843 classname = args[0]
845 # get the class
846 cl = self.get_class(classname)
848 # figure the property
849 if len(args) > 1:
850 propname = args[1]
851 else:
852 propname = cl.labelprop()
854 if self.separator:
855 if len(args) == 2:
856 # create a list of propnames since user specified propname
857 proplist=[]
858 for nodeid in cl.list():
859 try:
860 proplist.append(cl.get(nodeid, propname))
861 except KeyError:
862 raise UsageError, _('%(classname)s has no property '
863 '"%(propname)s"')%locals()
864 print self.separator.join(proplist)
865 else:
866 # create a list of index id's since user didn't specify
867 # otherwise
868 print self.separator.join(cl.list())
869 else:
870 for nodeid in cl.list():
871 try:
872 value = cl.get(nodeid, propname)
873 except KeyError:
874 raise UsageError, _('%(classname)s has no property '
875 '"%(propname)s"')%locals()
876 print _('%(nodeid)4s: %(value)s')%locals()
877 return 0
879 def do_table(self, args):
880 ""'''Usage: table classname [property[,property]*]
881 List the instances of a class in tabular form.
883 Lists all instances of the given class. If the properties are not
884 specified, all properties are displayed. By default, the column
885 widths are the width of the largest value. The width may be
886 explicitly defined by defining the property as "name:width".
887 For example::
889 roundup> table priority id,name:10
890 Id Name
891 1 fatal-bug
892 2 bug
893 3 usability
894 4 feature
896 Also to make the width of the column the width of the label,
897 leave a trailing : without a width on the property. For example::
899 roundup> table priority id,name:
900 Id Name
901 1 fata
902 2 bug
903 3 usab
904 4 feat
906 will result in a the 4 character wide "Name" column.
907 '''
908 if len(args) < 1:
909 raise UsageError, _('Not enough arguments supplied')
910 classname = args[0]
912 # get the class
913 cl = self.get_class(classname)
915 # figure the property names to display
916 if len(args) > 1:
917 prop_names = args[1].split(',')
918 all_props = cl.getprops()
919 for spec in prop_names:
920 if ':' in spec:
921 try:
922 propname, width = spec.split(':')
923 except (ValueError, TypeError):
924 raise UsageError, _('"%(spec)s" not name:width')%locals()
925 else:
926 propname = spec
927 if not all_props.has_key(propname):
928 raise UsageError, _('%(classname)s has no property '
929 '"%(propname)s"')%locals()
930 else:
931 prop_names = cl.getprops().keys()
933 # now figure column widths
934 props = []
935 for spec in prop_names:
936 if ':' in spec:
937 name, width = spec.split(':')
938 if width == '':
939 props.append((name, len(spec)))
940 else:
941 props.append((name, int(width)))
942 else:
943 # this is going to be slow
944 maxlen = len(spec)
945 for nodeid in cl.list():
946 curlen = len(str(cl.get(nodeid, spec)))
947 if curlen > maxlen:
948 maxlen = curlen
949 props.append((spec, maxlen))
951 # now display the heading
952 print ' '.join([name.capitalize().ljust(width) for name,width in props])
954 # and the table data
955 for nodeid in cl.list():
956 l = []
957 for name, width in props:
958 if name != 'id':
959 try:
960 value = str(cl.get(nodeid, name))
961 except KeyError:
962 # we already checked if the property is valid - a
963 # KeyError here means the node just doesn't have a
964 # value for it
965 value = ''
966 else:
967 value = str(nodeid)
968 f = '%%-%ds'%width
969 l.append(f%value[:width])
970 print ' '.join(l)
971 return 0
973 def do_history(self, args):
974 ""'''Usage: history designator
975 Show the history entries of a designator.
977 Lists the journal entries for the node identified by the designator.
978 '''
979 if len(args) < 1:
980 raise UsageError, _('Not enough arguments supplied')
981 try:
982 classname, nodeid = hyperdb.splitDesignator(args[0])
983 except hyperdb.DesignatorError, message:
984 raise UsageError, message
986 try:
987 print self.db.getclass(classname).history(nodeid)
988 except KeyError:
989 raise UsageError, _('no such class "%(classname)s"')%locals()
990 except IndexError:
991 raise UsageError, _('no such %(classname)s node "%(nodeid)s"')%locals()
992 return 0
994 def do_commit(self, args):
995 ""'''Usage: commit
996 Commit changes made to the database during an interactive session.
998 The changes made during an interactive session are not
999 automatically written to the database - they must be committed
1000 using this command.
1002 One-off commands on the command-line are automatically committed if
1003 they are successful.
1004 '''
1005 self.db.commit()
1006 self.db_uncommitted = False
1007 return 0
1009 def do_rollback(self, args):
1010 ""'''Usage: rollback
1011 Undo all changes that are pending commit to the database.
1013 The changes made during an interactive session are not
1014 automatically written to the database - they must be committed
1015 manually. This command undoes all those changes, so a commit
1016 immediately after would make no changes to the database.
1017 '''
1018 self.db.rollback()
1019 self.db_uncommitted = False
1020 return 0
1022 def do_retire(self, args):
1023 ""'''Usage: retire designator[,designator]*
1024 Retire the node specified by designator.
1026 This action indicates that a particular node is not to be retrieved
1027 by the list or find commands, and its key value may be re-used.
1028 '''
1029 if len(args) < 1:
1030 raise UsageError, _('Not enough arguments supplied')
1031 designators = args[0].split(',')
1032 for designator in designators:
1033 try:
1034 classname, nodeid = hyperdb.splitDesignator(designator)
1035 except hyperdb.DesignatorError, message:
1036 raise UsageError, message
1037 try:
1038 self.db.getclass(classname).retire(nodeid)
1039 except KeyError:
1040 raise UsageError, _('no such class "%(classname)s"')%locals()
1041 except IndexError:
1042 raise UsageError, _('no such %(classname)s node "%(nodeid)s"')%locals()
1043 self.db_uncommitted = True
1044 return 0
1046 def do_restore(self, args):
1047 ""'''Usage: restore designator[,designator]*
1048 Restore the retired node specified by designator.
1050 The given nodes will become available for users again.
1051 '''
1052 if len(args) < 1:
1053 raise UsageError, _('Not enough arguments supplied')
1054 designators = args[0].split(',')
1055 for designator in designators:
1056 try:
1057 classname, nodeid = hyperdb.splitDesignator(designator)
1058 except hyperdb.DesignatorError, message:
1059 raise UsageError, message
1060 try:
1061 self.db.getclass(classname).restore(nodeid)
1062 except KeyError:
1063 raise UsageError, _('no such class "%(classname)s"')%locals()
1064 except IndexError:
1065 raise UsageError, _('no such %(classname)s node "%(nodeid)s"')%locals()
1066 self.db_uncommitted = True
1067 return 0
1069 def do_export(self, args, export_files=True):
1070 ""'''Usage: export [[-]class[,class]] export_dir
1071 Export the database to colon-separated-value files.
1072 To exclude the files (e.g. for the msg or file class),
1073 use the exporttables command.
1075 Optionally limit the export to just the named classes
1076 or exclude the named classes, if the 1st argument starts with '-'.
1078 This action exports the current data from the database into
1079 colon-separated-value files that are placed in the nominated
1080 destination directory.
1081 '''
1082 # grab the directory to export to
1083 if len(args) < 1:
1084 raise UsageError, _('Not enough arguments supplied')
1086 dir = args[-1]
1088 # get the list of classes to export
1089 if len(args) == 2:
1090 if args[0].startswith('-'):
1091 classes = [ c for c in self.db.classes.keys()
1092 if not c in args[0][1:].split(',') ]
1093 else:
1094 classes = args[0].split(',')
1095 else:
1096 classes = self.db.classes.keys()
1098 class colon_separated(csv.excel):
1099 delimiter = ':'
1101 # make sure target dir exists
1102 if not os.path.exists(dir):
1103 os.makedirs(dir)
1105 # do all the classes specified
1106 for classname in classes:
1107 cl = self.get_class(classname)
1109 if not export_files and hasattr(cl, 'export_files'):
1110 sys.stdout.write('Exporting %s WITHOUT the files\r\n'%
1111 classname)
1113 f = open(os.path.join(dir, classname+'.csv'), 'wb')
1114 writer = csv.writer(f, colon_separated)
1116 properties = cl.getprops()
1117 propnames = cl.export_propnames()
1118 fields = propnames[:]
1119 fields.append('is retired')
1120 writer.writerow(fields)
1122 # all nodes for this class
1123 for nodeid in cl.getnodeids():
1124 if self.verbose:
1125 sys.stdout.write('\rExporting %s - %s'%(classname, nodeid))
1126 sys.stdout.flush()
1127 writer.writerow(cl.export_list(propnames, nodeid))
1128 if export_files and hasattr(cl, 'export_files'):
1129 cl.export_files(dir, nodeid)
1131 # close this file
1132 f.close()
1134 # export the journals
1135 jf = open(os.path.join(dir, classname+'-journals.csv'), 'wb')
1136 if self.verbose:
1137 sys.stdout.write("\nExporting Journal for %s\n" % classname)
1138 sys.stdout.flush()
1139 journals = csv.writer(jf, colon_separated)
1140 map(journals.writerow, cl.export_journals())
1141 jf.close()
1142 return 0
1144 def do_exporttables(self, args):
1145 ""'''Usage: exporttables [[-]class[,class]] export_dir
1146 Export the database to colon-separated-value files, excluding the
1147 files below $TRACKER_HOME/db/files/ (which can be archived separately).
1148 To include the files, use the export command.
1150 Optionally limit the export to just the named classes
1151 or exclude the named classes, if the 1st argument starts with '-'.
1153 This action exports the current data from the database into
1154 colon-separated-value files that are placed in the nominated
1155 destination directory.
1156 '''
1157 return self.do_export(args, export_files=False)
1159 def do_import(self, args):
1160 ""'''Usage: import import_dir
1161 Import a database from the directory containing CSV files,
1162 two per class to import.
1164 The files used in the import are:
1166 <class>.csv
1167 This must define the same properties as the class (including
1168 having a "header" line with those property names.)
1169 <class>-journals.csv
1170 This defines the journals for the items being imported.
1172 The imported nodes will have the same nodeid as defined in the
1173 import file, thus replacing any existing content.
1175 The new nodes are added to the existing database - if you want to
1176 create a new database using the imported data, then create a new
1177 database (or, tediously, retire all the old data.)
1178 '''
1179 if len(args) < 1:
1180 raise UsageError, _('Not enough arguments supplied')
1181 from roundup import hyperdb
1183 # directory to import from
1184 dir = args[0]
1186 class colon_separated(csv.excel):
1187 delimiter = ':'
1189 # import all the files
1190 for file in os.listdir(dir):
1191 classname, ext = os.path.splitext(file)
1192 # we only care about CSV files
1193 if ext != '.csv' or classname.endswith('-journals'):
1194 continue
1196 cl = self.get_class(classname)
1198 # ensure that the properties and the CSV file headings match
1199 f = open(os.path.join(dir, file), 'r')
1200 reader = csv.reader(f, colon_separated)
1201 file_props = None
1202 maxid = 1
1203 # loop through the file and create a node for each entry
1204 for n, r in enumerate(reader):
1205 if file_props is None:
1206 file_props = r
1207 continue
1209 if self.verbose:
1210 sys.stdout.write('\rImporting %s - %s'%(classname, n))
1211 sys.stdout.flush()
1213 # do the import and figure the current highest nodeid
1214 nodeid = cl.import_list(file_props, r)
1215 if hasattr(cl, 'import_files'):
1216 cl.import_files(dir, nodeid)
1217 maxid = max(maxid, int(nodeid))
1218 print
1219 f.close()
1221 # import the journals
1222 f = open(os.path.join(args[0], classname + '-journals.csv'), 'r')
1223 reader = csv.reader(f, colon_separated)
1224 cl.import_journals(reader)
1225 f.close()
1227 # set the id counter
1228 print 'setting', classname, maxid+1
1229 self.db.setid(classname, str(maxid+1))
1231 self.db_uncommitted = True
1232 return 0
1234 def do_pack(self, args):
1235 ""'''Usage: pack period | date
1237 Remove journal entries older than a period of time specified or
1238 before a certain date.
1240 A period is specified using the suffixes "y", "m", and "d". The
1241 suffix "w" (for "week") means 7 days.
1243 "3y" means three years
1244 "2y 1m" means two years and one month
1245 "1m 25d" means one month and 25 days
1246 "2w 3d" means two weeks and three days
1248 Date format is "YYYY-MM-DD" eg:
1249 2001-01-01
1251 '''
1252 if len(args) <> 1:
1253 raise UsageError, _('Not enough arguments supplied')
1255 # are we dealing with a period or a date
1256 value = args[0]
1257 date_re = re.compile(r'''
1258 (?P<date>\d\d\d\d-\d\d?-\d\d?)? # yyyy-mm-dd
1259 (?P<period>(\d+y\s*)?(\d+m\s*)?(\d+d\s*)?)?
1260 ''', re.VERBOSE)
1261 m = date_re.match(value)
1262 if not m:
1263 raise ValueError, _('Invalid format')
1264 m = m.groupdict()
1265 if m['period']:
1266 pack_before = date.Date(". - %s"%value)
1267 elif m['date']:
1268 pack_before = date.Date(value)
1269 self.db.pack(pack_before)
1270 self.db_uncommitted = True
1271 return 0
1273 def do_reindex(self, args, desre=re.compile('([A-Za-z]+)([0-9]+)')):
1274 ""'''Usage: reindex [classname|designator]*
1275 Re-generate a tracker's search indexes.
1277 This will re-generate the search indexes for a tracker.
1278 This will typically happen automatically.
1279 '''
1280 if args:
1281 for arg in args:
1282 m = desre.match(arg)
1283 if m:
1284 cl = self.get_class(m.group(1))
1285 try:
1286 cl.index(m.group(2))
1287 except IndexError:
1288 raise UsageError, _('no such item "%(designator)s"')%{
1289 'designator': arg}
1290 else:
1291 cl = self.get_class(arg)
1292 self.db.reindex(arg)
1293 else:
1294 self.db.reindex(show_progress=True)
1295 return 0
1297 def do_security(self, args):
1298 ""'''Usage: security [Role name]
1299 Display the Permissions available to one or all Roles.
1300 '''
1301 if len(args) == 1:
1302 role = args[0]
1303 try:
1304 roles = [(args[0], self.db.security.role[args[0]])]
1305 except KeyError:
1306 print _('No such Role "%(role)s"')%locals()
1307 return 1
1308 else:
1309 roles = self.db.security.role.items()
1310 role = self.db.config.NEW_WEB_USER_ROLES
1311 if ',' in role:
1312 print _('New Web users get the Roles "%(role)s"')%locals()
1313 else:
1314 print _('New Web users get the Role "%(role)s"')%locals()
1315 role = self.db.config.NEW_EMAIL_USER_ROLES
1316 if ',' in role:
1317 print _('New Email users get the Roles "%(role)s"')%locals()
1318 else:
1319 print _('New Email users get the Role "%(role)s"')%locals()
1320 roles.sort()
1321 for rolename, role in roles:
1322 print _('Role "%(name)s":')%role.__dict__
1323 for permission in role.permissions:
1324 d = permission.__dict__
1325 if permission.klass:
1326 if permission.properties:
1327 print _(' %(description)s (%(name)s for "%(klass)s"'
1328 ': %(properties)s only)')%d
1329 else:
1330 print _(' %(description)s (%(name)s for "%(klass)s" '
1331 'only)')%d
1332 else:
1333 print _(' %(description)s (%(name)s)')%d
1334 return 0
1337 def do_migrate(self, args):
1338 '''Usage: migrate
1339 Update a tracker's database to be compatible with the Roundup
1340 codebase.
1342 You should run the "migrate" command for your tracker once you've
1343 installed the latest codebase.
1345 Do this before you use the web, command-line or mail interface and
1346 before any users access the tracker.
1348 This command will respond with either "Tracker updated" (if you've
1349 not previously run it on an RDBMS backend) or "No migration action
1350 required" (if you have run it, or have used another interface to the
1351 tracker, or possibly because you are using anydbm).
1353 It's safe to run this even if it's not required, so just get into
1354 the habit.
1355 '''
1356 if getattr(self.db, 'db_version_updated'):
1357 print _('Tracker updated')
1358 self.db_uncommitted = True
1359 else:
1360 print _('No migration action required')
1361 return 0
1363 def run_command(self, args):
1364 '''Run a single command
1365 '''
1366 command = args[0]
1368 # handle help now
1369 if command == 'help':
1370 if len(args)>1:
1371 self.do_help(args[1:])
1372 return 0
1373 self.do_help(['help'])
1374 return 0
1375 if command == 'morehelp':
1376 self.do_help(['help'])
1377 self.help_commands()
1378 self.help_all()
1379 return 0
1380 if command == 'config':
1381 self.do_config(args[1:])
1382 return 0
1384 # figure what the command is
1385 try:
1386 functions = self.commands.get(command)
1387 except KeyError:
1388 # not a valid command
1389 print _('Unknown command "%(command)s" ("help commands" for a '
1390 'list)')%locals()
1391 return 1
1393 # check for multiple matches
1394 if len(functions) > 1:
1395 print _('Multiple commands match "%(command)s": %(list)s')%{'command':
1396 command, 'list': ', '.join([i[0] for i in functions])}
1397 return 1
1398 command, function = functions[0]
1400 # make sure we have a tracker_home
1401 while not self.tracker_home:
1402 self.tracker_home = raw_input(_('Enter tracker home: ')).strip()
1404 # before we open the db, we may be doing an install or init
1405 if command == 'initialise':
1406 try:
1407 return self.do_initialise(self.tracker_home, args)
1408 except UsageError, message:
1409 print _('Error: %(message)s')%locals()
1410 return 1
1411 elif command == 'install':
1412 try:
1413 return self.do_install(self.tracker_home, args)
1414 except UsageError, message:
1415 print _('Error: %(message)s')%locals()
1416 return 1
1418 # get the tracker
1419 try:
1420 tracker = roundup.instance.open(self.tracker_home)
1421 except ValueError, message:
1422 self.tracker_home = ''
1423 print _("Error: Couldn't open tracker: %(message)s")%locals()
1424 return 1
1426 # only open the database once!
1427 if not self.db:
1428 self.db = tracker.open('admin')
1430 # do the command
1431 ret = 0
1432 try:
1433 ret = function(args[1:])
1434 except UsageError, message:
1435 print _('Error: %(message)s')%locals()
1436 print
1437 print function.__doc__
1438 ret = 1
1439 except:
1440 import traceback
1441 traceback.print_exc()
1442 ret = 1
1443 return ret
1445 def interactive(self):
1446 '''Run in an interactive mode
1447 '''
1448 print _('Roundup %s ready for input.\nType "help" for help.'
1449 % roundup_version)
1450 try:
1451 import readline
1452 except ImportError:
1453 print _('Note: command history and editing not available')
1455 while 1:
1456 try:
1457 command = raw_input(_('roundup> '))
1458 except EOFError:
1459 print _('exit...')
1460 break
1461 if not command: continue
1462 args = token.token_split(command)
1463 if not args: continue
1464 if args[0] in ('quit', 'exit'): break
1465 self.run_command(args)
1467 # exit.. check for transactions
1468 if self.db and self.db_uncommitted:
1469 commit = raw_input(_('There are unsaved changes. Commit them (y/N)? '))
1470 if commit and commit[0].lower() == 'y':
1471 self.db.commit()
1472 return 0
1474 def main(self):
1475 try:
1476 opts, args = getopt.getopt(sys.argv[1:], 'i:u:hcdsS:vV')
1477 except getopt.GetoptError, e:
1478 self.usage(str(e))
1479 return 1
1481 # handle command-line args
1482 self.tracker_home = os.environ.get('TRACKER_HOME', '')
1483 # TODO: reinstate the user/password stuff (-u arg too)
1484 name = password = ''
1485 if os.environ.has_key('ROUNDUP_LOGIN'):
1486 l = os.environ['ROUNDUP_LOGIN'].split(':')
1487 name = l[0]
1488 if len(l) > 1:
1489 password = l[1]
1490 self.separator = None
1491 self.print_designator = 0
1492 self.verbose = 0
1493 for opt, arg in opts:
1494 if opt == '-h':
1495 self.usage()
1496 return 0
1497 elif opt == '-v':
1498 print '%s (python %s)'%(roundup_version, sys.version.split()[0])
1499 return 0
1500 elif opt == '-V':
1501 self.verbose = 1
1502 elif opt == '-i':
1503 self.tracker_home = arg
1504 elif opt == '-c':
1505 if self.separator != None:
1506 self.usage('Only one of -c, -S and -s may be specified')
1507 return 1
1508 self.separator = ','
1509 elif opt == '-S':
1510 if self.separator != None:
1511 self.usage('Only one of -c, -S and -s may be specified')
1512 return 1
1513 self.separator = arg
1514 elif opt == '-s':
1515 if self.separator != None:
1516 self.usage('Only one of -c, -S and -s may be specified')
1517 return 1
1518 self.separator = ' '
1519 elif opt == '-d':
1520 self.print_designator = 1
1522 # if no command - go interactive
1523 # wrap in a try/finally so we always close off the db
1524 ret = 0
1525 try:
1526 if not args:
1527 self.interactive()
1528 else:
1529 ret = self.run_command(args)
1530 if self.db: self.db.commit()
1531 return ret
1532 finally:
1533 if self.db:
1534 self.db.close()
1536 if __name__ == '__main__':
1537 tool = AdminTool()
1538 sys.exit(tool.main())
1540 # vim: set filetype=python sts=4 sw=4 et si :