comparison libervia/cli/cmd_event.py @ 4075:47401850dec6

refactoring: rename `libervia.frontends.jp` to `libervia.cli`
author Goffi <goffi@goffi.org>
date Fri, 02 Jun 2023 14:54:26 +0200
parents libervia/frontends/jp/cmd_event.py@26b7ed2817da
children e9d800b105c1
comparison
equal deleted inserted replaced
4074:26b7ed2817da 4075:47401850dec6
1 #!/usr/bin/env python3
2
3
4 # libervia-cli: Libervia CLI frontend
5 # Copyright (C) 2009-2021 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
21 import argparse
22 import sys
23
24 from sqlalchemy import desc
25
26 from libervia.backend.core.i18n import _
27 from libervia.backend.core.i18n import _
28 from libervia.backend.tools.common import data_format
29 from libervia.backend.tools.common import data_format
30 from libervia.backend.tools.common import date_utils
31 from libervia.backend.tools.common.ansi import ANSI as A
32 from libervia.backend.tools.common.ansi import ANSI as A
33 from libervia.cli import common
34 from libervia.cli.constants import Const as C
35 from libervia.cli.constants import Const as C
36
37 from . import base
38
39 __commands__ = ["Event"]
40
41 OUTPUT_OPT_TABLE = "table"
42
43
44 class Get(base.CommandBase):
45 def __init__(self, host):
46 base.CommandBase.__init__(
47 self,
48 host,
49 "get",
50 use_output=C.OUTPUT_LIST_DICT,
51 use_pubsub=True,
52 pubsub_flags={C.MULTI_ITEMS, C.CACHE},
53 use_verbose=True,
54 extra_outputs={
55 "default": self.default_output,
56 },
57 help=_("get event(s) data"),
58 )
59
60 def add_parser_options(self):
61 pass
62
63 async def start(self):
64 try:
65 events_data_s = await self.host.bridge.events_get(
66 self.args.service,
67 self.args.node,
68 self.args.items,
69 self.get_pubsub_extra(),
70 self.profile,
71 )
72 except Exception as e:
73 self.disp(f"can't get events data: {e}", error=True)
74 self.host.quit(C.EXIT_BRIDGE_ERRBACK)
75 else:
76 events_data = data_format.deserialise(events_data_s, type_check=list)
77 await self.output(events_data)
78 self.host.quit()
79
80 def default_output(self, events):
81 nb_events = len(events)
82 for idx, event in enumerate(events):
83 names = event["name"]
84 name = names.get("") or next(iter(names.values()))
85 start = event["start"]
86 start_human = date_utils.date_fmt(
87 start, "medium", tz_info=date_utils.TZ_LOCAL
88 )
89 end = event["end"]
90 self.disp(A.color(
91 A.BOLD, start_human, A.RESET, " ",
92 f"({date_utils.delta2human(start, end)}) ",
93 C.A_HEADER, name
94 ))
95 if self.verbosity > 0:
96 descriptions = event.get("descriptions", [])
97 if descriptions:
98 self.disp(descriptions[0]["description"])
99 if idx < (nb_events-1):
100 self.disp("")
101
102
103 class CategoryAction(argparse.Action):
104
105 def __init__(self, option_strings, dest, nargs=None, metavar=None, **kwargs):
106 if nargs is not None or metavar is not None:
107 raise ValueError("nargs and metavar must not be used")
108 if metavar is not None:
109 metavar="TERM WIKIDATA_ID LANG"
110 if "--help" in sys.argv:
111 # FIXME: dirty workaround to have correct --help message
112 # argparse doesn't normally allow variable number of arguments beside "+"
113 # and "*", this workaround show METAVAR as 3 arguments were expected, while
114 # we can actuall use 1, 2 or 3.
115 nargs = 3
116 metavar = ("TERM", "[WIKIDATA_ID]", "[LANG]")
117 else:
118 nargs = "+"
119
120 super().__init__(option_strings, dest, metavar=metavar, nargs=nargs, **kwargs)
121
122 def __call__(self, parser, namespace, values, option_string=None):
123 categories = getattr(namespace, self.dest)
124 if categories is None:
125 categories = []
126 setattr(namespace, self.dest, categories)
127
128 if not values:
129 parser.error("category values must be set")
130
131 category = {
132 "term": values[0]
133 }
134
135 if len(values) == 1:
136 pass
137 elif len(values) == 2:
138 value = values[1]
139 if value.startswith("Q"):
140 category["wikidata_id"] = value
141 else:
142 category["language"] = value
143 elif len(values) == 3:
144 __, wd, lang = values
145 category["wikidata_id"] = wd
146 category["language"] = lang
147 else:
148 parser.error("Category can't have more than 3 arguments")
149
150 categories.append(category)
151
152
153 class EventBase:
154 def add_parser_options(self):
155 self.parser.add_argument(
156 "-S", "--start", type=base.date_decoder, metavar="TIME_PATTERN",
157 help=_("the start time of the event"))
158 end_group = self.parser.add_mutually_exclusive_group()
159 end_group.add_argument(
160 "-E", "--end", type=base.date_decoder, metavar="TIME_PATTERN",
161 help=_("the time of the end of the event"))
162 end_group.add_argument(
163 "-D", "--duration", help=_("duration of the event"))
164 self.parser.add_argument(
165 "-H", "--head-picture", help="URL to a picture to use as head-picture"
166 )
167 self.parser.add_argument(
168 "-d", "--description", help="plain text description the event"
169 )
170 self.parser.add_argument(
171 "-C", "--category", action=CategoryAction, dest="categories",
172 help="Category of the event"
173 )
174 self.parser.add_argument(
175 "-l", "--location", action="append", nargs="+", metavar="[KEY] VALUE",
176 help="Location metadata"
177 )
178 rsvp_group = self.parser.add_mutually_exclusive_group()
179 rsvp_group.add_argument(
180 "--rsvp", action="store_true", help=_("RSVP is requested"))
181 rsvp_group.add_argument(
182 "--rsvp_json", metavar="JSON", help=_("JSON description of RSVP form"))
183 for node_type in ("invitees", "comments", "blog", "schedule"):
184 self.parser.add_argument(
185 f"--{node_type}",
186 nargs=2,
187 metavar=("JID", "NODE"),
188 help=_("link {node_type} pubsub node").format(node_type=node_type)
189 )
190 self.parser.add_argument(
191 "-a", "--attachment", action="append", dest="attachments",
192 help=_("attach a file")
193 )
194 self.parser.add_argument("--website", help=_("website of the event"))
195 self.parser.add_argument(
196 "--status", choices=["confirmed", "tentative", "cancelled"],
197 help=_("status of the event")
198 )
199 self.parser.add_argument(
200 "-T", "--language", metavar="LANG", action="append", dest="languages",
201 help=_("main languages spoken at the event")
202 )
203 self.parser.add_argument(
204 "--wheelchair", choices=["full", "partial", "no"],
205 help=_("is the location accessible by wheelchair")
206 )
207 self.parser.add_argument(
208 "--external",
209 nargs=3,
210 metavar=("JID", "NODE", "ITEM"),
211 help=_("link to an external event")
212 )
213
214 def get_event_data(self):
215 if self.args.duration is not None:
216 if self.args.start is None:
217 self.parser.error("--start must be send if --duration is used")
218 # if duration is used, we simply add it to start time to get end time
219 self.args.end = base.date_decoder(f"{self.args.start} + {self.args.duration}")
220
221 event = {}
222 if self.args.name is not None:
223 event["name"] = {"": self.args.name}
224
225 if self.args.start is not None:
226 event["start"] = self.args.start
227
228 if self.args.end is not None:
229 event["end"] = self.args.end
230
231 if self.args.head_picture:
232 event["head-picture"] = {
233 "sources": [{
234 "url": self.args.head_picture
235 }]
236 }
237 if self.args.description:
238 event["descriptions"] = [
239 {
240 "type": "text",
241 "description": self.args.description
242 }
243 ]
244 if self.args.categories:
245 event["categories"] = self.args.categories
246 if self.args.location is not None:
247 location = {}
248 for location_data in self.args.location:
249 if len(location_data) == 1:
250 location["description"] = location_data[0]
251 else:
252 key, *values = location_data
253 location[key] = " ".join(values)
254 event["locations"] = [location]
255
256 if self.args.rsvp:
257 event["rsvp"] = [{}]
258 elif self.args.rsvp_json:
259 if isinstance(self.args.rsvp_elt, dict):
260 event["rsvp"] = [self.args.rsvp_json]
261 else:
262 event["rsvp"] = self.args.rsvp_json
263
264 for node_type in ("invitees", "comments", "blog", "schedule"):
265 value = getattr(self.args, node_type)
266 if value:
267 service, node = value
268 event[node_type] = {"service": service, "node": node}
269
270 if self.args.attachments:
271 attachments = event["attachments"] = []
272 for attachment in self.args.attachments:
273 attachments.append({
274 "sources": [{"url": attachment}]
275 })
276
277 extra = {}
278
279 for arg in ("website", "status", "languages"):
280 value = getattr(self.args, arg)
281 if value is not None:
282 extra[arg] = value
283 if self.args.wheelchair is not None:
284 extra["accessibility"] = {"wheelchair": self.args.wheelchair}
285
286 if extra:
287 event["extra"] = extra
288
289 if self.args.external:
290 ext_jid, ext_node, ext_item = self.args.external
291 event["external"] = {
292 "jid": ext_jid,
293 "node": ext_node,
294 "item": ext_item
295 }
296 return event
297
298
299 class Create(EventBase, base.CommandBase):
300 def __init__(self, host):
301 super().__init__(
302 host,
303 "create",
304 use_pubsub=True,
305 help=_("create or replace event"),
306 )
307
308 def add_parser_options(self):
309 super().add_parser_options()
310 self.parser.add_argument(
311 "-i",
312 "--id",
313 default="",
314 help=_("ID of the PubSub Item"),
315 )
316 # name is mandatory here
317 self.parser.add_argument("name", help=_("name of the event"))
318
319 async def start(self):
320 if self.args.start is None:
321 self.parser.error("--start must be set")
322 event_data = self.get_event_data()
323 # we check self.args.end after get_event_data because it may be set there id
324 # --duration is used
325 if self.args.end is None:
326 self.parser.error("--end or --duration must be set")
327 try:
328 await self.host.bridge.event_create(
329 data_format.serialise(event_data),
330 self.args.id,
331 self.args.node,
332 self.args.service,
333 self.profile,
334 )
335 except Exception as e:
336 self.disp(f"can't create event: {e}", error=True)
337 self.host.quit(C.EXIT_BRIDGE_ERRBACK)
338 else:
339 self.disp(_("Event created successfuly)"))
340 self.host.quit()
341
342
343 class Modify(EventBase, base.CommandBase):
344 def __init__(self, host):
345 super(Modify, self).__init__(
346 host,
347 "modify",
348 use_pubsub=True,
349 pubsub_flags={C.SINGLE_ITEM},
350 help=_("modify an existing event"),
351 )
352 EventBase.__init__(self)
353
354 def add_parser_options(self):
355 super().add_parser_options()
356 # name is optional here
357 self.parser.add_argument("-N", "--name", help=_("name of the event"))
358
359 async def start(self):
360 event_data = self.get_event_data()
361 try:
362 await self.host.bridge.event_modify(
363 data_format.serialise(event_data),
364 self.args.item,
365 self.args.service,
366 self.args.node,
367 self.profile,
368 )
369 except Exception as e:
370 self.disp(f"can't update event data: {e}", error=True)
371 self.host.quit(C.EXIT_BRIDGE_ERRBACK)
372 else:
373 self.host.quit()
374
375
376 class InviteeGet(base.CommandBase):
377 def __init__(self, host):
378 base.CommandBase.__init__(
379 self,
380 host,
381 "get",
382 use_output=C.OUTPUT_DICT,
383 use_pubsub=True,
384 pubsub_flags={C.SINGLE_ITEM},
385 use_verbose=True,
386 help=_("get event attendance"),
387 )
388
389 def add_parser_options(self):
390 self.parser.add_argument(
391 "-j", "--jid", action="append", dest="jids", default=[],
392 help=_("only retrieve RSVP from those JIDs")
393 )
394
395 async def start(self):
396 try:
397 event_data_s = await self.host.bridge.event_invitee_get(
398 self.args.service,
399 self.args.node,
400 self.args.item,
401 self.args.jids,
402 "",
403 self.profile,
404 )
405 except Exception as e:
406 self.disp(f"can't get event data: {e}", error=True)
407 self.host.quit(C.EXIT_BRIDGE_ERRBACK)
408 else:
409 event_data = data_format.deserialise(event_data_s)
410 await self.output(event_data)
411 self.host.quit()
412
413
414 class InviteeSet(base.CommandBase):
415 def __init__(self, host):
416 super(InviteeSet, self).__init__(
417 host,
418 "set",
419 use_pubsub=True,
420 pubsub_flags={C.SINGLE_ITEM},
421 help=_("set event attendance"),
422 )
423
424 def add_parser_options(self):
425 self.parser.add_argument(
426 "-f",
427 "--field",
428 action="append",
429 nargs=2,
430 dest="fields",
431 metavar=("KEY", "VALUE"),
432 help=_("configuration field to set"),
433 )
434
435 async def start(self):
436 # TODO: handle RSVP with XMLUI in a similar way as for `ad-hoc run`
437 fields = dict(self.args.fields) if self.args.fields else {}
438 try:
439 self.host.bridge.event_invitee_set(
440 self.args.service,
441 self.args.node,
442 self.args.item,
443 data_format.serialise(fields),
444 self.profile,
445 )
446 except Exception as e:
447 self.disp(f"can't set event data: {e}", error=True)
448 self.host.quit(C.EXIT_BRIDGE_ERRBACK)
449 else:
450 self.host.quit()
451
452
453 class InviteesList(base.CommandBase):
454 def __init__(self, host):
455 extra_outputs = {"default": self.default_output}
456 base.CommandBase.__init__(
457 self,
458 host,
459 "list",
460 use_output=C.OUTPUT_DICT_DICT,
461 extra_outputs=extra_outputs,
462 use_pubsub=True,
463 pubsub_flags={C.NODE},
464 use_verbose=True,
465 help=_("get event attendance"),
466 )
467
468 def add_parser_options(self):
469 self.parser.add_argument(
470 "-m",
471 "--missing",
472 action="store_true",
473 help=_("show missing people (invited but no R.S.V.P. so far)"),
474 )
475 self.parser.add_argument(
476 "-R",
477 "--no-rsvp",
478 action="store_true",
479 help=_("don't show people which gave R.S.V.P."),
480 )
481
482 def _attend_filter(self, attend, row):
483 if attend == "yes":
484 attend_color = C.A_SUCCESS
485 elif attend == "no":
486 attend_color = C.A_FAILURE
487 else:
488 attend_color = A.FG_WHITE
489 return A.color(attend_color, attend)
490
491 def _guests_filter(self, guests):
492 return "(" + str(guests) + ")" if guests else ""
493
494 def default_output(self, event_data):
495 data = []
496 attendees_yes = 0
497 attendees_maybe = 0
498 attendees_no = 0
499 attendees_missing = 0
500 guests = 0
501 guests_maybe = 0
502 for jid_, jid_data in event_data.items():
503 jid_data["jid"] = jid_
504 try:
505 guests_int = int(jid_data["guests"])
506 except (ValueError, KeyError):
507 pass
508 attend = jid_data.get("attend", "")
509 if attend == "yes":
510 attendees_yes += 1
511 guests += guests_int
512 elif attend == "maybe":
513 attendees_maybe += 1
514 guests_maybe += guests_int
515 elif attend == "no":
516 attendees_no += 1
517 jid_data["guests"] = ""
518 else:
519 attendees_missing += 1
520 jid_data["guests"] = ""
521 data.append(jid_data)
522
523 show_table = OUTPUT_OPT_TABLE in self.args.output_opts
524
525 table = common.Table.from_list_dict(
526 self.host,
527 data,
528 ("nick",) + (("jid",) if self.host.verbosity else ()) + ("attend", "guests"),
529 headers=None,
530 filters={
531 "nick": A.color(C.A_HEADER, "{}" if show_table else "{} "),
532 "jid": "{}" if show_table else "{} ",
533 "attend": self._attend_filter,
534 "guests": "{}" if show_table else self._guests_filter,
535 },
536 defaults={"nick": "", "attend": "", "guests": 1},
537 )
538 if show_table:
539 table.display()
540 else:
541 table.display_blank(show_header=False, col_sep="")
542
543 if not self.args.no_rsvp:
544 self.disp("")
545 self.disp(
546 A.color(
547 C.A_SUBHEADER,
548 _("Attendees: "),
549 A.RESET,
550 str(len(data)),
551 _(" ("),
552 C.A_SUCCESS,
553 _("yes: "),
554 str(attendees_yes),
555 A.FG_WHITE,
556 _(", maybe: "),
557 str(attendees_maybe),
558 ", ",
559 C.A_FAILURE,
560 _("no: "),
561 str(attendees_no),
562 A.RESET,
563 ")",
564 )
565 )
566 self.disp(
567 A.color(C.A_SUBHEADER, _("confirmed guests: "), A.RESET, str(guests))
568 )
569 self.disp(
570 A.color(
571 C.A_SUBHEADER,
572 _("unconfirmed guests: "),
573 A.RESET,
574 str(guests_maybe),
575 )
576 )
577 self.disp(
578 A.color(C.A_SUBHEADER, _("total: "), A.RESET, str(guests + guests_maybe))
579 )
580 if attendees_missing:
581 self.disp("")
582 self.disp(
583 A.color(
584 C.A_SUBHEADER,
585 _("missing people (no reply): "),
586 A.RESET,
587 str(attendees_missing),
588 )
589 )
590
591 async def start(self):
592 if self.args.no_rsvp and not self.args.missing:
593 self.parser.error(_("you need to use --missing if you use --no-rsvp"))
594 if not self.args.missing:
595 prefilled = {}
596 else:
597 # we get prefilled data with all people
598 try:
599 affiliations = await self.host.bridge.ps_node_affiliations_get(
600 self.args.service,
601 self.args.node,
602 self.profile,
603 )
604 except Exception as e:
605 self.disp(f"can't get node affiliations: {e}", error=True)
606 self.host.quit(C.EXIT_BRIDGE_ERRBACK)
607 else:
608 # we fill all affiliations with empty data, answered one will be filled
609 # below. We only consider people with "publisher" affiliation as invited,
610 # creators are not, and members can just observe
611 prefilled = {
612 jid_: {}
613 for jid_, affiliation in affiliations.items()
614 if affiliation in ("publisher",)
615 }
616
617 try:
618 event_data = await self.host.bridge.event_invitees_list(
619 self.args.service,
620 self.args.node,
621 self.profile,
622 )
623 except Exception as e:
624 self.disp(f"can't get event data: {e}", error=True)
625 self.host.quit(C.EXIT_BRIDGE_ERRBACK)
626
627 # we fill nicknames and keep only requested people
628
629 if self.args.no_rsvp:
630 for jid_ in event_data:
631 # if there is a jid in event_data it must be there in prefilled too
632 # otherwie somebody is not on the invitees list
633 try:
634 del prefilled[jid_]
635 except KeyError:
636 self.disp(
637 A.color(
638 C.A_WARNING,
639 f"We got a RSVP from somebody who was not in invitees "
640 f"list: {jid_}",
641 ),
642 error=True,
643 )
644 else:
645 # we replace empty dicts for existing people with R.S.V.P. data
646 prefilled.update(event_data)
647
648 # we get nicknames for everybody, make it easier for organisers
649 for jid_, data in prefilled.items():
650 id_data = await self.host.bridge.identity_get(jid_, [], True, self.profile)
651 id_data = data_format.deserialise(id_data)
652 data["nick"] = id_data["nicknames"][0]
653
654 await self.output(prefilled)
655 self.host.quit()
656
657
658 class InviteeInvite(base.CommandBase):
659 def __init__(self, host):
660 base.CommandBase.__init__(
661 self,
662 host,
663 "invite",
664 use_pubsub=True,
665 pubsub_flags={C.NODE, C.SINGLE_ITEM},
666 help=_("invite someone to the event through email"),
667 )
668
669 def add_parser_options(self):
670 self.parser.add_argument(
671 "-e",
672 "--email",
673 action="append",
674 default=[],
675 help="email(s) to send the invitation to",
676 )
677 self.parser.add_argument(
678 "-N",
679 "--name",
680 default="",
681 help="name of the invitee",
682 )
683 self.parser.add_argument(
684 "-H",
685 "--host-name",
686 default="",
687 help="name of the host",
688 )
689 self.parser.add_argument(
690 "-l",
691 "--lang",
692 default="",
693 help="main language spoken by the invitee",
694 )
695 self.parser.add_argument(
696 "-U",
697 "--url-template",
698 default="",
699 help="template to construct the URL",
700 )
701 self.parser.add_argument(
702 "-S",
703 "--subject",
704 default="",
705 help="subject of the invitation email (default: generic subject)",
706 )
707 self.parser.add_argument(
708 "-b",
709 "--body",
710 default="",
711 help="body of the invitation email (default: generic body)",
712 )
713
714 async def start(self):
715 email = self.args.email[0] if self.args.email else None
716 emails_extra = self.args.email[1:]
717
718 try:
719 await self.host.bridge.event_invite_by_email(
720 self.args.service,
721 self.args.node,
722 self.args.item,
723 email,
724 emails_extra,
725 self.args.name,
726 self.args.host_name,
727 self.args.lang,
728 self.args.url_template,
729 self.args.subject,
730 self.args.body,
731 self.args.profile,
732 )
733 except Exception as e:
734 self.disp(f"can't create invitation: {e}", error=True)
735 self.host.quit(C.EXIT_BRIDGE_ERRBACK)
736 else:
737 self.host.quit()
738
739
740 class Invitee(base.CommandBase):
741 subcommands = (InviteeGet, InviteeSet, InviteesList, InviteeInvite)
742
743 def __init__(self, host):
744 super(Invitee, self).__init__(
745 host, "invitee", use_profile=False, help=_("manage invities")
746 )
747
748
749 class Event(base.CommandBase):
750 subcommands = (Get, Create, Modify, Invitee)
751
752 def __init__(self, host):
753 super(Event, self).__init__(
754 host, "event", use_profile=False, help=_("event management")
755 )