diff --git a/CHANGES.txt b/CHANGES.txt index e795bd49..2a75264c 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -127,6 +127,9 @@ Features: - save roundup-admin history between sessions. Load ~/.roundup_admin_rlrc file to set history-size persistently. Add pragma history_length to override for a session. (John Rouillard) +- the roundup-admin history command now dumps the journal entries + in a more human readable format. Use the raw option to get the older + machine parsible output. (John Rouillard) 2023-07-13 2.3.0 diff --git a/doc/admin_guide.txt b/doc/admin_guide.txt index dcea4132..13714d5e 100644 --- a/doc/admin_guide.txt +++ b/doc/admin_guide.txt @@ -1245,7 +1245,9 @@ Examples above show how to use it to: * creating a new user from the command line * list/find users in the tracker -The basic usage is:: +The basic usage is: + +.. code-block:: text Usage: roundup-admin [options] [ ] @@ -1282,7 +1284,7 @@ The basic usage is:: genconfig get property designator[,designator]* help topic - history designator [skipquiet] + history designator [skipquiet] [raw] import import_dir importtables export_dir initialise [adminpw] diff --git a/doc/upgrading.txt b/doc/upgrading.txt index 220c1476..6e608208 100644 --- a/doc/upgrading.txt +++ b/doc/upgrading.txt @@ -270,6 +270,74 @@ before changing your PostgreSQL configuration, try changing the pragma ``10000``. In some cases this may be too high. See the `administration guide`_ for further details. +roundup-admin's History Command Produces Readable Output +-------------------------------------------------------- + +The history command of roundup-admin used to print the raw journal +data. In this release the default is to produce more human readable +data. The original output (not pretty printed as below) was:: + + [('1', , '1', 'create', {}), + ('1', + , + '1', + 'set', + {'messages': (('+', ['3']),)}), + ('1', , '1', 'set', {'priority': '1'}), + ('1', + , + '1', + 'link', + ('issue', '2', 'dependson')), + ('1', , '1', 'link', ('issue', '2', + 'seealso')), + ('1', + , + '1', + 'set', + {'dependson': (('+', ['3']),), 'private': None, 'queue': None}), + ('1', + , + '1', + 'set', + {'dependson': (('+', ['2']),)}), + ('1', + , + '1', + 'unlink', + ('issue', '2', 'seealso')), + ... + +Now it produces (Each entry is on one line, lines wrapped +and indented for display):: + + admin(2013-02-18.20:30:34) create issue + admin(2013-02-19.21:24:20) set modified messages: added: msg3 + admin(2013-02-19.21:24:24) set priority was critical(1) + admin(2013-02-20.03:16:52) link added issue2 to dependson + admin(2013-02-21.20:51:40) link added issue2 to seealso + admin(2013-02-22.05:33:08) set modified dependson: added: issue3; + private was None; queue was None + admin(2013-02-22.05:33:19) set modified dependson: added: issue2 + admin(2013-02-27.03:24:42) unlink removed issue2 from seealso + ... + + +A few things to note: set operations can either assign a property or +report a modification of a multilink property. If an assignment +occurs, the value reported is the **old value** that was there before +the assignment. It is **not** the value that is assigned. In the +example above I don't know what the current value of priority is. All +I know it was set to critical when the issue was created. + +Modifications to multilink properties work differently. I know that +``msg3`` was present in the messages property after 2013-02-19 at +21:24:20 UTC. + +The history command gets a new optional argument ``raw`` that produces +the old style output. The old style is (marginally) more useful for +script automation. + .. index:: Upgrading; 2.2.0 to 2.3.0 Migrating from 2.2.0 to 2.3.0 diff --git a/locale/Makefile b/locale/Makefile index e6b7a19b..e6bde389 100644 --- a/locale/Makefile +++ b/locale/Makefile @@ -86,6 +86,7 @@ roundup.pot: $(SOURCES) $(TEMPLATES) VERSION="`${RUN_PYTHON} -c 'from roundup import __version__; \ print(__version__)';`"; \ ${XGETTEXT} -j -w 80 -F \ + --add-comments=".Hint " \ --package-name=Roundup \ --package-version=$$VERSION \ --msgid-bugs-address=roundup-devel@lists.sourceforge.net \ diff --git a/roundup/admin.py b/roundup/admin.py index da42f094..af07808e 100644 --- a/roundup/admin.py +++ b/roundup/admin.py @@ -953,16 +953,16 @@ def do_help(self, args, nl_re=nl_re, indent_re=indent_re): return 0 def do_history(self, args): - ''"""Usage: history designator [skipquiet] + ''"""Usage: history designator [skipquiet] [raw] Show the history entries of a designator. A designator is a classname and a nodeid concatenated, eg. bug1, user10, ... - Lists the journal entries viewable by the user for the - node identified by the designator. If skipquiet is the - second argument, journal entries for quiet properties - are not shown. + Lists the journal entries viewable by the user for the node + identified by the designator. If skipquiet is added, journal + entries for quiet properties are not shown. If raw is added, + the output is the raw representation of the journal entries. """ if len(args) < 1: @@ -972,15 +972,181 @@ def do_history(self, args): except hyperdb.DesignatorError as message: raise UsageError(message) - skipquiet = False - if len(args) == 2: - if args[1] != 'skipquiet': - raise UsageError("Second argument is not skipquiet") - skipquiet = True + valid_args = ['skipquiet', 'raw'] + + if len(args) >= 2: + check = [a for a in args[1:] if a not in valid_args] + if check: + raise UsageError( + _("Unexpected argument(s): %s. " + "Expected 'skipquiet' or 'raw'.") % ", ".join(check)) + + skipquiet = 'skipquiet' in args[1:] + raw = 'raw' in args[1:] + + getclass = self.db.getclass + def get_prop_name(key, prop_name): + # getclass and classname from enclosing method + klass = getclass(classname) + try: + property_obj = klass.properties[prop_name] + except KeyError: + # the property has been removed from the schema. + return None + if isinstance(property_obj, + (hyperdb.Link, hyperdb.Multilink)): + prop_class = getclass(property_obj.classname) + key_prop_name = prop_class.key + if key_prop_name: + return prop_class.get(key, key_prop_name) + # None indicates that there is no key_prop + return None + return None + + def get_prop_class(prop_name): + # getclass and classname from enclosing method + klass = getclass(classname) + try: + property_obj = klass.properties[prop_name] + except KeyError: + # the property has been removed from the schema. + return None + if isinstance(property_obj, + (hyperdb.Link, hyperdb.Multilink)): + prop_class = getclass(property_obj.classname) + return prop_class.classname + return None # it's not a link + + def _format_tuple_change(data, prop): + ''' ('-', ['2', '4'] -> + "removed fred(2), jim(6)" + ''' + if data[0] == '-': + op = _("removed") + elif data[0] == '+': + op = _("added") + else: + raise ValueError(_("Unknown history set operation '%s'. " + "Expected +/-.") % op) + op_params = data[1] + name = get_prop_name(op_params[0], prop) + if name is not None: + list_items = ["%s(%s)" % + (get_prop_name(o, prop), o) + for o in op_params] + else: + propclass = get_prop_class(prop) + if propclass: # noqa: SIM108 + list_items = ["%s%s" % (propclass, o) + for o in op_params] + else: + list_items = op_params + + return "%s: %s" % (op, ", ".join(list_items)) + + def format_report_class(_data): + """Eat the empty data dictionary or None""" + return classname + + def format_link (data): + '''data = ('issue', '157', 'dependson')''' + # .Hint added issue23 to superseder + f = _("added %(class)s%(item_id)s to %(propname)s") + return f % { + 'class': data[0], 'item_id': data[1], 'propname': data[2]} + + def format_set(data): + '''data = set {'fyi': None, 'priority': '5'} + set {'fyi': '....\ned through cleanly', 'priority': '3'} + ''' + result = [] + + # Note that set data is the old value. So don't use + # current/future tense in sentences. + + for prop, value in data.items(): + if isinstance(value, str): + name = get_prop_name(value, prop) + if name: + result.append( + # .Hint read as: assignedto was admin(1) + # .Hint where assignedto is the property + # .Hint admin is the key name for value 1 + _("%(prop)s was %(name)%(value)s)") % { + "prop": prop, "name": name, "value": value }) + else: + # use repr so strings with embedded \n etc. don't + # generate newlines in output. Try to keep each + # journal entry on 1 line. + result.append(_("%(prop)s was %(value)s") % { + "prop": prop, "value": repr(value)}) + elif isinstance(value, list): + # test to see if there is a key prop. + # Assumption, geting None here means no key + # is defined for the property's class. + name = get_prop_name(value[0], prop) + if name is not None: + list_items = ["%s(%s)" % + (get_prop_name(v, prop), v) + for v in value] + else: + prop_class = get_prop_class(prop) + if prop_class: # noqa: SIM108 + list_items = [ "%s%s" % (prop_class, v) + for v in value ] + else: + list_items = value + + result.append(_("%(prop)s was [%(value_list)s]") % { + "prop": prop, "value_list": ", ".join(list_items)}) + elif isinstance(value, tuple): + # operation data + decorated = [_format_tuple_change(data, prop) + for data in value] + result.append(# .Hint modified nosy: added demo(3) + _("modified %(prop)s: %(how)s") % { + "prop": prop, "how": ", ".join(decorated)}) + else: + result.append(_("%(prop)s was %(value)s") % { + "prop": prop, "value": value}) + + return '; '.join(result) + + def format_unlink (data): + '''data = ('issue', '157', 'dependson')''' + return "removed %s%s from %s" % (data[0], data[1], data[2]) + + formatters = { + "create": format_report_class, + "link": format_link, + "restored": format_report_class, + "retired": format_report_class, + "set": format_set, + "unlink": format_unlink, + } try: - print(self.db.getclass(classname).history(nodeid, - skipquiet=skipquiet)) + # returns a tuple: ( + # [0] = nodeid + # [1] = date + # [2] = userid + # [3] = operation + # [4] = details + raw_history = self.db.getclass(classname).history(nodeid, + skipquiet=skipquiet) + if raw: + print(raw_history) + return 0 + + def make_readable(hist): + return "%s(%s) %s %s" % (self.db.user.get(hist[2], 'username'), + hist[1], + hist[3], + formatters.get(hist[3], lambda x: x)( + hist[4])) + printable_history = [make_readable(hist) for hist in raw_history] + + print("\n".join(printable_history)) except KeyError: raise UsageError(_('no such class "%(classname)s"') % locals()) except IndexError: @@ -2103,7 +2269,7 @@ def interactive(self): ".roundup_admin_history") try: - import readline # noqa: F401 + import readline readline.read_init_file(initfile) try: readline.read_history_file(histfile) @@ -2168,10 +2334,10 @@ def main(self): # noqa: PLR0912, PLR0911 self.print_designator = 0 self.verbose = 0 for opt, arg in opts: - if opt == '-h': # noqa: RET505 - allow elif after returns + if opt == '-h': self.usage() return 0 - elif opt == '-v': + elif opt == '-v': # noqa: RET505 - allow elif after returns print('%s (python %s)' % (roundup_version, sys.version.split()[0])) return 0 diff --git a/share/man/man1/roundup-admin.1 b/share/man/man1/roundup-admin.1 index 1848b687..a35126b0 100644 --- a/share/man/man1/roundup-admin.1 +++ b/share/man/man1/roundup-admin.1 @@ -119,11 +119,11 @@ Retrieves the property value of the nodes specified by the designators. .TP -\fBhistory\fP \fIdesignator [skipquiet]\fP +\fBhistory\fP \fIdesignator [skipquiet] [raw]\fP Lists the journal entries viewable by the user for the -node identified by the designator. If skipquiet is the -second argument, journal entries for quiet properties -are not shown. +node identified by the designator. If skipquiet is added, journal +entries for quiet properties are not shown. Without the raw option +a more human readable output format is used. .TP \fBimport\fP \fIimport_dir\fP Import a database from the directory containing CSV files,