changeset 422:c21f31355ab9

configuration: "max_items" option: "max_items" is implemented using a text-single field, as it is done in the XEP-0060 example (there is no real formal description). When changing the node configuration, the max_items can't be set to a number lower than the total number of items in the node (the configuration will then be rejected), this is to avoid accidental deletion of items.
author Goffi <goffi@goffi.org>
date Tue, 10 Mar 2020 11:11:38 +0100
parents f124ed5ea78b
children af73d57829ed
files CHANGELOG sat_pubsub/backend.py sat_pubsub/const.py sat_pubsub/pgsql_storage.py
diffstat 4 files changed, 74 insertions(+), 18 deletions(-) [+]
line wrap: on
line diff
--- a/CHANGELOG	Sat Feb 22 13:06:51 2020 +0100
+++ b/CHANGELOG	Tue Mar 10 11:11:38 2020 +0100
@@ -1,6 +1,7 @@
 v 0.4.0 (NOT RELEASED YET):
     - Python 3 port
     - publish-options implementation
+    - max_items configuration option
     - bug fixes
 
 v 0.3.0 (16/08/2019)
--- a/sat_pubsub/backend.py	Sat Feb 22 13:06:51 2020 +0100
+++ b/sat_pubsub/backend.py	Tue Mar 10 11:11:38 2020 +0100
@@ -138,6 +138,9 @@
             const.OPT_PERSIST_ITEMS:
                 {"type": "boolean",
                  "label": "Persist items to storage"},
+            const.OPT_MAX_ITEMS:
+                {"type": "text-single",
+                 "label": 'number of items to keep ("max" for unlimited)'},
             const.OPT_DELIVER_PAYLOADS:
                 {"type": "boolean",
                  "label": "Deliver payloads with event notifications"},
@@ -670,16 +673,44 @@
 
         d = self.storage.getNode(nodeIdentifier, pep, recipient)
         d.addCallback(_getAffiliation, requestor)
-        d.addCallback(self._doSetNodeConfiguration, requestor, options)
+        d.addCallback(
+            lambda result: defer.ensureDeferred(
+                self._doSetNodeConfiguration(result, requestor, options))
+        )
         return d
 
-    def _doSetNodeConfiguration(self, result, requestor, options):
+    async def _doSetNodeConfiguration(self, result, requestor, options):
         node, affiliation = result
 
         if affiliation != 'owner' and not self.isAdmin(requestor):
             raise error.Forbidden()
 
-        return node.setConfiguration(options)
+        max_items = options.get(const.OPT_MAX_ITEMS, '').strip().lower()
+        if not max_items:
+            pass
+        elif max_items == 'max':
+            # FIXME: we use "0" for max for now, but as "0" may be used as legal value
+            #   in XEP-0060 ("max" is used otherwise), we should use "None" instead.
+            #   This require a schema update, as NULL is not allowed at the moment
+            options.fields[const.OPT_MAX_ITEMS].value = "0"
+        else:
+            # max_items is a number, we have to check that it's not bigger than the
+            # total number of items on this node
+            try:
+                max_items = int(max_items)
+            except ValueError:
+                raise error.InvalidConfigurationValue('Invalid "max_items" value')
+            else:
+                options.fields[const.OPT_MAX_ITEMS].value = str(max_items)
+            if max_items:
+                items_count = await node.getItemsCount(None, True)
+                if max_items < items_count:
+                    raise error.ConstraintFailed(
+                        "\"max_items\" can't be set to a value lower than the total "
+                        "number of items on this node. Please increase \"max_items\" "
+                        "or delete items from this node")
+
+        return await node.setConfiguration(options)
 
     def getNodeSchema(self, nodeIdentifier, pep, recipient):
         if not nodeIdentifier:
--- a/sat_pubsub/const.py	Sat Feb 22 13:06:51 2020 +0100
+++ b/sat_pubsub/const.py	Tue Mar 10 11:11:38 2020 +0100
@@ -62,6 +62,7 @@
 OPT_ACCESS_MODEL = 'pubsub#access_model'
 OPT_ROSTER_GROUPS_ALLOWED = 'pubsub#roster_groups_allowed'
 OPT_PERSIST_ITEMS = "pubsub#persist_items"
+OPT_MAX_ITEMS = "pubsub#max_items"
 OPT_DELIVER_PAYLOADS = "pubsub#deliver_payloads"
 OPT_SEND_LAST_PUBLISHED_ITEM = "pubsub#send_last_published_item"
 OPT_PUBLISH_MODEL = 'pubsub#publish_model'
--- a/sat_pubsub/pgsql_storage.py	Sat Feb 22 13:06:51 2020 +0100
+++ b/sat_pubsub/pgsql_storage.py	Tue Mar 10 11:11:38 2020 +0100
@@ -109,6 +109,7 @@
     defaultConfig = {
             'leaf': {
                 const.OPT_PERSIST_ITEMS: True,
+                const.OPT_MAX_ITEMS: 'max',
                 const.OPT_DELIVER_PAYLOADS: True,
                 const.OPT_SEND_LAST_PUBLISHED_ITEM: 'on_sub',
                 const.OPT_ACCESS_MODEL: const.VAL_AMODEL_DEFAULT,
@@ -142,22 +143,21 @@
 
     def _buildNode(self, row):
         """Build a note class from database result row"""
-        configuration = {}
-
         if not row:
             raise error.NodeNotFound()
 
         if row[2] == 'leaf':
             configuration = {
-                    'pubsub#persist_items': row[3],
-                    'pubsub#deliver_payloads': row[4],
-                    'pubsub#send_last_published_item': row[5],
-                    const.OPT_ACCESS_MODEL:row[6],
-                    const.OPT_PUBLISH_MODEL:row[7],
-                    const.OPT_SERIAL_IDS:row[8],
-                    const.OPT_CONSISTENT_PUBLISHER:row[9],
+                    const.OPT_PERSIST_ITEMS: row[3],
+                    const.OPT_MAX_ITEMS: 'max' if row[4] == 0 else str(row[4]),
+                    const.OPT_DELIVER_PAYLOADS: row[5],
+                    const.OPT_SEND_LAST_PUBLISHED_ITEM: row[6],
+                    const.OPT_ACCESS_MODEL:row[7],
+                    const.OPT_PUBLISH_MODEL:row[8],
+                    const.OPT_SERIAL_IDS:row[9],
+                    const.OPT_CONSISTENT_PUBLISHER:row[10],
                     }
-            schema = row[10]
+            schema = row[11]
             if schema is not None:
                 schema = parseXml(schema)
             node = LeafNode(row[0], row[1], configuration, schema)
@@ -165,10 +165,10 @@
             return node
         elif row[2] == 'collection':
             configuration = {
-                    'pubsub#deliver_payloads': row[4],
-                    'pubsub#send_last_published_item': row[5],
-                    const.OPT_ACCESS_MODEL: row[6],
-                    const.OPT_PUBLISH_MODEL:row[7],
+                    const.OPT_DELIVER_PAYLOADS: row[5],
+                    const.OPT_SEND_LAST_PUBLISHED_ITEM: row[6],
+                    const.OPT_ACCESS_MODEL: row[7],
+                    const.OPT_PUBLISH_MODEL:row[8],
                     }
             node = CollectionNode(row[0], row[1], configuration, None)
             node.dbpool = self.dbpool
@@ -188,6 +188,7 @@
                                  node,
                                  node_type,
                                  persist_items,
+                                 max_items,
                                  deliver_payloads,
                                  send_last_published_item,
                                  access_model,
@@ -210,6 +211,7 @@
                                           node,
                                           node_type,
                                           persist_items,
+                                          max_items,
                                           deliver_payloads,
                                           send_last_published_item,
                                           access_model,
@@ -537,6 +539,7 @@
         self._checkNodeExists(cursor)
         self._configurationTriggers(cursor, self.nodeDbId, self._config, config)
         cursor.execute("""UPDATE nodes SET persist_items=%s,
+                                           max_items=%s,
                                            deliver_payloads=%s,
                                            send_last_published_item=%s,
                                            access_model=%s,
@@ -545,6 +548,7 @@
                                            consistent_publisher=%s
                           WHERE node_id=%s""",
                        (config[const.OPT_PERSIST_ITEMS],
+                        config[const.OPT_MAX_ITEMS],
                         config[const.OPT_DELIVER_PAYLOADS],
                         config[const.OPT_SEND_LAST_PUBLISHED_ITEM],
                         config[const.OPT_ACCESS_MODEL],
@@ -951,7 +955,8 @@
 
         if access_model == const.VAL_AMODEL_PUBLISHER_ROSTER:
             if const.OPT_ROSTER_GROUPS_ALLOWED in item_config:
-                item_config.fields[const.OPT_ROSTER_GROUPS_ALLOWED].fieldType='list-multi' #XXX: needed to force list if there is only one value
+                # XXX: needed to force list if there is only one value
+                item_config.fields[const.OPT_ROSTER_GROUPS_ALLOWED].fieldType='list-multi'
                 allowed_groups = item_config[const.OPT_ROSTER_GROUPS_ALLOWED]
             else:
                 allowed_groups = []
@@ -961,6 +966,24 @@
                                   VALUES (%s,%s)""" , (item_id, group))
         # TODO: whitelist access model
 
+        max_items = self._config.get(const.OPT_MAX_ITEMS, 'max')
+
+        if max_items != 'max':
+            try:
+                max_items = int(self._config[const.OPT_MAX_ITEMS])
+            except ValueError:
+                log.err(f"Invalid max_items value: {max_items!r}")
+            else:
+                if max_items > 0:
+                    # we delete all items above the requested max
+                    cursor.execute(
+                        "DELETE FROM items WHERE node_id=%s and item_id in (SELECT "
+                        "item_id FROM items WHERE node_id=%s ORDER BY items.updated "
+                        "DESC, items.item_id DESC OFFSET %s)" ,
+                        (self.nodeDbId, self.nodeDbId, max_items)
+                    )
+
+
     def _storeCategories(self, cursor, item_id, categories, update=False):
         # TODO: handle canonical form
         if update: