comparison libervia/backend/plugins/plugin_misc_lists.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_misc_lists.py@524856bd7b19
children 0d7bb4df2343
comparison
equal deleted inserted replaced
4070:d10748475025 4071:4b842c1fb686
1 #!/usr/bin/env python3
2
3 # Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
4
5 # This program is free software: you can redistribute it and/or modify
6 # it under the terms of the GNU Affero General Public License as published by
7 # the Free Software Foundation, either version 3 of the License, or
8 # (at your option) any later version.
9
10 # This program is distributed in the hope that it will be useful,
11 # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 # GNU Affero General Public License for more details.
14
15 # You should have received a copy of the GNU Affero General Public License
16 # along with this program. If not, see <http://www.gnu.org/licenses/>.
17
18 import shortuuid
19 from typing import List, Tuple, Optional
20 from twisted.internet import defer
21 from twisted.words.xish import domish
22 from twisted.words.protocols.jabber import jid
23 from libervia.backend.core.i18n import _, D_
24 from libervia.backend.core.xmpp import SatXMPPEntity
25 from libervia.backend.core.constants import Const as C
26 from libervia.backend.tools import xml_tools
27 from libervia.backend.tools.common import uri
28 from libervia.backend.tools.common import data_format
29 from libervia.backend.core.log import getLogger
30
31 log = getLogger(__name__)
32
33 # XXX: this plugin was formely named "tickets", thus the namespace keeps this
34 # name
35 APP_NS_TICKETS = "org.salut-a-toi.tickets:0"
36 NS_TICKETS_TYPE = "org.salut-a-toi.tickets#type:0"
37
38 PLUGIN_INFO = {
39 C.PI_NAME: _("Pubsub Lists"),
40 C.PI_IMPORT_NAME: "LISTS",
41 C.PI_TYPE: "EXP",
42 C.PI_PROTOCOLS: [],
43 C.PI_DEPENDENCIES: ["XEP-0060", "XEP-0346", "XEP-0277", "IDENTITY",
44 "PUBSUB_INVITATION"],
45 C.PI_MAIN: "PubsubLists",
46 C.PI_HANDLER: "no",
47 C.PI_DESCRIPTION: _("""Pubsub lists management plugin"""),
48 }
49
50 TEMPLATES = {
51 "todo": {
52 "name": D_("TODO List"),
53 "icon": "check",
54 "fields": [
55 {"name": "title"},
56 {"name": "author"},
57 {"name": "created"},
58 {"name": "updated"},
59 {"name": "time_limit"},
60 {"name": "labels", "type": "text-multi"},
61 {
62 "name": "status",
63 "label": D_("status"),
64 "type": "list-single",
65 "options": [
66 {
67 "label": D_("to do"),
68 "value": "todo"
69 },
70 {
71 "label": D_("in progress"),
72 "value": "in_progress"
73 },
74 {
75 "label": D_("done"),
76 "value": "done"
77 },
78 ],
79 "value": "todo"
80 },
81 {
82 "name": "priority",
83 "label": D_("priority"),
84 "type": "list-single",
85 "options": [
86 {
87 "label": D_("major"),
88 "value": "major"
89 },
90 {
91 "label": D_("normal"),
92 "value": "normal"
93 },
94 {
95 "label": D_("minor"),
96 "value": "minor"
97 },
98 ],
99 "value": "normal"
100 },
101 {"name": "body", "type": "xhtml"},
102 {"name": "comments_uri"},
103 ]
104 },
105 "grocery": {
106 "name": D_("Grocery List"),
107 "icon": "basket",
108 "fields": [
109 {"name": "name", "label": D_("name")},
110 {"name": "quantity", "label": D_("quantity")},
111 {
112 "name": "status",
113 "label": D_("status"),
114 "type": "list-single",
115 "options": [
116 {
117 "label": D_("to buy"),
118 "value": "to_buy"
119 },
120 {
121 "label": D_("bought"),
122 "value": "bought"
123 },
124 ],
125 "value": "to_buy"
126 },
127 ]
128 },
129 "tickets": {
130 "name": D_("Tickets"),
131 "icon": "clipboard",
132 "fields": [
133 {"name": "title"},
134 {"name": "author"},
135 {"name": "created"},
136 {"name": "updated"},
137 {"name": "labels", "type": "text-multi"},
138 {
139 "name": "type",
140 "label": D_("type"),
141 "type": "list-single",
142 "options": [
143 {
144 "label": D_("bug"),
145 "value": "bug"
146 },
147 {
148 "label": D_("feature request"),
149 "value": "feature"
150 },
151 ],
152 "value": "bug"
153 },
154 {
155 "name": "status",
156 "label": D_("status"),
157 "type": "list-single",
158 "options": [
159 {
160 "label": D_("queued"),
161 "value": "queued"
162 },
163 {
164 "label": D_("started"),
165 "value": "started"
166 },
167 {
168 "label": D_("review"),
169 "value": "review"
170 },
171 {
172 "label": D_("closed"),
173 "value": "closed"
174 },
175 ],
176 "value": "queued"
177 },
178 {
179 "name": "priority",
180 "label": D_("priority"),
181 "type": "list-single",
182 "options": [
183 {
184 "label": D_("major"),
185 "value": "major"
186 },
187 {
188 "label": D_("normal"),
189 "value": "normal"
190 },
191 {
192 "label": D_("minor"),
193 "value": "minor"
194 },
195 ],
196 "value": "normal"
197 },
198 {"name": "body", "type": "xhtml"},
199 {"name": "comments_uri"},
200 ]
201 }
202 }
203
204
205 class PubsubLists:
206
207 def __init__(self, host):
208 log.info(_("Pubsub lists plugin initialization"))
209 self.host = host
210 self._s = self.host.plugins["XEP-0346"]
211 self.namespace = self._s.get_submitted_ns(APP_NS_TICKETS)
212 host.register_namespace("tickets", APP_NS_TICKETS)
213 host.register_namespace("tickets_type", NS_TICKETS_TYPE)
214 self.host.plugins["PUBSUB_INVITATION"].register(
215 APP_NS_TICKETS, self
216 )
217 self._p = self.host.plugins["XEP-0060"]
218 self._m = self.host.plugins["XEP-0277"]
219 host.bridge.add_method(
220 "list_get",
221 ".plugin",
222 in_sign="ssiassss",
223 out_sign="s",
224 method=lambda service, node, max_items, items_ids, sub_id, extra, profile_key:
225 self._s._get(
226 service,
227 node,
228 max_items,
229 items_ids,
230 sub_id,
231 extra,
232 default_node=self.namespace,
233 form_ns=APP_NS_TICKETS,
234 filters={
235 "author": self._s.value_or_publisher_filter,
236 "created": self._s.date_filter,
237 "updated": self._s.date_filter,
238 "time_limit": self._s.date_filter,
239 },
240 profile_key=profile_key),
241 async_=True,
242 )
243 host.bridge.add_method(
244 "list_set",
245 ".plugin",
246 in_sign="ssa{sas}ssss",
247 out_sign="s",
248 method=self._set,
249 async_=True,
250 )
251 host.bridge.add_method(
252 "list_delete_item",
253 ".plugin",
254 in_sign="sssbs",
255 out_sign="",
256 method=self._delete,
257 async_=True,
258 )
259 host.bridge.add_method(
260 "list_schema_get",
261 ".plugin",
262 in_sign="sss",
263 out_sign="s",
264 method=lambda service, nodeIdentifier, profile_key: self._s._get_ui_schema(
265 service, nodeIdentifier, default_node=self.namespace,
266 profile_key=profile_key),
267 async_=True,
268 )
269 host.bridge.add_method(
270 "lists_list",
271 ".plugin",
272 in_sign="sss",
273 out_sign="s",
274 method=self._lists_list,
275 async_=True,
276 )
277 host.bridge.add_method(
278 "list_templates_names_get",
279 ".plugin",
280 in_sign="ss",
281 out_sign="s",
282 method=self._get_templates_names,
283 )
284 host.bridge.add_method(
285 "list_template_get",
286 ".plugin",
287 in_sign="sss",
288 out_sign="s",
289 method=self._get_template,
290 )
291 host.bridge.add_method(
292 "list_template_create",
293 ".plugin",
294 in_sign="ssss",
295 out_sign="(ss)",
296 method=self._create_template,
297 async_=True,
298 )
299
300 async def on_invitation_preflight(
301 self,
302 client: SatXMPPEntity,
303 namespace: str,
304 name: str,
305 extra: dict,
306 service: jid.JID,
307 node: str,
308 item_id: Optional[str],
309 item_elt: domish.Element
310 ) -> None:
311 try:
312 schema = await self._s.get_schema_form(client, service, node)
313 except Exception as e:
314 log.warning(f"Can't retrive node schema as {node!r} [{service}]: {e}")
315 else:
316 try:
317 field_type = schema[NS_TICKETS_TYPE]
318 except KeyError:
319 log.debug("no type found in list schema")
320 else:
321 list_elt = extra["element"] = domish.Element((APP_NS_TICKETS, "list"))
322 list_elt["type"] = field_type
323
324 def _set(self, service, node, values, schema=None, item_id=None, extra_s='',
325 profile_key=C.PROF_KEY_NONE):
326 client, service, node, schema, item_id, extra = self._s.prepare_bridge_set(
327 service, node, schema, item_id, extra_s, profile_key
328 )
329 d = defer.ensureDeferred(self.set(
330 client, service, node, values, schema, item_id, extra, deserialise=True
331 ))
332 d.addCallback(lambda ret: ret or "")
333 return d
334
335 async def set(
336 self, client, service, node, values, schema=None, item_id=None, extra=None,
337 deserialise=False, form_ns=APP_NS_TICKETS
338 ):
339 """Publish a tickets
340
341 @param node(unicode, None): Pubsub node to use
342 None to use default tickets node
343 @param values(dict[key(unicode), [iterable[object]|object]]): values of the ticket
344
345 if value is not iterable, it will be put in a list
346 'created' and 'updated' will be forced to current time:
347 - 'created' is set if item_id is None, i.e. if it's a new ticket
348 - 'updated' is set everytime
349 @param extra(dict, None): same as for [XEP-0060.send_item] with additional keys:
350 - update(bool): if True, get previous item data to merge with current one
351 if True, item_id must be set
352 other arguments are same as for [self._s.send_data_form_item]
353 @return (unicode): id of the created item
354 """
355 if not node:
356 node = self.namespace
357
358 if not item_id:
359 comments_service = await self._m.get_comments_service(client, service)
360
361 # we need to use uuid for comments node, because we don't know item id in
362 # advance (we don't want to set it ourselves to let the server choose, so we
363 # can have a nicer id if serial ids is activated)
364 comments_node = self._m.get_comments_node(
365 node + "_" + str(shortuuid.uuid())
366 )
367 options = {
368 self._p.OPT_ACCESS_MODEL: self._p.ACCESS_OPEN,
369 self._p.OPT_PERSIST_ITEMS: 1,
370 self._p.OPT_DELIVER_PAYLOADS: 1,
371 self._p.OPT_SEND_ITEM_SUBSCRIBE: 1,
372 self._p.OPT_PUBLISH_MODEL: self._p.ACCESS_OPEN,
373 }
374 await self._p.createNode(client, comments_service, comments_node, options)
375 values["comments_uri"] = uri.build_xmpp_uri(
376 "pubsub",
377 subtype="microblog",
378 path=comments_service.full(),
379 node=comments_node,
380 )
381
382 return await self._s.set(
383 client, service, node, values, schema, item_id, extra, deserialise, form_ns
384 )
385
386 def _delete(
387 self, service_s, nodeIdentifier, itemIdentifier, notify, profile_key
388 ):
389 client = self.host.get_client(profile_key)
390 return defer.ensureDeferred(self.delete(
391 client,
392 jid.JID(service_s) if service_s else None,
393 nodeIdentifier,
394 itemIdentifier,
395 notify
396 ))
397
398 async def delete(
399 self,
400 client: SatXMPPEntity,
401 service: Optional[jid.JID],
402 node: Optional[str],
403 itemIdentifier: str,
404 notify: Optional[bool] = None
405 ) -> None:
406 if not node:
407 node = self.namespace
408 return await self._p.retract_items(
409 service, node, (itemIdentifier,), notify, client.profile
410 )
411
412 def _lists_list(self, service, node, profile):
413 service = jid.JID(service) if service else None
414 node = node or None
415 client = self.host.get_client(profile)
416 d = defer.ensureDeferred(self.lists_list(client, service, node))
417 d.addCallback(data_format.serialise)
418 return d
419
420 async def lists_list(
421 self, client, service: Optional[jid.JID], node: Optional[str]=None
422 ) -> List[dict]:
423 """Retrieve list of pubsub lists registered in personal interests
424
425 @return list: list of lists metadata
426 """
427 items, metadata = await self.host.plugins['LIST_INTEREST'].list_interests(
428 client, service, node, namespace=APP_NS_TICKETS)
429 lists = []
430 for item in items:
431 interest_elt = item.interest
432 if interest_elt is None:
433 log.warning(f"invalid interest for {client.profile}: {item.toXml}")
434 continue
435 if interest_elt.getAttribute("namespace") != APP_NS_TICKETS:
436 continue
437 pubsub_elt = interest_elt.pubsub
438 list_data = {
439 "id": item["id"],
440 "name": interest_elt["name"],
441 "service": pubsub_elt["service"],
442 "node": pubsub_elt["node"],
443 "creator": C.bool(pubsub_elt.getAttribute("creator", C.BOOL_FALSE)),
444 }
445 try:
446 list_elt = next(pubsub_elt.elements(APP_NS_TICKETS, "list"))
447 except StopIteration:
448 pass
449 else:
450 list_type = list_data["type"] = list_elt["type"]
451 if list_type in TEMPLATES:
452 list_data["icon_name"] = TEMPLATES[list_type]["icon"]
453 lists.append(list_data)
454
455 return lists
456
457 def _get_templates_names(self, language, profile):
458 client = self.host.get_client(profile)
459 return data_format.serialise(self.get_templates_names(client, language))
460
461 def get_templates_names(self, client, language: str) -> list:
462 """Retrieve well known list templates"""
463
464 templates = [{"id": tpl_id, "name": d["name"], "icon": d["icon"]}
465 for tpl_id, d in TEMPLATES.items()]
466 return templates
467
468 def _get_template(self, name, language, profile):
469 client = self.host.get_client(profile)
470 return data_format.serialise(self.get_template(client, name, language))
471
472 def get_template(self, client, name: str, language: str) -> dict:
473 """Retrieve a well known template"""
474 return TEMPLATES[name]
475
476 def _create_template(self, template_id, name, access_model, profile):
477 client = self.host.get_client(profile)
478 d = defer.ensureDeferred(self.create_template(
479 client, template_id, name, access_model
480 ))
481 d.addCallback(lambda node_data: (node_data[0].full(), node_data[1]))
482 return d
483
484 async def create_template(
485 self, client, template_id: str, name: str, access_model: str
486 ) -> Tuple[jid.JID, str]:
487 """Create a list from a template"""
488 name = name.strip()
489 if not name:
490 name = shortuuid.uuid()
491 fields = TEMPLATES[template_id]["fields"].copy()
492 fields.insert(
493 0,
494 {"type": "hidden", "name": NS_TICKETS_TYPE, "value": template_id}
495 )
496 schema = xml_tools.data_dict_2_data_form(
497 {"namespace": APP_NS_TICKETS, "fields": fields}
498 ).toElement()
499
500 service = client.jid.userhostJID()
501 node = self._s.get_submitted_ns(f"{APP_NS_TICKETS}_{name}")
502 options = {
503 self._p.OPT_ACCESS_MODEL: access_model,
504 }
505 if template_id == "grocery":
506 # for grocery list, we want all publishers to be able to set all items
507 # XXX: should node options be in TEMPLATE?
508 options[self._p.OPT_OVERWRITE_POLICY] = self._p.OWPOL_ANY_PUB
509 await self._p.createNode(client, service, node, options)
510 await self._s.set_schema(client, service, node, schema)
511 list_elt = domish.Element((APP_NS_TICKETS, "list"))
512 list_elt["type"] = template_id
513 try:
514 await self.host.plugins['LIST_INTEREST'].register_pubsub(
515 client, APP_NS_TICKETS, service, node, creator=True,
516 name=name, element=list_elt)
517 except Exception as e:
518 log.warning(f"Can't add list to interests: {e}")
519 return service, node