From 103c7ccab15886189024d036fed4bde1fd5498be Mon Sep 17 00:00:00 2001 From: Javier San Juan Cervera Date: Tue, 21 May 2019 18:26:54 +0200 Subject: [PATCH 1/4] Began work con conversation support. --- connect/models/__init__.py | 3 ++ connect/models/conversation.py | 66 ++++++++++++++++++++++++++++++++++ connect/models/fulfillment.py | 10 ++++++ connect/models/schemas.py | 24 +++++++++++++ 4 files changed, 103 insertions(+) create mode 100644 connect/models/conversation.py diff --git a/connect/models/__init__.py b/connect/models/__init__.py index 4d55c1d..e3235ba 100644 --- a/connect/models/__init__.py +++ b/connect/models/__init__.py @@ -9,6 +9,7 @@ from .company import Company, User from .connection import Connection from .contact import Contact, ContactInfo, PhoneNumber +from .conversation import Conversation, ConversationMessage from .event import EventInfo, Events from .fulfillment import Fulfillment from .hub import Hub, HubInstance, ExtIdHub, HubStats @@ -36,6 +37,8 @@ 'Contact', 'ContactInfo', 'Contract', + 'Conversation', + 'ConversationMessage', 'CustomerUiSettings', 'Document', 'DownloadLink', diff --git a/connect/models/conversation.py b/connect/models/conversation.py new file mode 100644 index 0000000..e15be9b --- /dev/null +++ b/connect/models/conversation.py @@ -0,0 +1,66 @@ +# -*- coding: utf-8 -*- + +# This file is part of the Ingram Micro Cloud Blue Connect SDK. +# Copyright (c) 2019 Ingram Micro. All Rights Reserved. + +import datetime + +from typing import List + +from .base import BaseModel +from .company import User +from connect.models.schemas import ConversationMessageSchema, ConversationSchema + + +class ConversationMessage(BaseModel): + """ Message in a :py:class:`.Conversation`. """ + + _schema = ConversationMessageSchema() + + conversation = None # type: str + """ (str) Primary ID of Conversation object. """ + + created = None # type: datetime.datetime + """ (datetime.datetime) Date of the Message creation. """ + + creator = None # type: User + """ (:py:class:`.User`) :py:class:`.User` that created the message. """ + + text = None # type: str + """ (str) Actual message. """ + + +class Conversation(BaseModel): + """ Conversation. """ + + _schema = ConversationSchema() + + instance_id = None # type: str + """ (str) The id of object based on which discussion is made, e.g. listing request. + It can be any object. + """ + + created = None # type: datetime.datetime + """ (datetime.datetime) Date of the Conversation creation. """ + + topic = None # type: str + """ (str) Conversation topic. """ + + messages = None # type: List[ConversationMessage] + """ (List[:py:class:`.ConversationMessage`]) List of :py:class:`.ConversationMessage` + objects. + """ + + def add_message(self, message, config=None): + """ Adds a message to the conversation. + + :param str message: Message to add. + :param Config config: Configuration, or ``None`` to use the environment config (default). + :return: The added message. + :rtype: ConversationMessage + """ + + from connect.resources.base import ApiClient + response, _ = ApiClient(config, base_path='conversations/' + self.id + '/messages')\ + .post(json={'text': message}) + return ConversationMessage.deserialize(response) diff --git a/connect/models/fulfillment.py b/connect/models/fulfillment.py index 7eaa2d7..8df60cf 100644 --- a/connect/models/fulfillment.py +++ b/connect/models/fulfillment.py @@ -99,3 +99,13 @@ def removed_items(self): return list(filter( lambda item: item.quantity == 0 and item.old_quantity > 0, self.asset.items)) + + def get_conversation(self, config=None): + """ + :param Config config: Configuration, or ``None`` to use the environment config (default). + :return: Conversation. + :rtype: Conversation + """ + from connect.resources.base import ApiClient + response, _ = ApiClient(config, base_path='conversations')\ + .get(params={'instance_id': self.id}) diff --git a/connect/models/schemas.py b/connect/models/schemas.py index 6003db3..c18027d 100644 --- a/connect/models/schemas.py +++ b/connect/models/schemas.py @@ -579,3 +579,27 @@ class UsageRecordSchema(BaseSchema): def make_object(self, data): from connect.models import UsageRecord return UsageRecord(**data) + + +class ConversationMessageSchema(BaseSchema): + conversation = fields.Str() + created = fields.DateTime() + creator = fields.Nested(UserSchema) + text = fields.Str() + + @post_load + def make_object(self, data): + from connect.models import ConversationMessage + return ConversationMessage(**data) + + +class ConversationSchema(BaseSchema): + instance_id = fields.Str() + created = fields.DateTime() + topic = fields.Str() + messages = fields.Nested(ConversationMessageSchema, many=True) + + @post_load + def make_object(self, data): + from connect.models import Conversation + return Conversation(**data) From be1eab7b20b63e9627cd0689a46527aa1c830940 Mon Sep 17 00:00:00 2001 From: Javier San Juan Cervera Date: Tue, 21 May 2019 18:27:11 +0200 Subject: [PATCH 2/4] Began work con conversation support. --- connect/models/conversation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/connect/models/conversation.py b/connect/models/conversation.py index e15be9b..63fa399 100644 --- a/connect/models/conversation.py +++ b/connect/models/conversation.py @@ -47,7 +47,7 @@ class Conversation(BaseModel): """ (str) Conversation topic. """ messages = None # type: List[ConversationMessage] - """ (List[:py:class:`.ConversationMessage`]) List of :py:class:`.ConversationMessage` + """ (List[:py:class:`.ConversationMessage`]) List of :py:class:`.ConversationMessage` objects. """ From 0266e0b1305cc8d34f594cb22d10f860d2065526 Mon Sep 17 00:00:00 2001 From: Javier San Juan Cervera Date: Wed, 22 May 2019 11:04:39 +0200 Subject: [PATCH 3/4] Conversation support added. --- connect/models/conversation.py | 1 + connect/models/fulfillment.py | 19 +++++-- connect/resources/fulfillment_automation.py | 58 ++++++++++++++++----- 3 files changed, 62 insertions(+), 16 deletions(-) diff --git a/connect/models/conversation.py b/connect/models/conversation.py index 63fa399..83ad445 100644 --- a/connect/models/conversation.py +++ b/connect/models/conversation.py @@ -58,6 +58,7 @@ def add_message(self, message, config=None): :param Config config: Configuration, or ``None`` to use the environment config (default). :return: The added message. :rtype: ConversationMessage + :raises TypeError: Raised if the message cannot be deserialized. """ from connect.resources.base import ApiClient diff --git a/connect/models/fulfillment.py b/connect/models/fulfillment.py index 8df60cf..c8b703d 100644 --- a/connect/models/fulfillment.py +++ b/connect/models/fulfillment.py @@ -7,6 +7,7 @@ from .asset import Asset from .base import BaseModel +from .conversation import Conversation from .marketplace import Contract, Marketplace from connect.models.schemas import FulfillmentSchema @@ -103,9 +104,19 @@ def removed_items(self): def get_conversation(self, config=None): """ :param Config config: Configuration, or ``None`` to use the environment config (default). - :return: Conversation. - :rtype: Conversation + :return: The conversation for this request, or ``None`` if there is none. + :rtype: Conversation|None """ from connect.resources.base import ApiClient - response, _ = ApiClient(config, base_path='conversations')\ - .get(params={'instance_id': self.id}) + + client = ApiClient(config, base_path='conversations') + response, _ = client.get(params={'instance_id': self.id}) + try: + conversations = Conversation.deserialize(response) + if conversations and conversations[0].id: + response, _ = client.get(conversations[0].id) + return Conversation.deserialize(response) + else: + return None + except TypeError: + return None diff --git a/connect/resources/fulfillment_automation.py b/connect/resources/fulfillment_automation.py index 3fa95cf..043e798 100644 --- a/connect/resources/fulfillment_automation.py +++ b/connect/resources/fulfillment_automation.py @@ -50,35 +50,69 @@ def filters(self, status='pending', **kwargs): def dispatch(self, request): # type: (Fulfillment) -> str + + conversation = request.get_conversation(self.config) + try: if self.config.products \ and request.asset.product.id not in self.config.products: return 'Invalid product' logger.info('Start request process / ID request - {}'.format(request.id)) - result = self.process_request(request) + process_result = self.process_request(request) - if not result: + if not process_result: logger.info('Method `process_request` did not return result') return '' - params = {} - if isinstance(result, ActivationTileResponse): - params = {'activation_tile': result.tile} - elif isinstance(result, ActivationTemplateResponse): - params = {'template_id': result.template_id} - - return self.approve(request.id, params) + if isinstance(process_result, ActivationTileResponse): + message = 'Activated using custom activation tile.' + approved = self.approve(request.id, {'activation_tile': process_result.tile}) + elif isinstance(process_result, ActivationTemplateResponse): + message = 'Activated using template {}.'.format(process_result.template_id) + approved = self.approve(request.id, {'template_id': process_result.template_id}) + else: + # We should not get here + message = '' + approved = '' + + if conversation: + try: + conversation.add_message(message) + except TypeError as ex: + logger.error('Error updating conversation for request {}: {}' + .format(request.id, ex)) + return approved except InquireRequest as inquire: self.update_parameters(request.id, inquire.params) - return self.inquire(request.id) + inquired = self.inquire(request.id) + try: + conversation.add_message(str(inquire)) + except TypeError as ex: + logger.error('Error updating conversation for request {}: {}' + .format(request.id, ex)) + return inquired except FailRequest as fail: - return self.fail(request.id, reason=str(fail)) + # PyCharm incorrectly detects unreachable code here, so disable + # noinspection PyUnreachableCode + failed = self.fail(request.id, reason=str(fail)) + try: + conversation.add_message(str(fail)) + except TypeError as ex: + logger.error('Error updating conversation for request {}: {}' + .format(request.id, ex)) + return failed except SkipRequest as skip: - return skip.code + skipped = skip.code + try: + conversation.add_message(str(skip)) + except TypeError as ex: + logger.error('Error updating conversation for request {}: {}' + .format(request.id, ex)) + return skipped @deprecated(deprecated_in='16.0', details='Use ``TierConfig.get`` instead.') def get_tier_config(self, tier_id, product_id): From eccac711521a867036c8e612b493578718b80704 Mon Sep 17 00:00:00 2001 From: Javier San Juan Cervera Date: Wed, 22 May 2019 15:45:29 +0200 Subject: [PATCH 4/4] Unit tests. --- connect/models/conversation.py | 3 + connect/models/fulfillment.py | 2 +- connect/models/schemas.py | 1 + tests/data/add_message_response.json | 10 ++ tests/data/conversation.json | 22 +++++ tests/test_conversation.py | 138 +++++++++++++++++++++++++++ 6 files changed, 175 insertions(+), 1 deletion(-) create mode 100644 tests/data/add_message_response.json create mode 100644 tests/data/conversation.json create mode 100644 tests/test_conversation.py diff --git a/connect/models/conversation.py b/connect/models/conversation.py index 83ad445..5422065 100644 --- a/connect/models/conversation.py +++ b/connect/models/conversation.py @@ -51,6 +51,9 @@ class Conversation(BaseModel): objects. """ + creator = None # type: User + """ (:py:class:`.User`) Creator of the conversation. """ + def add_message(self, message, config=None): """ Adds a message to the conversation. diff --git a/connect/models/fulfillment.py b/connect/models/fulfillment.py index c8b703d..06f76e6 100644 --- a/connect/models/fulfillment.py +++ b/connect/models/fulfillment.py @@ -118,5 +118,5 @@ def get_conversation(self, config=None): return Conversation.deserialize(response) else: return None - except TypeError: + except ValueError: return None diff --git a/connect/models/schemas.py b/connect/models/schemas.py index c18027d..d17fa6b 100644 --- a/connect/models/schemas.py +++ b/connect/models/schemas.py @@ -598,6 +598,7 @@ class ConversationSchema(BaseSchema): created = fields.DateTime() topic = fields.Str() messages = fields.Nested(ConversationMessageSchema, many=True) + creator = fields.Nested(UserSchema) @post_load def make_object(self, data): diff --git a/tests/data/add_message_response.json b/tests/data/add_message_response.json new file mode 100644 index 0000000..0b05910 --- /dev/null +++ b/tests/data/add_message_response.json @@ -0,0 +1,10 @@ +{ + "id": "ME-000-000-000", + "conversation": "CO-000-000-000", + "created": "2018-12-18T13:03:30+00:00", + "creator": { + "id": "UR-000-000-000", + "name": "Some User" + }, + "text": "Hi, please see my listing request" +} diff --git a/tests/data/conversation.json b/tests/data/conversation.json new file mode 100644 index 0000000..47bc6b3 --- /dev/null +++ b/tests/data/conversation.json @@ -0,0 +1,22 @@ +{ + "id": "CO-750-033-356", + "instance_id": "LST-038-662-242", + "created": "2018-12-18T12:49:34+00:00", + "topic": "Topic", + "messages": [ + { + "id": "ME-506-258-087", + "conversation": "CO-750-033-356", + "created": "2018-12-18T13:03:30+00:00", + "creator": { + "id": "UR-922-977-649", + "name": "Some User" + }, + "text": "Hi, check out" + } + ], + "creator": { + "id": "UR-922-977-649", + "name": "Some User" + } +} \ No newline at end of file diff --git a/tests/test_conversation.py b/tests/test_conversation.py new file mode 100644 index 0000000..2152a31 --- /dev/null +++ b/tests/test_conversation.py @@ -0,0 +1,138 @@ +# -*- coding: utf-8 -*- + +# This file is part of the Ingram Micro Cloud Blue Connect SDK. +# Copyright (c) 2019 Ingram Micro. All Rights Reserved. + +import datetime +import os + +from mock import patch, call, Mock + +from connect.models import Conversation, ConversationMessage, User, Fulfillment +from .common import Response, load_str + +conversation_contents = load_str( + os.path.join(os.path.dirname(__file__), 'data', 'conversation.json')) + +add_message_response = load_str( + os.path.join(os.path.dirname(__file__), 'data', 'add_message_response.json')) + + +def test_conversation_attributes(): + # type: () -> None + conversation = Conversation.deserialize(conversation_contents) + assert isinstance(conversation, Conversation) + assert conversation.id == 'CO-750-033-356' + assert conversation.instance_id == 'LST-038-662-242' + assert conversation.created == datetime.datetime(2018, 12, 18, 12, 49, 34) + assert conversation.topic == 'Topic' + assert isinstance(conversation.messages, list) + assert len(conversation.messages) == 1 + + message = conversation.messages[0] + assert isinstance(message, ConversationMessage) + assert message.id == 'ME-506-258-087' + assert message.conversation == conversation.id + assert message.created == datetime.datetime(2018, 12, 18, 13, 3, 30) + assert isinstance(message.creator, User) + assert message.creator.id == 'UR-922-977-649' + assert message.creator.name == 'Some User' + assert message.text == 'Hi, check out' + + assert isinstance(conversation.creator, User) + assert conversation.creator.id == 'UR-922-977-649' + assert conversation.creator.name == 'Some User' + + +@patch('requests.post') +def test_add_message(post_mock): + # type: (Mock) -> None + post_mock.return_value = Response(True, add_message_response, 200) + + text = 'Hi, please see my listing request' + + conversation = Conversation.deserialize(conversation_contents) + message = conversation.add_message(text) + + post_mock.assert_called_with( + headers={'Content-Type': 'application/json', 'Authorization': 'ApiKey XXXX:YYYYY'}, + json={'text': text}, + url='http://localhost:8080/api/public/v1/conversations/CO-750-033-356/messages/') + + assert isinstance(message, ConversationMessage) + assert message.id == 'ME-000-000-000' + assert message.conversation == 'CO-000-000-000' + assert message.created == datetime.datetime(2018, 12, 18, 13, 3, 30) + assert isinstance(message.creator, User) + assert message.creator.id == 'UR-000-000-000' + assert message.creator.name == 'Some User' + assert message.text == text + + +@patch('requests.get') +def test_get_conversation_ok(get_mock): + # type: (Mock) -> None + get_mock.side_effect = [ + Response(True, '[' + conversation_contents + ']', 200), + Response(True, conversation_contents, 200) + ] + + request = Fulfillment(id='PR-0000-0000-0000') + conversation = request.get_conversation() + + assert get_mock.call_count == 2 + get_mock.assert_has_calls([ + call( + headers={'Content-Type': 'application/json', 'Authorization': 'ApiKey XXXX:YYYYY'}, + params={'instance_id': request.id}, + url='http://localhost:8080/api/public/v1/conversations/'), + call( + headers={'Content-Type': 'application/json', 'Authorization': 'ApiKey XXXX:YYYYY'}, + url='http://localhost:8080/api/public/v1/conversations/' + conversation.id) + ]) + + assert isinstance(conversation, Conversation) + + +@patch('requests.get') +def test_get_conversation_empty(get_mock): + # type: (Mock) -> None + get_mock.return_value = Response(True, '[]', 200) + + request = Fulfillment(id='PR-0000-0000-0000') + conversation = request.get_conversation() + + assert get_mock.call_count == 1 + get_mock.assert_has_calls([ + call( + headers={'Content-Type': 'application/json', 'Authorization': 'ApiKey XXXX:YYYYY'}, + params={'instance_id': request.id}, + url='http://localhost:8080/api/public/v1/conversations/') + ]) + + assert conversation is None + + +@patch('requests.get') +def test_get_conversation_bad_deserialize(get_mock): + # type: (Mock) -> None + get_mock.side_effect = [ + Response(True, '[' + conversation_contents + ']', 200), + Response(True, '', 200) + ] + + request = Fulfillment(id='PR-0000-0000-0000') + conversation = request.get_conversation() + + assert get_mock.call_count == 2 + get_mock.assert_has_calls([ + call( + headers={'Content-Type': 'application/json', 'Authorization': 'ApiKey XXXX:YYYYY'}, + params={'instance_id': request.id}, + url='http://localhost:8080/api/public/v1/conversations/'), + call( + headers={'Content-Type': 'application/json', 'Authorization': 'ApiKey XXXX:YYYYY'}, + url='http://localhost:8080/api/public/v1/conversations/CO-750-033-356') + ]) + + assert conversation is None