Mercurial > libervia-backend
comparison libervia/backend/plugins/plugin_xep_0471.py @ 4071:4b842c1fb686
refactoring: renamed `sat` package to `libervia.backend`
author | Goffi <goffi@goffi.org> |
---|---|
date | Fri, 02 Jun 2023 11:49:51 +0200 |
parents | sat/plugins/plugin_xep_0471.py@524856bd7b19 |
children | bc898879af34 |
comparison
equal
deleted
inserted
replaced
4070:d10748475025 | 4071:4b842c1fb686 |
---|---|
1 #!/usr/bin/env python3 | |
2 | |
3 | |
4 # Libervia plugin to handle events | |
5 # Copyright (C) 2009-2022 Jérôme Poisson (goffi@goffi.org) | |
6 | |
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 | |
9 # the Free Software Foundation, either version 3 of the License, or | |
10 # (at your option) any later version. | |
11 | |
12 # This program is distributed in the hope that it will be useful, | |
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of | |
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
15 # GNU Affero General Public License for more details. | |
16 | |
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/>. | |
19 | |
20 from random import seed | |
21 from typing import Optional, Final, Dict, List, Union, Any, Optional | |
22 from attr import attr | |
23 | |
24 import shortuuid | |
25 from sqlalchemy.orm.events import event | |
26 from libervia.backend.core.xmpp import SatXMPPClient | |
27 from libervia.backend.core.i18n import _ | |
28 from libervia.backend.core import exceptions | |
29 from libervia.backend.core.constants import Const as C | |
30 from libervia.backend.core.log import getLogger | |
31 from libervia.backend.core.xmpp import SatXMPPEntity | |
32 from libervia.backend.core.core_types import SatXMPPEntity | |
33 from libervia.backend.tools import utils | |
34 from libervia.backend.tools import xml_tools | |
35 from libervia.backend.tools.common import uri as xmpp_uri | |
36 from libervia.backend.tools.common import date_utils | |
37 from libervia.backend.tools.common import data_format | |
38 from twisted.internet import defer | |
39 from twisted.words.protocols.jabber import jid, error | |
40 from twisted.words.xish import domish | |
41 from wokkel import disco, iwokkel | |
42 from zope.interface import implementer | |
43 from twisted.words.protocols.jabber.xmlstream import XMPPHandler | |
44 from wokkel import pubsub, data_form | |
45 | |
46 log = getLogger(__name__) | |
47 | |
48 | |
49 PLUGIN_INFO = { | |
50 C.PI_NAME: "Events", | |
51 C.PI_IMPORT_NAME: "XEP-0471", | |
52 C.PI_TYPE: "XEP", | |
53 C.PI_MODES: C.PLUG_MODE_BOTH, | |
54 C.PI_PROTOCOLS: [], | |
55 C.PI_DEPENDENCIES: [ | |
56 "XEP-0060", "XEP-0080", "XEP-0447", "XEP-0470", # "INVITATION", "PUBSUB_INVITATION", | |
57 # "LIST_INTEREST" | |
58 ], | |
59 C.PI_RECOMMENDATIONS: ["XEP-0277", "EMAIL_INVITATION"], | |
60 C.PI_MAIN: "XEP_0471", | |
61 C.PI_HANDLER: "yes", | |
62 C.PI_DESCRIPTION: _("""Calendar Events"""), | |
63 } | |
64 | |
65 NS_EVENT = "org.salut-a-toi.event:0" | |
66 NS_EVENTS: Final = "urn:xmpp:events:0" | |
67 NS_RSVP: Final = "urn:xmpp:events:rsvp:0" | |
68 NS_EXTRA: Final = "urn:xmpp:events:extra:0" | |
69 | |
70 | |
71 class XEP_0471: | |
72 namespace = NS_EVENTS | |
73 | |
74 def __init__(self, host): | |
75 log.info(_("Events plugin initialization")) | |
76 self.host = host | |
77 self._p = host.plugins["XEP-0060"] | |
78 self._g = host.plugins["XEP-0080"] | |
79 self._b = host.plugins.get("XEP-0277") | |
80 self._sfs = host.plugins["XEP-0447"] | |
81 self._a = host.plugins["XEP-0470"] | |
82 # self._i = host.plugins.get("EMAIL_INVITATION") | |
83 host.register_namespace("events", NS_EVENTS) | |
84 self._a.register_attachment_handler("rsvp", NS_EVENTS, self.rsvp_get, self.rsvp_set) | |
85 # host.plugins["PUBSUB_INVITATION"].register(NS_EVENTS, self) | |
86 host.bridge.add_method( | |
87 "events_get", | |
88 ".plugin", | |
89 in_sign="ssasss", | |
90 out_sign="s", | |
91 method=self._events_get, | |
92 async_=True, | |
93 ) | |
94 host.bridge.add_method( | |
95 "event_create", | |
96 ".plugin", | |
97 in_sign="sssss", | |
98 out_sign="", | |
99 method=self._event_create, | |
100 async_=True, | |
101 ) | |
102 host.bridge.add_method( | |
103 "event_modify", | |
104 ".plugin", | |
105 in_sign="sssss", | |
106 out_sign="", | |
107 method=self._event_modify, | |
108 async_=True, | |
109 ) | |
110 host.bridge.add_method( | |
111 "event_invitee_get", | |
112 ".plugin", | |
113 in_sign="sssasss", | |
114 out_sign="s", | |
115 method=self._event_invitee_get, | |
116 async_=True, | |
117 ) | |
118 host.bridge.add_method( | |
119 "event_invitee_set", | |
120 ".plugin", | |
121 in_sign="sssss", | |
122 out_sign="", | |
123 method=self._event_invitee_set, | |
124 async_=True, | |
125 ) | |
126 host.bridge.add_method( | |
127 "event_invitees_list", | |
128 ".plugin", | |
129 in_sign="sss", | |
130 out_sign="a{sa{ss}}", | |
131 method=self._event_invitees_list, | |
132 async_=True, | |
133 ), | |
134 host.bridge.add_method( | |
135 "event_invite", | |
136 ".plugin", | |
137 in_sign="sssss", | |
138 out_sign="", | |
139 method=self._invite, | |
140 async_=True, | |
141 ) | |
142 host.bridge.add_method( | |
143 "event_invite_by_email", | |
144 ".plugin", | |
145 in_sign="ssssassssssss", | |
146 out_sign="", | |
147 method=self._invite_by_email, | |
148 async_=True, | |
149 ) | |
150 | |
151 def get_handler(self, client): | |
152 return EventsHandler(self) | |
153 | |
154 def _parse_event_elt(self, event_elt): | |
155 """Helper method to parse event element | |
156 | |
157 @param (domish.Element): event_elt | |
158 @return (tuple[int, dict[unicode, unicode]): timestamp, event_data | |
159 """ | |
160 try: | |
161 timestamp = date_utils.date_parse(next(event_elt.elements(NS_EVENT, "date"))) | |
162 except StopIteration: | |
163 timestamp = -1 | |
164 | |
165 data = {} | |
166 | |
167 for key in ("name",): | |
168 try: | |
169 data[key] = event_elt[key] | |
170 except KeyError: | |
171 continue | |
172 | |
173 for elt_name in ("description",): | |
174 try: | |
175 elt = next(event_elt.elements(NS_EVENT, elt_name)) | |
176 except StopIteration: | |
177 continue | |
178 else: | |
179 data[elt_name] = str(elt) | |
180 | |
181 for elt_name in ("image", "background-image"): | |
182 try: | |
183 image_elt = next(event_elt.elements(NS_EVENT, elt_name)) | |
184 data[elt_name] = image_elt["src"] | |
185 except StopIteration: | |
186 continue | |
187 except KeyError: | |
188 log.warning(_("no src found for image")) | |
189 | |
190 for uri_type in ("invitees", "blog"): | |
191 try: | |
192 elt = next(event_elt.elements(NS_EVENT, uri_type)) | |
193 uri = data[uri_type + "_uri"] = elt["uri"] | |
194 uri_data = xmpp_uri.parse_xmpp_uri(uri) | |
195 if uri_data["type"] != "pubsub": | |
196 raise ValueError | |
197 except StopIteration: | |
198 log.warning(_("no {uri_type} element found!").format(uri_type=uri_type)) | |
199 except KeyError: | |
200 log.warning(_("incomplete {uri_type} element").format(uri_type=uri_type)) | |
201 except ValueError: | |
202 log.warning(_("bad {uri_type} element").format(uri_type=uri_type)) | |
203 else: | |
204 data[uri_type + "_service"] = uri_data["path"] | |
205 data[uri_type + "_node"] = uri_data["node"] | |
206 | |
207 for meta_elt in event_elt.elements(NS_EVENT, "meta"): | |
208 key = meta_elt["name"] | |
209 if key in data: | |
210 log.warning( | |
211 "Ignoring conflicting meta element: {xml}".format( | |
212 xml=meta_elt.toXml() | |
213 ) | |
214 ) | |
215 continue | |
216 data[key] = str(meta_elt) | |
217 if event_elt.link: | |
218 link_elt = event_elt.link | |
219 data["service"] = link_elt["service"] | |
220 data["node"] = link_elt["node"] | |
221 data["item"] = link_elt["item"] | |
222 if event_elt.getAttribute("creator") == "true": | |
223 data["creator"] = True | |
224 return timestamp, data | |
225 | |
226 def event_elt_2_event_data(self, event_elt: domish.Element) -> Dict[str, Any]: | |
227 """Convert <event/> element to event data | |
228 | |
229 @param event_elt: <event/> element | |
230 parent <item/> element can also be used | |
231 @raise exceptions.NotFound: can't find event payload | |
232 @raise ValueError: something is missing or badly formed | |
233 """ | |
234 if event_elt.name == "item": | |
235 try: | |
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 | |
263 try: | |
264 start_elt = next(event_elt.elements(NS_EVENTS, "start")) | |
265 except StopIteration: | |
266 raise exceptions.NotFound("<start/> element is missing") | |
267 event_data["start"] = utils.parse_xmpp_date(str(start_elt)) | |
268 | |
269 # end | |
270 | |
271 try: | |
272 end_elt = next(event_elt.elements(NS_EVENTS, "end")) | |
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.data_form_2_data_dict(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.get_client(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.get_items( | |
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 ): | |
474 client = self.host.get_client(profile_key) | |
475 return defer.ensureDeferred( | |
476 self.event_create( | |
477 client, | |
478 data_format.deserialise(data_s), | |
479 jid.JID(service) if service else None, | |
480 node or None, | |
481 event_id or None | |
482 ) | |
483 ) | |
484 | |
485 def event_data_2_event_elt(self, event_data: Dict[str, Any]) -> domish.Element: | |
486 """Convert Event Data to corresponding Element | |
487 | |
488 @param event_data: data of the event with keys as follow: | |
489 name (dict) | |
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.data_dict_2_data_form 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 | |
594 """ | |
595 event_elt = domish.Element((NS_EVENTS, "event")) | |
596 try: | |
597 for lang, name in event_data["name"].items(): | |
598 name_elt = event_elt.addElement("name", content=name) | |
599 if lang: | |
600 name_elt["xml:lang"] = lang | |
601 except (KeyError, TypeError): | |
602 raise ValueError('"name" field is not a dict mapping language to event name') | |
603 try: | |
604 event_elt.addElement("start", content=utils.xmpp_date(event_data["start"])) | |
605 event_elt.addElement("end", content=utils.xmpp_date(event_data["end"])) | |
606 except (KeyError, TypeError, ValueError): | |
607 raise ValueError('"start" and "end" fields are mandatory') | |
608 | |
609 if "head-picture" in event_data: | |
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.data_dict_2_data_form(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: | |
774 """Create or replace an event | |
775 | |
776 @param event_data: data of the event (cf. [event_data_2_event_elt]) | |
777 @param node: PubSub node of the event | |
778 None to use default node (default namespace for personal agenda) | |
779 @param service: PubSub service | |
780 None to use profile's PEP | |
781 @param event_id: ID of the item to create. | |
782 """ | |
783 if not service: | |
784 service = client.jid.userhostJID() | |
785 if not node: | |
786 node = NS_EVENTS | |
787 if event_id is None: | |
788 event_id = shortuuid.uuid() | |
789 event_elt = self.event_data_2_event_elt(event_data) | |
790 | |
791 item_elt = pubsub.Item(id=event_id, payload=event_elt) | |
792 options = {self._p.OPT_ACCESS_MODEL: self._p.ACCESS_WHITELIST} | |
793 await self._p.create_if_new_node( | |
794 client, service, nodeIdentifier=node, options=options | |
795 ) | |
796 await self._p.publish(client, service, node, items=[item_elt]) | |
797 if event_data.get("rsvp"): | |
798 await self._a.create_attachments_node(client, service, node, event_id) | |
799 | |
800 def _event_modify( | |
801 self, | |
802 data_s: str, | |
803 event_id: str, | |
804 service: str, | |
805 node: str, | |
806 profile_key: str = C.PROF_KEY_NONE | |
807 ) -> None: | |
808 client = self.host.get_client(profile_key) | |
809 defer.ensureDeferred( | |
810 self.event_modify( | |
811 client, | |
812 data_format.deserialise(data_s), | |
813 event_id, | |
814 jid.JID(service) if service else None, | |
815 node or None, | |
816 ) | |
817 ) | |
818 | |
819 async def event_modify( | |
820 self, | |
821 client: SatXMPPEntity, | |
822 event_data: Dict[str, Any], | |
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""" | |
848 try: | |
849 rsvp_elt = next( | |
850 attachments_elt.elements(NS_EVENTS, "rsvp") | |
851 ) | |
852 except StopIteration: | |
853 pass | |
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.set_timestamp(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 } | |
876 ) | |
877 rsvp_form = data_form.Form("submit", formNamespace=NS_RSVP) | |
878 rsvp_form.makeFields(rsvp_data) | |
879 rsvp_elt.addChild(rsvp_form.toElement()) | |
880 return rsvp_elt | |
881 else: | |
882 return None | |
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.get_client(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 [get_attachments] | |
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.get_attachments( | |
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 ): | |
955 client = self.host.get_client(profile_key) | |
956 return defer.ensureDeferred( | |
957 self.event_invitee_set( | |
958 client, | |
959 jid.JID(service) if service else None, | |
960 node or None, | |
961 item, | |
962 data_format.deserialise(rsvp_s) | |
963 ) | |
964 ) | |
965 | |
966 async def event_invitee_set( | |
967 self, | |
968 client: SatXMPPEntity, | |
969 service: Optional[jid.JID], | |
970 node: Optional[str], | |
971 item: str, | |
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) | |
980 """ | |
981 if service is None: | |
982 service = client.jid.userhostJID() | |
983 if node is None: | |
984 node = NS_EVENTS | |
985 await self._a.set_attachements(client, { | |
986 "service": service.full(), | |
987 "node": node, | |
988 "id": item, | |
989 "extra": {"rsvp": rsvp} | |
990 }) | |
991 | |
992 def _event_invitees_list(self, service, node, profile_key): | |
993 service = jid.JID(service) if service else None | |
994 node = node if node else NS_EVENT | |
995 client = self.host.get_client(profile_key) | |
996 return defer.ensureDeferred( | |
997 self.event_invitees_list(client, service, node) | |
998 ) | |
999 | |
1000 async def event_invitees_list(self, client, service, node): | |
1001 """Retrieve attendance from event node | |
1002 | |
1003 @param service(unicode, None): PubSub service | |
1004 @param node(unicode): PubSub node of the event | |
1005 @return (dict): a dict with current attendance status, | |
1006 an empty dict is returned if nothing has been answered yed | |
1007 """ | |
1008 items, metadata = await self._p.get_items(client, service, node) | |
1009 invitees = {} | |
1010 for item in items: | |
1011 try: | |
1012 event_elt = next(item.elements(NS_EVENT, "invitee")) | |
1013 except StopIteration: | |
1014 # no item found, event data are not set yet | |
1015 log.warning(_( | |
1016 "no data found for {item_id} (service: {service}, node: {node})" | |
1017 .format(item_id=item["id"], service=service, node=node))) | |
1018 else: | |
1019 data = {} | |
1020 for key in ("attend", "guests"): | |
1021 try: | |
1022 data[key] = event_elt[key] | |
1023 except KeyError: | |
1024 continue | |
1025 invitees[item["id"]] = data | |
1026 return invitees | |
1027 | |
1028 async def invite_preflight( | |
1029 self, | |
1030 client: SatXMPPEntity, | |
1031 invitee_jid: jid.JID, | |
1032 service: jid.JID, | |
1033 node: str, | |
1034 item_id: Optional[str] = None, | |
1035 name: str = '', | |
1036 extra: Optional[dict] = None, | |
1037 ) -> None: | |
1038 if self._b is None: | |
1039 raise exceptions.FeatureNotFound( | |
1040 _('"XEP-0277" (blog) plugin is needed for this feature') | |
1041 ) | |
1042 if item_id is None: | |
1043 item_id = extra["default_item_id"] = NS_EVENT | |
1044 | |
1045 __, event_data = await self.events_get(client, service, node, item_id) | |
1046 log.debug(_("got event data")) | |
1047 invitees_service = jid.JID(event_data["invitees_service"]) | |
1048 invitees_node = event_data["invitees_node"] | |
1049 blog_service = jid.JID(event_data["blog_service"]) | |
1050 blog_node = event_data["blog_node"] | |
1051 await self._p.set_node_affiliations( | |
1052 client, invitees_service, invitees_node, {invitee_jid: "publisher"} | |
1053 ) | |
1054 log.debug( | |
1055 f"affiliation set on invitee node (jid: {invitees_service}, " | |
1056 f"node: {invitees_node!r})" | |
1057 ) | |
1058 await self._p.set_node_affiliations( | |
1059 client, blog_service, blog_node, {invitee_jid: "member"} | |
1060 ) | |
1061 blog_items, __ = await self._b.mb_get(client, blog_service, blog_node, None) | |
1062 | |
1063 for item in blog_items: | |
1064 try: | |
1065 comments_service = jid.JID(item["comments_service"]) | |
1066 comments_node = item["comments_node"] | |
1067 except KeyError: | |
1068 log.debug( | |
1069 "no comment service set for item {item_id}".format( | |
1070 item_id=item["id"] | |
1071 ) | |
1072 ) | |
1073 else: | |
1074 await self._p.set_node_affiliations( | |
1075 client, comments_service, comments_node, {invitee_jid: "publisher"} | |
1076 ) | |
1077 log.debug(_("affiliation set on blog and comments nodes")) | |
1078 | |
1079 def _invite(self, invitee_jid, service, node, item_id, profile): | |
1080 return self.host.plugins["PUBSUB_INVITATION"]._send_pubsub_invitation( | |
1081 invitee_jid, service, node, item_id or NS_EVENT, profile_key=profile | |
1082 ) | |
1083 | |
1084 def _invite_by_email(self, service, node, id_=NS_EVENT, email="", emails_extra=None, | |
1085 name="", host_name="", language="", url_template="", | |
1086 message_subject="", message_body="", | |
1087 profile_key=C.PROF_KEY_NONE): | |
1088 client = self.host.get_client(profile_key) | |
1089 kwargs = { | |
1090 "profile": client.profile, | |
1091 "emails_extra": [str(e) for e in emails_extra], | |
1092 } | |
1093 for key in ( | |
1094 "email", | |
1095 "name", | |
1096 "host_name", | |
1097 "language", | |
1098 "url_template", | |
1099 "message_subject", | |
1100 "message_body", | |
1101 ): | |
1102 value = locals()[key] | |
1103 kwargs[key] = str(value) | |
1104 return defer.ensureDeferred(self.invite_by_email( | |
1105 client, jid.JID(service) if service else None, node, id_ or NS_EVENT, **kwargs | |
1106 )) | |
1107 | |
1108 async def invite_by_email(self, client, service, node, id_=NS_EVENT, **kwargs): | |
1109 """High level method to create an email invitation to an event | |
1110 | |
1111 @param service(unicode, None): PubSub service | |
1112 @param node(unicode): PubSub node of the event | |
1113 @param id_(unicode): id_ with even data | |
1114 """ | |
1115 if self._i is None: | |
1116 raise exceptions.FeatureNotFound( | |
1117 _('"Invitations" plugin is needed for this feature') | |
1118 ) | |
1119 if self._b is None: | |
1120 raise exceptions.FeatureNotFound( | |
1121 _('"XEP-0277" (blog) plugin is needed for this feature') | |
1122 ) | |
1123 service = service or client.jid.userhostJID() | |
1124 event_uri = xmpp_uri.build_xmpp_uri( | |
1125 "pubsub", path=service.full(), node=node, item=id_ | |
1126 ) | |
1127 kwargs["extra"] = {"event_uri": event_uri} | |
1128 invitation_data = await self._i.create(**kwargs) | |
1129 invitee_jid = invitation_data["jid"] | |
1130 log.debug(_("invitation created")) | |
1131 # now that we have a jid, we can send normal invitation | |
1132 await self.invite(client, invitee_jid, service, node, id_) | |
1133 | |
1134 def on_invitation_preflight( | |
1135 self, | |
1136 client: SatXMPPEntity, | |
1137 name: str, | |
1138 extra: dict, | |
1139 service: jid.JID, | |
1140 node: str, | |
1141 item_id: Optional[str], | |
1142 item_elt: domish.Element | |
1143 ) -> None: | |
1144 event_elt = item_elt.event | |
1145 link_elt = event_elt.addElement("link") | |
1146 link_elt["service"] = service.full() | |
1147 link_elt["node"] = node | |
1148 link_elt["item"] = item_id | |
1149 __, event_data = self._parse_event_elt(event_elt) | |
1150 try: | |
1151 name = event_data["name"] | |
1152 except KeyError: | |
1153 pass | |
1154 else: | |
1155 extra["name"] = name | |
1156 if 'image' in event_data: | |
1157 extra["thumb_url"] = event_data['image'] | |
1158 extra["element"] = event_elt | |
1159 | |
1160 | |
1161 @implementer(iwokkel.IDisco) | |
1162 class EventsHandler(XMPPHandler): | |
1163 | |
1164 def __init__(self, plugin_parent): | |
1165 self.plugin_parent = plugin_parent | |
1166 | |
1167 def getDiscoInfo(self, requestor, target, nodeIdentifier=""): | |
1168 return [ | |
1169 disco.DiscoFeature(NS_EVENTS), | |
1170 ] | |
1171 | |
1172 def getDiscoItems(self, requestor, target, nodeIdentifier=""): | |
1173 return [] |