changeset 3452:bb0225aaf4e6

plugin XEP-0346: "Form Discovery and Publishing" implementation: this implementation replaces the former non standard node schema, and works in a similar way (the schema is put in a separated node instead of a special field, thus it will now work with most/all PubSub services, and not only SàT PubSub). The implementation has been done in a way that nothing should be changed in frontends (bridge methods names and arguments stay the same). The nodes are modified, but if values are taken from backend, it's automatically adapted.
author Goffi <goffi@goffi.org>
date Fri, 11 Dec 2020 17:57:00 +0100
parents f37e6e78db12
children 864485605d12
files sat/plugins/plugin_exp_pubsub_schema.py sat/plugins/plugin_misc_merge_requests.py sat/plugins/plugin_misc_tickets.py sat/plugins/plugin_tickets_import.py sat/plugins/plugin_xep_0346.py
diffstat 5 files changed, 743 insertions(+), 719 deletions(-) [+]
line wrap: on
line diff
--- a/sat/plugins/plugin_exp_pubsub_schema.py	Fri Dec 11 17:50:53 2020 +0100
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,676 +0,0 @@
-#!/usr/bin/env python3
-
-# SAT plugin for Pubsub Schemas
-# Copyright (C) 2009-2020 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from collections import Iterable
-import itertools
-from zope.interface import implementer
-from twisted.words.protocols.jabber import jid
-from twisted.words.protocols.jabber.xmlstream import XMPPHandler
-from twisted.words.xish import domish
-from twisted.internet import defer
-from wokkel import disco, iwokkel
-from wokkel import data_form
-from wokkel import generic
-from sat.core.i18n import _
-from sat.core import exceptions
-from sat.core.constants import Const as C
-from sat.tools import xml_tools
-from sat.tools import utils
-from sat.tools.common import date_utils
-from sat.tools.common import data_format
-from sat.core.log import getLogger
-
-log = getLogger(__name__)
-
-NS_SCHEMA = "https://salut-a-toi/protocol/schema:0"
-
-PLUGIN_INFO = {
-    C.PI_NAME: "PubSub Schema",
-    C.PI_IMPORT_NAME: "PUBSUB_SCHEMA",
-    C.PI_TYPE: "EXP",
-    C.PI_PROTOCOLS: [],
-    C.PI_DEPENDENCIES: ["XEP-0060", "IDENTITY"],
-    C.PI_MAIN: "PubsubSchema",
-    C.PI_HANDLER: "yes",
-    C.PI_DESCRIPTION: _("""Handle Pubsub data schemas"""),
-}
-
-
-class PubsubSchema(object):
-    def __init__(self, host):
-        log.info(_("PubSub Schema initialization"))
-        self.host = host
-        self._p = self.host.plugins["XEP-0060"]
-        self._i = self.host.plugins["IDENTITY"]
-        host.bridge.addMethod(
-            "psSchemaGet",
-            ".plugin",
-            in_sign="sss",
-            out_sign="s",
-            method=self._getSchema,
-            async_=True,
-        )
-        host.bridge.addMethod(
-            "psSchemaSet",
-            ".plugin",
-            in_sign="ssss",
-            out_sign="",
-            method=self._setSchema,
-            async_=True,
-        )
-        host.bridge.addMethod(
-            "psSchemaUIGet",
-            ".plugin",
-            in_sign="sss",
-            out_sign="s",
-            method=lambda service, nodeIdentifier, profile_key: self._getUISchema(
-                service, nodeIdentifier, default_node=None, profile_key=profile_key),
-            async_=True,
-        )
-        host.bridge.addMethod(
-            "psItemsFormGet",
-            ".plugin",
-            in_sign="ssssiassa{ss}s",
-            out_sign="(asa{ss})",
-            method=self._getDataFormItems,
-            async_=True,
-        )
-        host.bridge.addMethod(
-            "psItemFormSend",
-            ".plugin",
-            in_sign="ssa{sas}ssa{ss}s",
-            out_sign="s",
-            method=self._sendDataFormItem,
-            async_=True,
-        )
-
-    def getHandler(self, client):
-        return SchemaHandler()
-
-    def _getSchemaBridgeCb(self, schema_elt):
-        if schema_elt is None:
-            return ""
-        return schema_elt.toXml()
-
-    def _getSchema(self, service, nodeIdentifier, profile_key=C.PROF_KEY_NONE):
-        client = self.host.getClient(profile_key)
-        service = None if not service else jid.JID(service)
-        d = self.getSchema(client, service, nodeIdentifier)
-        d.addCallback(self._getSchemaBridgeCb)
-        return d
-
-    def _getSchemaCb(self, iq_elt):
-        try:
-            schema_elt = next(iq_elt.elements(NS_SCHEMA, "schema"))
-        except StopIteration:
-            raise exceptions.DataError("missing <schema> element")
-        try:
-            x_elt = next(schema_elt.elements((data_form.NS_X_DATA, "x")))
-        except StopIteration:
-            # there is not schema on this node
-            return None
-        return x_elt
-
-    def getSchema(self, client, service, nodeIdentifier):
-        """retrieve PubSub node schema
-
-        @param service(jid.JID, None): jid of PubSub service
-            None to use our PEP
-        @param nodeIdentifier(unicode): node to get schema from
-        @return (domish.Element, None): schema (<x> element)
-            None if not schema has been set on this node
-        """
-        iq_elt = client.IQ("get")
-        if service is not None:
-            iq_elt["to"] = service.full()
-        pubsub_elt = iq_elt.addElement((NS_SCHEMA, "pubsub"))
-        schema_elt = pubsub_elt.addElement((NS_SCHEMA, "schema"))
-        schema_elt["node"] = nodeIdentifier
-        d = iq_elt.send()
-        d.addCallback(self._getSchemaCb)
-        return d
-
-    @defer.inlineCallbacks
-    def getSchemaForm(self, client, service, nodeIdentifier, schema=None,
-                      form_type="form", copy_form=True):
-        """get data form from node's schema
-
-        @param service(None, jid.JID): PubSub service
-        @param nodeIdentifier(unicode): node
-        @param schema(domish.Element, data_form.Form, None): node schema
-            if domish.Element, will be converted to data form
-            if data_form.Form it will be returned without modification
-            if None, it will be retrieved from node (imply one additional XMPP request)
-        @param form_type(unicode): type of the form
-        @param copy_form(bool): if True and if schema is already a data_form.Form, will deep copy it before returning
-            needed when the form is reused and it will be modified (e.g. in sendDataFormItem)
-        @return(data_form.Form): data form
-            the form should not be modified if copy_form is not set
-        """
-        if schema is None:
-            log.debug(_("unspecified schema, we need to request it"))
-            schema = yield self.getSchema(client, service, nodeIdentifier)
-            if schema is None:
-                raise exceptions.DataError(
-                    _(
-                        "no schema specified, and this node has no schema either, we can't construct the data form"
-                    )
-                )
-        elif isinstance(schema, data_form.Form):
-            if copy_form:
-                # XXX: we don't use deepcopy as it will do an infinite loop if a
-                #      domish.Element is present in the form fields (happens for
-                #      XEP-0315 data forms XML Element)
-                schema = data_form.Form(
-                    formType = schema.formType,
-                    title = schema.title,
-                    instructions = schema.instructions[:],
-                    formNamespace = schema.formNamespace,
-                    fields = schema.fieldList,
-                )
-            defer.returnValue(schema)
-
-        try:
-            form = data_form.Form.fromElement(schema)
-        except data_form.Error as e:
-            raise exceptions.DataError(_("Invalid Schema: {msg}").format(msg=e))
-        form.formType = form_type
-        defer.returnValue(form)
-
-    def schema2XMLUI(self, schema_elt):
-        form = data_form.Form.fromElement(schema_elt)
-        xmlui = xml_tools.dataForm2XMLUI(form, "")
-        return xmlui
-
-    def _getUISchema(self, service, nodeIdentifier, default_node=None,
-                     profile_key=C.PROF_KEY_NONE):
-        if not nodeIdentifier:
-            if not default_node:
-                raise ValueError(_("nodeIndentifier needs to be set"))
-            nodeIdentifier = default_node
-        client = self.host.getClient(profile_key)
-        service = None if not service else jid.JID(service)
-        d = self.getUISchema(client, service, nodeIdentifier)
-        d.addCallback(lambda xmlui: xmlui.toXml())
-        return d
-
-    def getUISchema(self, client, service, nodeIdentifier):
-        d = self.getSchema(client, service, nodeIdentifier)
-        d.addCallback(self.schema2XMLUI)
-        return d
-
-    def _setSchema(self, service, nodeIdentifier, schema, profile_key=C.PROF_KEY_NONE):
-        client = self.host.getClient(profile_key)
-        service = None if not service else jid.JID(service)
-        schema = generic.parseXml(schema.encode("utf-8"))
-        return self.setSchema(client, service, nodeIdentifier, schema)
-
-    def setSchema(self, client, service, nodeIdentifier, schema):
-        """set or replace PubSub node schema
-
-        @param schema(domish.Element, None): schema to set
-            None if schema need to be removed
-        """
-        iq_elt = client.IQ()
-        if service is not None:
-            iq_elt["to"] = service.full()
-        pubsub_elt = iq_elt.addElement((NS_SCHEMA, "pubsub"))
-        schema_elt = pubsub_elt.addElement((NS_SCHEMA, "schema"))
-        schema_elt["node"] = nodeIdentifier
-        if schema is not None:
-            schema_elt.addChild(schema)
-        return iq_elt.send()
-
-    def _getDataFormItems(self, form_ns="", service="", node="", schema="", max_items=10,
-                          item_ids=None, sub_id=None, extra_dict=None,
-                          profile_key=C.PROF_KEY_NONE):
-        client = self.host.getClient(profile_key)
-        service = jid.JID(service) if service else None
-        if not node:
-            raise exceptions.DataError(_("empty node is not allowed"))
-        if schema:
-            schema = generic.parseXml(schema.encode("utf-8"))
-        else:
-            schema = None
-        max_items = None if max_items == C.NO_LIMIT else max_items
-        extra = self._p.parseExtra(extra_dict)
-        d = self.getDataFormItems(
-            client,
-            service,
-            node,
-            schema,
-            max_items or None,
-            item_ids,
-            sub_id or None,
-            extra.rsm_request,
-            extra.extra,
-            form_ns=form_ns or None,
-        )
-        d.addCallback(self._p.transItemsData)
-        return d
-
-    @defer.inlineCallbacks
-    def getDataFormItems(self, client, service, nodeIdentifier, schema=None,
-                         max_items=None, item_ids=None, sub_id=None, rsm_request=None,
-                         extra=None, default_node=None, form_ns=None, filters=None):
-        """Get items known as being data forms, and convert them to XMLUI
-
-        @param schema(domish.Element, data_form.Form, None): schema of the node if known
-            if None, it will be retrieved from node
-        @param default_node(unicode): node to use if nodeIdentifier is None or empty
-        @param form_ns (unicode, None): namespace of the form
-            None to accept everything, even if form has no namespace
-        @param filters(dict, None): same as for xml_tools.dataFormResult2XMLUI
-        other parameters as the same as for [getItems]
-        @return (list[unicode]): XMLUI of the forms
-            if an item is invalid (not corresponding to form_ns or not a data_form)
-            it will be skipped
-        @raise ValueError: one argument is invalid
-        """
-        if not nodeIdentifier:
-            if not default_node:
-                raise ValueError(
-                    _("default_node must be set if nodeIdentifier is not set")
-                )
-            nodeIdentifier = default_node
-        # we need the initial form to get options of fields when suitable
-        schema_form = yield self.getSchemaForm(
-            client, service, nodeIdentifier, schema, form_type="result", copy_form=False
-        )
-        items_data = yield self._p.getItems(
-            client,
-            service,
-            nodeIdentifier,
-            max_items,
-            item_ids,
-            sub_id,
-            rsm_request,
-            extra,
-        )
-        items, metadata = items_data
-        items_xmlui = []
-        for item_elt in items:
-            for x_elt in item_elt.elements((data_form.NS_X_DATA, "x")):
-                form = data_form.Form.fromElement(x_elt)
-                if form_ns and form.formNamespace != form_ns:
-                    continue
-                prepend = [
-                    ("label", "id"),
-                    ("text", item_elt["id"], "id"),
-                    ("label", "publisher"),
-                ]
-                try:
-                    publisher = jid.JID(item_elt['publisher'])
-                except (KeyError, jid.InvalidFormat):
-                    pass
-                else:
-                    prepend.append(("jid", publisher, "publisher"))
-                xmlui = xml_tools.dataFormResult2XMLUI(
-                    form,
-                    schema_form,
-                    # FIXME: conflicts with schema (i.e. if "id" or "publisher" already exists)
-                    #        are not checked
-                    prepend=prepend,
-                    filters=filters,
-                    read_only=False,
-                )
-                items_xmlui.append(xmlui)
-                break
-        defer.returnValue((items_xmlui, metadata))
-
-    def _sendDataFormItem(self, service, nodeIdentifier, values, schema=None,
-                          item_id=None, extra=None, profile_key=C.PROF_KEY_NONE):
-        client = self.host.getClient(profile_key)
-        service = None if not service else jid.JID(service)
-        if schema:
-            schema = generic.parseXml(schema.encode("utf-8"))
-        else:
-            schema = None
-        d = self.sendDataFormItem(
-            client,
-            service,
-            nodeIdentifier,
-            values,
-            schema,
-            item_id or None,
-            extra,
-            deserialise=True,
-        )
-        d.addCallback(lambda ret: ret or "")
-        return d
-
-    @defer.inlineCallbacks
-    def sendDataFormItem(self, client, service, nodeIdentifier, values, schema=None,
-                         item_id=None, extra=None, deserialise=False):
-        """Publish an item as a dataform when we know that there is a schema
-
-        @param values(dict[key(unicode), [iterable[object], object]]): values set for the
-            form. If not iterable, will be put in a list.
-        @param schema(domish.Element, data_form.Form, None): data schema
-            None to retrieve data schema from node (need to do a additional XMPP call)
-            Schema is needed to construct data form to publish
-        @param deserialise(bool): if True, data are list of unicode and must be
-            deserialized according to expected type.
-            This is done in this method and not directly in _sendDataFormItem because we
-            need to know the data type which is in the form, not availablable in
-            _sendDataFormItem
-        other parameters as the same as for [self._p.sendItem]
-        @return (unicode): id of the created item
-        """
-        form = yield self.getSchemaForm(
-            client, service, nodeIdentifier, schema, form_type="submit"
-        )
-
-        for name, values_list in values.items():
-            try:
-                field = form.fields[name]
-            except KeyError:
-                log.warning(
-                    _("field {name} doesn't exist, ignoring it").format(name=name)
-                )
-                continue
-            if isinstance(values_list, str) or not isinstance(
-                values_list, Iterable
-            ):
-                values_list = [values_list]
-            if deserialise:
-                if field.fieldType == "boolean":
-                    values_list = [C.bool(v) for v in values_list]
-                elif field.fieldType == "text-multi":
-                    # for text-multi, lines must be put on separate values
-                    values_list = list(
-                        itertools.chain(*[v.splitlines() for v in values_list])
-                    )
-                elif xml_tools.isXHTMLField(field):
-                   values_list = [generic.parseXml(v.encode("utf-8"))
-                                  for v in values_list]
-                elif "jid" in (field.fieldType or ""):
-                    values_list = [jid.JID(v) for v in values_list]
-            if "list" in (field.fieldType or ""):
-                # for lists, we check that given values are allowed in form
-                allowed_values = [o.value for o in field.options]
-                values_list = [v for v in values_list if v in allowed_values]
-                if not values_list:
-                    # if values don't map to allowed values, we use default ones
-                    values_list = field.values
-            elif field.ext_type == 'xml':
-                # FIXME: XML elements are not handled correctly, we need to know if we
-                #        have actual XML/XHTML, or text to escape
-                for idx, value in enumerate(values_list[:]):
-                    if isinstance(value, domish.Element):
-                        if (field.value and (value.name != field.value.name
-                                             or value.uri != field.value.uri)):
-                            # the element is not the one expected in form, so we create the right element
-                            # to wrap the current value
-                            wrapper_elt = domish.Element((field.value.uri, field.value.name))
-                            wrapper_elt.addChild(value)
-                            values_list[idx] = wrapper_elt
-                    else:
-                        # we have to convert the value to a domish.Element
-                        if field.value and field.value.uri == C.NS_XHTML:
-                            div_elt = domish.Element((C.NS_XHTML, 'div'))
-                            div_elt.addContent(str(value))
-                            values_list[idx] = div_elt
-                        else:
-                            # only XHTML fields are handled for now
-                            raise NotImplementedError
-
-            field.values = values_list
-
-        yield self._p.sendItem(
-            client, service, nodeIdentifier, form.toElement(), item_id, extra
-        )
-
-    ## filters ##
-    # filters useful for data form to XMLUI conversion #
-
-    def valueOrPublisherFilter(self, form_xmlui, widget_type, args, kwargs):
-        """Replace missing value by publisher's user part"""
-        if not args[0]:
-            # value is not filled: we use user part of publisher (if we have it)
-            try:
-                publisher = jid.JID(form_xmlui.named_widgets["publisher"].value)
-            except (KeyError, RuntimeError):
-                pass
-            else:
-                args[0] = publisher.user.capitalize()
-        return widget_type, args, kwargs
-
-    def textbox2ListFilter(self, form_xmlui, widget_type, args, kwargs):
-        """Split lines of a textbox in a list
-
-        main use case is using a textbox for labels
-        """
-        if widget_type != "textbox":
-            return widget_type, args, kwargs
-        widget_type = "list"
-        options = [o for o in args.pop(0).split("\n") if o]
-        kwargs = {
-            "options": options,
-            "name": kwargs.get("name"),
-            "styles": ("noselect", "extensible", "reducible"),
-        }
-        return widget_type, args, kwargs
-
-    def dateFilter(self, form_xmlui, widget_type, args, kwargs):
-        """Convert a string with a date to a unix timestamp"""
-        if widget_type != "string" or not args[0]:
-            return widget_type, args, kwargs
-        # we convert XMPP date to timestamp
-        try:
-            args[0] = str(date_utils.date_parse(args[0]))
-        except Exception as e:
-            log.warning(_("Can't parse date field: {msg}").format(msg=e))
-        return widget_type, args, kwargs
-
-    ## Helper methods ##
-
-    def prepareBridgeGet(self, service, node, max_items, sub_id, extra_dict, profile_key):
-        """Parse arguments received from bridge *Get methods and return higher level data
-
-        @return (tuple): (client, service, node, max_items, extra, sub_id) usable for
-            internal methods
-        """
-        client = self.host.getClient(profile_key)
-        service = jid.JID(service) if service else None
-        if not node:
-            node = None
-        max_items = None if max_items == C.NO_LIMIT else max_items
-        if not sub_id:
-            sub_id = None
-        extra = self._p.parseExtra(extra_dict)
-
-        return client, service, node, max_items, extra, sub_id
-
-    def _get(self, service="", node="", max_items=10, item_ids=None, sub_id=None,
-             extra=None, default_node=None, form_ns=None, filters=None,
-             profile_key=C.PROF_KEY_NONE):
-        """Bridge method to retrieve data from node with schema
-
-        this method is a helper so dependant plugins can use it directly
-        when adding *Get methods
-        extra can have the key "labels_as_list" which is a hack to convert
-            labels from textbox to list in XMLUI, which usually render better
-            in final UI.
-        """
-        if filters is None:
-            filters = {}
-        if extra is None:
-            extra = {}
-        # XXX: Q&D way to get list for labels when displaying them, but text when we
-        #      have to modify them
-        if C.bool(extra.get("labels_as_list", C.BOOL_FALSE)):
-            filters = filters.copy()
-            filters["labels"] = self.textbox2ListFilter
-        client, service, node, max_items, extra, sub_id = self.prepareBridgeGet(
-            service, node, max_items, sub_id, extra, profile_key
-        )
-        d = self.getDataFormItems(
-            client,
-            service,
-            node or None,
-            max_items=max_items,
-            item_ids=item_ids,
-            sub_id=sub_id,
-            rsm_request=extra.rsm_request,
-            extra=extra.extra,
-            default_node=default_node,
-            form_ns=form_ns,
-            filters=filters,
-        )
-        d.addCallback(self._p.transItemsData)
-        d.addCallback(lambda data: data_format.serialise(data))
-        return d
-
-    def prepareBridgeSet(self, service, node, schema, item_id, extra, profile_key):
-        """Parse arguments received from bridge *Set methods and return higher level data
-
-        @return (tuple): (client, service, node, schema, item_id, extra) usable for
-            internal methods
-        """
-        client = self.host.getClient(profile_key)
-        service = None if not service else jid.JID(service)
-        if schema:
-            schema = generic.parseXml(schema.encode("utf-8"))
-        else:
-            schema = None
-        extra = data_format.deserialise(extra)
-        return client, service, node or None, schema, item_id or None, extra
-
-    @defer.inlineCallbacks
-    def copyMissingValues(self, client, service, node, item_id, form_ns, values):
-        """Retrieve values existing in original item and missing in update
-
-        Existing item will be retrieve, and values not already specified in values will
-        be filled
-        @param service: same as for [XEP_0060.getItems]
-        @param node: same as for [XEP_0060.getItems]
-        @param item_id(unicode): id of the item to retrieve
-        @param form_ns (unicode, None): namespace of the form
-        @param values(dict): values to fill
-            This dict will be modified *in place* to fill value present in existing
-            item and missing in the dict.
-        """
-        try:
-            # we get previous item
-            items_data = yield self._p.getItems(
-                client, service, node, item_ids=[item_id]
-            )
-            item_elt = items_data[0][0]
-        except Exception as e:
-            log.warning(
-                _("Can't get previous item, update ignored: {reason}").format(
-                    reason=e
-                )
-            )
-        else:
-            # and parse it
-            form = data_form.findForm(item_elt, form_ns)
-            if form is None:
-                log.warning(
-                    _("Can't parse previous item, update ignored: data form not found")
-                )
-            else:
-                for name, field in form.fields.items():
-                    if name not in values:
-                        values[name] = "\n".join(str(v) for v in field.values)
-
-    def _set(self, service, node, values, schema=None, item_id=None, extra=None,
-             default_node=None, form_ns=None, fill_author=True,
-             profile_key=C.PROF_KEY_NONE):
-        """Bridge method to set item in node with schema
-
-        this method is a helper so dependant plugins can use it directly
-        when adding *Set methods
-        """
-        client, service, node, schema, item_id, extra = self.prepareBridgeSet(
-            service, node, schema, item_id, extra
-        )
-        d = defer.ensureDeferred(self.set(
-            client,
-            service,
-            node,
-            values,
-            schema,
-            item_id,
-            extra,
-            deserialise=True,
-            form_ns=form_ns,
-            default_node=default_node,
-            fill_author=fill_author,
-        ))
-        d.addCallback(lambda ret: ret or "")
-        return d
-
-    async def set(
-            self, client, service, node, values, schema, item_id, extra, deserialise,
-            form_ns, default_node=None, fill_author=True):
-        """Set an item in a node with a schema
-
-        This method can be used directly by *Set methods added by dependant plugin
-        @param values(dict[key(unicode), [iterable[object]|object]]): values of the items
-            if value is not iterable, it will be put in a list
-            'created' and 'updated' will be forced to current time:
-                - 'created' is set if item_id is None, i.e. if it's a new ticket
-                - 'updated' is set everytime
-        @param extra(dict, None): same as for [XEP-0060.sendItem] with additional keys:
-            - update(bool): if True, get previous item data to merge with current one
-                if True, item_id must be None
-        @param form_ns (unicode, None): namespace of the form
-            needed when an update is done
-        @param default_node(unicode, None): value to use if node is not set
-        other arguments are same as for [self._s.sendDataFormItem]
-        @return (unicode): id of the created item
-        """
-        if extra is None:
-            extra = {}
-        if not node:
-            if default_node is None:
-                raise ValueError(_("default_node must be set if node is not set"))
-            node = default_node
-        now = utils.xmpp_date()
-        if not item_id:
-            values["created"] = now
-        elif extra.get("update", False):
-            if item_id is None:
-                raise exceptions.DataError(
-                    _('if extra["update"] is set, item_id must be set too')
-                )
-            await self.copyMissingValues(client, service, node, item_id, form_ns, values)
-
-        values["updated"] = now
-        if fill_author:
-            if not values.get("author"):
-                id_data = await self._i.getIdentity(client, None, ["nicknames"])
-                values["author"] = id_data['nicknames'][0]
-            if not values.get("author_jid"):
-                values["author_jid"] = client.jid.full()
-        item_id = await self.sendDataFormItem(
-            client, service, node, values, schema, item_id, extra, deserialise
-        )
-        return item_id
-
-
-@implementer(iwokkel.IDisco)
-class SchemaHandler(XMPPHandler):
-
-    def getDiscoInfo(self, requestor, service, nodeIdentifier=""):
-        return [disco.DiscoFeature(NS_SCHEMA)]
-
-    def getDiscoItems(self, requestor, service, nodeIdentifier=""):
-        return []
--- a/sat/plugins/plugin_misc_merge_requests.py	Fri Dec 11 17:50:53 2020 +0100
+++ b/sat/plugins/plugin_misc_merge_requests.py	Fri Dec 11 17:57:00 2020 +0100
@@ -29,14 +29,14 @@
 
 log = getLogger(__name__)
 
-NS_MERGE_REQUESTS = 'org.salut-a-toi.merge_requests:0'
+APP_NS_MERGE_REQUESTS = 'org.salut-a-toi.merge_requests:0'
 
 PLUGIN_INFO = {
     C.PI_NAME: _("Merge requests management"),
     C.PI_IMPORT_NAME: "MERGE_REQUESTS",
     C.PI_TYPE: "EXP",
     C.PI_PROTOCOLS: [],
-    C.PI_DEPENDENCIES: ["XEP-0060", "PUBSUB_SCHEMA", "TICKETS", "TEXT_SYNTAXES"],
+    C.PI_DEPENDENCIES: ["XEP-0060", "XEP-0346", "TICKETS", "TEXT_SYNTAXES"],
     C.PI_MAIN: "MergeRequests",
     C.PI_HANDLER: "no",
     C.PI_DESCRIPTION: _("""Merge requests management plugin""")
@@ -68,9 +68,10 @@
     def __init__(self, host):
         log.info(_("Merge requests plugin initialization"))
         self.host = host
-        host.registerNamespace('merge_requests', NS_MERGE_REQUESTS)
+        self._s = self.host.plugins["XEP-0346"]
+        self.namespace = self._s.getSubmittedNS(APP_NS_MERGE_REQUESTS)
+        host.registerNamespace('merge_requests', self.namespace)
         self._p = self.host.plugins["XEP-0060"]
-        self._s = self.host.plugins["PUBSUB_SCHEMA"]
         self._t = self.host.plugins["TICKETS"]
         self._handlers = {}
         self._handlers_list = []  # handlers sorted by priority
@@ -89,7 +90,7 @@
                               method=lambda service, nodeIdentifier, profile_key:
                                 self._s._getUISchema(service,
                                                      nodeIdentifier,
-                                                     default_node=NS_MERGE_REQUESTS,
+                                                     default_node=self.namespace,
                                                      profile_key=profile_key),
                               async_=True)
         host.bridge.addMethod("mergeRequestParseData", ".plugin",
@@ -172,7 +173,7 @@
             - list of parsed request data (if extra['parse'] is set, else empty list)
         """
         if not node:
-            node = NS_MERGE_REQUESTS
+            node = self.namespace
         if extra is None:
             extra = {}
         # XXX: Q&D way to get list for labels when displaying them, but text when we
@@ -181,17 +182,19 @@
             filters = {'labels': self._s.textbox2ListFilter}
         else:
             filters = {}
-        tickets_xmlui, metadata = yield self._s.getDataFormItems(
-            client,
-            service,
-            node,
-            max_items=max_items,
-            item_ids=item_ids,
-            sub_id=sub_id,
-            rsm_request=rsm_request,
-            extra=extra,
-            form_ns=NS_MERGE_REQUESTS,
-            filters = filters)
+        tickets_xmlui, metadata = yield defer.ensureDeferred(
+            self._s.getDataFormItems(
+                client,
+                service,
+                node,
+                max_items=max_items,
+                item_ids=item_ids,
+                sub_id=sub_id,
+                rsm_request=rsm_request,
+                extra=extra,
+                form_ns=APP_NS_MERGE_REQUESTS,
+                filters = filters)
+        )
         parsed_patches = []
         if extra.get('parse', False):
             for ticket in tickets_xmlui:
@@ -228,7 +231,7 @@
         @return (unicode): id of the created item
         """
         if not node:
-            node = NS_MERGE_REQUESTS
+            node = self.namespace
         if values is None:
             values = {}
         update = extra.get('update', False)
@@ -285,7 +288,7 @@
             values[FIELD_DATA] = data
 
         item_id = await self._t.set(client, service, node, values, schema, item_id, extra,
-                                    deserialise, form_ns=NS_MERGE_REQUESTS)
+                                    deserialise, form_ns=APP_NS_MERGE_REQUESTS)
         return item_id
 
     def _parseData(self, data_type, data):
@@ -326,14 +329,16 @@
         @param repository(unicode): path to the repository where the code stands
         """
         if not node:
-            node = NS_MERGE_REQUESTS
-        tickets_xmlui, metadata = yield self._s.getDataFormItems(
-            client,
-            service,
-            node,
-            max_items=1,
-            item_ids=[item],
-            form_ns=NS_MERGE_REQUESTS)
+            node = self.namespace
+        tickets_xmlui, metadata = yield defer.ensureDeferred(
+            self._s.getDataFormItems(
+                client,
+                service,
+                node,
+                max_items=1,
+                item_ids=[item],
+                form_ns=APP_NS_MERGE_REQUESTS)
+        )
         ticket_xmlui = tickets_xmlui[0]
         data = ticket_xmlui.named_widgets[FIELD_DATA].value
         data_type = ticket_xmlui.named_widgets[FIELD_DATA_TYPE].value
--- a/sat/plugins/plugin_misc_tickets.py	Fri Dec 11 17:50:53 2020 +0100
+++ b/sat/plugins/plugin_misc_tickets.py	Fri Dec 11 17:57:00 2020 +0100
@@ -1,6 +1,5 @@
 #!/usr/bin/env python3
 
-# SAT plugin for Pubsub Schemas
 # Copyright (C) 2009-2020 Jérôme Poisson (goffi@goffi.org)
 
 # This program is free software: you can redistribute it and/or modify
@@ -25,27 +24,29 @@
 
 log = getLogger(__name__)
 
-NS_TICKETS = "org.salut-a-toi.tickets:0"
+APP_NS_TICKETS = "org.salut-a-toi.tickets:0"
 
 PLUGIN_INFO = {
     C.PI_NAME: _("Tickets management"),
     C.PI_IMPORT_NAME: "TICKETS",
     C.PI_TYPE: "EXP",
     C.PI_PROTOCOLS: [],
-    C.PI_DEPENDENCIES: ["XEP-0060", "PUBSUB_SCHEMA", "XEP-0277", "IDENTITY"],
+    C.PI_DEPENDENCIES: ["XEP-0060", "XEP-0346", "XEP-0277", "IDENTITY"],
     C.PI_MAIN: "Tickets",
     C.PI_HANDLER: "no",
     C.PI_DESCRIPTION: _("""Tickets management plugin"""),
 }
 
 
-class Tickets(object):
+class Tickets:
+
     def __init__(self, host):
         log.info(_("Tickets plugin initialization"))
         self.host = host
-        host.registerNamespace("tickets", NS_TICKETS)
+        self._s = self.host.plugins["XEP-0346"]
+        self.namespace = self._s.getSubmittedNS(APP_NS_TICKETS)
+        host.registerNamespace("tickets", self.namespace)
         self._p = self.host.plugins["XEP-0060"]
-        self._s = self.host.plugins["PUBSUB_SCHEMA"]
         self._m = self.host.plugins["XEP-0277"]
         host.bridge.addMethod(
             "ticketsGet",
@@ -60,8 +61,8 @@
                 items_ids,
                 sub_id,
                 extra,
-                default_node=NS_TICKETS,
-                form_ns=NS_TICKETS,
+                default_node=self.namespace,
+                form_ns=APP_NS_TICKETS,
                 filters={
                     "author": self._s.valueOrPublisherFilter,
                     "created": self._s.dateFilter,
@@ -84,7 +85,7 @@
             in_sign="sss",
             out_sign="s",
             method=lambda service, nodeIdentifier, profile_key: self._s._getUISchema(
-                service, nodeIdentifier, default_node=NS_TICKETS,
+                service, nodeIdentifier, default_node=self.namespace,
                 profile_key=profile_key),
             async_=True,
         )
@@ -101,7 +102,7 @@
         return d
 
     async def set(self, client, service, node, values, schema=None, item_id=None, extra=None,
-            deserialise=False, form_ns=NS_TICKETS):
+            deserialise=False, form_ns=APP_NS_TICKETS):
         """Publish a tickets
 
         @param node(unicode, None): Pubsub node to use
@@ -119,7 +120,7 @@
         @return (unicode): id of the created item
         """
         if not node:
-            node = NS_TICKETS
+            node = self.namespace
 
         if not item_id:
             comments_service = await self._m.getCommentsService(client, service)
--- a/sat/plugins/plugin_tickets_import.py	Fri Dec 11 17:50:53 2020 +0100
+++ b/sat/plugins/plugin_tickets_import.py	Fri Dec 11 17:57:00 2020 +0100
@@ -32,7 +32,7 @@
     C.PI_NAME: "tickets import",
     C.PI_IMPORT_NAME: "TICKETS_IMPORT",
     C.PI_TYPE: C.PLUG_TYPE_IMPORT,
-    C.PI_DEPENDENCIES: ["IMPORT", "XEP-0060", "XEP-0277", "PUBSUB_SCHEMA"],
+    C.PI_DEPENDENCIES: ["IMPORT", "XEP-0060", "XEP-0277", "XEP-0346"],
     C.PI_MAIN: "TicketsImportPlugin",
     C.PI_HANDLER: "no",
     C.PI_DESCRIPTION: _(
@@ -45,7 +45,7 @@
 FIELDS_LIST = ("labels", "cc_emails")  # fields which must have a list as value
 FIELDS_DATE = ("created", "updated")
 
-NS_TICKETS = "org.salut-a-toi.tickets:0"
+NS_TICKETS = "fdp/submitted/org.salut-a-toi.tickets:0"
 
 
 class TicketsImportPlugin(object):
@@ -59,7 +59,7 @@
         self._importers = {}
         self._p = host.plugins["XEP-0060"]
         self._m = host.plugins["XEP-0277"]
-        self._s = host.plugins["PUBSUB_SCHEMA"]
+        self._s = host.plugins["XEP-0346"]
         host.plugins["IMPORT"].initialize(self, "tickets")
 
     @defer.inlineCallbacks
@@ -161,8 +161,10 @@
                 id=id_, title=ticket_data.get("title", "")
             )
         )
-        return self._s.sendDataFormItem(
-            client, service, node, ticket_data, session["schema"], id_
+        return defer.ensureDeferred(
+            self._s.sendDataFormItem(
+                client, service, node, ticket_data, session["schema"], id_
+            )
         )
 
     def itemFilters(self, client, ticket_data, session, options):
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/sat/plugins/plugin_xep_0346.py	Fri Dec 11 17:57:00 2020 +0100
@@ -0,0 +1,692 @@
+#!/usr/bin/env python3
+
+# SàT plugin for XEP-0346
+# Copyright (C) 2009-2020 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from collections import Iterable
+import itertools
+from zope.interface import implementer
+from twisted.words.protocols.jabber import jid
+from twisted.words.protocols.jabber.xmlstream import XMPPHandler
+from twisted.words.xish import domish
+from twisted.internet import defer
+from wokkel import disco, iwokkel
+from wokkel import data_form
+from wokkel import generic
+from sat.core.i18n import _
+from sat.core import exceptions
+from sat.core.constants import Const as C
+from sat.tools import xml_tools
+from sat.tools import utils
+from sat.tools.common import date_utils
+from sat.tools.common import data_format
+from sat.core.log import getLogger
+
+log = getLogger(__name__)
+
+NS_FDP = "urn:xmpp:fdp:0"
+TEMPLATE_PREFIX = "fdp/template/"
+SUBMITTED_PREFIX = "fdp/submitted/"
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Form Discovery and Publishing",
+    C.PI_IMPORT_NAME: "XEP-0346",
+    C.PI_TYPE: "EXP",
+    C.PI_PROTOCOLS: [],
+    C.PI_DEPENDENCIES: ["XEP-0060", "IDENTITY"],
+    C.PI_MAIN: "PubsubSchema",
+    C.PI_HANDLER: "yes",
+    C.PI_DESCRIPTION: _("""Handle Pubsub data schemas"""),
+}
+
+
+class PubsubSchema(object):
+    def __init__(self, host):
+        log.info(_("PubSub Schema initialization"))
+        self.host = host
+        self._p = self.host.plugins["XEP-0060"]
+        self._i = self.host.plugins["IDENTITY"]
+        host.bridge.addMethod(
+            "psSchemaGet",
+            ".plugin",
+            in_sign="sss",
+            out_sign="s",
+            method=self._getSchema,
+            async_=True,
+        )
+        host.bridge.addMethod(
+            "psSchemaSet",
+            ".plugin",
+            in_sign="ssss",
+            out_sign="",
+            method=self._setSchema,
+            async_=True,
+        )
+        host.bridge.addMethod(
+            "psSchemaUIGet",
+            ".plugin",
+            in_sign="sss",
+            out_sign="s",
+            method=lambda service, nodeIdentifier, profile_key: self._getUISchema(
+                service, nodeIdentifier, default_node=None, profile_key=profile_key),
+            async_=True,
+        )
+        host.bridge.addMethod(
+            "psItemsFormGet",
+            ".plugin",
+            in_sign="ssssiassa{ss}s",
+            out_sign="(asa{ss})",
+            method=self._getDataFormItems,
+            async_=True,
+        )
+        host.bridge.addMethod(
+            "psItemFormSend",
+            ".plugin",
+            in_sign="ssa{sas}ssa{ss}s",
+            out_sign="s",
+            method=self._sendDataFormItem,
+            async_=True,
+        )
+
+    def getHandler(self, client):
+        return SchemaHandler()
+
+    def getApplicationNS(self, namespace):
+        """Retrieve application namespace, i.e. namespace without FDP prefix"""
+        if namespace.startswith(SUBMITTED_PREFIX):
+            namespace = namespace[len(SUBMITTED_PREFIX):]
+        elif namespace.startswith(TEMPLATE_PREFIX):
+            namespace = namespace[len(TEMPLATE_PREFIX):]
+        return namespace
+
+    def getSubmittedNS(self, app_ns: str) -> str:
+        """Returns node to use to submit forms"""
+        return f"{SUBMITTED_PREFIX}{app_ns}"
+
+    def _getSchemaBridgeCb(self, schema_elt):
+        if schema_elt is None:
+            return ""
+        return schema_elt.toXml()
+
+    def _getSchema(self, service, nodeIdentifier, profile_key=C.PROF_KEY_NONE):
+        client = self.host.getClient(profile_key)
+        service = None if not service else jid.JID(service)
+        d = defer.ensureDeferred(self.getSchema(client, service, nodeIdentifier))
+        d.addCallback(self._getSchemaBridgeCb)
+        return d
+
+    async def getSchema(self, client, service, nodeIdentifier):
+        """retrieve PubSub node schema
+
+        @param service(jid.JID, None): jid of PubSub service
+            None to use our PEP
+        @param nodeIdentifier(unicode): node to get schema from
+        @return (domish.Element, None): schema (<x> element)
+            None if no schema has been set on this node
+        """
+        app_ns = self.getApplicationNS(nodeIdentifier)
+        node_id = f"{TEMPLATE_PREFIX}{app_ns}"
+        items_data = await self._p.getItems(client, service, node_id, max_items=1)
+        try:
+            schema = next(items_data[0][0].elements(data_form.NS_X_DATA, 'x'))
+        except IndexError:
+            schema = None
+        except StopIteration:
+            log.warning(
+                f"No schema found in item of {service!r} at node {nodeIdentifier!r}: "
+                f"\n{items_data[0][0].toXml()}"
+            )
+            schema = None
+        return schema
+
+    async def getSchemaForm(self, client, service, nodeIdentifier, schema=None,
+                      form_type="form", copy_form=True):
+        """Get data form from node's schema
+
+        @param service(None, jid.JID): PubSub service
+        @param nodeIdentifier(unicode): node
+        @param schema(domish.Element, data_form.Form, None): node schema
+            if domish.Element, will be converted to data form
+            if data_form.Form it will be returned without modification
+            if None, it will be retrieved from node (imply one additional XMPP request)
+        @param form_type(unicode): type of the form
+        @param copy_form(bool): if True and if schema is already a data_form.Form, will deep copy it before returning
+            needed when the form is reused and it will be modified (e.g. in sendDataFormItem)
+        @return(data_form.Form): data form
+            the form should not be modified if copy_form is not set
+        """
+        if schema is None:
+            log.debug(_("unspecified schema, we need to request it"))
+            schema = await self.getSchema(client, service, nodeIdentifier)
+            if schema is None:
+                raise exceptions.DataError(
+                    _(
+                        "no schema specified, and this node has no schema either, we can't construct the data form"
+                    )
+                )
+        elif isinstance(schema, data_form.Form):
+            if copy_form:
+                # XXX: we don't use deepcopy as it will do an infinite loop if a
+                #      domish.Element is present in the form fields (happens for
+                #      XEP-0315 data forms XML Element)
+                schema = data_form.Form(
+                    formType = schema.formType,
+                    title = schema.title,
+                    instructions = schema.instructions[:],
+                    formNamespace = schema.formNamespace,
+                    fields = schema.fieldList,
+                )
+            return schema
+
+        try:
+            form = data_form.Form.fromElement(schema)
+        except data_form.Error as e:
+            raise exceptions.DataError(_("Invalid Schema: {msg}").format(msg=e))
+        form.formType = form_type
+        return form
+
+    def schema2XMLUI(self, schema_elt):
+        form = data_form.Form.fromElement(schema_elt)
+        xmlui = xml_tools.dataForm2XMLUI(form, "")
+        return xmlui
+
+    def _getUISchema(self, service, nodeIdentifier, default_node=None,
+                     profile_key=C.PROF_KEY_NONE):
+        if not nodeIdentifier:
+            if not default_node:
+                raise ValueError(_("nodeIndentifier needs to be set"))
+            nodeIdentifier = default_node
+        client = self.host.getClient(profile_key)
+        service = None if not service else jid.JID(service)
+        d = self.getUISchema(client, service, nodeIdentifier)
+        d.addCallback(lambda xmlui: xmlui.toXml())
+        return d
+
+    def getUISchema(self, client, service, nodeIdentifier):
+        d = defer.ensureDeferred(self.getSchema(client, service, nodeIdentifier))
+        d.addCallback(self.schema2XMLUI)
+        return d
+
+    def _setSchema(self, service, nodeIdentifier, schema, profile_key=C.PROF_KEY_NONE):
+        client = self.host.getClient(profile_key)
+        service = None if not service else jid.JID(service)
+        schema = generic.parseXml(schema.encode())
+        return defer.ensureDeferred(
+            self.setSchema(client, service, nodeIdentifier, schema)
+        )
+
+    async def setSchema(self, client, service, nodeIdentifier, schema):
+        """Set or replace PubSub node schema
+
+        @param schema(domish.Element, None): schema to set
+            None if schema need to be removed
+        """
+        app_ns = self.getApplicationNS(nodeIdentifier)
+        node_id = f"{TEMPLATE_PREFIX}{app_ns}"
+        node_options = {
+            self._p.OPT_ACCESS_MODEL: self._p.ACCESS_OPEN,
+            self._p.OPT_PERSIST_ITEMS: 1,
+            self._p.OPT_MAX_ITEMS: 1,
+            self._p.OPT_DELIVER_PAYLOADS: 1,
+            self._p.OPT_SEND_ITEM_SUBSCRIBE: 1,
+            self._p.OPT_PUBLISH_MODEL: self._p.PUBLISH_MODEL_PUBLISHERS,
+        }
+        await self._p.createIfNewNode(client, service, node_id, node_options)
+        await self._p.sendItem(client, service, node_id, schema, self._p.ID_SINGLETON)
+
+    def _getDataFormItems(self, form_ns="", service="", node="", schema="", max_items=10,
+                          item_ids=None, sub_id=None, extra_dict=None,
+                          profile_key=C.PROF_KEY_NONE):
+        client = self.host.getClient(profile_key)
+        service = jid.JID(service) if service else None
+        if not node:
+            raise exceptions.DataError(_("empty node is not allowed"))
+        if schema:
+            schema = generic.parseXml(schema.encode("utf-8"))
+        else:
+            schema = None
+        max_items = None if max_items == C.NO_LIMIT else max_items
+        extra = self._p.parseExtra(extra_dict)
+        d = defer.ensureDeferred(
+            self.getDataFormItems(
+                client,
+                service,
+                node,
+                schema,
+                max_items or None,
+                item_ids,
+                sub_id or None,
+                extra.rsm_request,
+                extra.extra,
+                form_ns=form_ns or None,
+            )
+        )
+        d.addCallback(self._p.transItemsData)
+        return d
+
+    async def getDataFormItems(self, client, service, nodeIdentifier, schema=None,
+                         max_items=None, item_ids=None, sub_id=None, rsm_request=None,
+                         extra=None, default_node=None, form_ns=None, filters=None):
+        """Get items known as being data forms, and convert them to XMLUI
+
+        @param schema(domish.Element, data_form.Form, None): schema of the node if known
+            if None, it will be retrieved from node
+        @param default_node(unicode): node to use if nodeIdentifier is None or empty
+        @param form_ns (unicode, None): namespace of the form
+            None to accept everything, even if form has no namespace
+        @param filters(dict, None): same as for xml_tools.dataFormResult2XMLUI
+        other parameters as the same as for [getItems]
+        @return (list[unicode]): XMLUI of the forms
+            if an item is invalid (not corresponding to form_ns or not a data_form)
+            it will be skipped
+        @raise ValueError: one argument is invalid
+        """
+        if not nodeIdentifier:
+            if not default_node:
+                raise ValueError(
+                    _("default_node must be set if nodeIdentifier is not set")
+                )
+            nodeIdentifier = default_node
+        # we need the initial form to get options of fields when suitable
+        schema_form = await self.getSchemaForm(
+            client, service, nodeIdentifier, schema, form_type="result", copy_form=False
+        )
+        items_data = await self._p.getItems(
+            client,
+            service,
+            nodeIdentifier,
+            max_items,
+            item_ids,
+            sub_id,
+            rsm_request,
+            extra,
+        )
+        items, metadata = items_data
+        items_xmlui = []
+        for item_elt in items:
+            for x_elt in item_elt.elements((data_form.NS_X_DATA, "x")):
+                form = data_form.Form.fromElement(x_elt)
+                if form_ns and form.formNamespace != form_ns:
+                    continue
+                prepend = [
+                    ("label", "id"),
+                    ("text", item_elt["id"], "id"),
+                    ("label", "publisher"),
+                ]
+                try:
+                    publisher = jid.JID(item_elt['publisher'])
+                except (KeyError, jid.InvalidFormat):
+                    pass
+                else:
+                    prepend.append(("jid", publisher, "publisher"))
+                xmlui = xml_tools.dataFormResult2XMLUI(
+                    form,
+                    schema_form,
+                    # FIXME: conflicts with schema (i.e. if "id" or "publisher" already exists)
+                    #        are not checked
+                    prepend=prepend,
+                    filters=filters,
+                    read_only=False,
+                )
+                items_xmlui.append(xmlui)
+                break
+        return (items_xmlui, metadata)
+
+    def _sendDataFormItem(self, service, nodeIdentifier, values, schema=None,
+                          item_id=None, extra=None, profile_key=C.PROF_KEY_NONE):
+        client = self.host.getClient(profile_key)
+        service = None if not service else jid.JID(service)
+        if schema:
+            schema = generic.parseXml(schema.encode("utf-8"))
+        else:
+            schema = None
+        d = defer.ensureDeferred(
+            self.sendDataFormItem(
+                client,
+                service,
+                nodeIdentifier,
+                values,
+                schema,
+                item_id or None,
+                extra,
+                deserialise=True,
+            )
+        )
+        d.addCallback(lambda ret: ret or "")
+        return d
+
+    async def sendDataFormItem(
+        self, client, service, nodeIdentifier, values, schema=None, item_id=None,
+        extra=None, deserialise=False):
+        """Publish an item as a dataform when we know that there is a schema
+
+        @param values(dict[key(unicode), [iterable[object], object]]): values set for the
+            form. If not iterable, will be put in a list.
+        @param schema(domish.Element, data_form.Form, None): data schema
+            None to retrieve data schema from node (need to do a additional XMPP call)
+            Schema is needed to construct data form to publish
+        @param deserialise(bool): if True, data are list of unicode and must be
+            deserialized according to expected type.
+            This is done in this method and not directly in _sendDataFormItem because we
+            need to know the data type which is in the form, not availablable in
+            _sendDataFormItem
+        other parameters as the same as for [self._p.sendItem]
+        @return (unicode): id of the created item
+        """
+        form = await self.getSchemaForm(
+            client, service, nodeIdentifier, schema, form_type="submit"
+        )
+
+        for name, values_list in values.items():
+            try:
+                field = form.fields[name]
+            except KeyError:
+                log.warning(
+                    _("field {name} doesn't exist, ignoring it").format(name=name)
+                )
+                continue
+            if isinstance(values_list, str) or not isinstance(
+                values_list, Iterable
+            ):
+                values_list = [values_list]
+            if deserialise:
+                if field.fieldType == "boolean":
+                    values_list = [C.bool(v) for v in values_list]
+                elif field.fieldType == "text-multi":
+                    # for text-multi, lines must be put on separate values
+                    values_list = list(
+                        itertools.chain(*[v.splitlines() for v in values_list])
+                    )
+                elif xml_tools.isXHTMLField(field):
+                   values_list = [generic.parseXml(v.encode("utf-8"))
+                                  for v in values_list]
+                elif "jid" in (field.fieldType or ""):
+                    values_list = [jid.JID(v) for v in values_list]
+            if "list" in (field.fieldType or ""):
+                # for lists, we check that given values are allowed in form
+                allowed_values = [o.value for o in field.options]
+                values_list = [v for v in values_list if v in allowed_values]
+                if not values_list:
+                    # if values don't map to allowed values, we use default ones
+                    values_list = field.values
+            elif field.ext_type == 'xml':
+                # FIXME: XML elements are not handled correctly, we need to know if we
+                #        have actual XML/XHTML, or text to escape
+                for idx, value in enumerate(values_list[:]):
+                    if isinstance(value, domish.Element):
+                        if (field.value and (value.name != field.value.name
+                                             or value.uri != field.value.uri)):
+                            # the element is not the one expected in form, so we create the right element
+                            # to wrap the current value
+                            wrapper_elt = domish.Element((field.value.uri, field.value.name))
+                            wrapper_elt.addChild(value)
+                            values_list[idx] = wrapper_elt
+                    else:
+                        # we have to convert the value to a domish.Element
+                        if field.value and field.value.uri == C.NS_XHTML:
+                            div_elt = domish.Element((C.NS_XHTML, 'div'))
+                            div_elt.addContent(str(value))
+                            values_list[idx] = div_elt
+                        else:
+                            # only XHTML fields are handled for now
+                            raise NotImplementedError
+
+            field.values = values_list
+
+        await self._p.sendItem(
+            client, service, nodeIdentifier, form.toElement(), item_id, extra
+        )
+
+    ## filters ##
+    # filters useful for data form to XMLUI conversion #
+
+    def valueOrPublisherFilter(self, form_xmlui, widget_type, args, kwargs):
+        """Replace missing value by publisher's user part"""
+        if not args[0]:
+            # value is not filled: we use user part of publisher (if we have it)
+            try:
+                publisher = jid.JID(form_xmlui.named_widgets["publisher"].value)
+            except (KeyError, RuntimeError):
+                pass
+            else:
+                args[0] = publisher.user.capitalize()
+        return widget_type, args, kwargs
+
+    def textbox2ListFilter(self, form_xmlui, widget_type, args, kwargs):
+        """Split lines of a textbox in a list
+
+        main use case is using a textbox for labels
+        """
+        if widget_type != "textbox":
+            return widget_type, args, kwargs
+        widget_type = "list"
+        options = [o for o in args.pop(0).split("\n") if o]
+        kwargs = {
+            "options": options,
+            "name": kwargs.get("name"),
+            "styles": ("noselect", "extensible", "reducible"),
+        }
+        return widget_type, args, kwargs
+
+    def dateFilter(self, form_xmlui, widget_type, args, kwargs):
+        """Convert a string with a date to a unix timestamp"""
+        if widget_type != "string" or not args[0]:
+            return widget_type, args, kwargs
+        # we convert XMPP date to timestamp
+        try:
+            args[0] = str(date_utils.date_parse(args[0]))
+        except Exception as e:
+            log.warning(_("Can't parse date field: {msg}").format(msg=e))
+        return widget_type, args, kwargs
+
+    ## Helper methods ##
+
+    def prepareBridgeGet(self, service, node, max_items, sub_id, extra_dict, profile_key):
+        """Parse arguments received from bridge *Get methods and return higher level data
+
+        @return (tuple): (client, service, node, max_items, extra, sub_id) usable for
+            internal methods
+        """
+        client = self.host.getClient(profile_key)
+        service = jid.JID(service) if service else None
+        if not node:
+            node = None
+        max_items = None if max_items == C.NO_LIMIT else max_items
+        if not sub_id:
+            sub_id = None
+        extra = self._p.parseExtra(extra_dict)
+
+        return client, service, node, max_items, extra, sub_id
+
+    def _get(self, service="", node="", max_items=10, item_ids=None, sub_id=None,
+             extra=None, default_node=None, form_ns=None, filters=None,
+             profile_key=C.PROF_KEY_NONE):
+        """Bridge method to retrieve data from node with schema
+
+        this method is a helper so dependant plugins can use it directly
+        when adding *Get methods
+        extra can have the key "labels_as_list" which is a hack to convert
+            labels from textbox to list in XMLUI, which usually render better
+            in final UI.
+        """
+        if filters is None:
+            filters = {}
+        if extra is None:
+            extra = {}
+        # XXX: Q&D way to get list for labels when displaying them, but text when we
+        #      have to modify them
+        if C.bool(extra.get("labels_as_list", C.BOOL_FALSE)):
+            filters = filters.copy()
+            filters["labels"] = self.textbox2ListFilter
+        client, service, node, max_items, extra, sub_id = self.prepareBridgeGet(
+            service, node, max_items, sub_id, extra, profile_key
+        )
+        d = defer.ensureDeferred(
+            self.getDataFormItems(
+                client,
+                service,
+                node or None,
+                max_items=max_items,
+                item_ids=item_ids,
+                sub_id=sub_id,
+                rsm_request=extra.rsm_request,
+                extra=extra.extra,
+                default_node=default_node,
+                form_ns=form_ns,
+                filters=filters,
+            )
+        )
+        d.addCallback(self._p.transItemsData)
+        d.addCallback(lambda data: data_format.serialise(data))
+        return d
+
+    def prepareBridgeSet(self, service, node, schema, item_id, extra, profile_key):
+        """Parse arguments received from bridge *Set methods and return higher level data
+
+        @return (tuple): (client, service, node, schema, item_id, extra) usable for
+            internal methods
+        """
+        client = self.host.getClient(profile_key)
+        service = None if not service else jid.JID(service)
+        if schema:
+            schema = generic.parseXml(schema.encode("utf-8"))
+        else:
+            schema = None
+        extra = data_format.deserialise(extra)
+        return client, service, node or None, schema, item_id or None, extra
+
+    @defer.inlineCallbacks
+    def copyMissingValues(self, client, service, node, item_id, form_ns, values):
+        """Retrieve values existing in original item and missing in update
+
+        Existing item will be retrieve, and values not already specified in values will
+        be filled
+        @param service: same as for [XEP_0060.getItems]
+        @param node: same as for [XEP_0060.getItems]
+        @param item_id(unicode): id of the item to retrieve
+        @param form_ns (unicode, None): namespace of the form
+        @param values(dict): values to fill
+            This dict will be modified *in place* to fill value present in existing
+            item and missing in the dict.
+        """
+        try:
+            # we get previous item
+            items_data = yield self._p.getItems(
+                client, service, node, item_ids=[item_id]
+            )
+            item_elt = items_data[0][0]
+        except Exception as e:
+            log.warning(
+                _("Can't get previous item, update ignored: {reason}").format(
+                    reason=e
+                )
+            )
+        else:
+            # and parse it
+            form = data_form.findForm(item_elt, form_ns)
+            if form is None:
+                log.warning(
+                    _("Can't parse previous item, update ignored: data form not found")
+                )
+            else:
+                for name, field in form.fields.items():
+                    if name not in values:
+                        values[name] = "\n".join(str(v) for v in field.values)
+
+    def _set(self, service, node, values, schema=None, item_id=None, extra=None,
+             default_node=None, form_ns=None, fill_author=True,
+             profile_key=C.PROF_KEY_NONE):
+        """Bridge method to set item in node with schema
+
+        this method is a helper so dependant plugins can use it directly
+        when adding *Set methods
+        """
+        client, service, node, schema, item_id, extra = self.prepareBridgeSet(
+            service, node, schema, item_id, extra
+        )
+        d = defer.ensureDeferred(self.set(
+            client,
+            service,
+            node,
+            values,
+            schema,
+            item_id,
+            extra,
+            deserialise=True,
+            form_ns=form_ns,
+            default_node=default_node,
+            fill_author=fill_author,
+        ))
+        d.addCallback(lambda ret: ret or "")
+        return d
+
+    async def set(
+            self, client, service, node, values, schema, item_id, extra, deserialise,
+            form_ns, default_node=None, fill_author=True):
+        """Set an item in a node with a schema
+
+        This method can be used directly by *Set methods added by dependant plugin
+        @param values(dict[key(unicode), [iterable[object]|object]]): values of the items
+            if value is not iterable, it will be put in a list
+            'created' and 'updated' will be forced to current time:
+                - 'created' is set if item_id is None, i.e. if it's a new ticket
+                - 'updated' is set everytime
+        @param extra(dict, None): same as for [XEP-0060.sendItem] with additional keys:
+            - update(bool): if True, get previous item data to merge with current one
+                if True, item_id must be None
+        @param form_ns (unicode, None): namespace of the form
+            needed when an update is done
+        @param default_node(unicode, None): value to use if node is not set
+        other arguments are same as for [self._s.sendDataFormItem]
+        @return (unicode): id of the created item
+        """
+        if extra is None:
+            extra = {}
+        if not node:
+            if default_node is None:
+                raise ValueError(_("default_node must be set if node is not set"))
+            node = default_node
+        now = utils.xmpp_date()
+        if not item_id:
+            values["created"] = now
+        elif extra.get("update", False):
+            if item_id is None:
+                raise exceptions.DataError(
+                    _('if extra["update"] is set, item_id must be set too')
+                )
+            await self.copyMissingValues(client, service, node, item_id, form_ns, values)
+
+        values["updated"] = now
+        if fill_author:
+            if not values.get("author"):
+                id_data = await self._i.getIdentity(client, None, ["nicknames"])
+                values["author"] = id_data['nicknames'][0]
+            if not values.get("author_jid"):
+                values["author_jid"] = client.jid.full()
+        item_id = await self.sendDataFormItem(
+            client, service, node, values, schema, item_id, extra, deserialise
+        )
+        return item_id
+
+
+@implementer(iwokkel.IDisco)
+class SchemaHandler(XMPPHandler):
+
+    def getDiscoInfo(self, requestor, service, nodeIdentifier=""):
+        return [disco.DiscoFeature(NS_FDP)]
+
+    def getDiscoItems(self, requestor, service, nodeIdentifier=""):
+        return []