From 3529edce9dcb9d97a02a47ccce77af8b03b03204 Mon Sep 17 00:00:00 2001 From: Auston Bunsen Date: Fri, 3 Apr 2026 16:33:41 -0400 Subject: [PATCH 1/4] Sync SDK with API: add landing pages, credential profiles, fix bugs - Add landing pages support (list, create, update) - Add credential profiles support (list, create) - Fix HID orgs list to handle flat array API response - Add missing AccessCard fields (organization_name, temporary, employee_id, created_at) - Add missing Template metadata field - Rename template_id to card_template_id in read/update_template to match docs - Update README examples with all available params - Bump version to 0.4.0 --- README.md | 101 ++++++++++++++- accessgrid/__init__.py | 6 +- accessgrid/client.py | 146 +++++++++++++++++++++- setup.py | 2 +- tests/test_accessgrid.py | 258 +++++++++++++++++++++++++++++++++++++-- 5 files changed, 491 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 99381da..799e97a 100644 --- a/README.md +++ b/README.md @@ -29,13 +29,26 @@ client = AccessGrid(account_id, secret_key) card = client.access_cards.provision( card_template_id="0xd3adb00b5", employee_id="123456789", + tag_id="DDEADB33FB00B5", + allow_on_multiple_devices=True, full_name="Employee name", email="employee@yourwebsite.com", phone_number="+19547212241", classification="full_time", + department="Engineering", + location="San Francisco", + site_name="HQ Building A", + workstation="4F-207", + mail_stop="MS-401", + company_address="123 Main St, San Francisco, CA 94105", start_date="2025-01-31T22:46:25.601Z", expiration_date="2025-04-30T22:46:25.601Z", - employee_photo="[image_in_base64_encoded_format]" + employee_photo="[image_in_base64_encoded_format]", + title="Engineering Manager", + metadata={ + "department": "engineering", + "badge_type": "contractor" + } ) ``` @@ -47,8 +60,15 @@ card = client.access_cards.update( employee_id="987654321", full_name="Updated Employee Name", classification="contractor", + department="Marketing", + location="New York", + site_name="NYC Office", + workstation="2F-105", + mail_stop="MS-200", + company_address="456 Broadway, New York, NY 10013", expiration_date="2025-02-22T21:04:03.664Z", - employee_photo="[image_in_base64_encoded_format]" + employee_photo="[image_in_base64_encoded_format]", + title="Senior Developer" ) ``` @@ -112,7 +132,7 @@ template = client.console.create_template( ```python template = client.console.update_template( - template_id="0xd3adb00b5", + card_template_id="0xd3adb00b5", name="Updated Employee NFC key", allow_on_multiple_devices=True, watch_count=2, @@ -184,6 +204,76 @@ for item in result['ledger_items']: print(f" Card Template: {item['access_pass']['pass_template']['ex_id']}") ``` +### Landing Pages + +#### List landing pages + +```python +landing_pages = client.console.list_landing_pages() + +for page in landing_pages: + print(f"ID: {page.id}, Name: {page.name}, Kind: {page.kind}") + print(f" Password Protected: {page.password_protected}") + if page.logo_url: + print(f" Logo URL: {page.logo_url}") +``` + +#### Create a landing page + +```python +landing_page = client.console.create_landing_page( + name="Miami Office Access Pass", + kind="universal", + additional_text="Welcome to the Miami Office", + bg_color="#f1f5f9", + allow_immediate_download=True +) + +print(f"Landing page created: {landing_page.id}") +print(f"Name: {landing_page.name}, Kind: {landing_page.kind}") +``` + +#### Update a landing page + +```python +landing_page = client.console.update_landing_page( + landing_page_id="0xlandingpage1d", + name="Updated Miami Office Access Pass", + additional_text="Welcome! Tap below to get your access pass.", + bg_color="#e2e8f0" +) + +print(f"Landing page updated: {landing_page.id}") +print(f"Name: {landing_page.name}") +``` + +### Credential Profiles + +#### List credential profiles + +```python +profiles = client.console.credential_profiles.list() + +for profile in profiles: + print(f"ID: {profile.id}, Name: {profile.name}, AID: {profile.aid}") +``` + +#### Create a credential profile + +```python +profile = client.console.credential_profiles.create( + name='Main Office Profile', + app_name='KEY-ID-main', + keys=[ + {'value': 'your_32_char_hex_master_key_here'}, + {'value': 'your_32_char_hex__read_key__here'} + ] +) + +print(f"Profile created: {profile.id}") +print(f"AID: {profile.aid}") +``` + ### Webhooks #### Create a webhook @@ -325,6 +415,11 @@ MIT License - See LICENSE file for details. | 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/landing-pages | `console.list_landing_pages()` | Y | +| POST /v1/console/landing-pages | `console.create_landing_page()` | Y | +| PUT /v1/console/landing-pages/{id} | `console.update_landing_page()` | Y | +| GET /v1/console/credential-profiles | `console.credential_profiles.list()` | Y | +| POST /v1/console/credential-profiles | `console.credential_profiles.create()` | 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 | diff --git a/accessgrid/__init__.py b/accessgrid/__init__.py index 3bb2ef3..7aadd90 100644 --- a/accessgrid/__init__.py +++ b/accessgrid/__init__.py @@ -23,7 +23,9 @@ AccessGrid, AccessGridError, AuthenticationError, + CredentialProfile, IosPreflight, + LandingPage, Org, PassTemplatePair, Template, @@ -33,7 +35,7 @@ ) # Version of the accessgrid package -__version__ = "0.3.0" +__version__ = "0.4.0" # List of public objects that will be exported with "from accessgrid import *" __all__ = [ @@ -48,4 +50,6 @@ "IosPreflight", "Webhook", "Org", + "LandingPage", + "CredentialProfile", ] diff --git a/accessgrid/client.py b/accessgrid/client.py index e19a450..7cb5efe 100644 --- a/accessgrid/client.py +++ b/accessgrid/client.py @@ -35,12 +35,16 @@ def __init__(self, client, data: Dict[str, Any]): self.details = data.get("details") self.state = data.get("state") self.full_name = data.get("full_name") + self.employee_id = data.get("employee_id") self.expiration_date = data.get("expiration_date") self.card_template_id = data.get("card_template_id") self.card_number = data.get("card_number") self.site_code = data.get("site_code") self.file_data = data.get("file_data") self.direct_install_url = data.get("direct_install_url") + self.organization_name = data.get("organization_name") + self.temporary = data.get("temporary") + self.created_at = data.get("created_at") self.devices = data.get("devices", []) self.metadata = data.get("metadata", {}) @@ -91,6 +95,7 @@ def __init__(self, client, data: Dict[str, Any]): self.support_settings = data.get("support_settings") self.terms_settings = data.get("terms_settings") self.style_settings = data.get("style_settings") + self.metadata = data.get("metadata", {}) class Org: @@ -177,6 +182,48 @@ def __repr__(self) -> str: return self.__str__() +class LandingPage: + def __init__(self, client, data: Dict[str, Any]): + self._client = client + self.id = data.get("id") + self.name = data.get("name") + self.created_at = data.get("created_at") + self.kind = data.get("kind") + self.password_protected = data.get("password_protected") + self.logo_url = data.get("logo_url") + + def __str__(self) -> str: + return ( + f"LandingPage(id='{self.id}', " + f"name='{self.name}', kind='{self.kind}')" + ) + + def __repr__(self) -> str: + return self.__str__() + + +class CredentialProfile: + def __init__(self, client, data: Dict[str, Any]): + self._client = client + self.id = data.get("id") + self.aid = data.get("aid") + self.name = data.get("name") + self.apple_id = data.get("apple_id") + self.created_at = data.get("created_at") + self.card_storage = data.get("card_storage") + self.keys = data.get("keys", []) + self.files = data.get("files", []) + + def __str__(self) -> str: + return ( + f"CredentialProfile(id='{self.id}', " + f"name='{self.name}', aid='{self.aid}')" + ) + + def __repr__(self) -> str: + return self.__str__() + + class IosPreflight: def __init__(self, client, data: Dict[str, Any]): self._client = client @@ -315,7 +362,8 @@ def list(self) -> List["Org"]: List of Org objects """ response = self._client._get("/v1/console/hid/orgs") - return [Org(self._client, org) for org in response.get("orgs", [])] + orgs = response if isinstance(response, list) else response.get("orgs", []) + return [Org(self._client, org) for org in orgs] def create( self, name: str, full_address: str, phone: str, first_name: str, last_name: str @@ -381,27 +429,67 @@ def delete(self, webhook_id: str) -> None: self._client._delete(f"/v1/console/webhooks/{webhook_id}") +class CredentialProfiles: + def __init__(self, client): + self._client = client + + def create( + self, name: str, app_name: str = "KEY-ID-main", keys: Optional[List[Dict]] = None, file_id: Optional[str] = None + ) -> CredentialProfile: + """ + Create a new credential profile. + + Args: + name: Profile name + app_name: Application name (default: KEY-ID-main) + keys: List of key dicts, each with 'value' and optional 'keys_diversified', 'source_key_index' + file_id: Optional file ID (default: "00") + + Returns: + CredentialProfile object + """ + data: Dict[str, Any] = {"name": name, "app_name": app_name} + if keys is not None: + data["keys"] = keys + if file_id is not None: + data["file_id"] = file_id + response = self._client._post("/v1/console/credential-profiles", data) + return CredentialProfile(self._client, response) + + def list(self) -> List[CredentialProfile]: + """ + List all credential profiles. + + Returns: + List of CredentialProfile objects + """ + response = self._client._get("/v1/console/credential-profiles") + profiles = response if isinstance(response, list) else response.get("credential_profiles", []) + return [CredentialProfile(self._client, p) for p in profiles] + + class Console: def __init__(self, client): self._client = client self.hid = HID(client) self.webhooks = Webhooks(client) + self.credential_profiles = CredentialProfiles(client) def create_template(self, **kwargs) -> Template: """Create a new card template""" response = self._client._post("/v1/console/card-templates", kwargs) return Template(self._client, response) - def update_template(self, template_id: str, **kwargs) -> Template: + def update_template(self, card_template_id: str, **kwargs) -> Template: """Update an existing card template""" response = self._client._put( - f"/v1/console/card-templates/{template_id}", kwargs + f"/v1/console/card-templates/{card_template_id}", kwargs ) return Template(self._client, response) - def read_template(self, template_id: str) -> Union[Template, List[Template]]: + def read_template(self, card_template_id: str) -> Union[Template, List[Template]]: "Read card template by id or list the card template pairs" - response = self._client._get(f"/v1/console/card-templates/{template_id}") + response = self._client._get(f"/v1/console/card-templates/{card_template_id}") if "templates" in response: return [Template(self._client, item) for item in response["templates"]] return Template(self._client, response) @@ -441,6 +529,54 @@ def ledger_items(self, **kwargs) -> Dict[str, Any]: """ return self._client._get("/v1/console/ledger-items", params=kwargs) + def list_landing_pages(self) -> List[LandingPage]: + """List all landing pages.""" + response = self._client._get("/v1/console/landing-pages") + pages = response if isinstance(response, list) else response.get("landing_pages", []) + return [LandingPage(self._client, lp) for lp in pages] + + def create_landing_page(self, **kwargs) -> LandingPage: + """ + Create a new landing page. + + Args: + name: Landing page name + kind: Landing page kind (e.g. 'universal') + additional_text: Optional text to display + bg_color: Background color hex string + allow_immediate_download: Whether to allow immediate download + password: Optional password protection + is_2fa_enabled: Whether 2FA is enabled + logo: Optional base64-encoded logo image + + Returns: + LandingPage object + """ + response = self._client._post("/v1/console/landing-pages", kwargs) + return LandingPage(self._client, response) + + def update_landing_page(self, landing_page_id: str, **kwargs) -> LandingPage: + """ + Update an existing landing page. + + Args: + landing_page_id: The landing page ID + name: Updated name + additional_text: Updated text + bg_color: Updated background color + allow_immediate_download: Updated download setting + password: Updated password + is_2fa_enabled: Updated 2FA setting + logo: Updated base64-encoded logo image + + Returns: + LandingPage object + """ + response = self._client._put( + f"/v1/console/landing-pages/{landing_page_id}", kwargs + ) + return LandingPage(self._client, response) + def list_pass_template_pairs(self, **kwargs) -> Dict[str, Any]: """ List Pass Template Pairs with pagination support. diff --git a/setup.py b/setup.py index c1d6bf5..e18af7a 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ setup( name="accessgrid", - version="0.2.1", + version="0.4.0", author="Auston Bunsen", author_email="ab@accessgrid.com", description="Python SDK for the AccessGrid API", diff --git a/tests/test_accessgrid.py b/tests/test_accessgrid.py index 819963c..93fce4d 100644 --- a/tests/test_accessgrid.py +++ b/tests/test_accessgrid.py @@ -315,7 +315,7 @@ def test_create_template( @patch("requests.request") def test_update_template(self, mock_request, client, mock_response): mock_request.return_value = mock_response - template_id = "0xd3adb00b5" + card_template_id = "0xd3adb00b5" update_params = { "name": "Updated Template", "allow_on_multiple_devices": False, @@ -323,30 +323,46 @@ def test_update_template(self, mock_request, client, mock_response): "iphone_count": 2, } - client.console.update_template(template_id, **update_params) + client.console.update_template(card_template_id, **update_params) call_args = mock_request.call_args[1] assert call_args["method"] == "PUT" assert ( call_args["url"] - == f"{client.base_url}/v1/console/card-templates/{template_id}" + == f"{client.base_url}/v1/console/card-templates/{card_template_id}" ) assert call_args["json"] == update_params @patch("requests.request") def test_read_template(self, mock_request, client, mock_response): mock_request.return_value = mock_response - template_id = "0xd3adb00b5" + card_template_id = "0xd3adb00b5" - client.console.read_template(template_id) + client.console.read_template(card_template_id) 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}" + == f"{client.base_url}/v1/console/card-templates/{card_template_id}" ) + @patch("requests.request") + def test_read_template_includes_metadata(self, mock_request, client): + mock_resp = Mock() + mock_resp.status_code = 200 + mock_resp.json.return_value = { + "id": "tmpl-1", + "name": "Test", + "platform": "apple", + "metadata": {"version": "2.1"}, + } + mock_request.return_value = mock_resp + + template = client.console.read_template("tmpl-1") + + assert template.metadata == {"version": "2.1"} + @patch("requests.request") def test_list_pass_template_pairs(self, mock_request, client): mock_resp = Mock() @@ -481,12 +497,10 @@ def test_activate_org(self, mock_request, client): def test_list_orgs(self, mock_request, client): mock_resp = Mock() mock_resp.status_code = 200 - mock_resp.json.return_value = { - "orgs": [ - {"id": "org-1", "name": "Acme Corp", "status": "active"}, - {"id": "org-2", "name": "Globex", "status": "pending"}, - ] - } + mock_resp.json.return_value = [ + {"id": "org-1", "name": "Acme Corp", "status": "active"}, + {"id": "org-2", "name": "Globex", "status": "pending"}, + ] mock_request.return_value = mock_resp orgs = client.console.hid.orgs.list() @@ -761,3 +775,223 @@ def test_get_method(self): assert pair.get("id") == "pair-1" assert pair.get("missing", "default") == "default" + + +class TestAccessCardFields: + @patch("requests.request") + def test_get_card_includes_new_fields(self, mock_request, client): + mock_resp = Mock() + mock_resp.status_code = 200 + mock_resp.json.return_value = { + "id": "card-123", + "full_name": "John Doe", + "employee_id": "emp-456", + "state": "active", + "install_url": "https://example.com/install/card-123", + "card_template_id": "tmpl-456", + "expiration_date": "2025-12-31", + "organization_name": "Acme Corp", + "temporary": False, + "created_at": "2025-01-15T10:00:00Z", + "metadata": {"dept": "eng"}, + } + mock_request.return_value = mock_resp + + card = client.access_cards.get("card-123") + + assert card.organization_name == "Acme Corp" + assert card.temporary is False + assert card.employee_id == "emp-456" + assert card.created_at == "2025-01-15T10:00:00Z" + assert card.metadata == {"dept": "eng"} + + +class TestLandingPages: + @patch("requests.request") + def test_list_landing_pages(self, mock_request, client): + mock_resp = Mock() + mock_resp.status_code = 200 + mock_resp.json.return_value = [ + { + "id": "lp-1", + "name": "Miami Office", + "created_at": "2025-01-15T10:00:00Z", + "kind": "universal", + "password_protected": False, + "logo_url": None, + }, + { + "id": "lp-2", + "name": "NYC Office", + "created_at": "2025-02-01T12:00:00Z", + "kind": "universal", + "password_protected": True, + "logo_url": "https://example.com/logo.png", + }, + ] + mock_request.return_value = mock_resp + + pages = client.console.list_landing_pages() + + call_args = mock_request.call_args[1] + assert call_args["method"] == "GET" + assert call_args["url"] == f"{client.base_url}/v1/console/landing-pages" + assert len(pages) == 2 + assert pages[0].id == "lp-1" + assert pages[0].name == "Miami Office" + assert pages[0].kind == "universal" + assert pages[0].password_protected is False + assert pages[1].logo_url == "https://example.com/logo.png" + expected_str = "LandingPage(id='lp-1', name='Miami Office', kind='universal')" + assert str(pages[0]) == expected_str + assert repr(pages[0]) == expected_str + + @patch("requests.request") + def test_create_landing_page(self, mock_request, client): + mock_resp = Mock() + mock_resp.status_code = 201 + mock_resp.text = '{"id": "lp-1"}' + mock_resp.json.return_value = { + "id": "lp-1", + "name": "Miami Office", + "created_at": "2025-01-15T10:00:00Z", + "kind": "universal", + "password_protected": False, + "logo_url": None, + } + mock_request.return_value = mock_resp + + page = client.console.create_landing_page( + name="Miami Office", + kind="universal", + additional_text="Welcome", + bg_color="#f1f5f9", + allow_immediate_download=True, + ) + + call_args = mock_request.call_args[1] + assert call_args["method"] == "POST" + assert call_args["url"] == f"{client.base_url}/v1/console/landing-pages" + assert call_args["json"]["name"] == "Miami Office" + assert call_args["json"]["kind"] == "universal" + assert call_args["json"]["bg_color"] == "#f1f5f9" + assert page.id == "lp-1" + assert page.name == "Miami Office" + + @patch("requests.request") + def test_update_landing_page(self, mock_request, client): + mock_resp = Mock() + mock_resp.status_code = 200 + mock_resp.text = '{"id": "lp-1"}' + mock_resp.json.return_value = { + "id": "lp-1", + "name": "Updated Miami Office", + "created_at": "2025-01-15T10:00:00Z", + "kind": "universal", + "password_protected": False, + "logo_url": None, + } + mock_request.return_value = mock_resp + + page = client.console.update_landing_page( + landing_page_id="lp-1", + name="Updated Miami Office", + bg_color="#e2e8f0", + ) + + call_args = mock_request.call_args[1] + assert call_args["method"] == "PUT" + assert call_args["url"] == f"{client.base_url}/v1/console/landing-pages/lp-1" + assert call_args["json"]["name"] == "Updated Miami Office" + assert page.name == "Updated Miami Office" + + +class TestCredentialProfiles: + @patch("requests.request") + def test_list_credential_profiles(self, mock_request, client): + mock_resp = Mock() + mock_resp.status_code = 200 + mock_resp.json.return_value = [ + { + "id": "cp-1", + "aid": "F0394148000001", + "name": "Main Office", + "apple_id": None, + "created_at": "2025-01-15T10:00:00Z", + "card_storage": "4K", + "keys": [ + { + "ex_id": "00", + "label": "Master Key", + "keys_diversified": False, + "source_key_index": None, + } + ], + "files": [ + { + "ex_id": "00", + "file_type": "standard", + "file_size": None, + "communication_settings": "encrypted_with_mac", + "read_rights": "read", + "write_rights": "master", + "read_write_rights": "master", + "change_rights": "no-keys", + } + ], + } + ] + mock_request.return_value = mock_resp + + profiles = client.console.credential_profiles.list() + + call_args = mock_request.call_args[1] + assert call_args["method"] == "GET" + assert call_args["url"] == f"{client.base_url}/v1/console/credential-profiles" + assert len(profiles) == 1 + assert profiles[0].id == "cp-1" + assert profiles[0].aid == "F0394148000001" + assert profiles[0].name == "Main Office" + assert len(profiles[0].keys) == 1 + assert len(profiles[0].files) == 1 + expected_str = "CredentialProfile(id='cp-1', name='Main Office', aid='F0394148000001')" # noqa: E501 + assert str(profiles[0]) == expected_str + + @patch("requests.request") + def test_create_credential_profile(self, mock_request, client): + mock_resp = Mock() + mock_resp.status_code = 201 + mock_resp.text = '{"id": "cp-1"}' + mock_resp.json.return_value = { + "id": "cp-1", + "aid": "F0394148000001", + "name": "Main Office Profile", + "apple_id": None, + "created_at": "2025-01-15T10:00:00Z", + "card_storage": "4K", + "keys": [ + {"ex_id": "00", "label": "Master Key", "keys_diversified": False, "source_key_index": None}, + {"ex_id": "01", "label": "Read Key", "keys_diversified": False, "source_key_index": None}, + ], + "files": [], + } + mock_request.return_value = mock_resp + + profile = client.console.credential_profiles.create( + name="Main Office Profile", + app_name="KEY-ID-main", + keys=[ + {"value": "00112233445566778899AABBCCDDEEFF"}, + {"value": "FFEEDDCCBBAA99887766554433221100"}, + ], + ) + + call_args = mock_request.call_args[1] + assert call_args["method"] == "POST" + assert call_args["url"] == f"{client.base_url}/v1/console/credential-profiles" + assert call_args["json"]["name"] == "Main Office Profile" + assert call_args["json"]["app_name"] == "KEY-ID-main" + assert len(call_args["json"]["keys"]) == 2 + assert profile.id == "cp-1" + assert profile.aid == "F0394148000001" + assert len(profile.keys) == 2 From 29ad4aec6fce3ab2c464e72bc9c628a6dddcfc81 Mon Sep 17 00:00:00 2001 From: Auston Bunsen Date: Fri, 3 Apr 2026 16:37:12 -0400 Subject: [PATCH 2/4] Add additional test coverage for edge cases - Test update_template/read_template with card_template_id keyword arg - Test HID orgs list backward compat with wrapped response - Test AccessCard/Template field defaults when fields absent --- tests/test_accessgrid.py | 70 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/tests/test_accessgrid.py b/tests/test_accessgrid.py index 93fce4d..8442378 100644 --- a/tests/test_accessgrid.py +++ b/tests/test_accessgrid.py @@ -995,3 +995,73 @@ def test_create_credential_profile(self, mock_request, client): assert profile.id == "cp-1" assert profile.aid == "F0394148000001" assert len(profile.keys) == 2 + + +class TestUpdateTemplateKeywordArg: + @patch("requests.request") + def test_update_template_with_keyword_arg(self, mock_request, client, mock_response): + mock_request.return_value = mock_response + + client.console.update_template( + card_template_id="0xd3adb00b5", name="New Name" + ) + + call_args = mock_request.call_args[1] + assert call_args["method"] == "PUT" + assert ( + call_args["url"] + == f"{client.base_url}/v1/console/card-templates/0xd3adb00b5" + ) + assert call_args["json"]["name"] == "New Name" + + @patch("requests.request") + def test_read_template_with_keyword_arg(self, mock_request, client, mock_response): + mock_request.return_value = mock_response + + client.console.read_template(card_template_id="0xd3adb00b5") + + call_args = mock_request.call_args[1] + assert call_args["method"] == "GET" + assert ( + call_args["url"] + == f"{client.base_url}/v1/console/card-templates/0xd3adb00b5" + ) + + +class TestHIDOrgsListBackwardCompat: + @patch("requests.request") + def test_list_orgs_handles_wrapped_response(self, mock_request, client): + """If the API ever wraps the response in {"orgs": [...]}, it still works.""" + mock_resp = Mock() + mock_resp.status_code = 200 + mock_resp.json.return_value = { + "orgs": [ + {"id": "org-1", "name": "Acme Corp", "status": "active"}, + ] + } + mock_request.return_value = mock_resp + + orgs = client.console.hid.orgs.list() + + assert len(orgs) == 1 + assert orgs[0].id == "org-1" + + +class TestAccessCardFieldDefaults: + def test_missing_fields_default_to_none(self): + from accessgrid.client import AccessCard + + card = AccessCard(None, {"id": "card-1", "state": "active"}) + + assert card.organization_name is None + assert card.temporary is None + assert card.employee_id is None + assert card.created_at is None + assert card.metadata == {} + + def test_template_missing_metadata_defaults_to_empty(self): + from accessgrid.client import Template + + tmpl = Template(None, {"id": "t-1", "name": "Test"}) + + assert tmpl.metadata == {} From 8d82b10a6ffe6f2fcb8139f1aed9249afb6a3f44 Mon Sep 17 00:00:00 2001 From: Auston Bunsen Date: Fri, 3 Apr 2026 16:44:41 -0400 Subject: [PATCH 3/4] Remove allow_on_multiple_devices from provision example This is a template-level param, not a per-card issue param. --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 799e97a..3288d0d 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,6 @@ card = client.access_cards.provision( card_template_id="0xd3adb00b5", employee_id="123456789", tag_id="DDEADB33FB00B5", - allow_on_multiple_devices=True, full_name="Employee name", email="employee@yourwebsite.com", phone_number="+19547212241", From e3183f238ff159e2008701ed8030932cfd786010 Mon Sep 17 00:00:00 2001 From: Auston Bunsen Date: Fri, 3 Apr 2026 16:48:51 -0400 Subject: [PATCH 4/4] Run black, isort, flake8 formatting fixes --- accessgrid/client.py | 24 ++++++++++++++++++------ tests/test_accessgrid.py | 22 ++++++++++++++++------ 2 files changed, 34 insertions(+), 12 deletions(-) diff --git a/accessgrid/client.py b/accessgrid/client.py index 7cb5efe..4d20ab1 100644 --- a/accessgrid/client.py +++ b/accessgrid/client.py @@ -194,8 +194,7 @@ def __init__(self, client, data: Dict[str, Any]): def __str__(self) -> str: return ( - f"LandingPage(id='{self.id}', " - f"name='{self.name}', kind='{self.kind}')" + f"LandingPage(id='{self.id}', " f"name='{self.name}', kind='{self.kind}')" ) def __repr__(self) -> str: @@ -434,7 +433,11 @@ def __init__(self, client): self._client = client def create( - self, name: str, app_name: str = "KEY-ID-main", keys: Optional[List[Dict]] = None, file_id: Optional[str] = None + self, + name: str, + app_name: str = "KEY-ID-main", + keys: Optional[List[Dict]] = None, + file_id: Optional[str] = None, ) -> CredentialProfile: """ Create a new credential profile. @@ -442,7 +445,8 @@ def create( Args: name: Profile name app_name: Application name (default: KEY-ID-main) - keys: List of key dicts, each with 'value' and optional 'keys_diversified', 'source_key_index' + keys: List of key dicts with 'value' and optional + 'keys_diversified', 'source_key_index' file_id: Optional file ID (default: "00") Returns: @@ -464,7 +468,11 @@ def list(self) -> List[CredentialProfile]: List of CredentialProfile objects """ response = self._client._get("/v1/console/credential-profiles") - profiles = response if isinstance(response, list) else response.get("credential_profiles", []) + profiles = ( + response + if isinstance(response, list) + else response.get("credential_profiles", []) + ) return [CredentialProfile(self._client, p) for p in profiles] @@ -532,7 +540,11 @@ def ledger_items(self, **kwargs) -> Dict[str, Any]: def list_landing_pages(self) -> List[LandingPage]: """List all landing pages.""" response = self._client._get("/v1/console/landing-pages") - pages = response if isinstance(response, list) else response.get("landing_pages", []) + pages = ( + response + if isinstance(response, list) + else response.get("landing_pages", []) + ) return [LandingPage(self._client, lp) for lp in pages] def create_landing_page(self, **kwargs) -> LandingPage: diff --git a/tests/test_accessgrid.py b/tests/test_accessgrid.py index 8442378..a11a494 100644 --- a/tests/test_accessgrid.py +++ b/tests/test_accessgrid.py @@ -970,8 +970,18 @@ def test_create_credential_profile(self, mock_request, client): "created_at": "2025-01-15T10:00:00Z", "card_storage": "4K", "keys": [ - {"ex_id": "00", "label": "Master Key", "keys_diversified": False, "source_key_index": None}, - {"ex_id": "01", "label": "Read Key", "keys_diversified": False, "source_key_index": None}, + { + "ex_id": "00", + "label": "Master Key", + "keys_diversified": False, + "source_key_index": None, + }, + { + "ex_id": "01", + "label": "Read Key", + "keys_diversified": False, + "source_key_index": None, + }, ], "files": [], } @@ -999,12 +1009,12 @@ def test_create_credential_profile(self, mock_request, client): class TestUpdateTemplateKeywordArg: @patch("requests.request") - def test_update_template_with_keyword_arg(self, mock_request, client, mock_response): + def test_update_template_with_keyword_arg( + self, mock_request, client, mock_response + ): mock_request.return_value = mock_response - client.console.update_template( - card_template_id="0xd3adb00b5", name="New Name" - ) + client.console.update_template(card_template_id="0xd3adb00b5", name="New Name") call_args = mock_request.call_args[1] assert call_args["method"] == "PUT"