diff --git a/doc/customizing.txt b/doc/customizing.txt index 62faac18..c466046c 100644 --- a/doc/customizing.txt +++ b/doc/customizing.txt @@ -128,6 +128,72 @@ caches the schema). columns string:id,activity,due_date,title,creator,status; columns_showall string:id,activity,due_date,title,creator,assignedto,status; +Adding a new Computed field to the schema +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Computed properties are a bit different from other properties. They do +not actually change the database. Computed fields are not contained in +the database and can not be searched or used for sorting or +grouping. (Patches to add this capability are welcome.) + +In this example we will add a count of the number of files attached to +the issue. This could be done using an auditor (see below) to update +an integer field called ``filecount``. But we will implement this in a +different way. + +We have two changes to make: + +1. add a new python method to the hyperdb.Computed class. It will + count the number of files attached to the issue. This method will + be added in the (possibly new) interfaces.py file in the top level + of the tracker directory. (See interfaces.py above for more + information.) +2. add a new ``filecount`` property to the issue class calling the + new function. + +A Computed method receives three arguments when called: + + 1. the computed object (self) + 2. the id of the item in the class + 3. the database object + +To add the method to the Computed class, modify the trackers +interfaces.py adding:: + + import roundup.hyperdb as hyperdb + def filecount(self, nodeid, db): + return len(db.issue.get(nodeid, 'files')) + setattr(hyperdb.Computed, 'filecount', filecount) + +Then add:: + + filecount = Computed(Computed.filecount), + +to the existing IssueClass call. + +Now you can retrieve the value of the ``filecount`` property and it +will be computed on the fly from the existing list of attached files. + +This example was done with the IssueClass, but you could add a +Computed property to any class. E.G.:: + + user = Class(db, "user", + username=String(), + password=Password(), + address=String(), + realname=String(), + phone=String(), + office=Computed(Computed.getOfficeFromLdap), # new prop + organisation=String(), + alternate_addresses=String(), + queries=Multilink('query'), + roles=String(), + timezone=String()) + +where the method ``getOfficeFromLdap`` queries the local ldap server to +get the current office location information. The method will be called +with the Computed instance, the user id and the database object. + Adding a new constrained field to the classic schema ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/roundup/backends/back_anydbm.py b/roundup/backends/back_anydbm.py index ca02f171..dfcd0b8f 100644 --- a/roundup/backends/back_anydbm.py +++ b/roundup/backends/back_anydbm.py @@ -1116,6 +1116,9 @@ def get(self, nodeid, propname, default=_marker, cache=1, allow_abort=True): # get the property (raises KeyErorr if invalid) prop = self.properties[propname] + if isinstance(prop, hyperdb.Computed): + return prop.function(prop, nodeid, self.db) + if isinstance(prop, hyperdb.Multilink) and prop.computed: cls = self.db.getclass(prop.rev_classname) ids = cls.find(**{prop.rev_propname: nodeid}) diff --git a/roundup/backends/rdbms_common.py b/roundup/backends/rdbms_common.py index 771afecc..2012f102 100644 --- a/roundup/backends/rdbms_common.py +++ b/roundup/backends/rdbms_common.py @@ -68,7 +68,7 @@ from roundup.backends.sessions_rdbms import Sessions, OneTimeKeys from roundup.date import Range from roundup.hyperdb import String, Password, Date, Interval, Link, \ - Multilink, DatabaseError, Boolean, Number, Integer + Multilink, DatabaseError, Boolean, Computed, Number, Integer from roundup.i18n import _ from roundup.mlink_expr import compile_expression, ExpressionError @@ -1905,6 +1905,9 @@ def get(self, nodeid, propname, default=_marker, cache=1, allow_abort=True): # get the property (raises KeyError if invalid) prop = self.properties[propname] + if isinstance(prop, Computed): + return prop.function(prop, nodeid, self.db) + # lazy evaluation of Multilink if propname not in d and isinstance(prop, Multilink): self.db._materialize_multilink(self.classname, nodeid, d, propname) diff --git a/roundup/cgi/templating.py b/roundup/cgi/templating.py index b0b53f62..1751800b 100644 --- a/roundup/cgi/templating.py +++ b/roundup/cgi/templating.py @@ -1967,6 +1967,32 @@ def email(self, escape=1): value = html_escape(value) return value +class ComputedHTMLProperty(HTMLProperty): + def plain(self, escape=0): + """ Render a "plain" representation of the property + """ + if not self.is_view_ok(): + return self._('[hidden]') + + if self._value is None: + return '' + try: + if isinstance(self._value, str): + value = self._value + else: + value = str(self._value) + + except AttributeError: + value = self._('[hidden]') + if escape: + value = html_escape(value) + return value + + def field(self): + """ Computed properties are not editable so + just display the value via plain(). + """ + return self.plain(escape=1) class PasswordHTMLProperty(HTMLProperty): def plain(self, escape=0): @@ -2992,6 +3018,7 @@ def menu(self, size=None, height=None, showid=0, additional=[], (hyperdb.Boolean, BooleanHTMLProperty), (hyperdb.Date, DateHTMLProperty), (hyperdb.Interval, IntervalHTMLProperty), + (hyperdb.Computed, ComputedHTMLProperty), (hyperdb.Password, PasswordHTMLProperty), (hyperdb.Link, LinkHTMLProperty), (hyperdb.Multilink, MultilinkHTMLProperty), diff --git a/roundup/hyperdb.py b/roundup/hyperdb.py index 25d3119c..40ff87fd 100644 --- a/roundup/hyperdb.py +++ b/roundup/hyperdb.py @@ -84,6 +84,71 @@ def sort_repr(self, cls, val, name): """ return val +class Computed(object): + """A roundup computed property type. Not inherited from _Type + as the value is never changed. It is defined by running a + method. + + This property has some restrictions. It is read only and can + not be set. It can not (currently) be searched or used for + sorting or grouping. + """ + + def __init__(self, function, default_value=None, cachefor=None): + """ function: a callable method on this class. + default_value: default value to be returned by the method + cachefor: an interval property used to determine how long to + cache the value from the function. Not yet + implemented. + """ + self.function = function + self.__default_value = default_value + self.computed = True + + # alert the user that cachefor is not valid yet. + if cachefor is not None: + raise NotImplementedError + + def __repr__(self): + ' more useful for dumps ' + return '<%s.%s computed %s>' % (self.__class__.__module__, + self.__class__.__name__, + self.function.__name__) + + def get_default_value(self): + """The default value when creating a new instance of this property.""" + return self.__default_value + + def register (self, cls, propname): + """Register myself to the class of which we are a property + the given propname is the name we have in our class. + """ + assert not getattr(self, 'cls', None) + self.name = propname + self.cls = cls + + def sort_repr(self, cls, val, name): + """Representation used for sorting. This should be a python + built-in type, otherwise sorting will take ages. Note that + individual backends may chose to use something different for + sorting as long as the outcome is the same. + """ + return val + + def message_count(self, nodeid, db): + """Example method that counts the number of messages for an issue. + + Adding a property to the IssueClass like: + + msgcount = Computed(Computed.message_count) + + allows querying for the msgcount property to get a count of + the number of messages on the issue. Note that you can not + currently search, sort or group using a computed property + like msgcount. + """ + + return len(db.issue.get(nodeid, 'messages')) class String(_Type): """An object designating a String property.""" diff --git a/roundup/instance.py b/roundup/instance.py index 651dae2d..57133ec0 100644 --- a/roundup/instance.py +++ b/roundup/instance.py @@ -107,6 +107,7 @@ def open(self, name=None): 'Multilink': hyperdb.Multilink, 'Interval': hyperdb.Interval, 'Boolean': hyperdb.Boolean, + 'Computed': hyperdb.Computed, 'Number': hyperdb.Number, 'Integer': hyperdb.Integer, 'db': backend.Database(self.config, name)