comparison sat/plugins/plugin_exp_events.py @ 3902:32b38dd3ac18

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
author Goffi <goffi@goffi.org>
date Wed, 21 Sep 2022 22:41:49 +0200
parents 09f5ac48ffe3
children 287938675461
comparison
equal deleted inserted replaced
3901:43024e50b701 3902:32b38dd3ac18
1 #!/usr/bin/env python3 1 #!/usr/bin/env python3
2 2
3 3
4 # SAT plugin to detect language (experimental) 4 # Libervia plugin to handle events
5 # Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org) 5 # Copyright (C) 2009-2022 Jérôme Poisson (goffi@goffi.org)
6 6
7 # This program is free software: you can redistribute it and/or modify 7 # This program is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU Affero General Public License as published by 8 # it under the terms of the GNU Affero General Public License as published by
9 # the Free Software Foundation, either version 3 of the License, or 9 # the Free Software Foundation, either version 3 of the License, or
10 # (at your option) any later version. 10 # (at your option) any later version.
15 # GNU Affero General Public License for more details. 15 # GNU Affero General Public License for more details.
16 16
17 # You should have received a copy of the GNU Affero General Public License 17 # You should have received a copy of the GNU Affero General Public License
18 # along with this program. If not, see <http://www.gnu.org/licenses/>. 18 # along with this program. If not, see <http://www.gnu.org/licenses/>.
19 19
20 from typing import Optional 20 from random import seed
21 from typing import Optional, Final, Dict, List, Union, Any, Optional
22 from attr import attr
23
21 import shortuuid 24 import shortuuid
25 from sqlalchemy.orm.events import event
26 from build.lib.sat.core.xmpp import SatXMPPClient
22 from sat.core.i18n import _ 27 from sat.core.i18n import _
23 from sat.core import exceptions 28 from sat.core import exceptions
24 from sat.core.constants import Const as C 29 from sat.core.constants import Const as C
25 from sat.core.log import getLogger 30 from sat.core.log import getLogger
26 from sat.core.xmpp import SatXMPPEntity 31 from sat.core.xmpp import SatXMPPEntity
32 from sat.core.core_types import SatXMPPEntity
27 from sat.tools import utils 33 from sat.tools import utils
34 from sat.tools import xml_tools
28 from sat.tools.common import uri as xmpp_uri 35 from sat.tools.common import uri as xmpp_uri
29 from sat.tools.common import date_utils 36 from sat.tools.common import date_utils
37 from sat.tools.common import data_format
30 from twisted.internet import defer 38 from twisted.internet import defer
31 from twisted.words.protocols.jabber import jid, error 39 from twisted.words.protocols.jabber import jid, error
32 from twisted.words.xish import domish 40 from twisted.words.xish import domish
33 from wokkel import disco, iwokkel 41 from wokkel import disco, iwokkel
34 from zope.interface import implementer 42 from zope.interface import implementer
35 from twisted.words.protocols.jabber.xmlstream import XMPPHandler 43 from twisted.words.protocols.jabber.xmlstream import XMPPHandler
36 from wokkel import pubsub 44 from wokkel import pubsub, data_form
37 45
38 log = getLogger(__name__) 46 log = getLogger(__name__)
39 47
40 48
41 PLUGIN_INFO = { 49 PLUGIN_INFO = {
42 C.PI_NAME: "Events", 50 C.PI_NAME: "Events",
43 C.PI_IMPORT_NAME: "EVENTS", 51 C.PI_IMPORT_NAME: "EVENTS",
44 C.PI_TYPE: "EXP", 52 C.PI_TYPE: "EXP",
53 C.PI_MODES: C.PLUG_MODE_BOTH,
45 C.PI_PROTOCOLS: [], 54 C.PI_PROTOCOLS: [],
46 C.PI_DEPENDENCIES: ["XEP-0060", "INVITATION", "PUBSUB_INVITATION", "LIST_INTEREST"], 55 C.PI_DEPENDENCIES: [
56 "XEP-0060", "XEP-0080", "XEP-0447", "XEP-0470", # "INVITATION", "PUBSUB_INVITATION",
57 # "LIST_INTEREST"
58 ],
47 C.PI_RECOMMENDATIONS: ["XEP-0277", "EMAIL_INVITATION"], 59 C.PI_RECOMMENDATIONS: ["XEP-0277", "EMAIL_INVITATION"],
48 C.PI_MAIN: "Events", 60 C.PI_MAIN: "Events",
49 C.PI_HANDLER: "yes", 61 C.PI_HANDLER: "yes",
50 C.PI_DESCRIPTION: _("""Experimental implementation of XMPP events management"""), 62 C.PI_DESCRIPTION: _("""XMPP Events Management"""),
51 } 63 }
52 64
53 NS_EVENT = "org.salut-a-toi.event:0" 65 NS_EVENT = "org.salut-a-toi.event:0"
54 66 NS_EVENTS: Final = "urn:xmpp:events:0"
55 67 NS_RSVP: Final = "urn:xmpp:events:rsvp:0"
56 class Events(object): 68 NS_EXTRA: Final = "urn:xmpp:events:extra:0"
57 """Q&D module to handle event attendance answer, experimentation only""" 69
70
71 class Events:
72 namespace = NS_EVENTS
58 73
59 def __init__(self, host): 74 def __init__(self, host):
60 log.info(_("Event plugin initialization")) 75 log.info(_("Events plugin initialization"))
61 self.host = host 76 self.host = host
62 self._p = self.host.plugins["XEP-0060"] 77 self._p = host.plugins["XEP-0060"]
63 self._i = self.host.plugins.get("EMAIL_INVITATION") 78 self._g = host.plugins["XEP-0080"]
64 self._b = self.host.plugins.get("XEP-0277") 79 self._b = host.plugins.get("XEP-0277")
65 self.host.registerNamespace("event", NS_EVENT) 80 self._sfs = host.plugins["XEP-0447"]
66 self.host.plugins["PUBSUB_INVITATION"].register(NS_EVENT, self) 81 self._a = host.plugins["XEP-0470"]
82 # self._i = host.plugins.get("EMAIL_INVITATION")
83 host.registerNamespace("events", NS_EVENTS)
84 self._a.registerAttachmentHandler("rsvp", NS_EVENTS, self.rsvp_get, self.rsvp_set)
85 # host.plugins["PUBSUB_INVITATION"].register(NS_EVENTS, self)
67 host.bridge.addMethod( 86 host.bridge.addMethod(
68 "eventGet", 87 "eventsGet",
69 ".plugin", 88 ".plugin",
70 in_sign="ssss", 89 in_sign="ssasss",
71 out_sign="(ia{ss})", 90 out_sign="s",
72 method=self._eventGet, 91 method=self._events_get,
73 async_=True, 92 async_=True,
74 ) 93 )
75 host.bridge.addMethod( 94 host.bridge.addMethod(
76 "eventCreate", 95 "eventCreate",
77 ".plugin", 96 ".plugin",
78 in_sign="ia{ss}ssss", 97 in_sign="sssss",
79 out_sign="s", 98 out_sign="",
80 method=self._eventCreate, 99 method=self._event_create,
81 async_=True, 100 async_=True,
82 ) 101 )
83 host.bridge.addMethod( 102 host.bridge.addMethod(
84 "eventModify", 103 "eventModify",
85 ".plugin", 104 ".plugin",
86 in_sign="sssia{ss}s", 105 in_sign="sssss",
87 out_sign="", 106 out_sign="",
88 method=self._eventModify, 107 method=self._event_modify,
89 async_=True,
90 )
91 host.bridge.addMethod(
92 "eventsList",
93 ".plugin",
94 in_sign="sss",
95 out_sign="aa{ss}",
96 method=self._eventsList,
97 async_=True, 108 async_=True,
98 ) 109 )
99 host.bridge.addMethod( 110 host.bridge.addMethod(
100 "eventInviteeGet", 111 "eventInviteeGet",
101 ".plugin", 112 ".plugin",
102 in_sign="ssss", 113 in_sign="sssasss",
103 out_sign="a{ss}", 114 out_sign="s",
104 method=self._eventInviteeGet, 115 method=self._event_invitee_get,
105 async_=True, 116 async_=True,
106 ) 117 )
107 host.bridge.addMethod( 118 host.bridge.addMethod(
108 "eventInviteeSet", 119 "eventInviteeSet",
109 ".plugin", 120 ".plugin",
110 in_sign="ssa{ss}s", 121 in_sign="sssss",
111 out_sign="", 122 out_sign="",
112 method=self._eventInviteeSet, 123 method=self._event_invitee_set,
113 async_=True, 124 async_=True,
114 ) 125 )
115 host.bridge.addMethod( 126 host.bridge.addMethod(
116 "eventInviteesList", 127 "eventInviteesList",
117 ".plugin", 128 ".plugin",
210 data["item"] = link_elt["item"] 221 data["item"] = link_elt["item"]
211 if event_elt.getAttribute("creator") == "true": 222 if event_elt.getAttribute("creator") == "true":
212 data["creator"] = True 223 data["creator"] = True
213 return timestamp, data 224 return timestamp, data
214 225
215 async def getEventElement(self, client, service, node, id_): 226 def event_elt_2_event_data(self, event_elt: domish.Element) -> Dict[str, Any]:
216 """Retrieve event element 227 """Convert <event/> element to event data
217 228
218 @param service(jid.JID): pubsub service 229 @param event_elt: <event/> element
219 @param node(unicode): pubsub node 230 parent <item/> element can also be used
220 @param id_(unicode, None): event id 231 @raise exceptions.NotFound: can't find event payload
221 @return (domish.Element): event element 232 @raise ValueError: something is missing or badly formed
222 @raise exceptions.NotFound: no event element found
223 """ 233 """
224 if not id_: 234 if event_elt.name == "item":
225 id_ = NS_EVENT 235 try:
226 items, metadata = await self._p.getItems(client, service, node, item_ids=[id_]) 236 event_elt = next(event_elt.elements(NS_EVENTS, "event"))
237 except StopIteration:
238 raise exceptions.NotFound("<event/> payload is missing")
239
240 event_data: Dict[str, Any] = {}
241
242 # id
243
244 parent_elt = event_elt.parent
245 if parent_elt is not None and parent_elt.hasAttribute("id"):
246 event_data["id"] = parent_elt["id"]
247
248 # name
249
250 name_data: Dict[str, str] = {}
251 event_data["name"] = name_data
252 for name_elt in event_elt.elements(NS_EVENTS, "name"):
253 lang = name_elt.getAttribute("xml:lang", "")
254 if lang in name_data:
255 raise ValueError("<name/> elements don't have distinct xml:lang")
256 name_data[lang] = str(name_elt)
257
258 if not name_data:
259 raise exceptions.NotFound("<name/> element is missing")
260
261 # start
262
227 try: 263 try:
228 event_elt = next(items[0].elements(NS_EVENT, "event")) 264 start_elt = next(event_elt.elements(NS_EVENTS, "start"))
229 except StopIteration: 265 except StopIteration:
230 raise exceptions.NotFound(_("No event element has been found")) 266 raise exceptions.NotFound("<start/> element is missing")
231 except IndexError: 267 event_data["start"] = utils.parse_xmpp_date(str(start_elt))
232 raise exceptions.NotFound(_("No event with this id has been found")) 268
233 return event_elt 269 # end
234 270
235 def _eventGet(self, service, node, id_="", profile_key=C.PROF_KEY_NONE): 271 try:
236 service = jid.JID(service) if service else None 272 end_elt = next(event_elt.elements(NS_EVENTS, "end"))
237 node = node if node else NS_EVENT 273 except StopIteration:
274 raise exceptions.NotFound("<end/> element is missing")
275 event_data["end"] = utils.parse_xmpp_date(str(end_elt))
276
277 # head-picture
278
279 head_pic_elt = next(event_elt.elements(NS_EVENTS, "head-picture"), None)
280 if head_pic_elt is not None:
281 event_data["head-picture"] = self._sfs.parse_file_sharing_elt(head_pic_elt)
282
283 # description
284
285 seen_desc = set()
286 for description_elt in event_elt.elements(NS_EVENTS, "description"):
287 lang = description_elt.getAttribute("xml:lang", "")
288 desc_type = description_elt.getAttribute("type", "text")
289 lang_type = (lang, desc_type)
290 if lang_type in seen_desc:
291 raise ValueError(
292 "<description/> elements don't have distinct xml:lang/type"
293 )
294 seen_desc.add(lang_type)
295 descriptions = event_data.setdefault("descriptions", [])
296 description_data = {"description": str(description_elt)}
297 if lang:
298 description_data["language"] = lang
299 if desc_type:
300 description_data["type"] = desc_type
301 descriptions.append(description_data)
302
303 # categories
304
305 for category_elt in event_elt.elements(NS_EVENTS, "category"):
306 try:
307 category_data = {
308 "term": category_elt["term"]
309 }
310 except KeyError:
311 log.warning(
312 "<category/> element is missing mandatory term: "
313 f"{category_elt.toXml()}"
314 )
315 continue
316 wd = category_elt.getAttribute("wd")
317 if wd:
318 category_data["wikidata_id"] = wd
319 lang = category_elt.getAttribute("xml:lang")
320 if lang:
321 category_data["language"] = lang
322 event_data.setdefault("categories", []).append(category_data)
323
324 # locations
325
326 seen_location_ids = set()
327 for location_elt in event_elt.elements(NS_EVENTS, "location"):
328 location_id = location_elt.getAttribute("id", "")
329 if location_id in seen_location_ids:
330 raise ValueError("<location/> elements don't have distinct IDs")
331 seen_location_ids.add(location_id)
332 location_data = self._g.parse_geoloc_elt(location_elt)
333 if location_id:
334 location_data["id"] = location_id
335 lang = location_elt.getAttribute("xml:lang", "")
336 if lang:
337 location_data["language"] = lang
338 event_data.setdefault("locations", []).append(location_data)
339
340 # RSVPs
341
342 seen_rsvp_lang = set()
343 for rsvp_elt in event_elt.elements(NS_EVENTS, "rsvp"):
344 rsvp_lang = rsvp_elt.getAttribute("xml:lang", "")
345 if rsvp_lang in seen_rsvp_lang:
346 raise ValueError("<rsvp/> elements don't have distinct xml:lang")
347 seen_rsvp_lang.add(rsvp_lang)
348 rsvp_form = data_form.findForm(rsvp_elt, NS_RSVP)
349 if rsvp_form is None:
350 log.warning(f"RSVP form is missing: {rsvp_elt.toXml()}")
351 continue
352 rsvp_data = xml_tools.dataForm2dataDict(rsvp_form)
353 if rsvp_lang:
354 rsvp_data["language"] = rsvp_lang
355 event_data.setdefault("rsvp", []).append(rsvp_data)
356
357 # linked pubsub nodes
358
359 for name in ("invitees", "comments", "blog", "schedule"):
360 elt = next(event_elt.elements(NS_EVENTS, name), None)
361 if elt is not None:
362 try:
363 event_data[name] = {
364 "service": elt["jid"],
365 "node": elt["node"]
366 }
367 except KeyError:
368 log.warning(f"invalid {name} element: {elt.toXml()}")
369
370 # attachments
371
372 attachments_elt = next(event_elt.elements(NS_EVENTS, "attachments"), None)
373 if attachments_elt:
374 attachments = event_data["attachments"] = []
375 for file_sharing_elt in attachments_elt.elements(
376 self._sfs.namespace, "file-sharing"):
377 try:
378 file_sharing_data = self._sfs.parse_file_sharing_elt(file_sharing_elt)
379 except Exception as e:
380 log.warning(f"invalid attachment: {e}\n{file_sharing_elt.toXml()}")
381 continue
382 attachments.append(file_sharing_data)
383
384 # extra
385
386 extra_elt = next(event_elt.elements(NS_EVENTS, "extra"), None)
387 if extra_elt is not None:
388 extra_form = data_form.findForm(extra_elt, NS_EXTRA)
389 if extra_form is None:
390 log.warning(f"extra form is missing: {extra_elt.toXml()}")
391 else:
392 extra_data = event_data["extra"] = {}
393 for name, value in extra_form.items():
394 if name.startswith("accessibility:"):
395 extra_data.setdefault("accessibility", {})[name[14:]] = value
396 elif name == "accessibility":
397 log.warning(
398 'ignoring "accessibility" key which is not standard: '
399 f"{extra_form.toElement().toXml()}"
400 )
401 else:
402 extra_data[name] = value
403
404 # external
405
406 external_elt = next(event_elt.elements(NS_EVENTS, "external"), None)
407 if external_elt:
408 try:
409 event_data["external"] = {
410 "jid": external_elt["jid"],
411 "node": external_elt["node"],
412 "item": external_elt["item"]
413 }
414 except KeyError:
415 log.warning(f"invalid <external/> element: {external_elt.toXml()}")
416
417 return event_data
418
419 def _events_get(
420 self, service: str, node: str, event_ids: List[str], extra: str, profile_key: str
421 ):
422 client = self.host.getClient(profile_key)
423 d = defer.ensureDeferred(
424 self.events_get(
425 client,
426 jid.JID(service) if service else None,
427 node if node else NS_EVENTS,
428 event_ids,
429 data_format.deserialise(extra)
430 )
431 )
432 d.addCallback(data_format.serialise)
433 return d
434
435 async def events_get(
436 self,
437 client: SatXMPPEntity,
438 service: Optional[jid.JID],
439 node: str = NS_EVENTS,
440 events_ids: Optional[List[str]] = None,
441 extra: Optional[dict] = None,
442 ) -> List[Dict[str, Any]]:
443 """Retrieve event data
444
445 @param service: pubsub service
446 @param node: pubsub node
447 @param event_id: pubsub item ID
448 @return: event data:
449 """
450 if service is None:
451 service = client.jid.userhostJID()
452 items, __ = await self._p.getItems(
453 client, service, node, item_ids=events_ids, extra=extra
454 )
455 events = []
456 for item in items:
457 try:
458 events.append(self.event_elt_2_event_data((item)))
459 except (ValueError, exceptions.NotFound):
460 log.warning(
461 f"Can't parse event for item {item['id']}: {item.toXml()}"
462 )
463
464 return events
465
466 def _event_create(
467 self,
468 data_s: str,
469 service: str,
470 node: str,
471 event_id: str = "",
472 profile_key: str = C.PROF_KEY_NONE
473 ):
238 client = self.host.getClient(profile_key) 474 client = self.host.getClient(profile_key)
239 return defer.ensureDeferred( 475 return defer.ensureDeferred(
240 self.eventGet(client, service, node, id_) 476 self.event_create(
241 ) 477 client,
242 478 data_format.deserialise(data_s),
243 async def eventGet(self, client, service, node, id_=NS_EVENT): 479 jid.JID(service) if service else None,
244 """Retrieve event data 480 node or None,
245 481 event_id or None
246 @param service(unicode, None): PubSub service 482 )
247 @param node(unicode): PubSub node of the event 483 )
248 @param id_(unicode): id_ with even data 484
249 @return (tuple[int, dict[unicode, unicode]): event data: 485 def event_data_2_event_elt(self, event_data: Dict[str, Any]) -> domish.Element:
250 - timestamp of the event 486 """Convert Event Data to corresponding Element
251 - event metadata where key can be: 487
252 location: location of the event 488 @param event_data: data of the event with keys as follow:
253 image: URL of a picture to use to represent event 489 name (dict)
254 background-image: URL of a picture to use in background 490 map of language to name
491 empty string can be used as key if no language is specified
492 this key is mandatory
493 start (int|float)
494 starting time of the event
495 this key is mandatory
496 end (int|float)
497 ending time of the event
498 this key is mandatory
499 head-picture(dict)
500 file sharing data for the main picture to use to represent the event
501 description(list[dict])
502 list of descriptions. If there are several descriptions, they must have
503 distinct (language, type).
504 Description data is dict which following keys:
505 description(str)
506 the description itself, either in plain text or xhtml
507 this key is mandatory
508 language(str)
509 ISO-639 language code
510 type(str)
511 type of the description, either "text" (default) or "xhtml"
512 categories(list[dict])
513 each category is a dict with following keys:
514 term(str)
515 human readable short text of the category
516 this key is mandatory
517 wikidata_id(str)
518 Entity ID from WikiData
519 language(str)
520 ISO-639 language code
521 locations(list[dict])
522 list of location dict as used in plugin XEP-0080 [get_geoloc_elt].
523 If several locations are used, they must have distinct IDs
524 rsvp(list[dict])
525 RSVP data. The dict is a data dict as used in
526 sat.tools.xml_tools.dataDict2dataForm with some extra keys.
527 The "attending" key is automatically added if it's not already present,
528 except if the "no_default" key is present. Thus, an empty dict can be used
529 to use default RSVP.
530 If several dict are present in the list, they must have different "lang"
531 keys.
532 Following extra key can be used:
533 language(str)
534 ISO-639 code for language used in the form
535 no_default(bool)
536 if True, the "attending" field won't be automatically added
537 invitees(dict)
538 link to pubsub node holding invitees list.
539 Following keys are mandatory:
540 service(str)
541 pubsub service where the node is
542 node (str)
543 pubsub node to use
544 comments(dict)
545 link to pubsub node holding XEP-0277 comments on the event itself.
546 Following keys are mandatory:
547 service(str)
548 pubsub service where the node is
549 node (str)
550 pubsub node to use
551 blog(dict)
552 link to pubsub node holding a blog about the event.
553 Following keys are mandatory:
554 service(str)
555 pubsub service where the node is
556 node (str)
557 pubsub node to use
558 schedule(dict)
559 link to pubsub node holding an events node describing the schedule of this
560 event.
561 Following keys are mandatory:
562 service(str)
563 pubsub service where the node is
564 node (str)
565 pubsub node to use
566 attachments[list[dict]]
567 list of file sharing data about all kind of attachments of interest for
568 the event.
569 extra(dict)
570 extra information about the event.
571 Keys can be:
572 website(str)
573 main website about the event
574 status(str)
575 status of the event.
576 Can be one of "confirmed", "tentative" or "cancelled"
577 languages(list[str])
578 ISO-639 codes for languages which will be mainly spoken at the
579 event
580 accessibility(dict)
581 accessibility informations.
582 Keys can be:
583 wheelchair
584 tell if the event is accessible to wheelchair.
585 Value can be "full", "partial" or "no"
586 external(dict):
587 if present, this event is a link to an external one.
588 Keys (all mandatory) are:
589 jid: pubsub service
590 node: pubsub node
591 item: event id
592 @return: Event element
593 @raise ValueError: some expected data were missing or incorrect
255 """ 594 """
256 event_elt = await self.getEventElement(client, service, node, id_) 595 event_elt = domish.Element((NS_EVENTS, "event"))
257 596 try:
258 return self._parseEventElt(event_elt) 597 for lang, name in event_data["name"].items():
259 598 name_elt = event_elt.addElement("name", content=name)
260 def _eventCreate( 599 if lang:
261 self, timestamp, data, service, node, id_="", profile_key=C.PROF_KEY_NONE 600 name_elt["xml:lang"] = lang
262 ): 601 except (KeyError, TypeError):
263 service = jid.JID(service) if service else None 602 raise ValueError('"name" field is not a dict mapping language to event name')
264 node = node or None 603 try:
265 client = self.host.getClient(profile_key) 604 event_elt.addElement("start", content=utils.xmpp_date(event_data["start"]))
266 data["register"] = C.bool(data.get("register", C.BOOL_FALSE)) 605 event_elt.addElement("end", content=utils.xmpp_date(event_data["end"]))
267 return defer.ensureDeferred( 606 except (KeyError, TypeError, ValueError):
268 self.eventCreate(client, timestamp, data, service, node, id_ or NS_EVENT) 607 raise ValueError('"start" and "end" fields are mandatory')
269 ) 608
270 609 if "head-picture" in event_data:
271 async def eventCreate(self, client, timestamp, data, service, node=None, event_id=NS_EVENT): 610 head_pic_data = event_data["head-picture"]
611 head_picture_elt = event_elt.addElement("head-picture")
612 head_picture_elt.addChild(self._sfs.get_file_sharing_elt(**head_pic_data))
613
614 seen_desc = set()
615 if "descriptions" in event_data:
616 for desc_data in event_data["descriptions"]:
617 desc_type = desc_data.get("type", "text")
618 lang = desc_data.get("language") or ""
619 lang_type = (lang, desc_type)
620 if lang_type in seen_desc:
621 raise ValueError(
622 '"xml:lang" and "type" is not unique among descriptions: '
623 f"{desc_data}"
624 )
625 seen_desc.add(lang_type)
626 try:
627 description = desc_data["description"]
628 except KeyError:
629 log.warning(f"description is missing in {desc_data!r}")
630 continue
631
632 if desc_type == "text":
633 description_elt = event_elt.addElement(
634 "description", content=description
635 )
636 elif desc_type == "xhtml":
637 description_elt = event_elt.addElement("description")
638 div_elt = xml_tools.parse(description, namespace=C.NS_XHTML)
639 description_elt.addChild(div_elt)
640 else:
641 log.warning(f"unknown description type {desc_type!r}")
642 continue
643 if lang:
644 description_elt["xml:lang"] = lang
645 for category_data in event_data.get("categories", []):
646 try:
647 category_term = category_data["term"]
648 except KeyError:
649 log.warning(f'"term" is missing categories data: {category_data}')
650 continue
651 category_elt = event_elt.addElement("category")
652 category_elt["term"] = category_term
653 category_wd = category_data.get("wikidata_id")
654 if category_wd:
655 category_elt["wd"] = category_wd
656 category_lang = category_data.get("language")
657 if category_lang:
658 category_elt["xml:lang"] = category_lang
659
660 seen_location_ids = set()
661 for location_data in event_data.get("locations", []):
662 location_id = location_data.get("id", "")
663 if location_id in seen_location_ids:
664 raise ValueError("locations must have distinct IDs")
665 seen_location_ids.add(location_id)
666 location_elt = event_elt.addElement("location")
667 location_elt.addChild(self._g.get_geoloc_elt(location_data))
668 if location_id:
669 location_elt["id"] = location_id
670
671 rsvp_data_list: Optional[List[dict]] = event_data.get("rsvp")
672 if rsvp_data_list is not None:
673 seen_lang = set()
674 for rsvp_data in rsvp_data_list:
675 if not rsvp_data:
676 # we use a minimum data if an empty dict is received. It will be later
677 # filled with defaut "attending" field.
678 rsvp_data = {"fields": []}
679 rsvp_elt = event_elt.addElement("rsvp")
680 lang = rsvp_data.get("language", "")
681 if lang in seen_lang:
682 raise ValueError(
683 "If several RSVP are specified, they must have distinct "
684 f"languages. {lang!r} language has been used several times."
685 )
686 seen_lang.add(lang)
687 if lang:
688 rsvp_elt["xml:lang"] = lang
689 if not rsvp_data.get("no_default", False):
690 try:
691 next(f for f in rsvp_data["fields"] if f["name"] == "attending")
692 except StopIteration:
693 rsvp_data["fields"].append({
694 "type": "list-single",
695 "name": "attending",
696 "label": "Attending",
697 "options": [
698 {"label": "maybe", "value": "maybe"},
699 {"label": "yes", "value": "yes"},
700 {"label": "no", "value": "no"}
701 ],
702 "required": True
703 })
704 rsvp_data["namespace"] = NS_RSVP
705 rsvp_form = xml_tools.dataDict2dataForm(rsvp_data)
706 rsvp_elt.addChild(rsvp_form.toElement())
707
708 for node_type in ("invitees", "comments", "blog", "schedule"):
709 node_data = event_data.get(node_type)
710 if not node_data:
711 continue
712 try:
713 service, node = node_data["service"], node_data["node"]
714 except KeyError:
715 log.warning(f"invalid node data for {node_type}: {node_data}")
716 else:
717 pub_node_elt = event_elt.addElement(node_type)
718 pub_node_elt["jid"] = service
719 pub_node_elt["node"] = node
720
721 attachments = event_data.get("attachments")
722 if attachments:
723 attachments_elt = event_elt.addElement("attachments")
724 for attachment_data in attachments:
725 attachments_elt.addChild(
726 self._sfs.get_file_sharing_elt(**attachment_data)
727 )
728
729 extra = event_data.get("extra")
730 if extra:
731 extra_form = data_form.Form(
732 "result",
733 formNamespace=NS_EXTRA
734 )
735 for node_type in ("website", "status"):
736 if node_type in extra:
737 extra_form.addField(
738 data_form.Field(var=node_type, value=extra[node_type])
739 )
740 if "languages" in extra:
741 extra_form.addField(
742 data_form.Field(
743 "list-multi", var="languages", values=extra["languages"]
744 )
745 )
746 for node_type, value in extra.get("accessibility", {}).items():
747 extra_form.addField(
748 data_form.Field(var=f"accessibility:{node_type}", value=value)
749 )
750
751 extra_elt = event_elt.addElement("extra")
752 extra_elt.addChild(extra_form.toElement())
753
754 if "external" in event_data:
755 external_data = event_data["external"]
756 external_elt = event_elt.addElement("external")
757 for node_type in ("jid", "node", "item"):
758 try:
759 value = external_data[node_type]
760 except KeyError:
761 raise ValueError(f"Invalid external data: {external_data}")
762 external_elt[node_type] = value
763
764 return event_elt
765
766 async def event_create(
767 self,
768 client: SatXMPPEntity,
769 event_data: Dict[str, Any],
770 service: Optional[jid.JID] = None,
771 node: Optional[str] = None,
772 event_id: Optional[str] = None,
773 ) -> None:
272 """Create or replace an event 774 """Create or replace an event
273 775
274 @param service(jid.JID, None): PubSub service 776 @param event_data: data of the event (cf. [event_data_2_event_elt])
275 @param node(unicode, None): PubSub node of the event 777 @param node: PubSub node of the event
276 None will create instant node. 778 None to use default node (default namespace for personal agenda)
277 @param event_id(unicode): ID of the item to create. 779 @param service: PubSub service
278 @param timestamp(timestamp, None) 780 None to use profile's PEP
279 @param data(dict[unicode, unicode]): data to update 781 @param event_id: ID of the item to create.
280 dict will be cleared, do a copy if data are still needed
281 key can be:
282 - name: name of the event
283 - description: details
284 - image: main picture of the event
285 - background-image: image to use as background
286 - register: bool, True if we want to register the event in our local list
287 @return (unicode): created node
288 """ 782 """
289 if not event_id:
290 raise ValueError(_("event_id must be set"))
291 if not service: 783 if not service:
292 service = client.jid.userhostJID() 784 service = client.jid.userhostJID()
293 if not node: 785 if not node:
294 node = NS_EVENT + "__" + shortuuid.uuid() 786 node = NS_EVENTS
295 event_elt = domish.Element((NS_EVENT, "event")) 787 if event_id is None:
296 if timestamp is not None and timestamp != -1: 788 event_id = shortuuid.uuid()
297 formatted_date = utils.xmpp_date(timestamp) 789 event_elt = self.event_data_2_event_elt(event_data)
298 event_elt.addElement((NS_EVENT, "date"), content=formatted_date) 790
299 register = data.pop("register", False) 791 item_elt = pubsub.Item(id=event_id, payload=event_elt)
300 for key in ("name",): 792 options = {self._p.OPT_ACCESS_MODEL: self._p.ACCESS_WHITELIST}
301 if key in data: 793 await self._p.createIfNewNode(
302 event_elt[key] = data.pop(key) 794 client, service, nodeIdentifier=node, options=options
303 for key in ("description",): 795 )
304 if key in data: 796 await self._p.publish(client, service, node, items=[item_elt])
305 event_elt.addElement((NS_EVENT, key), content=data.pop(key)) 797 if event_data.get("rsvp"):
306 for key in ("image", "background-image"): 798 await self._a.create_attachments_node(client, service, node, event_id)
307 if key in data: 799
308 elt = event_elt.addElement((NS_EVENT, key)) 800 def _event_modify(
309 elt["src"] = data.pop(key) 801 self,
310 802 data_s: str,
311 # we first create the invitees and blog nodes (if not specified in data) 803 event_id: str,
312 for uri_type in ("invitees", "blog"): 804 service: str,
313 key = uri_type + "_uri" 805 node: str,
314 for to_delete in ("service", "node"): 806 profile_key: str = C.PROF_KEY_NONE
315 k = uri_type + "_" + to_delete 807 ) -> None:
316 if k in data: 808 client = self.host.getClient(profile_key)
317 del data[k] 809 defer.ensureDeferred(
318 if key not in data: 810 self.event_modify(
319 # FIXME: affiliate invitees 811 client,
320 uri_node = await self._p.createNode(client, service) 812 data_format.deserialise(data_s),
321 await self._p.setConfiguration( 813 event_id,
322 client, 814 jid.JID(service) if service else None,
323 service, 815 node or None,
324 uri_node,
325 {self._p.OPT_ACCESS_MODEL: self._p.ACCESS_WHITELIST},
326 )
327 uri_service = service
328 else:
329 uri = data.pop(key)
330 uri_data = xmpp_uri.parseXMPPUri(uri)
331 if uri_data["type"] != "pubsub":
332 raise ValueError(
333 _("The given URI is not valid: {uri}").format(uri=uri)
334 )
335 uri_service = jid.JID(uri_data["path"])
336 uri_node = uri_data["node"]
337
338 elt = event_elt.addElement((NS_EVENT, uri_type))
339 elt["uri"] = xmpp_uri.buildXMPPUri(
340 "pubsub", path=uri_service.full(), node=uri_node
341 ) 816 )
342 817 )
343 # remaining data are put in <meta> elements 818
344 for key in list(data.keys()): 819 async def event_modify(
345 elt = event_elt.addElement((NS_EVENT, "meta"), content=data.pop(key)) 820 self,
346 elt["name"] = key 821 client: SatXMPPEntity,
347 822 event_data: Dict[str, Any],
348 item_elt = pubsub.Item(id=event_id, payload=event_elt) 823 event_id: str,
824 service: Optional[jid.JID] = None,
825 node: Optional[str] = None,
826 ) -> None:
827 """Update an event
828
829 Similar as create instead that it update existing item instead of
830 creating or replacing it. Params are the same as for [event_create].
831 """
832 if not service:
833 service = client.jid.userhostJID()
834 if not node:
835 node = NS_EVENTS
836 old_event = (await self.events_get(client, service, node, [event_id]))[0]
837 old_event.update(event_data)
838 event_data = old_event
839 await self.event_create(client, event_data, service, node, event_id)
840
841 def rsvp_get(
842 self,
843 client: SatXMPPEntity,
844 attachments_elt: domish.Element,
845 data: Dict[str, Any],
846 ) -> None:
847 """Get RSVP answers from attachments"""
349 try: 848 try:
350 # TODO: check auto-create, no need to create node first if available 849 rsvp_elt = next(
351 node = await self._p.createNode(client, service, nodeIdentifier=node) 850 attachments_elt.elements(NS_EVENTS, "rsvp")
352 except error.StanzaError as e:
353 if e.condition == "conflict":
354 log.debug(_("requested node already exists"))
355
356 await self._p.publish(client, service, node, items=[item_elt])
357
358 if register:
359 extra = {}
360 self.onInvitationPreflight(
361 client, "", extra, service, node, event_id, item_elt
362 ) 851 )
363 await self.host.plugins['LIST_INTEREST'].registerPubsub( 852 except StopIteration:
364 client, NS_EVENT, service, node, event_id, True, 853 pass
365 extra.pop("name", ""), extra.pop("element"), extra 854 else:
855 rsvp_form = data_form.findForm(rsvp_elt, NS_RSVP)
856 if rsvp_form is not None:
857 data["rsvp"] = rsvp_data = dict(rsvp_form)
858 self._a.setTimestamp(rsvp_elt, rsvp_data)
859
860 def rsvp_set(
861 self,
862 client: SatXMPPEntity,
863 data: Dict[str, Any],
864 former_elt: Optional[domish.Element]
865 ) -> Optional[domish.Element]:
866 """update the <reaction> attachment"""
867 rsvp_data = data["extra"].get("rsvp")
868 if rsvp_data is None:
869 return former_elt
870 elif rsvp_data:
871 rsvp_elt = domish.Element(
872 (NS_EVENTS, "rsvp"),
873 attribs = {
874 "timestamp": utils.xmpp_date()
875 }
366 ) 876 )
367 return node 877 rsvp_form = data_form.Form("submit", formNamespace=NS_RSVP)
368 878 rsvp_form.makeFields(rsvp_data)
369 def _eventModify(self, service, node, id_, timestamp_update, data_update, 879 rsvp_elt.addChild(rsvp_form.toElement())
370 profile_key=C.PROF_KEY_NONE): 880 return rsvp_elt
371 service = jid.JID(service) if service else None 881 else:
372 if not node: 882 return None
373 raise ValueError(_("missing node")) 883
884 def _event_invitee_get(
885 self,
886 service: str,
887 node: str,
888 item: str,
889 invitees_s: List[str],
890 extra: str,
891 profile_key: str
892 ) -> defer.Deferred:
893 client = self.host.getClient(profile_key)
894 if invitees_s:
895 invitees = [jid.JID(i) for i in invitees_s]
896 else:
897 invitees = None
898 d = defer.ensureDeferred(
899 self.event_invitee_get(
900 client,
901 jid.JID(service) if service else None,
902 node or None,
903 item,
904 invitees,
905 data_format.deserialise(extra)
906 )
907 )
908 d.addCallback(lambda ret: data_format.serialise(ret))
909 return d
910
911 async def event_invitee_get(
912 self,
913 client: SatXMPPEntity,
914 service: Optional[jid.JID],
915 node: Optional[str],
916 item: str,
917 invitees: Optional[List[jid.JID]] = None,
918 extra: Optional[Dict[str, Any]] = None,
919 ) -> Dict[str, Dict[str, Any]]:
920 """Retrieve attendance from event node
921
922 @param service: PubSub service
923 @param node: PubSub node of the event
924 @param item: PubSub item of the event
925 @param invitees: if set, only retrieve RSVPs from those guests
926 @param extra: extra data used to retrieve items as for [getAttachments]
927 @return: mapping of invitee bare JID to their RSVP
928 an empty dict is returned if nothing has been answered yed
929 """
930 if service is None:
931 service = client.jid.userhostJID()
932 if node is None:
933 node = NS_EVENTS
934 attachments, metadata = await self._a.getAttachments(
935 client, service, node, item, invitees, extra
936 )
937 ret = {}
938 for attachment in attachments:
939 try:
940 rsvp = attachment["rsvp"]
941 except KeyError:
942 continue
943 ret[attachment["from"]] = rsvp
944
945 return ret
946
947 def _event_invitee_set(
948 self,
949 service: str,
950 node: str,
951 item: str,
952 rsvp_s: str,
953 profile_key: str
954 ):
374 client = self.host.getClient(profile_key) 955 client = self.host.getClient(profile_key)
375 return defer.ensureDeferred( 956 return defer.ensureDeferred(
376 self.eventModify( 957 self.event_invitee_set(
377 client, service, node, id_ or NS_EVENT, timestamp_update or None, 958 client,
378 data_update 959 jid.JID(service) if service else None,
960 node or None,
961 item,
962 data_format.deserialise(rsvp_s)
379 ) 963 )
380 ) 964 )
381 965
382 async def eventModify( 966 async def event_invitee_set(
383 self, client, service, node, id_=NS_EVENT, timestamp_update=None, data_update=None 967 self,
384 ): 968 client: SatXMPPEntity,
385 """Update an event 969 service: Optional[jid.JID],
386 970 node: Optional[str],
387 Similar as create instead that it update existing item instead of 971 item: str,
388 creating or replacing it. Params are the same as for [eventCreate]. 972 rsvp: Dict[str, Any],
973 ) -> None:
974 """Set or update attendance data in event node
975
976 @param service: PubSub service
977 @param node: PubSub node of the event
978 @param item: PubSub item of the event
979 @param rsvp: RSVP data (values to submit to the form)
389 """ 980 """
390 event_timestamp, event_metadata = await self.eventGet(client, service, node, id_) 981 if service is None:
391 new_timestamp = event_timestamp if timestamp_update is None else timestamp_update 982 service = client.jid.userhostJID()
392 new_data = event_metadata 983 if node is None:
393 if data_update: 984 node = NS_EVENTS
394 for k, v in data_update.items(): 985 await self._a.setAttachments(client, {
395 new_data[k] = v 986 "service": service.full(),
396 await self.eventCreate(client, new_timestamp, new_data, service, node, id_) 987 "node": node,
397 988 "id": item,
398 def _eventsListSerialise(self, events): 989 "extra": {"rsvp": rsvp}
399 for timestamp, data in events: 990 })
400 data["date"] = str(timestamp)
401 data["creator"] = C.boolConst(data.get("creator", False))
402 return [e[1] for e in events]
403
404 def _eventsList(self, service, node, profile):
405 service = jid.JID(service) if service else None
406 node = node or None
407 client = self.host.getClient(profile)
408 d = self.eventsList(client, service, node)
409 d.addCallback(self._eventsListSerialise)
410 return d
411
412 @defer.inlineCallbacks
413 def eventsList(self, client, service, node=None):
414 """Retrieve list of registered events
415
416 @return list(tuple(int, dict)): list of events (timestamp + metadata)
417 """
418 items, metadata = yield self.host.plugins['LIST_INTEREST'].listInterests(
419 client, service, node, namespace=NS_EVENT)
420 events = []
421 for item in items:
422 try:
423 event_elt = next(item.interest.pubsub.elements(NS_EVENT, "event"))
424 except StopIteration:
425 log.warning(
426 _("No event found in item {item_id}, ignoring").format(
427 item_id=item["id"])
428 )
429 else:
430 timestamp, data = self._parseEventElt(event_elt)
431 data["interest_id"] = item["id"]
432 events.append((timestamp, data))
433 defer.returnValue(events)
434
435 def _eventInviteeGet(self, service, node, invitee_jid_s, profile_key):
436 service = jid.JID(service) if service else None
437 node = node if node else NS_EVENT
438 client = self.host.getClient(profile_key)
439 invitee_jid = jid.JID(invitee_jid_s) if invitee_jid_s else None
440 return defer.ensureDeferred(
441 self.eventInviteeGet(client, service, node, invitee_jid)
442 )
443
444 async def eventInviteeGet(self, client, service, node, invitee_jid=None):
445 """Retrieve attendance from event node
446
447 @param service(unicode, None): PubSub service
448 @param node(unicode): PubSub node of the event's invitees
449 @param invitee_jid(jid.JId, None): jid of the invitee to retrieve (None to
450 retrieve profile's invitation). The bare jid correspond to the PubSub item id.
451 @return (dict): a dict with current attendance status,
452 an empty dict is returned if nothing has been answered yed
453 """
454 if invitee_jid is None:
455 invitee_jid = client.jid
456 try:
457 items, metadata = await self._p.getItems(
458 client, service, node, item_ids=[invitee_jid.userhost()]
459 )
460 event_elt = next(items[0].elements(NS_EVENT, "invitee"))
461 except (exceptions.NotFound, IndexError):
462 # no item found, event data are not set yet
463 return {}
464 data = {}
465 for key in ("attend", "guests"):
466 try:
467 data[key] = event_elt[key]
468 except KeyError:
469 continue
470 return data
471
472 def _eventInviteeSet(self, service, node, event_data, profile_key):
473 service = jid.JID(service) if service else None
474 node = node if node else NS_EVENT
475 client = self.host.getClient(profile_key)
476 return defer.ensureDeferred(
477 self.eventInviteeSet(client, service, node, event_data)
478 )
479
480 async def eventInviteeSet(self, client, service, node, data):
481 """Set or update attendance data in event node
482
483 @param service(unicode, None): PubSub service
484 @param node(unicode): PubSub node of the event
485 @param data(dict[unicode, unicode]): data to update
486 key can be:
487 attend: one of "yes", "no", "maybe"
488 guests: an int
489 """
490 event_elt = domish.Element((NS_EVENT, "invitee"))
491 for key in ("attend", "guests"):
492 try:
493 event_elt[key] = data.pop(key)
494 except KeyError:
495 pass
496 item_elt = pubsub.Item(id=client.jid.userhost(), payload=event_elt)
497 return await self._p.publish(client, service, node, items=[item_elt])
498 991
499 def _eventInviteesList(self, service, node, profile_key): 992 def _eventInviteesList(self, service, node, profile_key):
500 service = jid.JID(service) if service else None 993 service = jid.JID(service) if service else None
501 node = node if node else NS_EVENT 994 node = node if node else NS_EVENT
502 client = self.host.getClient(profile_key) 995 client = self.host.getClient(profile_key)
547 _('"XEP-0277" (blog) plugin is needed for this feature') 1040 _('"XEP-0277" (blog) plugin is needed for this feature')
548 ) 1041 )
549 if item_id is None: 1042 if item_id is None:
550 item_id = extra["default_item_id"] = NS_EVENT 1043 item_id = extra["default_item_id"] = NS_EVENT
551 1044
552 __, event_data = await self.eventGet(client, service, node, item_id) 1045 __, event_data = await self.events_get(client, service, node, item_id)
553 log.debug(_("got event data")) 1046 log.debug(_("got event data"))
554 invitees_service = jid.JID(event_data["invitees_service"]) 1047 invitees_service = jid.JID(event_data["invitees_service"])
555 invitees_node = event_data["invitees_node"] 1048 invitees_node = event_data["invitees_node"]
556 blog_service = jid.JID(event_data["blog_service"]) 1049 blog_service = jid.JID(event_data["blog_service"])
557 blog_node = event_data["blog_node"] 1050 blog_node = event_data["blog_node"]
671 def __init__(self, plugin_parent): 1164 def __init__(self, plugin_parent):
672 self.plugin_parent = plugin_parent 1165 self.plugin_parent = plugin_parent
673 1166
674 def getDiscoInfo(self, requestor, target, nodeIdentifier=""): 1167 def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
675 return [ 1168 return [
676 disco.DiscoFeature(NS_EVENT), 1169 disco.DiscoFeature(NS_EVENTS),
677 ] 1170 ]
678 1171
679 def getDiscoItems(self, requestor, target, nodeIdentifier=""): 1172 def getDiscoItems(self, requestor, target, nodeIdentifier=""):
680 return [] 1173 return []