diff --git a/CHANGELOG.md b/CHANGELOG.md index 477b1fb..38b5d76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,23 @@ All notable changes to this project will be documented in this file. The format ## Unreleased +## [1.5.0][1.5.0] - 2024-12-19 + +feat: Add DeviceActivity support for POS Gateway integration + +- Add DeviceActivity resource with create() and get_status() methods +- Support PUBLIC authentication for DeviceActivity APIs +- Add X-Razorpay-Device-Mode header injection for wired/wireless modes +- Add DeviceMode constants (WIRED, WIRELESS) +- Enhance Client to support public_auth parameter +- Add comprehensive test coverage and mock responses +- Fix test_multiple_client URL mismatch +- Maintain backward compatibility with existing APIs + +Endpoints: +- POST /v1/devices/activity (create device activity) +- GET /v1/devices/activity/{id} (get activity status) + ## [1.4.2][1.4.2] - 2024-03-19 feat: Added new API endpoints diff --git a/razorpay/client.py b/razorpay/client.py index ea6cf08..d77e795 100644 --- a/razorpay/client.py +++ b/razorpay/client.py @@ -115,9 +115,25 @@ def request(self, method, path, **options): """ options = self._update_user_agent_header(options) + # Determine authentication type + use_public_auth = options.pop('use_public_auth', False) + auth_to_use = self.auth + + if use_public_auth: + # For public auth, use key_id only + if self.auth and isinstance(self.auth, tuple) and len(self.auth) >= 1: + auth_to_use = (self.auth[0], '') # Use key_id only, empty key_secret + + # Inject device mode header if provided + device_mode = options.pop('device_mode', None) + if device_mode is not None: + if 'headers' not in options: + options['headers'] = {} + options['headers']['X-Razorpay-Device-Mode'] = device_mode + url = "{}{}".format(self.base_url, path) - response = getattr(self.session, method)(url, auth=self.auth, + response = getattr(self.session, method)(url, auth=auth_to_use, verify=self.cert_path, **options) if ((response.status_code >= HTTP_STATUS_CODE.OK) and diff --git a/razorpay/constants/device.py b/razorpay/constants/device.py new file mode 100644 index 0000000..05c47a6 --- /dev/null +++ b/razorpay/constants/device.py @@ -0,0 +1,3 @@ +class DeviceMode: + WIRED = "wired" + WIRELESS = "wireless" \ No newline at end of file diff --git a/razorpay/constants/url.py b/razorpay/constants/url.py index 9f3fcfc..a283c16 100644 --- a/razorpay/constants/url.py +++ b/razorpay/constants/url.py @@ -29,3 +29,5 @@ class URL(object): DOCUMENT= "/documents" DISPUTE= "/disputes" + DEVICE_ACTIVITY_URL = "/devices/activity" + diff --git a/razorpay/resources/__init__.py b/razorpay/resources/__init__.py index f1ec4e6..66615f3 100644 --- a/razorpay/resources/__init__.py +++ b/razorpay/resources/__init__.py @@ -23,6 +23,7 @@ from .webhook import Webhook from .document import Document from .dispute import Dispute +from .device_activity import DeviceActivity __all__ = [ 'Payment', @@ -50,4 +51,5 @@ 'Webhook', 'Document', 'Dispute', + 'DeviceActivity', ] diff --git a/razorpay/resources/device_activity.py b/razorpay/resources/device_activity.py new file mode 100644 index 0000000..722cbf6 --- /dev/null +++ b/razorpay/resources/device_activity.py @@ -0,0 +1,66 @@ +from typing import Any, Dict, Optional + +from .base import Resource +from ..constants.url import URL +from ..constants.device import DeviceMode +from ..errors import BadRequestError + + +class DeviceActivity(Resource): + def __init__(self, client=None): + super(DeviceActivity, self).__init__(client) + self.base_url = URL.V1 + URL.DEVICE_ACTIVITY_URL + + def _validate_device_mode(self, mode: Optional[str]) -> Optional[str]: + """ + Validate device communication mode + + Args: + mode: Device communication mode ("wired" or "wireless") + + Returns: + Validated mode or None if mode is None + + Raises: + BadRequestError: If mode is invalid + """ + if mode is not None: + if mode not in (DeviceMode.WIRED, DeviceMode.WIRELESS): + raise BadRequestError("Invalid device mode. Allowed values are 'wired' and 'wireless'.") + return mode + return None + + def create(self, data: Dict[str, Any], mode: Optional[str] = None, **kwargs) -> Dict[str, Any]: + """ + Create a new device activity for POS gateway + + Args: + data: Dictionary containing device activity data in the format expected by rzp-pos-gateway + mode: Device communication mode ("wired" or "wireless") + + Returns: + DeviceActivity object + """ + device_mode = self._validate_device_mode(mode) + + url = self.base_url + return self.post_url(url, data, device_mode=device_mode, use_public_auth=True, **kwargs) + + def get_status(self, activity_id: str, mode: Optional[str] = None, **kwargs) -> Dict[str, Any]: + """ + Get the status of a device activity + + Args: + activity_id: Activity ID to fetch status for + mode: Device communication mode ("wired" or "wireless") + + Returns: + DeviceActivity object with current status + """ + if not activity_id: + raise BadRequestError("Activity ID must be provided") + + device_mode = self._validate_device_mode(mode) + + url = f"{self.base_url}/{activity_id}" + return self.get_url(url, {}, device_mode=device_mode, use_public_auth=True, **kwargs) \ No newline at end of file diff --git a/setup.py b/setup.py index e478f00..8f0bcc0 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ setup( name="razorpay", - version="1.4.2", + version="1.5.0", description="Razorpay Python Client", long_description=readme_content, long_description_content_type='text/markdown', diff --git a/tests/mocks/fake_device_activity.json b/tests/mocks/fake_device_activity.json new file mode 100644 index 0000000..05785fc --- /dev/null +++ b/tests/mocks/fake_device_activity.json @@ -0,0 +1 @@ +{"id": "act_123", "status": "created", "mode": "wired"} \ No newline at end of file diff --git a/tests/mocks/fake_device_activity_status.json b/tests/mocks/fake_device_activity_status.json new file mode 100644 index 0000000..17a560e --- /dev/null +++ b/tests/mocks/fake_device_activity_status.json @@ -0,0 +1 @@ +{"id": "act_123", "status": "in_progress", "mode": "wireless"} \ No newline at end of file diff --git a/tests/test_client_device_activity.py b/tests/test_client_device_activity.py new file mode 100644 index 0000000..ca64f13 --- /dev/null +++ b/tests/test_client_device_activity.py @@ -0,0 +1,38 @@ +import unittest +import responses +import json + +from .helpers import mock_file, ClientTestCase +from razorpay.errors import BadRequestError +import razorpay + + +class TestClientDeviceActivity(ClientTestCase): + + def setUp(self): + super(TestClientDeviceActivity, self).setUp() + self.device_activity_base_url = f"{self.base_url}/devices/activity" + # Device APIs automatically use public authentication (key_id only) + # by passing use_public_auth=True internally in device_activity.py + self.public_client = razorpay.Client(auth=('key_id', 'key_secret')) + + @responses.activate + def test_create_device_activity(self): + result = mock_file('fake_device_activity') + url = self.device_activity_base_url + responses.add(responses.POST, url, status=200, + body=json.dumps(result), match_querystring=True) + self.assertEqual(self.public_client.device_activity.create({'foo': 'bar'}, mode='wired'), result) + + @responses.activate + def test_get_status_device_activity(self): + activity_id = 'act_123' + result = mock_file('fake_device_activity_status') + url = f"{self.device_activity_base_url}/{activity_id}" + responses.add(responses.GET, url, status=200, + body=json.dumps(result), match_querystring=True) + self.assertEqual(self.public_client.device_activity.get_status(activity_id, mode='wireless'), result) + + def test_invalid_mode_raises(self): + with self.assertRaises(BadRequestError): + self.public_client.device_activity.create({'foo': 'bar'}, mode='invalid') \ No newline at end of file diff --git a/tests/test_multiple_client.py b/tests/test_multiple_client.py index ea229a3..02ebf64 100644 --- a/tests/test_multiple_client.py +++ b/tests/test_multiple_client.py @@ -10,7 +10,7 @@ class TestClientPayment(ClientTestCase): def setUp(self): super(TestClientPayment, self).setUp() self.base_url = '{}/payments'.format(self.base_url) - self.secondary_base_url = '{}/payments'.format(self.secondary_url) + self.secondary_base_url = '{}/v1/payments'.format(self.secondary_url) @responses.activate def test_payment_primary_url(self): @@ -20,7 +20,7 @@ def test_payment_primary_url(self): body=json.dumps(result), match_querystring=True) self.assertEqual(self.client.payment.all(), result) - @unittest.skip + @responses.activate def test_payment_secondary_url(self): result = mock_file('payment_collection') url = self.secondary_base_url