diff roundup/hyperdb.py @ 8416:370689471a08 issue2550923_computed_property

merge from default branch accumulated changes since Nov 2023
author John Rouillard <rouilj@ieee.org>
date Sun, 17 Aug 2025 16:12:25 -0400
parents 14a8e11f3a87 a81a3cd067fa
children
line wrap: on
line diff
--- a/roundup/hyperdb.py	Sun Nov 05 11:38:18 2023 -0500
+++ b/roundup/hyperdb.py	Sun Aug 17 16:12:25 2025 -0400
@@ -21,6 +21,7 @@
 __docformat__ = 'restructuredtext'
 
 # standard python modules
+import copy
 import logging
 import os
 import re
@@ -29,6 +30,8 @@
 import traceback
 import weakref
 
+from hashlib import md5
+
 # roundup modules
 from . import date, password
 from .support import ensureParentsExist, PrioList
@@ -36,10 +39,13 @@
 from roundup.i18n import _
 from roundup.cgi.exceptions import DetectorError
 from roundup.anypy.cmp_ import NoneAndDictComparable
-from roundup.anypy.strings import eval_import
+from roundup.anypy.strings import b2s, bs2b, eval_import
 
 logger = logging.getLogger('roundup.hyperdb')
 
+# marker used for an unspecified keyword argument
+_marker = []
+
 
 #
 # Types
@@ -193,13 +199,19 @@
 
 
 class Date(_Type):
-    """An object designating a Date property."""
+    """An object designating a Date property.
+       The display_time parameter specifies if we want date and time or
+       date only. Both display_time and format are defaults for the
+       field method of the DateHTMLProperty (for rendering html).
+    """
     def __init__(self, offset=None, required=False, default_value=None,
-                 quiet=False):
+                 quiet=False, display_time='yes', format=None):
         super(Date, self).__init__(required=required,
                                    default_value=default_value,
                                    quiet=quiet)
         self._offset = offset
+        self.display_time = display_time == 'yes'
+        self.format = format
 
     def offset(self, db):
         if self._offset is not None:
@@ -1118,10 +1130,12 @@
         """
         return node
 
-    def getnode(self, classname, nodeid):
+    def getnode(self, classname, nodeid, allow_abort=True):
         """Get a node from the database.
 
         'cache' exists for backwards compatibility, and is not used.
+        'allow_abort' determines if we allow that the current
+        transaction is aborted due to a data error (e.g. invalid nodeid).
         """
         raise NotImplementedError
 
@@ -1288,9 +1302,7 @@
         """
         raise NotImplementedError
 
-    _marker = []
-
-    def get(self, nodeid, propname, default=_marker, cache=1):
+    def get(self, nodeid, propname, default=_marker, cache=1, allow_abort=True):
         """Get the value of a property on an existing node of this class.
 
         'nodeid' must be the id of an existing node of this class or an
@@ -1298,6 +1310,8 @@
         of this class or a KeyError is raised.
 
         'cache' exists for backwards compatibility, and is not used.
+        'allow_abort' determines if we allow that the current
+        transaction is aborted due to a data error (e.g. invalid nodeid).
         """
         raise NotImplementedError
 
@@ -1355,8 +1369,10 @@
         """
         raise NotImplementedError
 
-    def is_retired(self, nodeid):
+    def is_retired(self, nodeid, allow_abort=True):
         """Return true if the node is rerired
+           'allow_abort' specifies if we allow the transaction to be
+           aborted if a syntactically invalid nodeid is passed.
         """
         raise NotImplementedError
 
@@ -1861,6 +1877,60 @@
     # anyway).
     filter_iter = filter
 
+    def filter_with_permissions(self, search_matches, filterspec, sort=[],
+                                group=[], retired=False, exact_match_spec={},
+                                limit=None, offset=None,
+                                permission='View', userid=None):
+        """ Do the same as filter but return only the items the user is
+            entitled to see, running the results through security checks.
+            The userid defaults to the current database user.
+        """
+        if userid is None:
+            userid = self.db.getuid()
+        cn = self.classname
+        sec = self.db.security
+        filterspec = sec.filterFilterspec(userid, cn, filterspec)
+        if exact_match_spec:
+            exact_match_spec = sec.filterFilterspec(userid, cn,
+                                                    exact_match_spec)
+        sort = sec.filterSortspec(userid, cn, sort)
+        group = sec.filterSortspec(userid, cn, group)
+        item_ids = self.filter(search_matches, filterspec, sort, group,
+                               retired, exact_match_spec, limit, offset)
+        check = sec.hasPermission
+        if check(permission, userid, cn, skip_permissions_with_check = True):
+            allowed = item_ids
+        else:
+            debug = self.db.config.RDBMS_DEBUG_FILTER
+            # Note that is_filterable returns True if no permissions are
+            # found. This makes it fail early (with an empty allowed list)
+            # instead of running through all ids with an empty
+            # permission list.
+            if not debug and sec.is_filterable(permission, userid, cn):
+                new_ids = set(item_ids)
+                confirmed = set()
+                for perm in sec.filter_iter(permission, userid, cn):
+                    fargs = perm.filter(self.db, userid, self)
+                    for farg in fargs:
+                        farg.update(sort=[], group=[], retired=None)
+                        result = self.filter(list(new_ids), **farg)
+                        new_ids.difference_update(result)
+                        confirmed.update(result)
+                        # all allowed?
+                        if not new_ids:
+                            break
+                    # all allowed?
+                    if not new_ids:
+                        break
+                # Need to sort again in database
+                allowed = self.filter(confirmed, {}, sort=sort, group=group,
+                                      retired=None)
+            else: # Last resort: filter in python
+                allowed = [id for id in item_ids
+                           if check(permission, userid, cn, itemid=id)]
+        return allowed
+
+
     def count(self):
         """Get the number of nodes in this class.
 
@@ -2103,17 +2173,59 @@
 
 
 class FileClass:
-    """ A class that requires the "content" property and stores it on
-        disk.
+    """ This class defines a large chunk of data. To support this, it
+        has a mandatory String property "content" which is saved off
+        externally to the hyperdb.
+
+        The default MIME type of this data is defined by the
+        "default_mime_type" class attribute, which may be overridden by
+        each node if the class defines a "type" String property.
     """
     default_mime_type = 'text/plain'
 
-    def __init__(self, db, classname, **properties):
+    def _update_properties(self, properties):
         """The newly-created class automatically includes the "content"
-        property.
+        and "type" properties. This method must be called by __init__.
         """
         if 'content' not in properties:
             properties['content'] = String(indexme='yes')
+        if 'type' not in properties:
+            properties['type'] = String()
+
+    def create(self, **propvalues):
+        """ snaffle the file propvalue and store in a file
+        """
+        # we need to fire the auditors now, or the content property won't
+        # be in propvalues for the auditors to play with
+        self.fireAuditors('create', None, propvalues)
+
+        # now remove the content property so it's not stored in the db
+        content = propvalues['content']
+        del propvalues['content']
+
+        # do the database create
+        newid = self.create_inner(**propvalues)
+
+        # figure the mime type
+        mime_type = propvalues.get('type', self.default_mime_type)
+
+        # optionally index
+        # This wasn't done for the anydbm backend (but the 'set' method
+        # *did* update the index) so this is probably a bug-fix for anydbm
+        if self.properties['content'].indexme:
+            index_content = content
+            if bytes != str and isinstance(content, bytes):
+                index_content = content.decode('utf-8', errors='ignore')
+            self.db.indexer.add_text((self.classname, newid, 'content'),
+                                     index_content, mime_type)
+
+        # store off the content as a file
+        self.db.storefile(self.classname, newid, None, bs2b(content))
+
+        # fire reactors
+        self.fireReactors('create', newid, None)
+
+        return newid
 
     def export_propnames(self):
         """ Don't export the "content" property
@@ -2141,6 +2253,45 @@
         ensureParentsExist(dest)
         shutil.copyfile(source, dest)
 
+    def get(self, nodeid, propname, default=_marker, cache=1, allow_abort=True):
+        """ Trap the content propname and get it from the file
+
+        'cache' exists for backwards compatibility, and is not used.
+
+        'allow_abort' determines if we allow that the current
+        transaction is aborted due to a data error (e.g. invalid nodeid).
+        """
+        poss_msg = 'Possibly an access right configuration problem.'
+        if propname == 'content':
+            try:
+                return b2s(self.db.getfile(self.classname, nodeid, None))
+            except IOError as strerror:
+                # BUG: by catching this we don't see an error in the log.
+                return 'ERROR reading file: %s%s\n%s\n%s' % (
+                        self.classname, nodeid, poss_msg, strerror)
+            except UnicodeDecodeError:
+                # if content is not text (e.g. jpeg file) we get
+                # unicode error trying to convert to string in python 3.
+                # trap it and supply an error message. Include md5sum
+                # of content as this string is included in the etag
+                # calculation of the object.
+                return ('%s%s is not text, retrieve using '
+                        'binary_content property. mdsum: %s') % (
+                            self.classname, nodeid,
+                            md5(self.db.getfile(
+                                self.classname,
+                                nodeid,
+                                None)).hexdigest())  # nosec - bandit md5 use ok
+        elif propname == 'binary_content':
+            return self.db.getfile(self.classname, nodeid, None)
+
+        if default is not _marker:
+            return self.subclass.get(self, nodeid, propname, default,
+                                     allow_abort=allow_abort)
+        else:
+            return self.subclass.get(self, nodeid, propname,
+                                     allow_abort=allow_abort)
+
     def import_files(self, dirname, nodeid):
         """ Import the "content" property as a file
         """
@@ -2166,6 +2317,72 @@
             self.db.indexer.add_text((self.classname, nodeid, 'content'),
                                      index_content, mime_type)
 
+    def index(self, nodeid):
+        """ Add (or refresh) the node to search indexes.
+
+        Use the content-type property for the content property.
+        """
+        # find all the String properties that have indexme
+        for prop, propclass in self.getprops().items():
+            if prop == 'content' and propclass.indexme:
+                mime_type = self.get(nodeid, 'type', self.default_mime_type)
+                index_content = self.get(nodeid, 'binary_content')
+                if bytes != str and isinstance(index_content, bytes):
+                    index_content = index_content.decode('utf-8',
+                                                         errors='ignore')
+                self.db.indexer.add_text((self.classname, nodeid, 'content'),
+                                         index_content, mime_type)
+            elif isinstance(propclass, String) and propclass.indexme:
+                # index them under (classname, nodeid, property)
+                try:
+                    value = str(self.get(nodeid, prop))
+                except IndexError:
+                    # node has been destroyed
+                    continue
+                self.db.indexer.add_text((self.classname, nodeid, prop), value)
+
+    def set(self, itemid, **propvalues):
+        """ Snarf the "content" propvalue and update it in a file
+        """
+        self.fireAuditors('set', itemid, propvalues)
+
+        # create the oldvalues dict - fill in any missing values
+        oldvalues = copy.deepcopy(self.db.getnode(self.classname, itemid))
+        # The following is redundant for rdbms backends but needed for anydbm
+        # the performance impact is so low we that we don't factor this.
+        for name, prop in self.getprops(protected=0).items():
+            if name in oldvalues:
+                continue
+            if isinstance(prop, Multilink):
+                oldvalues[name] = []
+            else:
+                oldvalues[name] = None
+
+        # now remove the content property so it's not stored in the db
+        content = None
+        if 'content' in propvalues:
+            content = propvalues['content']
+            del propvalues['content']
+
+        # do the database update
+        propvalues = self.set_inner(itemid, **propvalues)
+
+        # do content?
+        if content:
+            # store and possibly index
+            self.db.storefile(self.classname, itemid, None, bs2b(content))
+            if self.properties['content'].indexme:
+                index_content = content
+                if bytes != str and isinstance(content, bytes):
+                    index_content = content.decode('utf-8', errors='ignore')
+                mime_type = self.get(itemid, 'type', self.default_mime_type)
+                self.db.indexer.add_text((self.classname, itemid, 'content'),
+                                         index_content, mime_type)
+            propvalues['content'] = content
+
+        # fire reactors
+        self.fireReactors('set', itemid, oldvalues)
+        return propvalues
 
 class Node:
     """ A convenience wrapper for the given node

Roundup Issue Tracker: http://roundup-tracker.org/