1173 lines
43 KiB
Python
1173 lines
43 KiB
Python
#!/usr/bin/env python3
|
||
|
||
|
||
# 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
|
||
# 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 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 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, data_form
|
||
|
||
log = getLogger(__name__)
|
||
|
||
|
||
PLUGIN_INFO = {
|
||
C.PI_NAME: "Events",
|
||
C.PI_IMPORT_NAME: "XEP-0471",
|
||
C.PI_TYPE: "XEP",
|
||
C.PI_MODES: C.PLUG_MODE_BOTH,
|
||
C.PI_PROTOCOLS: [],
|
||
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: "XEP_0471",
|
||
C.PI_HANDLER: "yes",
|
||
C.PI_DESCRIPTION: _("""Calendar Events"""),
|
||
}
|
||
|
||
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 XEP_0471:
|
||
namespace = NS_EVENTS
|
||
|
||
def __init__(self, host):
|
||
log.info(_("Events plugin initialization"))
|
||
self.host = host
|
||
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.register_attachment_handler("rsvp", NS_EVENTS, self.rsvp_get, self.rsvp_set)
|
||
# host.plugins["PUBSUB_INVITATION"].register(NS_EVENTS, self)
|
||
host.bridge.addMethod(
|
||
"eventsGet",
|
||
".plugin",
|
||
in_sign="ssasss",
|
||
out_sign="s",
|
||
method=self._events_get,
|
||
async_=True,
|
||
)
|
||
host.bridge.addMethod(
|
||
"eventCreate",
|
||
".plugin",
|
||
in_sign="sssss",
|
||
out_sign="",
|
||
method=self._event_create,
|
||
async_=True,
|
||
)
|
||
host.bridge.addMethod(
|
||
"eventModify",
|
||
".plugin",
|
||
in_sign="sssss",
|
||
out_sign="",
|
||
method=self._event_modify,
|
||
async_=True,
|
||
)
|
||
host.bridge.addMethod(
|
||
"eventInviteeGet",
|
||
".plugin",
|
||
in_sign="sssasss",
|
||
out_sign="s",
|
||
method=self._event_invitee_get,
|
||
async_=True,
|
||
)
|
||
host.bridge.addMethod(
|
||
"eventInviteeSet",
|
||
".plugin",
|
||
in_sign="sssss",
|
||
out_sign="",
|
||
method=self._event_invitee_set,
|
||
async_=True,
|
||
)
|
||
host.bridge.addMethod(
|
||
"eventInviteesList",
|
||
".plugin",
|
||
in_sign="sss",
|
||
out_sign="a{sa{ss}}",
|
||
method=self._eventInviteesList,
|
||
async_=True,
|
||
),
|
||
host.bridge.addMethod(
|
||
"eventInvite",
|
||
".plugin",
|
||
in_sign="sssss",
|
||
out_sign="",
|
||
method=self._invite,
|
||
async_=True,
|
||
)
|
||
host.bridge.addMethod(
|
||
"eventInviteByEmail",
|
||
".plugin",
|
||
in_sign="ssssassssssss",
|
||
out_sign="",
|
||
method=self._invite_by_email,
|
||
async_=True,
|
||
)
|
||
|
||
def getHandler(self, client):
|
||
return EventsHandler(self)
|
||
|
||
def _parseEventElt(self, event_elt):
|
||
"""Helper method to parse event element
|
||
|
||
@param (domish.Element): event_elt
|
||
@return (tuple[int, dict[unicode, unicode]): timestamp, event_data
|
||
"""
|
||
try:
|
||
timestamp = date_utils.date_parse(next(event_elt.elements(NS_EVENT, "date")))
|
||
except StopIteration:
|
||
timestamp = -1
|
||
|
||
data = {}
|
||
|
||
for key in ("name",):
|
||
try:
|
||
data[key] = event_elt[key]
|
||
except KeyError:
|
||
continue
|
||
|
||
for elt_name in ("description",):
|
||
try:
|
||
elt = next(event_elt.elements(NS_EVENT, elt_name))
|
||
except StopIteration:
|
||
continue
|
||
else:
|
||
data[elt_name] = str(elt)
|
||
|
||
for elt_name in ("image", "background-image"):
|
||
try:
|
||
image_elt = next(event_elt.elements(NS_EVENT, elt_name))
|
||
data[elt_name] = image_elt["src"]
|
||
except StopIteration:
|
||
continue
|
||
except KeyError:
|
||
log.warning(_("no src found for image"))
|
||
|
||
for uri_type in ("invitees", "blog"):
|
||
try:
|
||
elt = next(event_elt.elements(NS_EVENT, uri_type))
|
||
uri = data[uri_type + "_uri"] = elt["uri"]
|
||
uri_data = xmpp_uri.parseXMPPUri(uri)
|
||
if uri_data["type"] != "pubsub":
|
||
raise ValueError
|
||
except StopIteration:
|
||
log.warning(_("no {uri_type} element found!").format(uri_type=uri_type))
|
||
except KeyError:
|
||
log.warning(_("incomplete {uri_type} element").format(uri_type=uri_type))
|
||
except ValueError:
|
||
log.warning(_("bad {uri_type} element").format(uri_type=uri_type))
|
||
else:
|
||
data[uri_type + "_service"] = uri_data["path"]
|
||
data[uri_type + "_node"] = uri_data["node"]
|
||
|
||
for meta_elt in event_elt.elements(NS_EVENT, "meta"):
|
||
key = meta_elt["name"]
|
||
if key in data:
|
||
log.warning(
|
||
"Ignoring conflicting meta element: {xml}".format(
|
||
xml=meta_elt.toXml()
|
||
)
|
||
)
|
||
continue
|
||
data[key] = str(meta_elt)
|
||
if event_elt.link:
|
||
link_elt = event_elt.link
|
||
data["service"] = link_elt["service"]
|
||
data["node"] = link_elt["node"]
|
||
data["item"] = link_elt["item"]
|
||
if event_elt.getAttribute("creator") == "true":
|
||
data["creator"] = True
|
||
return timestamp, data
|
||
|
||
def event_elt_2_event_data(self, event_elt: domish.Element) -> Dict[str, Any]:
|
||
"""Convert <event/> element to event data
|
||
|
||
@param event_elt: <event/> element
|
||
parent <item/> 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("<event/> 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
|
||
|
||
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("<name/> elements don't have distinct xml:lang")
|
||
name_data[lang] = str(name_elt)
|
||
|
||
if not name_data:
|
||
raise exceptions.NotFound("<name/> element is missing")
|
||
|
||
# start
|
||
|
||
try:
|
||
start_elt = next(event_elt.elements(NS_EVENTS, "start"))
|
||
except StopIteration:
|
||
raise exceptions.NotFound("<start/> 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("<end/> 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(
|
||
"<description/> 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(
|
||
"<category/> 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("<location/> 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("<rsvp/> 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
|
||
|
||
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 <external/> 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)
|
||
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 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: pubsub service
|
||
@param node: pubsub node
|
||
@param event_id: pubsub item ID
|
||
@return: event data:
|
||
"""
|
||
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 events
|
||
|
||
def _event_create(
|
||
self,
|
||
data_s: str,
|
||
service: str,
|
||
node: str,
|
||
event_id: str = "",
|
||
profile_key: str = C.PROF_KEY_NONE
|
||
):
|
||
client = self.host.getClient(profile_key)
|
||
return defer.ensureDeferred(
|
||
self.event_create(
|
||
client,
|
||
data_format.deserialise(data_s),
|
||
jid.JID(service) if service else None,
|
||
node or None,
|
||
event_id or None
|
||
)
|
||
)
|
||
|
||
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 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 service:
|
||
service = client.jid.userhostJID()
|
||
if not node:
|
||
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)
|
||
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)
|
||
|
||
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)
|
||
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 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 [event_create].
|
||
"""
|
||
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 rsvp_set(
|
||
self,
|
||
client: SatXMPPEntity,
|
||
data: Dict[str, Any],
|
||
former_elt: Optional[domish.Element]
|
||
) -> Optional[domish.Element]:
|
||
"""update the <reaction> 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 _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
|
||
|
||
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: 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 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:
|
||
rsvp = attachment["rsvp"]
|
||
except KeyError:
|
||
continue
|
||
ret[attachment["from"]] = rsvp
|
||
|
||
return ret
|
||
|
||
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.event_invitee_set(
|
||
client,
|
||
jid.JID(service) if service else None,
|
||
node or None,
|
||
item,
|
||
data_format.deserialise(rsvp_s)
|
||
)
|
||
)
|
||
|
||
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: 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)
|
||
"""
|
||
if service is None:
|
||
service = client.jid.userhostJID()
|
||
if node is None:
|
||
node = NS_EVENTS
|
||
await self._a.set_attachements(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
|
||
node = node if node else NS_EVENT
|
||
client = self.host.getClient(profile_key)
|
||
return defer.ensureDeferred(
|
||
self.eventInviteesList(client, service, node)
|
||
)
|
||
|
||
async def eventInviteesList(self, client, service, node):
|
||
"""Retrieve attendance from event node
|
||
|
||
@param service(unicode, None): PubSub service
|
||
@param node(unicode): PubSub node of the event
|
||
@return (dict): a dict with current attendance status,
|
||
an empty dict is returned if nothing has been answered yed
|
||
"""
|
||
items, metadata = await self._p.getItems(client, service, node)
|
||
invitees = {}
|
||
for item in items:
|
||
try:
|
||
event_elt = next(item.elements(NS_EVENT, "invitee"))
|
||
except StopIteration:
|
||
# no item found, event data are not set yet
|
||
log.warning(_(
|
||
"no data found for {item_id} (service: {service}, node: {node})"
|
||
.format(item_id=item["id"], service=service, node=node)))
|
||
else:
|
||
data = {}
|
||
for key in ("attend", "guests"):
|
||
try:
|
||
data[key] = event_elt[key]
|
||
except KeyError:
|
||
continue
|
||
invitees[item["id"]] = data
|
||
return invitees
|
||
|
||
async def invitePreflight(
|
||
self,
|
||
client: SatXMPPEntity,
|
||
invitee_jid: jid.JID,
|
||
service: jid.JID,
|
||
node: str,
|
||
item_id: Optional[str] = None,
|
||
name: str = '',
|
||
extra: Optional[dict] = None,
|
||
) -> None:
|
||
if self._b is None:
|
||
raise exceptions.FeatureNotFound(
|
||
_('"XEP-0277" (blog) plugin is needed for this feature')
|
||
)
|
||
if item_id is None:
|
||
item_id = extra["default_item_id"] = NS_EVENT
|
||
|
||
__, 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"]
|
||
blog_service = jid.JID(event_data["blog_service"])
|
||
blog_node = event_data["blog_node"]
|
||
await self._p.setNodeAffiliations(
|
||
client, invitees_service, invitees_node, {invitee_jid: "publisher"}
|
||
)
|
||
log.debug(
|
||
f"affiliation set on invitee node (jid: {invitees_service}, "
|
||
f"node: {invitees_node!r})"
|
||
)
|
||
await self._p.setNodeAffiliations(
|
||
client, blog_service, blog_node, {invitee_jid: "member"}
|
||
)
|
||
blog_items, __ = await self._b.mbGet(client, blog_service, blog_node, None)
|
||
|
||
for item in blog_items:
|
||
try:
|
||
comments_service = jid.JID(item["comments_service"])
|
||
comments_node = item["comments_node"]
|
||
except KeyError:
|
||
log.debug(
|
||
"no comment service set for item {item_id}".format(
|
||
item_id=item["id"]
|
||
)
|
||
)
|
||
else:
|
||
await self._p.setNodeAffiliations(
|
||
client, comments_service, comments_node, {invitee_jid: "publisher"}
|
||
)
|
||
log.debug(_("affiliation set on blog and comments nodes"))
|
||
|
||
def _invite(self, invitee_jid, service, node, item_id, profile):
|
||
return self.host.plugins["PUBSUB_INVITATION"]._sendPubsubInvitation(
|
||
invitee_jid, service, node, item_id or NS_EVENT, profile_key=profile
|
||
)
|
||
|
||
def _invite_by_email(self, service, node, id_=NS_EVENT, email="", emails_extra=None,
|
||
name="", host_name="", language="", url_template="",
|
||
message_subject="", message_body="",
|
||
profile_key=C.PROF_KEY_NONE):
|
||
client = self.host.getClient(profile_key)
|
||
kwargs = {
|
||
"profile": client.profile,
|
||
"emails_extra": [str(e) for e in emails_extra],
|
||
}
|
||
for key in (
|
||
"email",
|
||
"name",
|
||
"host_name",
|
||
"language",
|
||
"url_template",
|
||
"message_subject",
|
||
"message_body",
|
||
):
|
||
value = locals()[key]
|
||
kwargs[key] = str(value)
|
||
return defer.ensureDeferred(self.invite_by_email(
|
||
client, jid.JID(service) if service else None, node, id_ or NS_EVENT, **kwargs
|
||
))
|
||
|
||
async def invite_by_email(self, client, service, node, id_=NS_EVENT, **kwargs):
|
||
"""High level method to create an email invitation to an event
|
||
|
||
@param service(unicode, None): PubSub service
|
||
@param node(unicode): PubSub node of the event
|
||
@param id_(unicode): id_ with even data
|
||
"""
|
||
if self._i is None:
|
||
raise exceptions.FeatureNotFound(
|
||
_('"Invitations" plugin is needed for this feature')
|
||
)
|
||
if self._b is None:
|
||
raise exceptions.FeatureNotFound(
|
||
_('"XEP-0277" (blog) plugin is needed for this feature')
|
||
)
|
||
service = service or client.jid.userhostJID()
|
||
event_uri = xmpp_uri.buildXMPPUri(
|
||
"pubsub", path=service.full(), node=node, item=id_
|
||
)
|
||
kwargs["extra"] = {"event_uri": event_uri}
|
||
invitation_data = await self._i.create(**kwargs)
|
||
invitee_jid = invitation_data["jid"]
|
||
log.debug(_("invitation created"))
|
||
# now that we have a jid, we can send normal invitation
|
||
await self.invite(client, invitee_jid, service, node, id_)
|
||
|
||
def on_invitation_preflight(
|
||
self,
|
||
client: SatXMPPEntity,
|
||
name: str,
|
||
extra: dict,
|
||
service: jid.JID,
|
||
node: str,
|
||
item_id: Optional[str],
|
||
item_elt: domish.Element
|
||
) -> None:
|
||
event_elt = item_elt.event
|
||
link_elt = event_elt.addElement("link")
|
||
link_elt["service"] = service.full()
|
||
link_elt["node"] = node
|
||
link_elt["item"] = item_id
|
||
__, event_data = self._parseEventElt(event_elt)
|
||
try:
|
||
name = event_data["name"]
|
||
except KeyError:
|
||
pass
|
||
else:
|
||
extra["name"] = name
|
||
if 'image' in event_data:
|
||
extra["thumb_url"] = event_data['image']
|
||
extra["element"] = event_elt
|
||
|
||
|
||
@implementer(iwokkel.IDisco)
|
||
class EventsHandler(XMPPHandler):
|
||
|
||
def __init__(self, plugin_parent):
|
||
self.plugin_parent = plugin_parent
|
||
|
||
def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
|
||
return [
|
||
disco.DiscoFeature(NS_EVENTS),
|
||
]
|
||
|
||
def getDiscoItems(self, requestor, target, nodeIdentifier=""):
|
||
return []
|