diff --git a/README.md b/README.md index dcc526d..99381da 100644 --- a/README.md +++ b/README.md @@ -86,27 +86,24 @@ client.access_cards.delete(card_id="0xc4rd1d") ```python template = client.console.create_template( - name="Employee NFC key", + name="Employee Access Pass", platform="apple", use_case="employee_badge", protocol="desfire", allow_on_multiple_devices=True, watch_count=2, iphone_count=3, - design={ - "background_color": "#FFFFFF", - "label_color": "#000000", - "label_secondary_color": "#333333", - "background_image": "[image_in_base64_encoded_format]", - "logo_image": "[image_in_base64_encoded_format]", - "icon_image": "[image_in_base64_encoded_format]" - }, - support_info={ - "support_url": "https://help.yourcompany.com", - "support_phone_number": "+1-555-123-4567", - "support_email": "support@yourcompany.com", - "privacy_policy_url": "https://yourcompany.com/privacy", - "terms_and_conditions_url": "https://yourcompany.com/terms" + background_color="#FFFFFF", + label_color="#000000", + label_secondary_color="#333333", + support_url="https://help.yourcompany.com", + support_phone_number="+1-555-123-4567", + support_email="support@yourcompany.com", + privacy_policy_url="https://yourcompany.com/privacy", + terms_and_conditions_url="https://yourcompany.com/terms", + metadata={ + "version": "2.1", + "approval_status": "approved" } ) ``` @@ -115,18 +112,16 @@ template = client.console.create_template( ```python template = client.console.update_template( - card_template_id="0xd3adb00b5", + template_id="0xd3adb00b5", name="Updated Employee NFC key", allow_on_multiple_devices=True, watch_count=2, iphone_count=3, - support_info={ - "support_url": "https://help.yourcompany.com", - "support_phone_number": "+1-555-123-4567", - "support_email": "support@yourcompany.com", - "privacy_policy_url": "https://yourcompany.com/privacy", - "terms_and_conditions_url": "https://yourcompany.com/terms" - } + support_url="https://help.yourcompany.com", + support_phone_number="+1-555-123-4567", + support_email="support@yourcompany.com", + privacy_policy_url="https://yourcompany.com/privacy", + terms_and_conditions_url="https://yourcompany.com/terms" ) ``` @@ -152,6 +147,73 @@ events = client.console.event_log( ) ``` +#### iOS In-App Provisioning Preflight + +```python +response = client.console.ios_preflight( + card_template_id="0xt3mp14t3-3x1d", + access_pass_ex_id="0xp455-3x1d" +) + +print(f"Provisioning Credential ID: {response.provisioningCredentialIdentifier}") +print(f"Sharing Instance ID: {response.sharingInstanceIdentifier}") +print(f"Card Template ID: {response.cardTemplateIdentifier}") +print(f"Environment ID: {response.environmentIdentifier}") +``` + +#### List ledger items + +```python +from datetime import datetime, timezone, timedelta + +start_date = (datetime.now(timezone.utc) - timedelta(days=30)).isoformat() +end_date = datetime.now(timezone.utc).isoformat() + +result = client.console.ledger_items( + page=1, + per_page=50, + start_date=start_date, + end_date=end_date +) + +for item in result['ledger_items']: + print(f"Amount: {item['amount']}, Kind: {item['kind']}, Date: {item['created_at']}") + if item.get('access_pass'): + print(f" Access Pass: {item['access_pass']['ex_id']}") + if item['access_pass'].get('pass_template'): + print(f" Card Template: {item['access_pass']['pass_template']['ex_id']}") +``` + +### Webhooks + +#### Create a webhook + +```python +webhook = client.console.webhooks.create( + name='Production', + url='https://example.com/webhooks', + subscribed_events=['ag.access_pass.issued'] +) + +print(f"Webhook created: {webhook.id}") +print(f"Private key: {webhook.private_key}") +``` + +#### List webhooks + +```python +webhooks = client.console.webhooks.list() + +for webhook in webhooks: + print(f"ID: {webhook.id}, Name: {webhook.name}") +``` + +#### Delete a webhook + +```python +client.console.webhooks.delete('abc123') +``` + ### HID Organizations #### Create a HID organization @@ -243,3 +305,29 @@ Never expose your `secret_key` in source code. Always use environment variables ## License MIT License - See LICENSE file for details. + +## Feature Matrix + +| Endpoint | Method | Supported | +|---|---|:---:| +| POST /v1/key-cards | `access_cards.issue()` | Y | +| GET /v1/key-cards/{id} | `access_cards.get()` | Y | +| PATCH /v1/key-cards/{id} | `access_cards.update()` | Y | +| GET /v1/key-cards | `access_cards.list()` | Y | +| POST /v1/key-cards/{id}/suspend | `access_cards.suspend()` | Y | +| POST /v1/key-cards/{id}/resume | `access_cards.resume()` | Y | +| POST /v1/key-cards/{id}/unlink | `access_cards.unlink()` | Y | +| POST /v1/key-cards/{id}/delete | `access_cards.delete()` | Y | +| POST /v1/console/card-templates | `console.create_template()` | Y | +| PUT /v1/console/card-templates/{id} | `console.update_template()` | Y | +| GET /v1/console/card-templates/{id} | `console.read_template()` | Y | +| GET /v1/console/card-templates/{id}/logs | `console.get_logs()` / `console.event_log()` | Y | +| GET /v1/console/pass-template-pairs | `console.list_pass_template_pairs()` | Y | +| POST /v1/console/card-templates/{id}/ios_preflight | `console.ios_preflight()` | Y | +| GET /v1/console/ledger-items | `console.ledger_items()` | Y | +| GET /v1/console/webhooks | `console.webhooks.list()` | Y | +| POST /v1/console/webhooks | `console.webhooks.create()` | Y | +| DELETE /v1/console/webhooks/{id} | `console.webhooks.delete()` | Y | +| POST /v1/console/hid/orgs | `console.hid.orgs.create()` | Y | +| POST /v1/console/hid/orgs/activate | `console.hid.orgs.activate()` | Y | +| GET /v1/console/hid/orgs | `console.hid.orgs.list()` | Y | diff --git a/accessgrid/__init__.py b/accessgrid/__init__.py index a2e1040..3bb2ef3 100644 --- a/accessgrid/__init__.py +++ b/accessgrid/__init__.py @@ -23,13 +23,17 @@ AccessGrid, AccessGridError, AuthenticationError, + IosPreflight, Org, + PassTemplatePair, Template, + TemplateInfo, UnifiedAccessPass, + Webhook, ) # Version of the accessgrid package -__version__ = "0.2.1" +__version__ = "0.3.0" # List of public objects that will be exported with "from accessgrid import *" __all__ = [ @@ -39,5 +43,9 @@ "AccessCard", "UnifiedAccessPass", "Template", + "TemplateInfo", + "PassTemplatePair", + "IosPreflight", + "Webhook", "Org", ] diff --git a/accessgrid/client.py b/accessgrid/client.py index 08c37c1..e19a450 100644 --- a/accessgrid/client.py +++ b/accessgrid/client.py @@ -121,10 +121,17 @@ def __repr__(self) -> str: class TemplateInfo: def __init__(self, client, data: Dict[str, Any]): self._client = client + self._data = data self.id = data.get("id") self.name = data.get("name") self.platform = data.get("platform") + def __getitem__(self, key): + return self._data[key] + + def get(self, key, default=None): + return self._data.get(key, default) + def __str__(self) -> str: return ( f"TemplateInfo(id='{self.id}', " @@ -138,6 +145,7 @@ def __repr__(self) -> str: class PassTemplatePair: def __init__(self, client, data: Dict[str, Any]): self._client = client + self._data = data self.id = data.get("id") self.name = data.get("name") self.created_at = data.get("created_at") @@ -152,6 +160,16 @@ def __init__(self, client, data: Dict[str, Any]): else None ) + def __getitem__(self, key): + if key in ("android_template", "ios_template"): + return getattr(self, key) + return self._data[key] + + def get(self, key, default=None): + if key in ("android_template", "ios_template"): + return getattr(self, key, default) + return self._data.get(key, default) + def __str__(self) -> str: return f"PassTemplatePair(id='{self.id}', name='{self.name}')" @@ -159,6 +177,47 @@ def __repr__(self) -> str: return self.__str__() +class IosPreflight: + def __init__(self, client, data: Dict[str, Any]): + self._client = client + self.provisioningCredentialIdentifier = data.get( + "provisioningCredentialIdentifier" + ) + self.sharingInstanceIdentifier = data.get("sharingInstanceIdentifier") + self.cardTemplateIdentifier = data.get("cardTemplateIdentifier") + self.environmentIdentifier = data.get("environmentIdentifier") + + def __str__(self) -> str: + return ( + f"IosPreflight(" + f"provisioningCredentialIdentifier=" + f"'{self.provisioningCredentialIdentifier}')" + ) + + def __repr__(self) -> str: + return self.__str__() + + +class Webhook: + def __init__(self, client, data: Dict[str, Any]): + self._client = client + self.id = data.get("id") + self.name = data.get("name") + self.url = data.get("url") + self.auth_method = data.get("auth_method") + self.subscribed_events = data.get("subscribed_events", []) + self.created_at = data.get("created_at") + self.private_key = data.get("private_key") + self.client_cert = data.get("client_cert") + self.cert_expires_at = data.get("cert_expires_at") + + def __str__(self) -> str: + return f"Webhook(id='{self.id}', name='{self.name}', url='{self.url}')" + + def __repr__(self) -> str: + return self.__str__() + + class AccessCards: def __init__(self, client): self._client = client @@ -291,10 +350,42 @@ def __init__(self, client): self.orgs = HIDOrgs(client) +class Webhooks: + def __init__(self, client): + self._client = client + + def create( + self, + name: str, + url: str, + subscribed_events: List[str], + auth_method: str = "bearer_token", + ) -> Webhook: + """Create a new webhook.""" + data = { + "name": name, + "url": url, + "subscribed_events": subscribed_events, + "auth_method": auth_method, + } + response = self._client._post("/v1/console/webhooks", data) + return Webhook(self._client, response) + + def list(self, **kwargs) -> List[Webhook]: + """List all webhooks.""" + response = self._client._get("/v1/console/webhooks", params=kwargs) + return [Webhook(self._client, wh) for wh in response.get("webhooks", [])] + + def delete(self, webhook_id: str) -> None: + """Delete a webhook by ID.""" + self._client._delete(f"/v1/console/webhooks/{webhook_id}") + + class Console: def __init__(self, client): self._client = client self.hid = HID(client) + self.webhooks = Webhooks(client) def create_template(self, **kwargs) -> Template: """Create a new card template""" @@ -321,6 +412,35 @@ def get_logs(self, template_id: str, **kwargs) -> Dict[str, Any]: f"/v1/console/card-templates/{template_id}/logs", params=kwargs ) + def event_log(self, card_template_id: str, **kwargs) -> Dict[str, Any]: + """Alias for get_logs. Get event logs for a card template.""" + return self.get_logs(card_template_id, **kwargs) + + def ios_preflight( + self, card_template_id: str, access_pass_ex_id: str + ) -> IosPreflight: + """Run iOS In-App Provisioning preflight for an access pass.""" + data = {"access_pass_ex_id": access_pass_ex_id} + response = self._client._post( + f"/v1/console/card-templates/{card_template_id}/ios_preflight", data + ) + return IosPreflight(self._client, response) + + def ledger_items(self, **kwargs) -> Dict[str, Any]: + """ + List ledger items with pagination and date filtering. + + Args: + page: Page number for pagination (default: 1) + per_page: Number of results per page (default: 50, max: 100) + start_date: ISO8601 start date filter + end_date: ISO8601 end date filter + + Returns: + Dict containing ledger_items list and pagination info + """ + return self._client._get("/v1/console/ledger-items", params=kwargs) + def list_pass_template_pairs(self, **kwargs) -> Dict[str, Any]: """ List Pass Template Pairs with pagination support. @@ -396,7 +516,9 @@ def _make_request( # Extract resource ID from the endpoint if needed for signature resource_id = None - if method == "GET" or (method == "POST" and (not data or data == {})): + if method in ("GET", "DELETE") or ( + method == "POST" and (not data or data == {}) + ): # Extract ID from endpoint patterns like # /resource/{id} or /resource/{id}/action parts = endpoint.strip("/").split("/") @@ -412,7 +534,7 @@ def _make_request( # Special handling for requests with no payload: # 1. POST requests with empty body (like unlink/suspend/resume) # 2. GET requests - if (method == "POST" and not data) or method == "GET": + if (method == "POST" and not data) or method in ("GET", "DELETE"): # Use {"id": "card_id"} as the signature payload if resource_id: payload = json.dumps({"id": resource_id}) @@ -439,7 +561,7 @@ def _make_request( try: # For empty-body requests (GET or actions), # include the sig_payload parameter - if method == "GET" or (method == "POST" and not data): + if method in ("GET", "DELETE") or (method == "POST" and not data): if not params: params = {} # Include the ID payload in the query params @@ -460,11 +582,13 @@ def _make_request( elif response.status_code == 402: raise AccessGridError("Insufficient account balance") elif not 200 <= response.status_code < 300: - print(f"response.status_code: {response.status_code}") error_data = response.json() if response.text else {} error_message = error_data.get("message", response.text) raise AccessGridError(f"API request failed: {error_message}") + if response.status_code == 204 or not response.text: + return {} + return response.json() except requests.exceptions.RequestException as e: @@ -485,3 +609,7 @@ def _put(self, endpoint: str, data: Dict) -> Dict[str, Any]: def _patch(self, endpoint: str, data: Dict) -> Dict[str, Any]: """Make a PATCH request""" return self._make_request("PATCH", endpoint, data=data) + + def _delete(self, endpoint: str) -> Optional[Dict[str, Any]]: + """Make a DELETE request""" + return self._make_request("DELETE", endpoint) diff --git a/tests/test_accessgrid.py b/tests/test_accessgrid.py index 8449a75..819963c 100644 --- a/tests/test_accessgrid.py +++ b/tests/test_accessgrid.py @@ -524,3 +524,240 @@ def test_get_logs(self, mock_request, client, mock_response): assert call_args["params"]["start_date"] == "2025-01-01T00:00:00Z" assert call_args["params"]["end_date"] == "2025-02-01T00:00:00Z" assert call_args["params"]["event_type"] == "install" + + @patch("requests.request") + def test_event_log_alias(self, mock_request, client, mock_response): + mock_request.return_value = mock_response + template_id = "0xd3adb00b5" + + client.console.event_log(card_template_id=template_id, device="mobile") + + call_args = mock_request.call_args[1] + assert call_args["method"] == "GET" + assert ( + call_args["url"] + == f"{client.base_url}/v1/console/card-templates/{template_id}/logs" + ) + assert call_args["params"]["device"] == "mobile" + + +class TestIosPreflight: + @patch("requests.request") + def test_ios_preflight(self, mock_request, client): + mock_resp = Mock() + mock_resp.status_code = 200 + mock_resp.text = '{"provisioningCredentialIdentifier": "pci-123"}' + mock_resp.json.return_value = { + "provisioningCredentialIdentifier": "pci-123", + "sharingInstanceIdentifier": "si-456", + "cardTemplateIdentifier": "ct-789", + "environmentIdentifier": "env-abc", + } + mock_request.return_value = mock_resp + + result = client.console.ios_preflight( + card_template_id="0xt3mp14t3", + access_pass_ex_id="0xp455", + ) + + call_args = mock_request.call_args[1] + assert call_args["method"] == "POST" + assert ( + call_args["url"] + == f"{client.base_url}/v1/console/card-templates/0xt3mp14t3/ios_preflight" + ) + assert call_args["json"]["access_pass_ex_id"] == "0xp455" + assert result.provisioningCredentialIdentifier == "pci-123" + assert result.sharingInstanceIdentifier == "si-456" + assert result.cardTemplateIdentifier == "ct-789" + assert result.environmentIdentifier == "env-abc" + + +class TestLedgerItems: + @patch("requests.request") + def test_ledger_items(self, mock_request, client): + mock_resp = Mock() + mock_resp.status_code = 200 + mock_resp.text = '{"ledger_items": []}' + mock_resp.json.return_value = { + "ledger_items": [ + { + "id": "li-1", + "amount": 100, + "kind": "ap_debit", + "event": "ag.access_pass.issued", + "created_at": "2025-01-15T10:00:00Z", + "metadata": {}, + "access_pass": { + "id": "ap-1", + "ex_id": "ap-1", + "full_name": "John Doe", + "state": "active", + "pass_template": { + "id": "tmpl-1", + "ex_id": "tmpl-1", + "name": "Employee Badge", + "protocol": "desfire", + "platform": "apple", + "use_case": "employee_badge", + }, + }, + } + ], + "pagination": { + "current_page": 1, + "per_page": 50, + "total_pages": 1, + "total_count": 1, + }, + } + mock_request.return_value = mock_resp + + result = client.console.ledger_items( + page=1, per_page=50, start_date="2025-01-01T00:00:00Z" + ) + + call_args = mock_request.call_args[1] + assert call_args["method"] == "GET" + assert call_args["url"] == f"{client.base_url}/v1/console/ledger-items" + assert call_args["params"]["page"] == 1 + assert call_args["params"]["per_page"] == 50 + assert call_args["params"]["start_date"] == "2025-01-01T00:00:00Z" + + items = result["ledger_items"] + assert len(items) == 1 + assert items[0]["amount"] == 100 + assert items[0]["kind"] == "ap_debit" + assert items[0]["access_pass"]["full_name"] == "John Doe" + assert items[0]["access_pass"]["pass_template"]["name"] == "Employee Badge" + assert result["pagination"]["total_count"] == 1 + + +class TestWebhooks: + @patch("requests.request") + def test_create_webhook(self, mock_request, client): + mock_resp = Mock() + mock_resp.status_code = 201 + mock_resp.text = '{"id": "wh-1"}' + mock_resp.json.return_value = { + "id": "wh-1", + "name": "Production", + "url": "https://example.com/webhooks", + "auth_method": "bearer_token", + "subscribed_events": ["ag.access_pass.issued"], + "created_at": "2025-01-15T10:00:00Z", + "private_key": "pk-secret-123", + } + mock_request.return_value = mock_resp + + webhook = client.console.webhooks.create( + name="Production", + url="https://example.com/webhooks", + subscribed_events=["ag.access_pass.issued"], + ) + + call_args = mock_request.call_args[1] + assert call_args["method"] == "POST" + assert call_args["url"] == f"{client.base_url}/v1/console/webhooks" + assert call_args["json"]["name"] == "Production" + assert call_args["json"]["url"] == "https://example.com/webhooks" + assert call_args["json"]["subscribed_events"] == ["ag.access_pass.issued"] + assert webhook.id == "wh-1" + assert webhook.name == "Production" + assert webhook.private_key == "pk-secret-123" + expected_str = ( + "Webhook(id='wh-1', name='Production', " + "url='https://example.com/webhooks')" + ) + assert str(webhook) == expected_str + + @patch("requests.request") + def test_list_webhooks(self, mock_request, client): + mock_resp = Mock() + mock_resp.status_code = 200 + mock_resp.text = '{"webhooks": []}' + mock_resp.json.return_value = { + "webhooks": [ + { + "id": "wh-1", + "name": "Production", + "url": "https://example.com/webhooks", + "auth_method": "bearer_token", + "subscribed_events": ["ag.access_pass.issued"], + "created_at": "2025-01-15T10:00:00Z", + }, + { + "id": "wh-2", + "name": "Staging", + "url": "https://staging.example.com/webhooks", + "auth_method": "mtls", + "subscribed_events": ["ag.access_pass.issued"], + "created_at": "2025-02-01T12:00:00Z", + "cert_expires_at": "2026-02-01T12:00:00Z", + }, + ] + } + mock_request.return_value = mock_resp + + webhooks = client.console.webhooks.list() + + call_args = mock_request.call_args[1] + assert call_args["method"] == "GET" + assert call_args["url"] == f"{client.base_url}/v1/console/webhooks" + assert len(webhooks) == 2 + assert webhooks[0].id == "wh-1" + assert webhooks[0].name == "Production" + assert webhooks[1].id == "wh-2" + assert webhooks[1].cert_expires_at == "2026-02-01T12:00:00Z" + + @patch("requests.request") + def test_delete_webhook(self, mock_request, client): + mock_resp = Mock() + mock_resp.status_code = 204 + mock_resp.text = "" + mock_request.return_value = mock_resp + + client.console.webhooks.delete("wh-1") + + call_args = mock_request.call_args[1] + assert call_args["method"] == "DELETE" + assert call_args["url"] == f"{client.base_url}/v1/console/webhooks/wh-1" + + +class TestPassTemplatePairDictAccess: + def test_bracket_access(self): + from accessgrid.client import PassTemplatePair + + data = { + "id": "pair-1", + "name": "Employee Badge", + "created_at": "2025-01-15T10:00:00Z", + "android_template": { + "id": "tmpl-android-1", + "name": "Android Badge", + "platform": "google", + }, + "ios_template": None, + } + pair = PassTemplatePair(None, data) + + assert pair["id"] == "pair-1" + assert pair["name"] == "Employee Badge" + assert pair["android_template"].id == "tmpl-android-1" + assert pair["android_template"]["name"] == "Android Badge" + assert pair["ios_template"] is None + + def test_get_method(self): + from accessgrid.client import PassTemplatePair + + data = { + "id": "pair-1", + "name": "Test", + "created_at": "2025-01-15T10:00:00Z", + "android_template": None, + "ios_template": None, + } + pair = PassTemplatePair(None, data) + + assert pair.get("id") == "pair-1" + assert pair.get("missing", "default") == "default"