Skip to content

Commit f9211cc

Browse files
authored
Nextcloud Talk: Polls API (#132)
Signed-off-by: Alexander Piskun <[email protected]>
1 parent f9b2352 commit f9211cc

File tree

6 files changed

+269
-2
lines changed

6 files changed

+269
-2
lines changed

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ All notable changes to this project will be documented in this file.
77
### Added
88

99
- FilesAPI: [Chunked v2 upload](https://docs.nextcloud.com/server/latest/developer_manual/client_apis/WebDAV/chunking.html#chunked-upload-v2) support, enabled by default.
10-
- New option to disable `chunked v2 upload` if there is need for that: `CHUNKED_UPLOAD_V2`
10+
- New option to disable `chunked v2 upload` if there is a need for that: `CHUNKED_UPLOAD_V2`
11+
- TalkAPI: Poll API support(create_poll, get_poll, vote_poll, close_poll).
1112

1213
### Changed
1314

docs/reference/Talk.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,3 +63,9 @@ Talk API
6363

6464
.. autoclass:: nc_py_api.talk.BotInfoBasic
6565
:members:
66+
67+
.. autoclass:: nc_py_api.talk.Poll
68+
:members:
69+
70+
.. autoclass:: nc_py_api.talk.PollDetail
71+
:members:

nc_py_api/_talk_api.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
ConversationType,
1818
MessageReactions,
1919
NotificationLevel,
20+
Poll,
2021
TalkMessage,
2122
)
2223

@@ -386,6 +387,82 @@ def disable_bot(self, conversation: typing.Union[Conversation, str], bot: typing
386387
bot_id = bot.bot_id if isinstance(bot, BotInfoBasic) else bot
387388
self._session.ocs("DELETE", self._ep_base + f"/api/v1/bot/{token}/{bot_id}")
388389

390+
def create_poll(
391+
self,
392+
conversation: typing.Union[Conversation, str],
393+
question: str,
394+
options: list[str],
395+
hidden_results: bool = True,
396+
max_votes: int = 1,
397+
) -> Poll:
398+
"""Creates a poll in a conversation.
399+
400+
:param conversation: conversation token or :py:class:`~nc_py_api.talk.Conversation`.
401+
:param question: The question of the poll.
402+
:param options: Array of strings with the voting options.
403+
:param hidden_results: Are the results hidden until the poll is closed and then only the summary is published.
404+
:param max_votes: The maximum amount of options a participant can vote for.
405+
"""
406+
token = conversation.token if isinstance(conversation, Conversation) else conversation
407+
params = {
408+
"question": question,
409+
"options": options,
410+
"resultMode": int(hidden_results),
411+
"maxVotes": max_votes,
412+
}
413+
return Poll(self._session.ocs("POST", self._ep_base + f"/api/v1/poll/{token}", json=params), token)
414+
415+
def get_poll(self, poll: typing.Union[Poll, int], conversation: typing.Union[Conversation, str] = "") -> Poll:
416+
"""Get state or result of a poll.
417+
418+
:param poll: Poll ID or :py:class:`~nc_py_api.talk.Poll`.
419+
:param conversation: conversation token or :py:class:`~nc_py_api.talk.Conversation`.
420+
"""
421+
if isinstance(poll, Poll):
422+
poll_id = poll.poll_id
423+
token = poll.conversation_token
424+
else:
425+
poll_id = poll
426+
token = conversation.token if isinstance(conversation, Conversation) else conversation
427+
return Poll(self._session.ocs("GET", self._ep_base + f"/api/v1/poll/{token}/{poll_id}"), token)
428+
429+
def vote_poll(
430+
self,
431+
options_ids: list[int],
432+
poll: typing.Union[Poll, int],
433+
conversation: typing.Union[Conversation, str] = "",
434+
) -> Poll:
435+
"""Vote on a poll.
436+
437+
:param options_ids: The option IDs the participant wants to vote for.
438+
:param poll: Poll ID or :py:class:`~nc_py_api.talk.Poll`.
439+
:param conversation: conversation token or :py:class:`~nc_py_api.talk.Conversation`.
440+
"""
441+
if isinstance(poll, Poll):
442+
poll_id = poll.poll_id
443+
token = poll.conversation_token
444+
else:
445+
poll_id = poll
446+
token = conversation.token if isinstance(conversation, Conversation) else conversation
447+
r = self._session.ocs(
448+
"POST", self._ep_base + f"/api/v1/poll/{token}/{poll_id}", json={"optionIds": options_ids}
449+
)
450+
return Poll(r, token)
451+
452+
def close_poll(self, poll: typing.Union[Poll, int], conversation: typing.Union[Conversation, str] = "") -> Poll:
453+
"""Close a poll.
454+
455+
:param poll: Poll ID or :py:class:`~nc_py_api.talk.Poll`.
456+
:param conversation: conversation token or :py:class:`~nc_py_api.talk.Conversation`.
457+
"""
458+
if isinstance(poll, Poll):
459+
poll_id = poll.poll_id
460+
token = poll.conversation_token
461+
else:
462+
poll_id = poll
463+
token = conversation.token if isinstance(conversation, Conversation) else conversation
464+
return Poll(self._session.ocs("DELETE", self._ep_base + f"/api/v1/poll/{token}/{poll_id}"), token)
465+
389466
@staticmethod
390467
def _get_token(message: typing.Union[TalkMessage, str], conversation: typing.Union[Conversation, str]) -> str:
391468
if not conversation and not isinstance(message, TalkMessage):

nc_py_api/_version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
"""Version of nc_py_api."""
22

3-
__version__ = "0.2.1"
3+
__version__ = "0.2.2.dev0"

nc_py_api/talk.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -625,3 +625,117 @@ def last_error_date(self) -> int:
625625
def last_error_message(self) -> typing.Optional[str]:
626626
"""The last exception message or error response information when trying to reach the bot."""
627627
return self._raw_data["last_error_message"]
628+
629+
630+
@dataclasses.dataclass
631+
class PollDetail:
632+
"""Detail about who voted for option."""
633+
634+
def __init__(self, raw_data: dict):
635+
self._raw_data = raw_data
636+
637+
@property
638+
def actor_type(self) -> str:
639+
"""The actor type of the participant that voted: **users**, **groups**, **circles**, **guests**, **emails**."""
640+
return self._raw_data["actorType"]
641+
642+
@property
643+
def actor_id(self) -> str:
644+
"""The actor id of the participant that voted."""
645+
return self._raw_data["actorId"]
646+
647+
@property
648+
def actor_display_name(self) -> str:
649+
"""The display name of the participant that voted."""
650+
return self._raw_data["actorDisplayName"]
651+
652+
@property
653+
def option(self) -> int:
654+
"""The option that was voted for."""
655+
return self._raw_data["optionId"]
656+
657+
658+
@dataclasses.dataclass
659+
class Poll:
660+
"""Conversation Poll information."""
661+
662+
def __init__(self, raw_data: dict, conversation_token: str):
663+
self._raw_data = raw_data
664+
self._conversation_token = conversation_token
665+
666+
@property
667+
def conversation_token(self) -> str:
668+
"""Token identifier of the conversation to which poll belongs."""
669+
return self._conversation_token
670+
671+
@property
672+
def poll_id(self) -> int:
673+
"""ID of the poll."""
674+
return self._raw_data["id"]
675+
676+
@property
677+
def question(self) -> str:
678+
"""The question of the poll."""
679+
return self._raw_data["question"]
680+
681+
@property
682+
def options(self) -> list[str]:
683+
"""Options participants can vote for."""
684+
return self._raw_data["options"]
685+
686+
@property
687+
def votes(self) -> dict[str, int]:
688+
"""Map with 'option-' + optionId => number of votes.
689+
690+
.. note:: Only available for when the actor voted on the public poll or the poll is closed.
691+
"""
692+
return self._raw_data.get("votes", {})
693+
694+
@property
695+
def actor_type(self) -> str:
696+
"""Actor type of the poll author: **users**, **groups**, **circles**, **guests**, **emails**."""
697+
return self._raw_data["actorType"]
698+
699+
@property
700+
def actor_id(self) -> str:
701+
"""Actor ID identifying the poll author."""
702+
return self._raw_data["actorId"]
703+
704+
@property
705+
def actor_display_name(self) -> str:
706+
"""The display name of the poll author."""
707+
return self._raw_data["actorDisplayName"]
708+
709+
@property
710+
def closed(self) -> bool:
711+
"""Participants can no longer cast votes and the result is displayed."""
712+
return bool(self._raw_data["status"] == 1)
713+
714+
@property
715+
def hidden_results(self) -> bool:
716+
"""The results are hidden until the poll is closed."""
717+
return bool(self._raw_data["resultMode"] == 1)
718+
719+
@property
720+
def max_votes(self) -> int:
721+
"""The maximum amount of options a user can vote for, ``0`` means unlimited."""
722+
return self._raw_data["maxVotes"]
723+
724+
@property
725+
def voted_self(self) -> list[int]:
726+
"""Array of option ids the participant voted for."""
727+
return self._raw_data["votedSelf"]
728+
729+
@property
730+
def num_voters(self) -> int:
731+
"""The number of unique voters that voted.
732+
733+
.. note:: only available when the actor voted on the public poll or the
734+
poll is closed unless for the creator and moderators.
735+
"""
736+
return self._raw_data.get("numVoters", 0)
737+
738+
@property
739+
def details(self) -> list[PollDetail]:
740+
"""Detailed list who voted for which option (only available for public closed polls)."""
741+
return [PollDetail(i) for i in self._raw_data.get("details", [])]

tests/actual_tests/talk_test.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,3 +253,72 @@ def test_chat_bot_receive_message(nc_app):
253253
talk_bot_inst.callback_url = "invalid_url"
254254
with pytest.raises(RuntimeError):
255255
talk_bot_inst.send_message("message", 999999, token="sometoken")
256+
257+
258+
def test_create_close_poll(nc_any):
259+
if nc_any.talk.available is False:
260+
pytest.skip("Nextcloud Talk is not installed")
261+
262+
conversation = nc_any.talk.create_conversation(talk.ConversationType.GROUP, "admin")
263+
try:
264+
poll = nc_any.talk.create_poll(conversation, "When was this test written?", ["2000", "2023", "2030"])
265+
266+
def check_poll(closed: bool):
267+
assert isinstance(poll.poll_id, int)
268+
assert poll.question == "When was this test written?"
269+
assert poll.options == ["2000", "2023", "2030"]
270+
assert poll.max_votes == 1
271+
assert poll.num_voters == 0
272+
assert poll.hidden_results is True
273+
assert poll.details == []
274+
assert poll.closed is closed
275+
assert poll.conversation_token == conversation.token
276+
assert poll.actor_type == "users"
277+
assert poll.actor_id == nc_any.user
278+
assert isinstance(poll.actor_display_name, str)
279+
assert poll.voted_self == []
280+
assert poll.votes == []
281+
282+
check_poll(False)
283+
poll = nc_any.talk.get_poll(poll)
284+
check_poll(False)
285+
poll = nc_any.talk.get_poll(poll.poll_id, conversation.token)
286+
check_poll(False)
287+
poll = nc_any.talk.close_poll(poll.poll_id, conversation.token)
288+
check_poll(True)
289+
finally:
290+
nc_any.talk.delete_conversation(conversation)
291+
292+
293+
def test_vote_poll(nc_any):
294+
if nc_any.talk.available is False:
295+
pytest.skip("Nextcloud Talk is not installed")
296+
297+
conversation = nc_any.talk.create_conversation(talk.ConversationType.GROUP, "admin")
298+
try:
299+
poll = nc_any.talk.create_poll(
300+
conversation, "what color is the grass", ["red", "green", "blue"], hidden_results=False, max_votes=3
301+
)
302+
assert poll.hidden_results is False
303+
assert not poll.voted_self
304+
poll = nc_any.talk.vote_poll([0, 2], poll)
305+
assert poll.voted_self == [0, 2]
306+
assert poll.votes == {
307+
"option-0": 1,
308+
"option-2": 1,
309+
}
310+
assert poll.num_voters == 1
311+
poll = nc_any.talk.vote_poll([1], poll.poll_id, conversation)
312+
assert poll.voted_self == [1]
313+
assert poll.votes == {
314+
"option-1": 1,
315+
}
316+
poll = nc_any.talk.close_poll(poll)
317+
assert poll.closed is True
318+
assert len(poll.details) == 1
319+
assert poll.details[0].actor_id == nc_any.user
320+
assert poll.details[0].actor_type == "users"
321+
assert poll.details[0].option == 1
322+
assert isinstance(poll.details[0].actor_display_name, str)
323+
finally:
324+
nc_any.talk.delete_conversation(conversation)

0 commit comments

Comments
 (0)