diff --git a/omise/__init__.py b/omise/__init__.py index ed900d7..3ab88f0 100644 --- a/omise/__init__.py +++ b/omise/__init__.py @@ -36,6 +36,7 @@ def iteritems(d, **kw): 'Collection', 'Customer', 'Dispute', + 'Document', 'Event', 'Forex', 'Link', @@ -72,6 +73,7 @@ def _get_class_for(type): 'charge': Charge, 'customer': Customer, 'dispute': Dispute, + 'document': Document, 'event': Event, 'forex': Forex, 'link': Link, @@ -206,6 +208,9 @@ class _MainResource(Base): def _request(cls, *args, **kwargs): return Request(api_secret, api_main, api_version).send(*args, **kwargs) + def _upload(cls, *args, **kwargs): + return Request(api_secret, api_main, api_version).send_file(*args, **kwargs) + def _nested_object_path(self, association_cls): return ( self.__class__._collection_path(), @@ -1111,7 +1116,7 @@ def _fetch_objects(self, **kwargs): class Dispute(_MainResource, Base): - """API class representing a recipient in an account. + """API class representing a dispute in an account. This API class is used for retrieving and updating a dispute in an account for charge back handling. @@ -1121,7 +1126,7 @@ class Dispute(_MainResource, Base): >>> import omise >>> omise.api_secret = 'skey_test_4xs8breq3htbkj03d2x' >>> dispute = omise.Dispute.retrieve('dspt_test_4zgf15h89w8t775kcm8') - + >>> dispute.status 'open' """ @@ -1178,6 +1183,13 @@ def list_closed_disputes(cls): """ return LazyCollection(cls._collection_path("closed")) + def list_documents(self): + """Returns all documents that belong to a given dispute. + + :rtype: LazyCollection + """ + return LazyCollection(self._nested_object_path(Document)) + def reload(self): """Reload the dispute details. @@ -1234,6 +1246,97 @@ def accept(self): path = self._instance_path(self._attributes['id']) + ('accept',) return self._reload_data(self._request('patch', path)) + def upload_document(self, document): + """Add a dispute evidence document. + + See the `create a document`_ section in the API documentation for list + of available arguments. + + :rtype: Document + + .. _create a document: https://www.omise.co/documents-api#create + """ + path = self._instance_path(self._attributes['id']) + ('documents',) + document = _as_object(self._upload('post', path, files=document)) + self.reload() + return document + + +class Document(_MainResource, Base): + """API class representing a dispute document in an account. + + This API class is used for managing dispute document files. Documents are + used to help resolve disputes. Supported file types include PNG, JPG, and + PDF. + + Basic usage:: + + >>> import omise + >>> omise.api_secret = 'skey_test_4xs8breq3htbkj03d2x' + >>> dispute = omise.Dispute.retrieve('dspt_test_5mr4ox8e818viqtaqs1') + >>> document = dispute.documents.retrieve("docu_test_5mr4oyqphijal1ps9u6") + + >>> document.filename + 'evidence.png' + """ + + @classmethod + def _collection_path(cls): + return 'documents' + + @classmethod + def _instance_path(cls, dispute_id, document_id): + return ('disputes', dispute_id, 'documents', document_id) + + @classmethod + def retrieve(cls, dispute_id, document_id): + """Retrieve the document details for the given :param:`document_id`. + + :param dispute_id: a dispute id of a document. + :type dispute_id: str + :param document_id: a document id to retrieve. + :type document_id: str + :rtype: Document + """ + return _as_object(cls._request('get', cls._instance_path(dispute_id, document_id))) + + def reload(self): + """Reload the document details. + + :rtype: Document + """ + return self._reload_data( + self._request('get', + self._attributes['location'])) + + def destroy(self): + """Delete the document and unassociated it from the dispute. + + Basic usage:: + + >>> import omise + >>> omise.api_secret = 'skey_test_4xs8breq3htbkj03d2x' + >>> dispute = omise.Dispute.retrieve('dspt_test_5mr4ox8e818viqtaqs1') + >>> document = dispute.documents.retrieve("docu_test_5mr4oyqphijal1ps9u6") + >>> document.destroy() + + >>> document.destroyed + True + + :rtype: Document + """ + return self._reload_data( + self._request('delete', + self._attributes['location'])) + + @property + def destroyed(self): + """Returns ``True`` if document has been deleted. + + :rtype: bool + """ + return self._attributes.get('deleted', False) + class Event(_MainResource, Base): """API class representing an event in an account. diff --git a/omise/request.py b/omise/request.py index 382bb35..0d08145 100644 --- a/omise/request.py +++ b/omise/request.py @@ -102,12 +102,40 @@ def send(self, method, path, payload=None, headers=None): errors._raise_from_data(response) return response + def send_file(self, method, path, files=None, headers=None): + request_path = self._build_path(path) + request_files = self._build_files(files) + request_headers = self._build_file_header(headers) + + logger.info('Sending HTTP request: %s %s', method.upper(), request_path) + logger.debug('Authorization: %s', self.api_key) + logger.debug('Files: %s', request_files) + logger.debug('Headers: %s', request_headers) + + response = getattr(requests, method)( + request_path, + files=request_files, + headers=request_headers, + auth=(self.api_key, '') + ).json() + + logger.info('Received HTTP response: %s', response) + + if response.get('object') == 'error': + errors._raise_from_data(response) + return response + def _build_path(self, path): if not hasattr(path, '__iter__') or isinstance(path, basestring): path = (path,) path = map(str, path) return urlparse.urljoin(self.api_base, '/'.join(path)) + def _build_files(self, files): + if files is None: + files = {} + return files + def _build_payload(self, payload): if payload is None: payload = {} @@ -122,3 +150,12 @@ def _build_headers(self, headers): headers['Omise-Version'] = self.api_version headers['User-Agent'] = 'OmisePython/%s' % version.__VERSION__ return headers + + def _build_file_header(self, headers): + if headers is None: + headers = {} + headers['Accept'] = 'application/json' + if self.api_version is not None: + headers['Omise-Version'] = self.api_version + headers['User-Agent'] = 'OmisePython/%s' % version.__VERSION__ + return headers \ No newline at end of file diff --git a/omise/test/helper.py b/omise/test/helper.py index 136dbcf..0337ca6 100644 --- a/omise/test/helper.py +++ b/omise/test/helper.py @@ -32,6 +32,17 @@ def assertRequest(self, api_call, url, data=None, headers=None): headers=headers, auth=(mock.ANY, '')) + def assertUpload(self, api_call, url, files=None, headers=None): + if files is None: + files = {} + if headers is None: + headers = mock.ANY + api_call.assert_called_with( + url, + files=files, + headers=headers, + auth=(mock.ANY, '')) + class _ResourceMixin(_RequestAssertable): diff --git a/omise/test/test_dispute.py b/omise/test/test_dispute.py index 808c5f1..96b7643 100644 --- a/omise/test/test_dispute.py +++ b/omise/test/test_dispute.py @@ -1,5 +1,6 @@ import mock import unittest +import tempfile from .helper import _ResourceMixin @@ -228,4 +229,87 @@ def test_accept(self, api_call): self.assertRequest( api_call, 'https://api.omise.co/disputes/dspt_test/accept' - ) \ No newline at end of file + ) + + @mock.patch('requests.get') + @mock.patch('requests.post') + def test_upload_document(self, api_call, reload_call): + dispute = self._makeOne() + class_ = self._getTargetClass() + self.mockResponse(api_call, """{ + "object": "document", + "livemode": false, + "id": "docu_test", + "deleted": false, + "filename": "evidence.png", + "location": "/disputes/dspt_test/documents/docu_test", + "download_uri": null, + "created_at": "2021-02-05T10:40:32Z" + }""") + + self.mockResponse(reload_call, """{ + "object": "dispute", + "id": "dspt_test", + "livemode": false, + "location": "/disputes/dspt_test", + "currency": "THB", + "amount": 1101000, + "funding_amount": 1101000, + "funding_currency": "THB", + "metadata": { + }, + "charge": "chrg_test_5m7wj8yi1pa9vlk9bq8", + "documents": { + "object": "list", + "data": [ + { + "object": "document", + "livemode": false, + "id": "docu_test", + "deleted": false, + "filename": "evidence.png", + "location": "/disputes/dspt_test/documents/docu_test", + "download_uri": null, + "created_at": "2021-02-05T10:40:32Z" + } + ], + "limit": 20, + "offset": 0, + "total": 1, + "location": "/disputes/dspt_test/documents", + "order": "chronological", + "from": "1970-01-01T00:00:00Z", + "to": "2021-02-05T10:42:02Z" + }, + "transactions": [ + { + "object": "transaction", + "id": "trxn_test", + "livemode": false, + "currency": "THB", + "amount": 1101000, + "location": "/transactions/trxn_test", + "direction": "debit", + "key": "dispute.started.debit", + "origin": "dspt_test", + "transferable_at": "2021-02-04T12:08:04Z", + "created_at": "2021-02-04T12:08:04Z" + } + ], + "admin_message": null, + "message": null, + "reason_code": "goods_or_services_not_provided", + "reason_message": "Services not provided or Merchandise not received", + "status": "open", + "closed_at": null, + "created_at": "2021-02-04T12:08:04Z" + }""") + + self.assertTrue(isinstance(dispute, class_)) + + files = tempfile.TemporaryFile() + document = dispute.upload_document(files) + files.close() + self.assertEqual(dispute.id, 'dspt_test') + self.assertEqual(document.filename, 'evidence.png') + self.assertUpload(api_call, 'https://api.omise.co/disputes/dspt_test/documents', files) diff --git a/omise/test/test_document.py b/omise/test/test_document.py new file mode 100644 index 0000000..6b708bd --- /dev/null +++ b/omise/test/test_document.py @@ -0,0 +1,97 @@ +import mock +import unittest + +from .helper import _ResourceMixin + + +class DocumentTest(_ResourceMixin, unittest.TestCase): + + def _getTargetClass(self): + from .. import Document + return Document + + def _getCollectionClass(self): + from .. import Collection + return Collection + + def _makeOne(self): + return self._getTargetClass().from_data({ + 'object': 'document', + 'id': 'docu_test', + 'livemode': False, + 'location': '/disputes/dspt_test/documents/docu_test', + 'deleted': False, + 'filename': 'evidence.png', + 'created': '2021-02-04T03:12:43Z' + }) + + @mock.patch('requests.get') + def test_retrieve(self, api_call): + class_ = self._getTargetClass() + self.mockResponse(api_call, """{ + "object": "document", + "id": "docu_test", + "livemode": false, + "location": "/disputes/dspt_test/documents/docu_test", + "deleted": false, + "filename": "evidence.png", + "created": "2021-02-04T03:12:43Z" + }""") + + document = class_.retrieve('dspt_test', 'docu_test') + self.assertTrue(isinstance(document, class_)) + self.assertEqual(document.id, 'docu_test') + self.assertEqual(document.filename, 'evidence.png') + self.assertFalse(document.deleted) + self.assertRequest(api_call, 'https://api.omise.co/disputes/dspt_test/documents/docu_test') + + @mock.patch('requests.get') + def test_reload(self, api_call): + document = self._makeOne() + class_ = self._getTargetClass() + + self.assertTrue(isinstance(document, class_)) + self.assertEqual(document.id, 'docu_test') + self.assertFalse(document.deleted) + + self.mockResponse(api_call, """{ + "object": "document", + "id": "docu_test", + "livemode": false, + "location": "/disputes/dspt_test/documents/docu_test", + "deleted": true, + "filename": "evidence.png", + "created": "2021-02-04T03:12:43Z" + }""") + + document.reload() + self.assertEqual(document.id, 'docu_test') + self.assertTrue(document.deleted) + self.assertRequest( + api_call, + 'https://api.omise.co/disputes/dspt_test/documents/docu_test' + ) + + @mock.patch('requests.delete') + def test_destroy(self, api_call): + document = self._makeOne() + class_ = self._getTargetClass() + self.mockResponse(api_call, """{ + "object": "document", + "id": "docu_test", + "livemode": false, + "location": "/disputes/dspt_test/documents/docu_test", + "deleted": true, + "filename": "evidence.png", + "created": "2021-02-04T03:12:43Z" + }""") + + self.assertTrue(isinstance(document, class_)) + self.assertEqual(document.id, 'docu_test') + + document.destroy() + self.assertTrue(document.destroyed) + self.assertRequest( + api_call, + 'https://api.omise.co/disputes/dspt_test/documents/docu_test' + ) \ No newline at end of file