X-Git-Url: https://git.tokkee.org/?a=blobdiff_plain;f=roundup%2Fadmin.py;h=94e38462d4eb60dda9283f68ecf66d4893fdef73;hb=caa5cbb02293756565461df92b1a36f04c8ea782;hp=bed62a43414ed97a88504f44b458f2cb1feeefbc;hpb=49b15d82daaa071613963d33c99a84ee02e127c2;p=roundup.git diff --git a/roundup/admin.py b/roundup/admin.py index bed62a4..94e3846 100644 --- a/roundup/admin.py +++ b/roundup/admin.py @@ -16,7 +16,10 @@ # BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE, # SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. # -# $Id: admin.py,v 1.21 2002-08-01 01:07:37 richard Exp $ +# $Id: admin.py,v 1.38 2003-02-25 10:19:31 richard Exp $ + +'''Administration commands for maintaining Roundup trackers. +''' import sys, os, getpass, getopt, re, UserDict, shlex, shutil try: @@ -51,7 +54,17 @@ class UsageError(ValueError): pass class AdminTool: + ''' A collection of methods used in maintaining Roundup trackers. + + Typically these methods are accessed through the roundup-admin + script. The main() method provided on this class gives the main + loop for the roundup-admin script. + + Actions are defined by do_*() methods, with help for the action + given in the method docstring. + Additional help may be supplied by help_*() methods. + ''' def __init__(self): self.commands = CommandDict() for k in AdminTool.__dict__.keys(): @@ -61,7 +74,7 @@ class AdminTool: for k in AdminTool.__dict__.keys(): if k[:5] == 'help_': self.help[k[5:]] = getattr(self, k) - self.instance_home = '' + self.tracker_home = '' self.db = None def get_class(self, classname): @@ -73,34 +86,49 @@ class AdminTool: raise UsageError, _('no such class "%(classname)s"')%locals() def props_from_args(self, args): + ''' Produce a dictionary of prop: value from the args list. + + The args list is specified as ``prop=value prop=value ...``. + ''' props = {} for arg in args: if arg.find('=') == -1: - raise UsageError, _('argument "%(arg)s" not propname=value')%locals() + raise UsageError, _('argument "%(arg)s" not propname=value' + )%locals() try: key, value = arg.split('=') except ValueError: - raise UsageError, _('argument "%(arg)s" not propname=value')%locals() - props[key] = value + raise UsageError, _('argument "%(arg)s" not propname=value' + )%locals() + if value: + props[key] = value + else: + props[key] = None return props def usage(self, message=''): + ''' Display a simple usage message. + ''' if message: message = _('Problem: %(message)s)\n\n')%locals() - print _('''%(message)sUsage: roundup-admin [-i instance home] [-u login] [-c] + print _('''%(message)sUsage: roundup-admin [options] + +Options: + -i instance home -- specify the issue tracker "home directory" to administer + -u -- the user[:password] to use for commands + -c -- when outputting lists of data, just comma-separate them Help: roundup-admin -h roundup-admin help -- this help roundup-admin help -- command-specific help roundup-admin help all -- all available help -Options: - -i instance home -- specify the issue tracker "home directory" to administer - -u -- the user[:password] to use for commands - -c -- when outputting lists of data, just comma-separate them''')%locals() +''')%locals() self.help_commands() def help_commands(self): + ''' List the commands available with their precis help. + ''' print _('Commands:'), commands = [''] for command in self.commands.values(): @@ -113,11 +141,13 @@ Options: print def help_commands_html(self, indent_re=re.compile(r'^(\s+)\S+')): - commands = self.commands.values() + ''' Produce an HTML command list. + ''' + commands = self.commands.values() def sortfun(a, b): return cmp(a.__name__, b.__name__) commands.sort(sortfun) - for command in commands: + for command in commands: h = command.__doc__.split('\n') name = command.__name__[3:] usage = h[0] @@ -136,12 +166,12 @@ Options: def help_all(self): print _(''' -All commands (except help) require an instance specifier. This is just the path -to the roundup instance you're working with. A roundup instance is where +All commands (except help) require a tracker specifier. This is just the path +to the roundup tracker you're working with. A roundup tracker is where roundup keeps the database and configuration file that defines an issue tracker. It may be thought of as the issue tracker's "home directory". It may -be specified in the environment variable ROUNDUP_INSTANCE or on the command -line as "-i instance". +be specified in the environment variable TRACKER_HOME or on the command +line as "-i tracker". A designator is a classname and a nodeid concatenated, eg. bug1, user10, ... @@ -249,27 +279,27 @@ Command help: backends = roundup.backends.__all__ print _('Back ends:'), ', '.join(backends) - def do_install(self, instance_home, args): + def do_install(self, tracker_home, args): '''Usage: install [template [backend [admin password]]] - Install a new Roundup instance. + Install a new Roundup tracker. - The command will prompt for the instance home directory (if not supplied - through INSTANCE_HOME or the -i option). The template, backend and admin + The command will prompt for the tracker home directory (if not supplied + through TRACKER_HOME or the -i option). The template, backend and admin password may be specified on the command-line as arguments, in that order. The initialise command must be called after this command in order - to initialise the instance's database. You may edit the instance's + to initialise the tracker's database. You may edit the tracker's initial database contents before running that command by editing - the instance's dbinit.py module init() function. + the tracker's dbinit.py module init() function. See also initopts help. ''' if len(args) < 1: raise UsageError, _('Not enough arguments supplied') - # make sure the instance home can be created - parent = os.path.split(instance_home)[0] + # make sure the tracker home can be created + parent = os.path.split(tracker_home)[0] if not os.path.exists(parent): raise UsageError, _('Instance home parent directory "%(parent)s"' ' does not exist')%locals() @@ -295,33 +325,36 @@ Command help: backend = raw_input(_('Select backend [anydbm]: ')).strip() if not backend: backend = 'anydbm' + # XXX perform a unit test based on the user's selections # install! - init.install(instance_home, template, backend) + init.install(tracker_home, template) + init.write_select_db(tracker_home, backend) print _(''' - You should now edit the instance configuration file: - %(instance_config_file)s - ... at a minimum, you must set MAILHOST, MAIL_DOMAIN and ADMIN_EMAIL. + You should now edit the tracker configuration file: + %(config_file)s + ... at a minimum, you must set MAILHOST, TRACKER_WEB, MAIL_DOMAIN and + ADMIN_EMAIL. If you wish to modify the default schema, you should also edit the database initialisation file: %(database_config_file)s ... see the documentation on customizing for more information. ''')%{ - 'instance_config_file': os.path.join(instance_home, 'instance_config.py'), - 'database_config_file': os.path.join(instance_home, 'dbinit.py') + 'config_file': os.path.join(tracker_home, 'config.py'), + 'database_config_file': os.path.join(tracker_home, 'dbinit.py') } return 0 - def do_initialise(self, instance_home, args): + def do_initialise(self, tracker_home, args): '''Usage: initialise [adminpw] - Initialise a new Roundup instance. + Initialise a new Roundup tracker. The administrator details will be set at this step. - Execute the instance's initialisation function dbinit.init() + Execute the tracker's initialisation function dbinit.init() ''' # password if len(args) > 1: @@ -333,25 +366,37 @@ Command help: adminpw = getpass.getpass(_('Admin Password: ')) confirm = getpass.getpass(_(' Confirm: ')) - # make sure the instance home is installed - if not os.path.exists(instance_home): + # make sure the tracker home is installed + if not os.path.exists(tracker_home): raise UsageError, _('Instance home does not exist')%locals() - if not os.path.exists(os.path.join(instance_home, 'html')): + try: + tracker = roundup.instance.open(tracker_home) + except roundup.instance.TrackerError: raise UsageError, _('Instance has not been installed')%locals() # is there already a database? - if os.path.exists(os.path.join(instance_home, 'db')): + try: + db_exists = tracker.select_db.Database.exists(tracker.config) + except AttributeError: + # TODO: move this code to exists() static method in every backend + db_exists = os.path.exists(os.path.join(tracker_home, 'db')) + if db_exists: print _('WARNING: The database is already initialised!') print _('If you re-initialise it, you will lose all the data!') ok = raw_input(_('Erase it? Y/[N]: ')).strip() if ok.lower() != 'y': return 0 - # nuke it - shutil.rmtree(os.path.join(instance_home, 'db')) + # Get a database backend in use by tracker + try: + # nuke it + tracker.select_db.Database.nuke(tracker.config) + except AttributeError: + # TODO: move this code to nuke() static method in every backend + shutil.rmtree(os.path.join(tracker_home, 'db')) # GO - init.initialise(instance_home, adminpw) + init.initialise(tracker_home, adminpw) return 0 @@ -391,39 +436,65 @@ Command help: return 0 - def do_set(self, args): - '''Usage: set designator[,designator]* propname=value ... - Set the given property of one or more designator(s). + def do_set(self, args, pwre = re.compile(r'{(\w+)}(.+)')): + '''Usage: set [items] property=value property=value ... + Set the given properties of one or more items(s). + + The items may be specified as a class or as a comma-separeted + list of item designators (ie "designator[,designator,...]"). - Sets the property to the value for all designators given. + This command sets the properties to the values for all designators + given. If the value is missing (ie. "property=") then the property is + un-set. ''' if len(args) < 2: raise UsageError, _('Not enough arguments supplied') from roundup import hyperdb designators = args[0].split(',') + if len(designators) == 1: + designator = designators[0] + try: + designator = hyperdb.splitDesignator(designator) + designators = [designator] + except hyperdb.DesignatorError: + cl = self.get_class(designator) + designators = [(designator, x) for x in cl.list()] + else: + try: + designators = [hyperdb.splitDesignator(x) for x in designators] + except hyperdb.DesignatorError, message: + raise UsageError, message # get the props from the args props = self.props_from_args(args[1:]) # now do the set for all the nodes - for designator in designators: - # decode the node designator - try: - classname, nodeid = hyperdb.splitDesignator(designator) - except hyperdb.DesignatorError, message: - raise UsageError, message - - # get the class + for classname, itemid in designators: cl = self.get_class(classname) properties = cl.getprops() for key, value in props.items(): proptype = properties[key] - if isinstance(proptype, hyperdb.String): + if isinstance(proptype, hyperdb.Multilink): + if value is None: + props[key] = [] + else: + props[key] = value.split(',') + elif value is None: + continue + elif isinstance(proptype, hyperdb.String): continue elif isinstance(proptype, hyperdb.Password): - props[key] = password.Password(value) + m = pwre.match(value) + if m: + # password is being given to us encrypted + p = password.Password() + p.scheme = m.group(1) + p.password = m.group(2) + props[key] = p + else: + props[key] = password.Password(value) elif isinstance(proptype, hyperdb.Date): try: props[key] = date.Date(value) @@ -436,17 +507,16 @@ Command help: raise UsageError, '"%s": %s'%(value, message) elif isinstance(proptype, hyperdb.Link): props[key] = value - elif isinstance(proptype, hyperdb.Multilink): - props[key] = value.split(',') elif isinstance(proptype, hyperdb.Boolean): props[key] = value.lower() in ('yes', 'true', 'on', '1') elif isinstance(proptype, hyperdb.Number): - props[key] = int(value) + props[key] = float(value) # try the set try: - apply(cl.set, (nodeid, ), props) + apply(cl.set, (itemid, ), props) except (TypeError, IndexError, ValueError), message: + import traceback; traceback.print_exc() raise UsageError, message return 0 @@ -548,7 +618,7 @@ Command help: value = cl.get(nodeid, key) print _('%(key)s: %(value)s')%locals() - def do_create(self, args): + def do_create(self, args, pwre = re.compile(r'{(\w+)}(.+)')): '''Usage: create classname property=value ... Create a new entry of a given class. @@ -611,13 +681,21 @@ Command help: except ValueError, message: raise UsageError, _('"%(value)s": %(message)s')%locals() elif isinstance(proptype, hyperdb.Password): - props[propname] = password.Password(value) + m = pwre.match(value) + if m: + # password is being given to us encrypted + p = password.Password() + p.scheme = m.group(1) + p.password = m.group(2) + props[propname] = p + else: + props[propname] = password.Password(value) elif isinstance(proptype, hyperdb.Multilink): props[propname] = value.split(',') elif isinstance(proptype, hyperdb.Boolean): props[propname] = value.lower() in ('yes', 'true', 'on', '1') elif isinstance(proptype, hyperdb.Number): - props[propname] = int(value) + props[propname] = float(value) # check for the key property propname = cl.getkey() @@ -808,69 +886,63 @@ Command help: return 0 def do_export(self, args): - '''Usage: export class[,class] destination_dir - Export the database to tab-separated-value files. + '''Usage: export [class[,class]] export_dir + Export the database to colon-separated-value files. This action exports the current data from the database into - tab-separated-value files that are placed in the nominated destination - directory. The journals are not exported. + colon-separated-value files that are placed in the nominated + destination directory. The journals are not exported. ''' - if len(args) < 2: + # we need the CSV module + if csv is None: + raise UsageError, \ + _('Sorry, you need the csv module to use this function.\n' + 'Get it from: http://www.object-craft.com.au/projects/csv/') + + # grab the directory to export to + if len(args) < 1: raise UsageError, _('Not enough arguments supplied') - classes = args[0].split(',') - dir = args[1] + dir = args[-1] + + # get the list of classes to export + if len(args) == 2: + classes = args[0].split(',') + else: + classes = self.db.classes.keys() # use the csv parser if we can - it's faster - if csv is not None: - p = csv.parser(field_sep=':') + p = csv.parser(field_sep=':') # do all the classes specified for classname in classes: cl = self.get_class(classname) f = open(os.path.join(dir, classname+'.csv'), 'w') - f.write(':'.join(cl.properties.keys()) + '\n') + properties = cl.getprops() + propnames = properties.keys() + propnames.sort() + print >> f, p.join(propnames) # all nodes for this class - properties = cl.properties.items() for nodeid in cl.list(): - l = [] - for prop, proptype in properties: - value = cl.get(nodeid, prop) - # convert data where needed - if isinstance(proptype, hyperdb.Date): - value = value.get_tuple() - elif isinstance(proptype, hyperdb.Interval): - value = value.get_tuple() - elif isinstance(proptype, hyperdb.Password): - value = str(value) - l.append(repr(value)) - - # now write - if csv is not None: - f.write(p.join(l) + '\n') - else: - # escape the individual entries to they're valid CSV - m = [] - for entry in l: - if '"' in entry: - entry = '""'.join(entry.split('"')) - if ':' in entry: - entry = '"%s"'%entry - m.append(entry) - f.write(':'.join(m) + '\n') + print >>f, p.join(cl.export_list(propnames, nodeid)) return 0 def do_import(self, args): - '''Usage: import class file - Import the contents of the tab-separated-value file. - - The file must define the same properties as the class (including having - a "header" line with those property names.) The new nodes are added to - the existing database - if you want to create a new database using the - imported data, then create a new database (or, tediously, retire all - the old data.) + '''Usage: import import_dir + Import a database from the directory containing CSV files, one per + class to import. + + The files must define the same properties as the class (including having + a "header" line with those property names.) + + The imported nodes will have the same nodeid as defined in the + import file, thus replacing any existing content. + + The new nodes are added to the existing database - if you want to + create a new database using the imported data, then create a new + database (or, tediously, retire all the old data.) ''' - if len(args) < 2: + if len(args) < 1: raise UsageError, _('Not enough arguments supplied') if csv is None: raise UsageError, \ @@ -879,56 +951,44 @@ Command help: from roundup import hyperdb - # ensure that the properties and the CSV file headings match - classname = args[0] - cl = self.get_class(classname) - f = open(args[1]) - p = csv.parser(field_sep=':') - file_props = p.parse(f.readline()) - props = cl.properties.keys() - m = file_props[:] - m.sort() - props.sort() - if m != props: - raise UsageError, _('Import file doesn\'t define the same ' - 'properties as "%(arg0)s".')%{'arg0': args[0]} - - # loop through the file and create a node for each entry - n = range(len(props)) - while 1: - line = f.readline() - if not line: break + for file in os.listdir(args[0]): + f = open(os.path.join(args[0], file)) + + # get the classname + classname = os.path.splitext(file)[0] - # parse lines until we get a complete entry + # ensure that the properties and the CSV file headings match + cl = self.get_class(classname) + p = csv.parser(field_sep=':') + file_props = p.parse(f.readline()) + properties = cl.getprops() + propnames = properties.keys() + propnames.sort() + m = file_props[:] + m.sort() + if m != propnames: + raise UsageError, _('Import file doesn\'t define the same ' + 'properties as "%(arg0)s".')%{'arg0': args[0]} + + # loop through the file and create a node for each entry + maxid = 1 while 1: - l = p.parse(line) - if l: break line = f.readline() - if not line: - raise ValueError, "Unexpected EOF during CSV parse" - - # make the new node's property map - d = {} - for i in n: - # Use eval to reverse the repr() used to output the CSV - value = eval(l[i]) - # Figure the property for this column - key = file_props[i] - proptype = cl.properties[key] - # Convert for property type - if isinstance(proptype, hyperdb.Date): - value = date.Date(value) - elif isinstance(proptype, hyperdb.Interval): - value = date.Interval(value) - elif isinstance(proptype, hyperdb.Password): - pwd = password.Password() - pwd.unpack(value) - value = pwd - if value is not None: - d[key] = value - - # and create the new node - apply(cl.create, (), d) + if not line: break + + # parse lines until we get a complete entry + while 1: + l = p.parse(line) + if l: break + line = f.readline() + if not line: + raise ValueError, "Unexpected EOF during CSV parse" + + # do the import and figure the current highest nodeid + maxid = max(maxid, int(cl.import_list(propnames, l))) + + print 'setting', classname, maxid+1 + self.db.setid(classname, str(maxid+1)) return 0 def do_pack(self, args): @@ -971,9 +1031,9 @@ Date format is "YYYY-MM-DD" eg: def do_reindex(self, args): '''Usage: reindex - Re-generate an instance's search indexes. + Re-generate a tracker's search indexes. - This will re-generate the search indexes for an instance. This will + This will re-generate the search indexes for a tracker. This will typically happen automatically. ''' self.db.indexer.force_reindex() @@ -1048,35 +1108,35 @@ Date format is "YYYY-MM-DD" eg: return 1 command, function = functions[0] - # make sure we have an instance_home - while not self.instance_home: - self.instance_home = raw_input(_('Enter instance home: ')).strip() + # make sure we have a tracker_home + while not self.tracker_home: + self.tracker_home = raw_input(_('Enter tracker home: ')).strip() # before we open the db, we may be doing an install or init if command == 'initialise': try: - return self.do_initialise(self.instance_home, args) + return self.do_initialise(self.tracker_home, args) except UsageError, message: print _('Error: %(message)s')%locals() return 1 elif command == 'install': try: - return self.do_install(self.instance_home, args) + return self.do_install(self.tracker_home, args) except UsageError, message: print _('Error: %(message)s')%locals() return 1 - # get the instance + # get the tracker try: - instance = roundup.instance.open(self.instance_home) + tracker = roundup.instance.open(self.tracker_home) except ValueError, message: - self.instance_home = '' - print _("Error: Couldn't open instance: %(message)s")%locals() + self.tracker_home = '' + print _("Error: Couldn't open tracker: %(message)s")%locals() return 1 # only open the database once! if not self.db: - self.db = instance.open('admin') + self.db = tracker.open('admin') # do the command ret = 0 @@ -1130,7 +1190,7 @@ Date format is "YYYY-MM-DD" eg: return 1 # handle command-line args - self.instance_home = os.environ.get('ROUNDUP_INSTANCE', '') + self.tracker_home = os.environ.get('TRACKER_HOME', '') # TODO: reinstate the user/password stuff (-u arg too) name = password = '' if os.environ.has_key('ROUNDUP_LOGIN'): @@ -1144,104 +1204,26 @@ Date format is "YYYY-MM-DD" eg: self.usage() return 0 if opt == '-i': - self.instance_home = arg + self.tracker_home = arg if opt == '-c': self.comma_sep = 1 # if no command - go interactive + # wrap in a try/finally so we always close off the db ret = 0 - if not args: - self.interactive() - else: - ret = self.run_command(args) - if self.db: self.db.commit() - return ret - + try: + if not args: + self.interactive() + else: + ret = self.run_command(args) + if self.db: self.db.commit() + return ret + finally: + if self.db: + self.db.close() if __name__ == '__main__': tool = AdminTool() sys.exit(tool.main()) -# -# $Log: not supported by cvs2svn $ -# Revision 1.20 2002/08/01 00:56:22 richard -# Added the web access and email access permissions, so people can restrict -# access to users who register through the email interface (for example). -# Also added "security" command to the roundup-admin interface to display the -# Role/Permission config for an instance. -# -# Revision 1.19 2002/07/25 07:14:05 richard -# Bugger it. Here's the current shape of the new security implementation. -# Still to do: -# . call the security funcs from cgi and mailgw -# . change shipped templates to include correct initialisation and remove -# the old config vars -# ... that seems like a lot. The bulk of the work has been done though. Honest :) -# -# Revision 1.18 2002/07/18 11:17:30 gmcm -# Add Number and Boolean types to hyperdb. -# Add conversion cases to web, mail & admin interfaces. -# Add storage/serialization cases to back_anydbm & back_metakit. -# -# Revision 1.17 2002/07/14 06:05:50 richard -# . fixed the date module so that Date(". - 2d") works -# -# Revision 1.16 2002/07/09 04:19:09 richard -# Added reindex command to roundup-admin. -# Fixed reindex on first access. -# Also fixed reindexing of entries that change. -# -# Revision 1.15 2002/06/17 23:14:44 richard -# . #569415 ] {version} -# -# Revision 1.14 2002/06/11 06:41:50 richard -# Removed prompt for admin email in initialisation. -# -# Revision 1.13 2002/05/30 23:58:14 richard -# oops -# -# Revision 1.12 2002/05/26 09:04:42 richard -# out by one in the init args -# -# Revision 1.11 2002/05/23 01:14:20 richard -# . split instance initialisation into two steps, allowing config changes -# before the database is initialised. -# -# Revision 1.10 2002/04/27 10:07:23 richard -# minor fix to error message -# -# Revision 1.9 2002/03/12 22:51:47 richard -# . #527416 ] roundup-admin uses undefined value -# . #527503 ] unfriendly init blowup when parent dir -# (also handles UsageError correctly now in init) -# -# Revision 1.8 2002/02/27 03:28:21 richard -# Ran it through pychecker, made fixes -# -# Revision 1.7 2002/02/20 05:04:32 richard -# Wasn't handling the cvs parser feeding properly. -# -# Revision 1.6 2002/01/23 07:27:19 grubert -# . allow abbreviation of "help" in admin tool too. -# -# Revision 1.5 2002/01/21 16:33:19 rochecompaan -# You can now use the roundup-admin tool to pack the database -# -# Revision 1.4 2002/01/14 06:51:09 richard -# . #503164 ] create and passwords -# -# Revision 1.3 2002/01/08 05:26:32 rochecompaan -# Missing "self" in props_from_args -# -# Revision 1.2 2002/01/07 10:41:44 richard -# #500140 ] AdminTool.get_class() returns nothing -# -# Revision 1.1 2002/01/05 02:11:22 richard -# I18N'ed roundup admin - and split the code off into a module so it can be used -# elsewhere. -# Big issue with this is the doc strings - that's the help. We're probably going to -# have to switch to not use docstrings, which will suck a little :( -# -# -# # vim: set filetype=python ts=4 sw=4 et si