# HG changeset patch # User Goffi # Date 1663792909 -7200 # Node ID 32b38dd3ac18c70e81eea39ab93b0c435997cadb # Parent 43024e50b701aed902f858e7eac31d08e4de7bce plugin events: update following `Events` protoXEP submission: update the plugin to follow the specification proposed at https://github.com/xsf/xeps/pull/1206 Events are internally converted to a dict following a format described in `event_data_2_event_elt` docstring. RSVP mechanism now uses Pubsub Attachments (XEP-0470), with a user extensible data form. Bridge methods signatures have changed. rel 372 diff -r 43024e50b701 -r 32b38dd3ac18 sat/plugins/plugin_exp_events.py --- a/sat/plugins/plugin_exp_events.py Wed Sep 21 22:36:30 2022 +0200 +++ b/sat/plugins/plugin_exp_events.py Wed Sep 21 22:41:49 2022 +0200 @@ -1,8 +1,8 @@ #!/usr/bin/env python3 -# SAT plugin to detect language (experimental) -# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org) +# Libervia plugin to handle events +# Copyright (C) 2009-2022 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 @@ -17,23 +17,31 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from typing import Optional +from random import seed +from typing import Optional, Final, Dict, List, Union, Any, Optional +from attr import attr + import shortuuid +from sqlalchemy.orm.events import event +from build.lib.sat.core.xmpp import SatXMPPClient from sat.core.i18n import _ from sat.core import exceptions from sat.core.constants import Const as C from sat.core.log import getLogger from sat.core.xmpp import SatXMPPEntity +from sat.core.core_types import SatXMPPEntity from sat.tools import utils +from sat.tools import xml_tools from sat.tools.common import uri as xmpp_uri from sat.tools.common import date_utils +from sat.tools.common import data_format from twisted.internet import defer from twisted.words.protocols.jabber import jid, error from twisted.words.xish import domish from wokkel import disco, iwokkel from zope.interface import implementer from twisted.words.protocols.jabber.xmlstream import XMPPHandler -from wokkel import pubsub +from wokkel import pubsub, data_form log = getLogger(__name__) @@ -42,74 +50,77 @@ C.PI_NAME: "Events", C.PI_IMPORT_NAME: "EVENTS", C.PI_TYPE: "EXP", + C.PI_MODES: C.PLUG_MODE_BOTH, C.PI_PROTOCOLS: [], - C.PI_DEPENDENCIES: ["XEP-0060", "INVITATION", "PUBSUB_INVITATION", "LIST_INTEREST"], + C.PI_DEPENDENCIES: [ + "XEP-0060", "XEP-0080", "XEP-0447", "XEP-0470", # "INVITATION", "PUBSUB_INVITATION", + # "LIST_INTEREST" + ], C.PI_RECOMMENDATIONS: ["XEP-0277", "EMAIL_INVITATION"], C.PI_MAIN: "Events", C.PI_HANDLER: "yes", - C.PI_DESCRIPTION: _("""Experimental implementation of XMPP events management"""), + C.PI_DESCRIPTION: _("""XMPP Events Management"""), } NS_EVENT = "org.salut-a-toi.event:0" +NS_EVENTS: Final = "urn:xmpp:events:0" +NS_RSVP: Final = "urn:xmpp:events:rsvp:0" +NS_EXTRA: Final = "urn:xmpp:events:extra:0" -class Events(object): - """Q&D module to handle event attendance answer, experimentation only""" +class Events: + namespace = NS_EVENTS def __init__(self, host): - log.info(_("Event plugin initialization")) + log.info(_("Events plugin initialization")) self.host = host - self._p = self.host.plugins["XEP-0060"] - self._i = self.host.plugins.get("EMAIL_INVITATION") - self._b = self.host.plugins.get("XEP-0277") - self.host.registerNamespace("event", NS_EVENT) - self.host.plugins["PUBSUB_INVITATION"].register(NS_EVENT, self) + self._p = host.plugins["XEP-0060"] + self._g = host.plugins["XEP-0080"] + self._b = host.plugins.get("XEP-0277") + self._sfs = host.plugins["XEP-0447"] + self._a = host.plugins["XEP-0470"] + # self._i = host.plugins.get("EMAIL_INVITATION") + host.registerNamespace("events", NS_EVENTS) + self._a.registerAttachmentHandler("rsvp", NS_EVENTS, self.rsvp_get, self.rsvp_set) + # host.plugins["PUBSUB_INVITATION"].register(NS_EVENTS, self) host.bridge.addMethod( - "eventGet", + "eventsGet", ".plugin", - in_sign="ssss", - out_sign="(ia{ss})", - method=self._eventGet, + in_sign="ssasss", + out_sign="s", + method=self._events_get, async_=True, ) host.bridge.addMethod( "eventCreate", ".plugin", - in_sign="ia{ss}ssss", - out_sign="s", - method=self._eventCreate, + in_sign="sssss", + out_sign="", + method=self._event_create, async_=True, ) host.bridge.addMethod( "eventModify", ".plugin", - in_sign="sssia{ss}s", + in_sign="sssss", out_sign="", - method=self._eventModify, - async_=True, - ) - host.bridge.addMethod( - "eventsList", - ".plugin", - in_sign="sss", - out_sign="aa{ss}", - method=self._eventsList, + method=self._event_modify, async_=True, ) host.bridge.addMethod( "eventInviteeGet", ".plugin", - in_sign="ssss", - out_sign="a{ss}", - method=self._eventInviteeGet, + in_sign="sssasss", + out_sign="s", + method=self._event_invitee_get, async_=True, ) host.bridge.addMethod( "eventInviteeSet", ".plugin", - in_sign="ssa{ss}s", + in_sign="sssss", out_sign="", - method=self._eventInviteeSet, + method=self._event_invitee_set, async_=True, ) host.bridge.addMethod( @@ -212,289 +223,771 @@ data["creator"] = True return timestamp, data - async def getEventElement(self, client, service, node, id_): - """Retrieve event element + def event_elt_2_event_data(self, event_elt: domish.Element) -> Dict[str, Any]: + """Convert element to event data + + @param event_elt: element + parent element can also be used + @raise exceptions.NotFound: can't find event payload + @raise ValueError: something is missing or badly formed + """ + if event_elt.name == "item": + try: + event_elt = next(event_elt.elements(NS_EVENTS, "event")) + except StopIteration: + raise exceptions.NotFound(" payload is missing") + + event_data: Dict[str, Any] = {} + + # id + + parent_elt = event_elt.parent + if parent_elt is not None and parent_elt.hasAttribute("id"): + event_data["id"] = parent_elt["id"] + + # name - @param service(jid.JID): pubsub service - @param node(unicode): pubsub node - @param id_(unicode, None): event id - @return (domish.Element): event element - @raise exceptions.NotFound: no event element found - """ - if not id_: - id_ = NS_EVENT - items, metadata = await self._p.getItems(client, service, node, item_ids=[id_]) + name_data: Dict[str, str] = {} + event_data["name"] = name_data + for name_elt in event_elt.elements(NS_EVENTS, "name"): + lang = name_elt.getAttribute("xml:lang", "") + if lang in name_data: + raise ValueError(" elements don't have distinct xml:lang") + name_data[lang] = str(name_elt) + + if not name_data: + raise exceptions.NotFound(" element is missing") + + # start + try: - event_elt = next(items[0].elements(NS_EVENT, "event")) + start_elt = next(event_elt.elements(NS_EVENTS, "start")) + except StopIteration: + raise exceptions.NotFound(" element is missing") + event_data["start"] = utils.parse_xmpp_date(str(start_elt)) + + # end + + try: + end_elt = next(event_elt.elements(NS_EVENTS, "end")) except StopIteration: - raise exceptions.NotFound(_("No event element has been found")) - except IndexError: - raise exceptions.NotFound(_("No event with this id has been found")) - return event_elt + raise exceptions.NotFound(" element is missing") + event_data["end"] = utils.parse_xmpp_date(str(end_elt)) + + # head-picture + + head_pic_elt = next(event_elt.elements(NS_EVENTS, "head-picture"), None) + if head_pic_elt is not None: + event_data["head-picture"] = self._sfs.parse_file_sharing_elt(head_pic_elt) + + # description + + seen_desc = set() + for description_elt in event_elt.elements(NS_EVENTS, "description"): + lang = description_elt.getAttribute("xml:lang", "") + desc_type = description_elt.getAttribute("type", "text") + lang_type = (lang, desc_type) + if lang_type in seen_desc: + raise ValueError( + " elements don't have distinct xml:lang/type" + ) + seen_desc.add(lang_type) + descriptions = event_data.setdefault("descriptions", []) + description_data = {"description": str(description_elt)} + if lang: + description_data["language"] = lang + if desc_type: + description_data["type"] = desc_type + descriptions.append(description_data) + + # categories + + for category_elt in event_elt.elements(NS_EVENTS, "category"): + try: + category_data = { + "term": category_elt["term"] + } + except KeyError: + log.warning( + " element is missing mandatory term: " + f"{category_elt.toXml()}" + ) + continue + wd = category_elt.getAttribute("wd") + if wd: + category_data["wikidata_id"] = wd + lang = category_elt.getAttribute("xml:lang") + if lang: + category_data["language"] = lang + event_data.setdefault("categories", []).append(category_data) + + # locations + + seen_location_ids = set() + for location_elt in event_elt.elements(NS_EVENTS, "location"): + location_id = location_elt.getAttribute("id", "") + if location_id in seen_location_ids: + raise ValueError(" elements don't have distinct IDs") + seen_location_ids.add(location_id) + location_data = self._g.parse_geoloc_elt(location_elt) + if location_id: + location_data["id"] = location_id + lang = location_elt.getAttribute("xml:lang", "") + if lang: + location_data["language"] = lang + event_data.setdefault("locations", []).append(location_data) + + # RSVPs + + seen_rsvp_lang = set() + for rsvp_elt in event_elt.elements(NS_EVENTS, "rsvp"): + rsvp_lang = rsvp_elt.getAttribute("xml:lang", "") + if rsvp_lang in seen_rsvp_lang: + raise ValueError(" elements don't have distinct xml:lang") + seen_rsvp_lang.add(rsvp_lang) + rsvp_form = data_form.findForm(rsvp_elt, NS_RSVP) + if rsvp_form is None: + log.warning(f"RSVP form is missing: {rsvp_elt.toXml()}") + continue + rsvp_data = xml_tools.dataForm2dataDict(rsvp_form) + if rsvp_lang: + rsvp_data["language"] = rsvp_lang + event_data.setdefault("rsvp", []).append(rsvp_data) + + # linked pubsub nodes - def _eventGet(self, service, node, id_="", profile_key=C.PROF_KEY_NONE): - service = jid.JID(service) if service else None - node = node if node else NS_EVENT + for name in ("invitees", "comments", "blog", "schedule"): + elt = next(event_elt.elements(NS_EVENTS, name), None) + if elt is not None: + try: + event_data[name] = { + "service": elt["jid"], + "node": elt["node"] + } + except KeyError: + log.warning(f"invalid {name} element: {elt.toXml()}") + + # attachments + + attachments_elt = next(event_elt.elements(NS_EVENTS, "attachments"), None) + if attachments_elt: + attachments = event_data["attachments"] = [] + for file_sharing_elt in attachments_elt.elements( + self._sfs.namespace, "file-sharing"): + try: + file_sharing_data = self._sfs.parse_file_sharing_elt(file_sharing_elt) + except Exception as e: + log.warning(f"invalid attachment: {e}\n{file_sharing_elt.toXml()}") + continue + attachments.append(file_sharing_data) + + # extra + + extra_elt = next(event_elt.elements(NS_EVENTS, "extra"), None) + if extra_elt is not None: + extra_form = data_form.findForm(extra_elt, NS_EXTRA) + if extra_form is None: + log.warning(f"extra form is missing: {extra_elt.toXml()}") + else: + extra_data = event_data["extra"] = {} + for name, value in extra_form.items(): + if name.startswith("accessibility:"): + extra_data.setdefault("accessibility", {})[name[14:]] = value + elif name == "accessibility": + log.warning( + 'ignoring "accessibility" key which is not standard: ' + f"{extra_form.toElement().toXml()}" + ) + else: + extra_data[name] = value + + # external + + external_elt = next(event_elt.elements(NS_EVENTS, "external"), None) + if external_elt: + try: + event_data["external"] = { + "jid": external_elt["jid"], + "node": external_elt["node"], + "item": external_elt["item"] + } + except KeyError: + log.warning(f"invalid element: {external_elt.toXml()}") + + return event_data + + def _events_get( + self, service: str, node: str, event_ids: List[str], extra: str, profile_key: str + ): client = self.host.getClient(profile_key) - return defer.ensureDeferred( - self.eventGet(client, service, node, id_) + d = defer.ensureDeferred( + self.events_get( + client, + jid.JID(service) if service else None, + node if node else NS_EVENTS, + event_ids, + data_format.deserialise(extra) + ) ) + d.addCallback(data_format.serialise) + return d - async def eventGet(self, client, service, node, id_=NS_EVENT): + async def events_get( + self, + client: SatXMPPEntity, + service: Optional[jid.JID], + node: str = NS_EVENTS, + events_ids: Optional[List[str]] = None, + extra: Optional[dict] = None, + ) -> List[Dict[str, Any]]: """Retrieve event data - @param service(unicode, None): PubSub service - @param node(unicode): PubSub node of the event - @param id_(unicode): id_ with even data - @return (tuple[int, dict[unicode, unicode]): event data: - - timestamp of the event - - event metadata where key can be: - location: location of the event - image: URL of a picture to use to represent event - background-image: URL of a picture to use in background + @param service: pubsub service + @param node: pubsub node + @param event_id: pubsub item ID + @return: event data: """ - event_elt = await self.getEventElement(client, service, node, id_) + if service is None: + service = client.jid.userhostJID() + items, __ = await self._p.getItems( + client, service, node, item_ids=events_ids, extra=extra + ) + events = [] + for item in items: + try: + events.append(self.event_elt_2_event_data((item))) + except (ValueError, exceptions.NotFound): + log.warning( + f"Can't parse event for item {item['id']}: {item.toXml()}" + ) - return self._parseEventElt(event_elt) + return events - def _eventCreate( - self, timestamp, data, service, node, id_="", profile_key=C.PROF_KEY_NONE + def _event_create( + self, + data_s: str, + service: str, + node: str, + event_id: str = "", + profile_key: str = C.PROF_KEY_NONE ): - service = jid.JID(service) if service else None - node = node or None client = self.host.getClient(profile_key) - data["register"] = C.bool(data.get("register", C.BOOL_FALSE)) return defer.ensureDeferred( - self.eventCreate(client, timestamp, data, service, node, id_ or NS_EVENT) + self.event_create( + client, + data_format.deserialise(data_s), + jid.JID(service) if service else None, + node or None, + event_id or None + ) ) - async def eventCreate(self, client, timestamp, data, service, node=None, event_id=NS_EVENT): + def event_data_2_event_elt(self, event_data: Dict[str, Any]) -> domish.Element: + """Convert Event Data to corresponding Element + + @param event_data: data of the event with keys as follow: + name (dict) + map of language to name + empty string can be used as key if no language is specified + this key is mandatory + start (int|float) + starting time of the event + this key is mandatory + end (int|float) + ending time of the event + this key is mandatory + head-picture(dict) + file sharing data for the main picture to use to represent the event + description(list[dict]) + list of descriptions. If there are several descriptions, they must have + distinct (language, type). + Description data is dict which following keys: + description(str) + the description itself, either in plain text or xhtml + this key is mandatory + language(str) + ISO-639 language code + type(str) + type of the description, either "text" (default) or "xhtml" + categories(list[dict]) + each category is a dict with following keys: + term(str) + human readable short text of the category + this key is mandatory + wikidata_id(str) + Entity ID from WikiData + language(str) + ISO-639 language code + locations(list[dict]) + list of location dict as used in plugin XEP-0080 [get_geoloc_elt]. + If several locations are used, they must have distinct IDs + rsvp(list[dict]) + RSVP data. The dict is a data dict as used in + sat.tools.xml_tools.dataDict2dataForm with some extra keys. + The "attending" key is automatically added if it's not already present, + except if the "no_default" key is present. Thus, an empty dict can be used + to use default RSVP. + If several dict are present in the list, they must have different "lang" + keys. + Following extra key can be used: + language(str) + ISO-639 code for language used in the form + no_default(bool) + if True, the "attending" field won't be automatically added + invitees(dict) + link to pubsub node holding invitees list. + Following keys are mandatory: + service(str) + pubsub service where the node is + node (str) + pubsub node to use + comments(dict) + link to pubsub node holding XEP-0277 comments on the event itself. + Following keys are mandatory: + service(str) + pubsub service where the node is + node (str) + pubsub node to use + blog(dict) + link to pubsub node holding a blog about the event. + Following keys are mandatory: + service(str) + pubsub service where the node is + node (str) + pubsub node to use + schedule(dict) + link to pubsub node holding an events node describing the schedule of this + event. + Following keys are mandatory: + service(str) + pubsub service where the node is + node (str) + pubsub node to use + attachments[list[dict]] + list of file sharing data about all kind of attachments of interest for + the event. + extra(dict) + extra information about the event. + Keys can be: + website(str) + main website about the event + status(str) + status of the event. + Can be one of "confirmed", "tentative" or "cancelled" + languages(list[str]) + ISO-639 codes for languages which will be mainly spoken at the + event + accessibility(dict) + accessibility informations. + Keys can be: + wheelchair + tell if the event is accessible to wheelchair. + Value can be "full", "partial" or "no" + external(dict): + if present, this event is a link to an external one. + Keys (all mandatory) are: + jid: pubsub service + node: pubsub node + item: event id + @return: Event element + @raise ValueError: some expected data were missing or incorrect + """ + event_elt = domish.Element((NS_EVENTS, "event")) + try: + for lang, name in event_data["name"].items(): + name_elt = event_elt.addElement("name", content=name) + if lang: + name_elt["xml:lang"] = lang + except (KeyError, TypeError): + raise ValueError('"name" field is not a dict mapping language to event name') + try: + event_elt.addElement("start", content=utils.xmpp_date(event_data["start"])) + event_elt.addElement("end", content=utils.xmpp_date(event_data["end"])) + except (KeyError, TypeError, ValueError): + raise ValueError('"start" and "end" fields are mandatory') + + if "head-picture" in event_data: + head_pic_data = event_data["head-picture"] + head_picture_elt = event_elt.addElement("head-picture") + head_picture_elt.addChild(self._sfs.get_file_sharing_elt(**head_pic_data)) + + seen_desc = set() + if "descriptions" in event_data: + for desc_data in event_data["descriptions"]: + desc_type = desc_data.get("type", "text") + lang = desc_data.get("language") or "" + lang_type = (lang, desc_type) + if lang_type in seen_desc: + raise ValueError( + '"xml:lang" and "type" is not unique among descriptions: ' + f"{desc_data}" + ) + seen_desc.add(lang_type) + try: + description = desc_data["description"] + except KeyError: + log.warning(f"description is missing in {desc_data!r}") + continue + + if desc_type == "text": + description_elt = event_elt.addElement( + "description", content=description + ) + elif desc_type == "xhtml": + description_elt = event_elt.addElement("description") + div_elt = xml_tools.parse(description, namespace=C.NS_XHTML) + description_elt.addChild(div_elt) + else: + log.warning(f"unknown description type {desc_type!r}") + continue + if lang: + description_elt["xml:lang"] = lang + for category_data in event_data.get("categories", []): + try: + category_term = category_data["term"] + except KeyError: + log.warning(f'"term" is missing categories data: {category_data}') + continue + category_elt = event_elt.addElement("category") + category_elt["term"] = category_term + category_wd = category_data.get("wikidata_id") + if category_wd: + category_elt["wd"] = category_wd + category_lang = category_data.get("language") + if category_lang: + category_elt["xml:lang"] = category_lang + + seen_location_ids = set() + for location_data in event_data.get("locations", []): + location_id = location_data.get("id", "") + if location_id in seen_location_ids: + raise ValueError("locations must have distinct IDs") + seen_location_ids.add(location_id) + location_elt = event_elt.addElement("location") + location_elt.addChild(self._g.get_geoloc_elt(location_data)) + if location_id: + location_elt["id"] = location_id + + rsvp_data_list: Optional[List[dict]] = event_data.get("rsvp") + if rsvp_data_list is not None: + seen_lang = set() + for rsvp_data in rsvp_data_list: + if not rsvp_data: + # we use a minimum data if an empty dict is received. It will be later + # filled with defaut "attending" field. + rsvp_data = {"fields": []} + rsvp_elt = event_elt.addElement("rsvp") + lang = rsvp_data.get("language", "") + if lang in seen_lang: + raise ValueError( + "If several RSVP are specified, they must have distinct " + f"languages. {lang!r} language has been used several times." + ) + seen_lang.add(lang) + if lang: + rsvp_elt["xml:lang"] = lang + if not rsvp_data.get("no_default", False): + try: + next(f for f in rsvp_data["fields"] if f["name"] == "attending") + except StopIteration: + rsvp_data["fields"].append({ + "type": "list-single", + "name": "attending", + "label": "Attending", + "options": [ + {"label": "maybe", "value": "maybe"}, + {"label": "yes", "value": "yes"}, + {"label": "no", "value": "no"} + ], + "required": True + }) + rsvp_data["namespace"] = NS_RSVP + rsvp_form = xml_tools.dataDict2dataForm(rsvp_data) + rsvp_elt.addChild(rsvp_form.toElement()) + + for node_type in ("invitees", "comments", "blog", "schedule"): + node_data = event_data.get(node_type) + if not node_data: + continue + try: + service, node = node_data["service"], node_data["node"] + except KeyError: + log.warning(f"invalid node data for {node_type}: {node_data}") + else: + pub_node_elt = event_elt.addElement(node_type) + pub_node_elt["jid"] = service + pub_node_elt["node"] = node + + attachments = event_data.get("attachments") + if attachments: + attachments_elt = event_elt.addElement("attachments") + for attachment_data in attachments: + attachments_elt.addChild( + self._sfs.get_file_sharing_elt(**attachment_data) + ) + + extra = event_data.get("extra") + if extra: + extra_form = data_form.Form( + "result", + formNamespace=NS_EXTRA + ) + for node_type in ("website", "status"): + if node_type in extra: + extra_form.addField( + data_form.Field(var=node_type, value=extra[node_type]) + ) + if "languages" in extra: + extra_form.addField( + data_form.Field( + "list-multi", var="languages", values=extra["languages"] + ) + ) + for node_type, value in extra.get("accessibility", {}).items(): + extra_form.addField( + data_form.Field(var=f"accessibility:{node_type}", value=value) + ) + + extra_elt = event_elt.addElement("extra") + extra_elt.addChild(extra_form.toElement()) + + if "external" in event_data: + external_data = event_data["external"] + external_elt = event_elt.addElement("external") + for node_type in ("jid", "node", "item"): + try: + value = external_data[node_type] + except KeyError: + raise ValueError(f"Invalid external data: {external_data}") + external_elt[node_type] = value + + return event_elt + + async def event_create( + self, + client: SatXMPPEntity, + event_data: Dict[str, Any], + service: Optional[jid.JID] = None, + node: Optional[str] = None, + event_id: Optional[str] = None, + ) -> None: """Create or replace an event - @param service(jid.JID, None): PubSub service - @param node(unicode, None): PubSub node of the event - None will create instant node. - @param event_id(unicode): ID of the item to create. - @param timestamp(timestamp, None) - @param data(dict[unicode, unicode]): data to update - dict will be cleared, do a copy if data are still needed - key can be: - - name: name of the event - - description: details - - image: main picture of the event - - background-image: image to use as background - - register: bool, True if we want to register the event in our local list - @return (unicode): created node + @param event_data: data of the event (cf. [event_data_2_event_elt]) + @param node: PubSub node of the event + None to use default node (default namespace for personal agenda) + @param service: PubSub service + None to use profile's PEP + @param event_id: ID of the item to create. """ - if not event_id: - raise ValueError(_("event_id must be set")) if not service: service = client.jid.userhostJID() if not node: - node = NS_EVENT + "__" + shortuuid.uuid() - event_elt = domish.Element((NS_EVENT, "event")) - if timestamp is not None and timestamp != -1: - formatted_date = utils.xmpp_date(timestamp) - event_elt.addElement((NS_EVENT, "date"), content=formatted_date) - register = data.pop("register", False) - for key in ("name",): - if key in data: - event_elt[key] = data.pop(key) - for key in ("description",): - if key in data: - event_elt.addElement((NS_EVENT, key), content=data.pop(key)) - for key in ("image", "background-image"): - if key in data: - elt = event_elt.addElement((NS_EVENT, key)) - elt["src"] = data.pop(key) - - # we first create the invitees and blog nodes (if not specified in data) - for uri_type in ("invitees", "blog"): - key = uri_type + "_uri" - for to_delete in ("service", "node"): - k = uri_type + "_" + to_delete - if k in data: - del data[k] - if key not in data: - # FIXME: affiliate invitees - uri_node = await self._p.createNode(client, service) - await self._p.setConfiguration( - client, - service, - uri_node, - {self._p.OPT_ACCESS_MODEL: self._p.ACCESS_WHITELIST}, - ) - uri_service = service - else: - uri = data.pop(key) - uri_data = xmpp_uri.parseXMPPUri(uri) - if uri_data["type"] != "pubsub": - raise ValueError( - _("The given URI is not valid: {uri}").format(uri=uri) - ) - uri_service = jid.JID(uri_data["path"]) - uri_node = uri_data["node"] - - elt = event_elt.addElement((NS_EVENT, uri_type)) - elt["uri"] = xmpp_uri.buildXMPPUri( - "pubsub", path=uri_service.full(), node=uri_node - ) - - # remaining data are put in elements - for key in list(data.keys()): - elt = event_elt.addElement((NS_EVENT, "meta"), content=data.pop(key)) - elt["name"] = key + node = NS_EVENTS + if event_id is None: + event_id = shortuuid.uuid() + event_elt = self.event_data_2_event_elt(event_data) item_elt = pubsub.Item(id=event_id, payload=event_elt) - try: - # TODO: check auto-create, no need to create node first if available - node = await self._p.createNode(client, service, nodeIdentifier=node) - except error.StanzaError as e: - if e.condition == "conflict": - log.debug(_("requested node already exists")) - + options = {self._p.OPT_ACCESS_MODEL: self._p.ACCESS_WHITELIST} + await self._p.createIfNewNode( + client, service, nodeIdentifier=node, options=options + ) await self._p.publish(client, service, node, items=[item_elt]) + if event_data.get("rsvp"): + await self._a.create_attachments_node(client, service, node, event_id) - if register: - extra = {} - self.onInvitationPreflight( - client, "", extra, service, node, event_id, item_elt - ) - await self.host.plugins['LIST_INTEREST'].registerPubsub( - client, NS_EVENT, service, node, event_id, True, - extra.pop("name", ""), extra.pop("element"), extra - ) - return node - - def _eventModify(self, service, node, id_, timestamp_update, data_update, - profile_key=C.PROF_KEY_NONE): - service = jid.JID(service) if service else None - if not node: - raise ValueError(_("missing node")) + def _event_modify( + self, + data_s: str, + event_id: str, + service: str, + node: str, + profile_key: str = C.PROF_KEY_NONE + ) -> None: client = self.host.getClient(profile_key) - return defer.ensureDeferred( - self.eventModify( - client, service, node, id_ or NS_EVENT, timestamp_update or None, - data_update + defer.ensureDeferred( + self.event_modify( + client, + data_format.deserialise(data_s), + event_id, + jid.JID(service) if service else None, + node or None, ) ) - async def eventModify( - self, client, service, node, id_=NS_EVENT, timestamp_update=None, data_update=None - ): + async def event_modify( + self, + client: SatXMPPEntity, + event_data: Dict[str, Any], + event_id: str, + service: Optional[jid.JID] = None, + node: Optional[str] = None, + ) -> None: """Update an event Similar as create instead that it update existing item instead of - creating or replacing it. Params are the same as for [eventCreate]. + creating or replacing it. Params are the same as for [event_create]. """ - event_timestamp, event_metadata = await self.eventGet(client, service, node, id_) - new_timestamp = event_timestamp if timestamp_update is None else timestamp_update - new_data = event_metadata - if data_update: - for k, v in data_update.items(): - new_data[k] = v - await self.eventCreate(client, new_timestamp, new_data, service, node, id_) + if not service: + service = client.jid.userhostJID() + if not node: + node = NS_EVENTS + old_event = (await self.events_get(client, service, node, [event_id]))[0] + old_event.update(event_data) + event_data = old_event + await self.event_create(client, event_data, service, node, event_id) + + def rsvp_get( + self, + client: SatXMPPEntity, + attachments_elt: domish.Element, + data: Dict[str, Any], + ) -> None: + """Get RSVP answers from attachments""" + try: + rsvp_elt = next( + attachments_elt.elements(NS_EVENTS, "rsvp") + ) + except StopIteration: + pass + else: + rsvp_form = data_form.findForm(rsvp_elt, NS_RSVP) + if rsvp_form is not None: + data["rsvp"] = rsvp_data = dict(rsvp_form) + self._a.setTimestamp(rsvp_elt, rsvp_data) - def _eventsListSerialise(self, events): - for timestamp, data in events: - data["date"] = str(timestamp) - data["creator"] = C.boolConst(data.get("creator", False)) - return [e[1] for e in events] + def rsvp_set( + self, + client: SatXMPPEntity, + data: Dict[str, Any], + former_elt: Optional[domish.Element] + ) -> Optional[domish.Element]: + """update the attachment""" + rsvp_data = data["extra"].get("rsvp") + if rsvp_data is None: + return former_elt + elif rsvp_data: + rsvp_elt = domish.Element( + (NS_EVENTS, "rsvp"), + attribs = { + "timestamp": utils.xmpp_date() + } + ) + rsvp_form = data_form.Form("submit", formNamespace=NS_RSVP) + rsvp_form.makeFields(rsvp_data) + rsvp_elt.addChild(rsvp_form.toElement()) + return rsvp_elt + else: + return None - def _eventsList(self, service, node, profile): - service = jid.JID(service) if service else None - node = node or None - client = self.host.getClient(profile) - d = self.eventsList(client, service, node) - d.addCallback(self._eventsListSerialise) + def _event_invitee_get( + self, + service: str, + node: str, + item: str, + invitees_s: List[str], + extra: str, + profile_key: str + ) -> defer.Deferred: + client = self.host.getClient(profile_key) + if invitees_s: + invitees = [jid.JID(i) for i in invitees_s] + else: + invitees = None + d = defer.ensureDeferred( + self.event_invitee_get( + client, + jid.JID(service) if service else None, + node or None, + item, + invitees, + data_format.deserialise(extra) + ) + ) + d.addCallback(lambda ret: data_format.serialise(ret)) return d - @defer.inlineCallbacks - def eventsList(self, client, service, node=None): - """Retrieve list of registered events - - @return list(tuple(int, dict)): list of events (timestamp + metadata) - """ - items, metadata = yield self.host.plugins['LIST_INTEREST'].listInterests( - client, service, node, namespace=NS_EVENT) - events = [] - for item in items: - try: - event_elt = next(item.interest.pubsub.elements(NS_EVENT, "event")) - except StopIteration: - log.warning( - _("No event found in item {item_id}, ignoring").format( - item_id=item["id"]) - ) - else: - timestamp, data = self._parseEventElt(event_elt) - data["interest_id"] = item["id"] - events.append((timestamp, data)) - defer.returnValue(events) - - def _eventInviteeGet(self, service, node, invitee_jid_s, profile_key): - service = jid.JID(service) if service else None - node = node if node else NS_EVENT - client = self.host.getClient(profile_key) - invitee_jid = jid.JID(invitee_jid_s) if invitee_jid_s else None - return defer.ensureDeferred( - self.eventInviteeGet(client, service, node, invitee_jid) - ) - - async def eventInviteeGet(self, client, service, node, invitee_jid=None): + async def event_invitee_get( + self, + client: SatXMPPEntity, + service: Optional[jid.JID], + node: Optional[str], + item: str, + invitees: Optional[List[jid.JID]] = None, + extra: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Dict[str, Any]]: """Retrieve attendance from event node - @param service(unicode, None): PubSub service - @param node(unicode): PubSub node of the event's invitees - @param invitee_jid(jid.JId, None): jid of the invitee to retrieve (None to - retrieve profile's invitation). The bare jid correspond to the PubSub item id. - @return (dict): a dict with current attendance status, + @param service: PubSub service + @param node: PubSub node of the event + @param item: PubSub item of the event + @param invitees: if set, only retrieve RSVPs from those guests + @param extra: extra data used to retrieve items as for [getAttachments] + @return: mapping of invitee bare JID to their RSVP an empty dict is returned if nothing has been answered yed """ - if invitee_jid is None: - invitee_jid = client.jid - try: - items, metadata = await self._p.getItems( - client, service, node, item_ids=[invitee_jid.userhost()] - ) - event_elt = next(items[0].elements(NS_EVENT, "invitee")) - except (exceptions.NotFound, IndexError): - # no item found, event data are not set yet - return {} - data = {} - for key in ("attend", "guests"): + if service is None: + service = client.jid.userhostJID() + if node is None: + node = NS_EVENTS + attachments, metadata = await self._a.getAttachments( + client, service, node, item, invitees, extra + ) + ret = {} + for attachment in attachments: try: - data[key] = event_elt[key] + rsvp = attachment["rsvp"] except KeyError: continue - return data + ret[attachment["from"]] = rsvp + + return ret - def _eventInviteeSet(self, service, node, event_data, profile_key): - service = jid.JID(service) if service else None - node = node if node else NS_EVENT + def _event_invitee_set( + self, + service: str, + node: str, + item: str, + rsvp_s: str, + profile_key: str + ): client = self.host.getClient(profile_key) return defer.ensureDeferred( - self.eventInviteeSet(client, service, node, event_data) + self.event_invitee_set( + client, + jid.JID(service) if service else None, + node or None, + item, + data_format.deserialise(rsvp_s) + ) ) - async def eventInviteeSet(self, client, service, node, data): + async def event_invitee_set( + self, + client: SatXMPPEntity, + service: Optional[jid.JID], + node: Optional[str], + item: str, + rsvp: Dict[str, Any], + ) -> None: """Set or update attendance data in event node - @param service(unicode, None): PubSub service - @param node(unicode): PubSub node of the event - @param data(dict[unicode, unicode]): data to update - key can be: - attend: one of "yes", "no", "maybe" - guests: an int + @param service: PubSub service + @param node: PubSub node of the event + @param item: PubSub item of the event + @param rsvp: RSVP data (values to submit to the form) """ - event_elt = domish.Element((NS_EVENT, "invitee")) - for key in ("attend", "guests"): - try: - event_elt[key] = data.pop(key) - except KeyError: - pass - item_elt = pubsub.Item(id=client.jid.userhost(), payload=event_elt) - return await self._p.publish(client, service, node, items=[item_elt]) + if service is None: + service = client.jid.userhostJID() + if node is None: + node = NS_EVENTS + await self._a.setAttachments(client, { + "service": service.full(), + "node": node, + "id": item, + "extra": {"rsvp": rsvp} + }) def _eventInviteesList(self, service, node, profile_key): service = jid.JID(service) if service else None @@ -549,7 +1042,7 @@ if item_id is None: item_id = extra["default_item_id"] = NS_EVENT - __, event_data = await self.eventGet(client, service, node, item_id) + __, event_data = await self.events_get(client, service, node, item_id) log.debug(_("got event data")) invitees_service = jid.JID(event_data["invitees_service"]) invitees_node = event_data["invitees_node"] @@ -673,7 +1166,7 @@ def getDiscoInfo(self, requestor, target, nodeIdentifier=""): return [ - disco.DiscoFeature(NS_EVENT), + disco.DiscoFeature(NS_EVENTS), ] def getDiscoItems(self, requestor, target, nodeIdentifier=""):