From 273f7dafd71d09b44986987679ba94e98e35f8b3 Mon Sep 17 00:00:00 2001 From: manish soni Date: Tue, 19 Aug 2025 18:46:50 +0530 Subject: [PATCH 1/4] 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) Usage: client = razorpay.Client(auth=(...), public_auth=(...)) client.device_activity.create(data, mode='wired') client.device_activity.get_status('act_123', mode='wireless') --- razorpay/client.py | 16 +++++- razorpay/constants/device.py | 3 ++ razorpay/constants/url.py | 2 + razorpay/resources/__init__.py | 2 + razorpay/resources/device_activity.py | 55 ++++++++++++++++++++ tests/mocks/fake_device_activity.json | 1 + tests/mocks/fake_device_activity_status.json | 1 + tests/test_client_device_activity.py | 36 +++++++++++++ tests/test_multiple_client.py | 4 +- 9 files changed, 116 insertions(+), 4 deletions(-) create mode 100644 razorpay/constants/device.py create mode 100644 razorpay/resources/device_activity.py create mode 100644 tests/mocks/fake_device_activity.json create mode 100644 tests/mocks/fake_device_activity_status.json create mode 100644 tests/test_client_device_activity.py diff --git a/razorpay/client.py b/razorpay/client.py index ea6cf08d..6d639d3e 100644 --- a/razorpay/client.py +++ b/razorpay/client.py @@ -39,13 +39,14 @@ class Client: 'base_url': URL.BASE_URL } - def __init__(self, session=None, auth=None, **options): + def __init__(self, session=None, auth=None, public_auth=None, **options): """ Initialize a Client object with session, optional auth handler, and options """ self.session = session or requests.Session() self.auth = auth + self.public_auth = public_auth file_dir = os.path.dirname(__file__) self.cert_path = file_dir + '/ca-bundle.crt' @@ -115,9 +116,20 @@ 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.public_auth if use_public_auth and self.public_auth else self.auth + + # 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 00000000..05c47a61 --- /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 9f3fcfc2..a283c16a 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 f1ec4e6a..66615f3a 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 00000000..53b5bd14 --- /dev/null +++ b/razorpay/resources/device_activity.py @@ -0,0 +1,55 @@ +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 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 = None + if mode is not None: + if mode not in (DeviceMode.WIRED, DeviceMode.WIRELESS): + raise BadRequestError("Invalid device mode. Allowed values are 'wired' and 'wireless'.") + device_mode = mode + + url = self.base_url + return self.post_url(url, data, use_public_auth=True, device_mode=device_mode, **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 = None + if mode is not None: + if mode not in (DeviceMode.WIRED, DeviceMode.WIRELESS): + raise BadRequestError("Invalid device mode. Allowed values are 'wired' and 'wireless'.") + device_mode = mode + + url = f"{self.base_url}/{activity_id}" + return self.get_url(url, {}, use_public_auth=True, device_mode=device_mode, **kwargs) \ No newline at end of file diff --git a/tests/mocks/fake_device_activity.json b/tests/mocks/fake_device_activity.json new file mode 100644 index 00000000..05785fc0 --- /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 00000000..17a560ef --- /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 00000000..d34b5ae8 --- /dev/null +++ b/tests/test_client_device_activity.py @@ -0,0 +1,36 @@ +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" + self.public_client = razorpay.Client(auth=('key_id', 'key_secret'), public_auth=('public_key', '')) + + @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 ea229a30..02ebf64f 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 From 260ec9ee41c544240e40c1d8d6bf127635615ca2 Mon Sep 17 00:00:00 2001 From: manish soni Date: Tue, 26 Aug 2025 17:45:38 +0530 Subject: [PATCH 2/4] refactor: simplify authentication and improve DeviceActivity API - Remove public_auth complexity from Client class - Use standard HTTP Basic Auth for all APIs instead of dual auth system - Refactor DeviceActivity to use single authentication approach - Extract device mode validation into reusable private method - Update tests to use simplified client initialization - Maintain backward compatibility while reducing authentication complexity --- razorpay/client.py | 9 ++----- razorpay/resources/device_activity.py | 35 ++++++++++++++++++--------- tests/test_client_device_activity.py | 2 +- 3 files changed, 26 insertions(+), 20 deletions(-) diff --git a/razorpay/client.py b/razorpay/client.py index 6d639d3e..b457aacd 100644 --- a/razorpay/client.py +++ b/razorpay/client.py @@ -39,14 +39,13 @@ class Client: 'base_url': URL.BASE_URL } - def __init__(self, session=None, auth=None, public_auth=None, **options): + def __init__(self, session=None, auth=None, **options): """ Initialize a Client object with session, optional auth handler, and options """ self.session = session or requests.Session() self.auth = auth - self.public_auth = public_auth file_dir = os.path.dirname(__file__) self.cert_path = file_dir + '/ca-bundle.crt' @@ -116,10 +115,6 @@ 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.public_auth if use_public_auth and self.public_auth else self.auth - # Inject device mode header if provided device_mode = options.pop('device_mode', None) if device_mode is not None: @@ -129,7 +124,7 @@ def request(self, method, path, **options): url = "{}{}".format(self.base_url, path) - response = getattr(self.session, method)(url, auth=auth_to_use, + response = getattr(self.session, method)(url, auth=self.auth, verify=self.cert_path, **options) if ((response.status_code >= HTTP_STATUS_CODE.OK) and diff --git a/razorpay/resources/device_activity.py b/razorpay/resources/device_activity.py index 53b5bd14..647bbc1f 100644 --- a/razorpay/resources/device_activity.py +++ b/razorpay/resources/device_activity.py @@ -10,6 +10,25 @@ 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]: """ @@ -22,14 +41,10 @@ def create(self, data: Dict[str, Any], mode: Optional[str] = None, **kwargs) -> Returns: DeviceActivity object """ - device_mode = None - if mode is not None: - if mode not in (DeviceMode.WIRED, DeviceMode.WIRELESS): - raise BadRequestError("Invalid device mode. Allowed values are 'wired' and 'wireless'.") - device_mode = mode + device_mode = self._validate_device_mode(mode) url = self.base_url - return self.post_url(url, data, use_public_auth=True, device_mode=device_mode, **kwargs) + return self.post_url(url, data, device_mode=device_mode, **kwargs) def get_status(self, activity_id: str, mode: Optional[str] = None, **kwargs) -> Dict[str, Any]: """ @@ -45,11 +60,7 @@ def get_status(self, activity_id: str, mode: Optional[str] = None, **kwargs) -> if not activity_id: raise BadRequestError("Activity ID must be provided") - device_mode = None - if mode is not None: - if mode not in (DeviceMode.WIRED, DeviceMode.WIRELESS): - raise BadRequestError("Invalid device mode. Allowed values are 'wired' and 'wireless'.") - device_mode = mode + device_mode = self._validate_device_mode(mode) url = f"{self.base_url}/{activity_id}" - return self.get_url(url, {}, use_public_auth=True, device_mode=device_mode, **kwargs) \ No newline at end of file + return self.get_url(url, {}, device_mode=device_mode, **kwargs) \ No newline at end of file diff --git a/tests/test_client_device_activity.py b/tests/test_client_device_activity.py index d34b5ae8..4f190381 100644 --- a/tests/test_client_device_activity.py +++ b/tests/test_client_device_activity.py @@ -12,7 +12,7 @@ class TestClientDeviceActivity(ClientTestCase): def setUp(self): super(TestClientDeviceActivity, self).setUp() self.device_activity_base_url = f"{self.base_url}/devices/activity" - self.public_client = razorpay.Client(auth=('key_id', 'key_secret'), public_auth=('public_key', '')) + self.public_client = razorpay.Client(auth=('key_id', 'key_secret')) @responses.activate def test_create_device_activity(self): From d384304753423f643c8d8164e3eaf8ba419c1147 Mon Sep 17 00:00:00 2001 From: manish soni Date: Tue, 26 Aug 2025 18:17:57 +0530 Subject: [PATCH 3/4] Implement single client with use_public_auth parameter for device APIs - Modified client.py to use use_public_auth parameter for authentication type selection - When use_public_auth=True, only key_id is used for authentication (empty key_secret) - Device APIs in device_activity.py now automatically pass use_public_auth=True - Regular APIs continue using full authentication (use_public_auth=False by default) - Single client can handle both device APIs and regular APIs seamlessly - Updated tests to reflect new authentication approach - No breaking changes to existing API usage --- razorpay/client.py | 11 ++++++++++- razorpay/resources/device_activity.py | 4 ++-- tests/test_client_device_activity.py | 2 ++ 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/razorpay/client.py b/razorpay/client.py index b457aacd..d77e795f 100644 --- a/razorpay/client.py +++ b/razorpay/client.py @@ -115,6 +115,15 @@ 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: @@ -124,7 +133,7 @@ def request(self, method, path, **options): 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/resources/device_activity.py b/razorpay/resources/device_activity.py index 647bbc1f..722cbf6f 100644 --- a/razorpay/resources/device_activity.py +++ b/razorpay/resources/device_activity.py @@ -44,7 +44,7 @@ def create(self, data: Dict[str, Any], mode: Optional[str] = None, **kwargs) -> device_mode = self._validate_device_mode(mode) url = self.base_url - return self.post_url(url, data, device_mode=device_mode, **kwargs) + 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]: """ @@ -63,4 +63,4 @@ def get_status(self, activity_id: str, mode: Optional[str] = None, **kwargs) -> device_mode = self._validate_device_mode(mode) url = f"{self.base_url}/{activity_id}" - return self.get_url(url, {}, device_mode=device_mode, **kwargs) \ No newline at end of file + return self.get_url(url, {}, device_mode=device_mode, use_public_auth=True, **kwargs) \ No newline at end of file diff --git a/tests/test_client_device_activity.py b/tests/test_client_device_activity.py index 4f190381..ca64f135 100644 --- a/tests/test_client_device_activity.py +++ b/tests/test_client_device_activity.py @@ -12,6 +12,8 @@ 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 From f0f8061ee4e80d908b4bd10a4103d3e45399369a Mon Sep 17 00:00:00 2001 From: manish soni Date: Wed, 27 Aug 2025 12:28:58 +0530 Subject: [PATCH 4/4] chore: bump version to 1.5.0 --- CHANGELOG.md | 17 +++++++++++++++++ setup.py | 2 +- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 477b1fb7..38b5d765 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/setup.py b/setup.py index e478f008..8f0bcc01 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',