diff --git a/connect/models/__init__.py b/connect/models/__init__.py index 021e296..8a78ae8 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 @@ -34,6 +35,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..5422065 --- /dev/null +++ b/connect/models/conversation.py @@ -0,0 +1,70 @@ +# -*- 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. + """ + + creator = None # type: User + """ (:py:class:`.User`) Creator of the conversation. """ + + 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 + :raises TypeError: Raised if the message cannot be deserialized. + """ + + 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 69439bf..6470428 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 @@ -113,3 +114,23 @@ def needs_migration(self, migration_key='migration_info'): :rtype: bool """ return self.asset.get_param_by_id(migration_key) is not None + + def get_conversation(self, config=None): + """ + :param Config config: Configuration, or ``None`` to use the environment config (default). + :return: The conversation for this request, or ``None`` if there is none. + :rtype: Conversation|None + """ + from connect.resources.base import ApiClient + + 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 ValueError: + return None diff --git a/connect/models/schemas.py b/connect/models/schemas.py index a9f3aa9..bb5f237 100644 --- a/connect/models/schemas.py +++ b/connect/models/schemas.py @@ -567,3 +567,28 @@ 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) + creator = fields.Nested(UserSchema) + + @post_load + def make_object(self, data): + from connect.models import Conversation + return Conversation(**data) 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): 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