From 2e26fbf905c387747afacb2f1a950648c638ba76 Mon Sep 17 00:00:00 2001 From: Per Olofsson Date: Fri, 2 Dec 2022 18:22:28 +0100 Subject: [PATCH] Release v3.0.7 (#63) * resolves the bug in issue #25 by removing id_override, replacing data with params, and adding specific input parameters. * required changes to resolve the bug in issue #25. migrated from updating the url in _get_data to a local _params variable that is updated with the input params var. * added Unreleased section and updated with issue #25 changes. * Fix calls that return a single item. * Return single items without wrapping in list. * added params= to be explicit, and marked a potential bug. * updated CHANGELOG * added docstring * updated CHANGELOG * Resolves Issue 38 (#1) * Resolves issue #38 * Resolves issue #24 - Updated update_device input to accept both name and device_name input (breaking change) - Data is now updated with the inputs - Added validation that data has input - Updated the README with update_device's new inputs * Adding download option to profile * Update CHANGELOG.md * v3.0.7 * adding _get_xml connection * update CHANGELOG * update README * Adding include_awaiting_enrollment option #43 (#44) * Merging dev branch (#46) * Use request params instead of url string in SimpleMDM._get_data() * Fix Devices.delete_device() * Add methods for enabling/disabling remote desktop * Add /devices request rate limiting * Add profile and user listing * Add retry on 5xx errors to GET requests * Updates gitignore and changelog (#47) - Added ignoring egg files - Updated changelog * A little clean up, some fixen, and a few tests. (#48) - Cleaned up my bad merge on Devices.get_device() and adds some help docs - Closes the session on deinit that the Connection class now opens - Resolves issue #45 by preserving input parameters instead of overwriting them - Added setup.cfg and pyproject.toml files for packaging new releases - Added a few basic tests - Updates the changelog and gitignore files * Add script support * Add error handling for update_script * Fix handling of req_params for pagination * Update CHANGELOG.md * Fix handling of req_params for pagination * Update CHANGELOG.md * Add Sample Projects Adding some samples projects for issue #28 * Use monotonic time for rate limit and fix sleep time calc * Update CHANGELOG.md Co-authored-by: Steve Co-authored-by: Bryan Heinz Co-authored-by: Jon Crain --- .gitignore | 4 +- CHANGELOG.md | 54 +++++++++++--- README.md | 18 +++-- SimpleMDMpy/CustomConfigurationProfiles.py | 5 ++ SimpleMDMpy/Devices.py | 68 ++++++++++++++---- SimpleMDMpy/Logs.py | 25 +++++-- SimpleMDMpy/ScriptJobs.py | 68 ++++++++++++++++++ SimpleMDMpy/Scripts.py | 82 ++++++++++++++++++++++ SimpleMDMpy/SimpleMDM.py | 74 ++++++++++++++++--- SimpleMDMpy/__init__.py | 2 + pyproject.toml | 3 + setup.cfg | 21 ++++++ tests/readme.md | 15 ++++ tests/settings-sample.py | 10 +++ tests/test_Account.py | 16 +++++ tests/test_CustomConfigurationProfiles.py | 29 ++++++++ tests/test_Devices.py | 30 ++++++++ 17 files changed, 481 insertions(+), 43 deletions(-) create mode 100644 SimpleMDMpy/ScriptJobs.py create mode 100644 SimpleMDMpy/Scripts.py create mode 100644 pyproject.toml create mode 100644 setup.cfg create mode 100644 tests/readme.md create mode 100644 tests/settings-sample.py create mode 100644 tests/test_Account.py create mode 100644 tests/test_CustomConfigurationProfiles.py create mode 100644 tests/test_Devices.py diff --git a/.gitignore b/.gitignore index 726d41a..7b58d1f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ *.pyc +*.egg* .DS_Store +settings.py -.vscode \ No newline at end of file +.vscode diff --git a/CHANGELOG.md b/CHANGELOG.md index c127ed2..5ad1fb2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,42 @@ # CHANGELOG -## v3.0.6 +## [Unreleased] + +### Added +- setup.cfg - Python package setup file ([#29](https://github.com/macadmins/simpleMDMpy/issues/29)) - TY [@bryanheinz](https://github.com/bryanheinz) +- pyproject.toml - Python package meta setup file ([#29](https://github.com/macadmins/simpleMDMpy/issues/29)) - TY [@bryanheinz](https://github.com/bryanheinz) +- tests - Added a few basic tests and including a readme on how to setup testing - TY [@bryanheinz](https://github.com/bryanheinz) + +### Changes + +- Added ability to update the actual device name via SimpleMDM ([#24](https://github.com/macadmins/simpleMDMpy/issues/24), [#38](https://github.com/macadmins/simpleMDMpy/issues/38)) - TY [@bryanheinz](https://github.com/bryanheinz) +- Replaced get_logs() `id_override` input parameter with `starting_after` and `limit` ([#25](https://github.com/macadmins/simpleMDMpy/issues/25)) - TY [@bryanheinz](https://github.com/bryanheinz) +- Fixes calls that return a single item ([#26](https://github.com/macadmins/simpleMDMpy/issues/26)) - TY [@MagerValp](https://github.com/MagerValp) +- Add method to download profiles ([#40](https://github.com/macadmins/simpleMDMpy/issues/40)) - TY [@joncrain](https://github.com/joncrain) +- Adds option for get_devices to include_awaiting_enrollment ([#43](https://github.com/macadmins/simpleMDMpy/issues/43)) - TY [@joncrain](https://github.com/joncrain) +- Fixes `Devices.delete_device()` - TY [@MagerValp](https://github.com/MagerValp) +- Add Devices methods for enabling/disabling remote desktop, and profile and user listing ([@MagerValp](https://github.com/MagerValp)) +- Add /devices request rate limiting to `_get_data` - TY [@MagerValp](https://github.com/MagerValp) +- Add retry on 5xx errors to GET requests to `_get_data` - TY [@MagerValp](https://github.com/MagerValp) +- Fixes `_get_data` so that it properly preserves all input parameters ([#45](https://github.com/macadmins/simpleMDMpy/issues/45)) - TY [@bryanheinz](https://github.com/bryanheinz) +- Adds help docs to Devices.get_device() - TY [@bryanheinz](https://github.com/bryanheinz) +- Add Scripts and ScriptJobs - TY [@MagerValp](https://github.com/MagerValp) +- Fix pagination - TY [@jcfrt](https://github.com/jcfrt) +- Fix rate limiting - TY [@MagerValp](https://github.com/MagerValp) + +### Issues + +- Closes issue #24 +- Closes issue #38 +- Closes issue #25 +- Closes issue #26 +- Closes issue #40 +- Closes issue #43 +- Closes issue #29 +- Closes issue #45 +- Closes issue #57 + +## [v3.0.6] ### PRs Included @@ -12,7 +48,7 @@ - Add method to get all custom attributes for a device -## v3.0.5 +## [v3.0.5] ### Issues @@ -22,7 +58,7 @@ - CODEOWNERS -## v3.0.4 +## [v3.0.4] ### Issues @@ -39,7 +75,7 @@ - Merged with @MagerValp / simpleMDMpy @ [508540928](https://github.com/MagerValp/simpleMDMpy/commit/50854094bee2ac5306eded7c5614d76f3eab4c25) - minor tweaks on the readme -## v3.0.3 +## [v3.0.3] ### Issues @@ -57,7 +93,7 @@ - default branch is now `main` - remove `data` payload from Devices.delete_device -## v3.0.2 +## [v3.0.2] ### Issues @@ -73,7 +109,7 @@ - Changed paginaition to work without compounding to a `414` -## v3.0.1 +## [v3.0.1] ### Issues @@ -84,7 +120,7 @@ - Changed paginaition to work, now returns obj not response - good catch @bryanheinz -## v3.0.0 +## [v3.0.0] - Closes #3 @@ -93,7 +129,7 @@ - removed forced encoding for `GET` responses - added some pylint comments -## v2.1.0 +## [v2.1.0] ### Issues @@ -103,7 +139,7 @@ - fixed module names -## v2.0.0 +## [v2.0.0] ### Issues diff --git a/README.md b/README.md index a73135f..ef40784 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,11 @@ Your SimpleMDM API key will need to be set as an environmental variable `api_key Help available via `help(SimpleMDMpy)` +## Sample Projects + +* [Making SimpleMDM Complicated](https://github.com/lucasjhall/CONF-2021_MDO_YVR-Making_SimpleMDM_Complicated) +* [SimpleCLI](https://github.com/MagerValp/SimpleCLI) + ## Available Modules ### Account @@ -179,7 +184,6 @@ class CustomAttributes(SimpleMDMpy.SimpleMDM.Connection) ### Custom Configuration Profiles - ```python class CustomConfigurationProfiles(SimpleMDMpy.SimpleMDM.Connection) | work with custom profiles @@ -197,6 +201,9 @@ class CustomConfigurationProfiles(SimpleMDMpy.SimpleMDM.Connection) | delete_profile(self, profile_id) | deletes custom profile | + | download_profile(self, profile_id) + | downloads custom profile + | | get_profiles(self) | returns profiles | @@ -258,9 +265,10 @@ class Devices(SimpleMDMpy.SimpleMDM.Connection) | get_custom_attribute(self, device_id, custom_attribute_name) | get a devices custom attributes | - | get_device(self, device_id='all', search=None) + | get_device(self, device_id='all', search=None, include_awaiting_enrollment=False) | Returns a device specified by id. If no ID or search is - | specified all devices will be returned + | specified all devices will be returned. Default does not include devices + | waiting for enrollment | | list_installed_apps(self, device_id) | Returns a listing of the apps installed on a device. @@ -288,7 +296,7 @@ class Devices(SimpleMDMpy.SimpleMDM.Connection) | shutdown_device(self, device_id) | This command sends a shutdown command to the device. | - | update_device(self, name, device_id) + | update_device(self, device_id, name=None, device_name=None) | Update the SimpleMDM name or device name of a device object. | | update_os(self, device_id) @@ -374,7 +382,7 @@ class Logs(SimpleMDMpy.SimpleMDM.Connection) | | __init__(self, api_key) | - | get_logs(self) + | get_logs(self, starting_after=None, limit=None) | And I mean all the LOGS, before pagination | diff --git a/SimpleMDMpy/CustomConfigurationProfiles.py b/SimpleMDMpy/CustomConfigurationProfiles.py index ee7eccd..aa2fd51 100644 --- a/SimpleMDMpy/CustomConfigurationProfiles.py +++ b/SimpleMDMpy/CustomConfigurationProfiles.py @@ -49,6 +49,11 @@ def delete_profile(self, profile_id): url = self.url + "/" + profile_id return self._delete_data(url) + def download_profile(self, profile_id): + """downloads custom profile""" + url = self.url + "/" + profile_id + "/download/" + return self._get_xml(url) + def assign_to_device_group(self, profile_id, device_group_id): """assigns custom profile to group""" url = self.url + "/" + profile_id + "/device_groups/" + device_group_id diff --git a/SimpleMDMpy/Devices.py b/SimpleMDMpy/Devices.py index 0c47e4c..0239ca5 100644 --- a/SimpleMDMpy/Devices.py +++ b/SimpleMDMpy/Devices.py @@ -11,16 +11,32 @@ def __init__(self, api_key): SimpleMDMpy.SimpleMDM.Connection.__init__(self, api_key) self.url = self._url("/devices") - def get_device(self, device_id="all", search=None): - """Returns a device specified by id. If no ID or search is - specified all devices will be returned""" + def get_device(self, device_id="all", search=None, include_awaiting_enrollment=False): + """ + Returns a device specified by id. If no ID or search is specified all + devices will be returned. + + Args: + device_id (str, optional): Returns a dictionary of the specified + device id. By default, it returns a list of all devices. If a + device_id and search is specified, then search will be ignored. + search (str, optional): Returns a list of devices that match the + search criteria. Defaults to None. Ignored if device_id is set. + include_awaiting_enrollment (bool, optional): Returns a list of all + devices including devices in the "awaiting_enrollment" state. + + Returns: + dict: A single dictionary object with device information. + array: An array of dictionary objects with device information. + """ url = self.url - data = None - if search: - data = {'search': search} - elif device_id != 'all': + params = {'include_awaiting_enrollment': include_awaiting_enrollment} + # if a device ID is specified, then ignore any searches + if device_id != 'all': url = url + "/" + str(device_id) - return self._get_data(url, data) + elif search: + params['search'] = search + return self._get_data(url, params) def create_device(self, name, group_id): """Creates a new device object in SimpleMDM. The response @@ -29,23 +45,38 @@ def create_device(self, name, group_id): data = {'name': name, 'group_id': group_id} return self._post_data(self.url, data) - def update_device(self, name, device_id): - """Update the SimpleMDM name or device name of a device object.""" + def update_device(self, device_id, name=None, device_name=None): + """Update the SimpleMDM name and/or device name of a device object.""" url = self.url + "/" + str(device_id) - data = {'name': name} + data = {} + if name is not None: + data.update({'name':name}) + if device_name is not None: + data.update({'device_name':device_name}) + if data == {}: + raise Exception(f"Missing name and/or device_name variables.") return self._patch_data(url, data) def delete_device(self, device_id): """Unenroll a device and remove it from the account.""" url = self.url + "/" + str(device_id) - data = {} - return self._delete_data(url, data) #pylint: disable=too-many-function-args + return self._delete_data(url) #pylint: disable=too-many-function-args + + def list_profiles(self, device_id): + """Returns a listing of profiles that are directly assigned to the device.""" + url = self.url + "/" + str(device_id) + "/profiles" + return self._get_data(url) def list_installed_apps(self, device_id): """Returns a listing of the apps installed on a device.""" url = self.url + "/" + str(device_id) + "/installed_apps" return self._get_data(url) + def list_users(self, device_id): + """Returns a listing of the user accounts on a device.""" + url = self.url + "/" + str(device_id) + "/users" + return self._get_data(url) + def push_apps_device(self, device_id): """You can use this method to push all assigned apps to a device that are not already installed.""" @@ -102,6 +133,17 @@ def update_os(self, device_id): data = {} return self._post_data(url, data) + def enable_remote_desktop(self, device_id): + """You can use this method to enable remote desktop. Supported by macOS 10.14.4+ devices only.""" + url = self.url + "/" + str(device_id) + "/remote_desktop" + data = {} + return self._post_data(url, data) + + def disable_remote_desktop(self, device_id): + """You can use this method to disable remote desktop. Supported by macOS 10.14.4+ devices only.""" + url = self.url + "/" + str(device_id) + "/remote_desktop" + return self._delete_data(url) + def refresh_device(self, device_id): """Request a refresh of the device information and app inventory. SimpleMDM will update the inventory information when the device responds diff --git a/SimpleMDMpy/Logs.py b/SimpleMDMpy/Logs.py index 9d42806..52c02de 100644 --- a/SimpleMDMpy/Logs.py +++ b/SimpleMDMpy/Logs.py @@ -10,9 +10,24 @@ class Logs(SimpleMDMpy.SimpleMDM.Connection): def __init__(self, api_key): SimpleMDMpy.SimpleMDM.Connection.__init__(self, api_key) self.url = self._url("/logs") - - def get_logs(self, id_override=0): - """And I mean all the LOGS""" + + def get_logs(self, starting_after=None, limit=None): + """Returns logs, and I mean all the LOGS + + Args: + starting_after (str, optional): set to the id of the log object you + want to start with. Defaults to the first object. + limit (str, optional): A limit on the number of objects that will be + returned per API call. Setting this will still return all logs. + Defaults to 100. + + Returns: + array: An array of dictionary log objects. + """ url = self.url - data = {} - return self._get_data(url, data, id_override=id_override) \ No newline at end of file + params = {} + if starting_after: + params['starting_after'] = starting_after + if limit: + params['limit'] = limit + return self._get_data(url, params=params) diff --git a/SimpleMDMpy/ScriptJobs.py b/SimpleMDMpy/ScriptJobs.py new file mode 100644 index 0000000..09fd664 --- /dev/null +++ b/SimpleMDMpy/ScriptJobs.py @@ -0,0 +1,68 @@ +"""Scripts module for SimpleMDMpy""" + + +from SimpleMDMpy.SimpleMDM import Connection, ApiError + + +class ScriptJobs(Connection): + """scripts module for SimpleMDMpy""" + def __init__(self, api_key): + Connection.__init__(self, api_key) + self.url = self._url("/script_jobs") + + def get_job(self, job_id="all"): + """Jobs represent scripts that have been set to run on a collection of + devices. Jobs remain listed for one month. + + Args: + job_id (int, optional): Returns a dictionary of the specified job + id. By default, it returns a list of all jobs. + + Returns: + dict: A single dictionary object with job information. + array: An array of dictionary objects with job information. + """ + url = self.url + if job_id != 'all': + url = url + "/" + str(job_id) + return self._get_data(url) + + def create_job(self, script_id, device_ids=None, group_ids=None, assignment_group_ids=None): + """ + You can use this method to upload a new script to your account. + + Args: + script_id (int): Required. The ID of the script to be run on the devices + device_ids (list of ints, optional): A list of device IDs to run + the script on + group_ids (list of ints, optional): A list of group IDs to run the + script on. All macOS devices from these groups will be included. + assignment_group_ids (list of ints, optional): A comma separated + list of assignment group IDs to run the script on. All macOS + devices from these assignment groups will be included. + + Returns: + dict: A dictionary object with job information. + """ + params = {} + if device_ids is not None: + params['device_ids'] = ",".join(str(x) for x in device_ids) + if group_ids is not None: + params['group_ids'] = ",".join(str(x) for x in group_ids) + if assignment_group_ids is not None: + params['assignment_group_ids'] = ",".join(str(x) for x in assignment_group_ids) + if not params: + raise ApiError(f"At least one of device_ids, group_ids, or assignment_group_ids must be provided") + params['script_id'] = str(script_id) + resp = self._post_data(self.url, params) + if not 200 <= resp.status_code <= 207: + raise ApiError(f"Job creation failed with status code {resp.status_code}: {resp.content}") + return resp.json()['data'] + + def cancel_job(self, job_id): + """ + You can use this method delete cancel a job. Jobs can only be canceled + before the device has received the command. + """ + url = self.url + "/" + str(job_id) + return self._delete_data(url) diff --git a/SimpleMDMpy/Scripts.py b/SimpleMDMpy/Scripts.py new file mode 100644 index 0000000..c55ff72 --- /dev/null +++ b/SimpleMDMpy/Scripts.py @@ -0,0 +1,82 @@ +"""Scripts module for SimpleMDMpy""" + + +from SimpleMDMpy.SimpleMDM import Connection, ApiError + + +class Scripts(Connection): + """scripts module for SimpleMDMpy""" + def __init__(self, api_key): + Connection.__init__(self, api_key) + self.url = self._url("/scripts") + + def get_script(self, script_id="all"): + """ + Returns a listing of all scripts in the account, or the script + specified by id. + + Args: + script_id (int, optional): Returns a dictionary of the specified + script id. By default, it returns a list of all scripts. + + Returns: + dict: A single dictionary object with script information. + array: An array of dictionary objects with script information. + """ + url = self.url + if script_id != 'all': + url = url + "/" + str(script_id) + return self._get_data(url) + + def create_script(self, name, variable_support, content): + """ + You can use this method to upload a new script to your account. + + Args: + name (str): The name for the script. This is how it will appear + in the Admin UI. + variable_support (bool): Whether or not to enable variable support + in this script. + content (str): The script content. All scripts must begin with a + valid shebang such as #!/bin/sh to be processed. + """ + params = { + 'name': name, + 'variable_support': "1" if variable_support else "0", + } + files = { + 'file': ('script.sh', content) + } + resp = self._post_data(self.url, params, files) + if not 200 <= resp.status_code <= 207: + raise ApiError(f"Script creation failed with status code {resp.status_code}: {resp.content}") + return resp.json()['data'] + + def update_script(self, script_id, name=None, variable_support=None, content=None): + """ + You can use this method to update an existing script in your account. + Any existing Script Jobs will not be changed. + """ + url = self.url + "/" + str(script_id) + params = {} + files = None + if name is not None: + params['name'] = name + if variable_support is not None: + params['variable_support'] = "1" if variable_support else "0" + if content is not None: + files = { + 'file': ('script.sh', content) + } + if not params and not files: + raise ApiError(f"Missing updated variables.") + resp = self._patch_data(url, params, files) + if not 200 <= resp.status_code <= 207: + raise ApiError(f"Script update failed with status code {resp.status_code}: {resp.content}") + return resp.json()['data'] + + def delete_script(self, script_id): + """You can use this method to delete a script from your account. Any + existing Script Jobs will not be changed.""" + url = self.url + "/" + str(script_id) + return self._delete_data(url) diff --git a/SimpleMDMpy/SimpleMDM.py b/SimpleMDMpy/SimpleMDM.py index 3aea9dd..9f53121 100644 --- a/SimpleMDMpy/SimpleMDM.py +++ b/SimpleMDMpy/SimpleMDM.py @@ -6,8 +6,11 @@ from builtins import str from builtins import range from builtins import object -import json import requests +from requests.adapters import HTTPAdapter +from requests.packages.urllib3.util.retry import Retry +import time + class ApiError(Exception): """Catch for API Error""" @@ -17,31 +20,82 @@ class Connection(object): #pylint: disable=old-style-class,too-few-public-method """create connection with api key""" proxyDict = dict() + last_device_req_timestamp = 0 + device_req_rate_limit = 1.0 + def __init__(self, api_key): self.api_key = api_key + # setup a session that can retry, helps with rate limiting end-points + # https://findwork.dev/blog/advanced-usage-python-requests-timeouts-retries-hooks/#retry-on-failure + # https://macadmins.slack.com/archives/C4HJ6U742/p1652996411750219 + retry_strategy = Retry( + total = 5, + backoff_factor = 1, + status_forcelist = [500, 502, 503, 504], + ) + adapter = HTTPAdapter(max_retries=retry_strategy) + self.session = requests.Session() + self.session.mount("https://", adapter) + self.session.mount("http://", adapter) + + def __del__(self): + # this runs when the Connection object is being deinitialized + # this properly closes the session + self.session.close() def _url(self, path): #pylint: disable=no-self-use """base api url""" return 'https://a.simplemdm.com/api/v1' + path + # TODO: make _is_devices_req generic for any future rate limited endpoints + def _is_devices_req(self, url): + return url.startswith(self._url("/devices")) + def _get_data(self, url, params=None): """GET call to SimpleMDM API""" - start_id = 0 has_more = True - resp_data = [] - base_url = url + list_data = [] + # by using the local req_params variable, we can set our own defaults if + # the parameters aren't included with the input params. This is needed + # so that certain other functions, like Logs.get_logs(), can send custom + # starting_after and limit parameters. + if params is None: + req_params = {} + else: + req_params = params.copy() + req_params['limit'] = req_params.get('limit', 100) while has_more: - url = base_url + "?limit=100&starting_after=" + str(start_id) - resp = requests.get(url, params, auth=(self.api_key, ""), proxies=self.proxyDict) + # Calls to /devices should be rate limited + if self._is_devices_req(url): + seconds_since_last_device_req = time.monotonic() - self.last_device_req_timestamp + if seconds_since_last_device_req < self.device_req_rate_limit: + time.sleep(self.device_req_rate_limit - seconds_since_last_device_req) + self.last_device_req_timestamp = time.monotonic() + while True: + resp = self.session.get(url, params=req_params, auth=(self.api_key, ""), proxies=self.proxyDict) + # A 429 means we've hit the rate limit, so back off and retry + if resp.status_code == 429: + time.sleep(1) + else: + break if not 200 <= resp.status_code <= 207: raise ApiError(f"API returned status code {resp.status_code}") resp_json = resp.json() data = resp_json['data'] - resp_data.extend(data) - has_more = resp_json.get('has_more', None) + # If the response isn't a list, return the single item. + if not isinstance(data, list): + return data + # If it's a list we save it and see if there is more data coming. + list_data.extend(data) + has_more = resp_json.get('has_more', False) if has_more: - start_id = data[-1].get('id') - return resp_data + req_params["starting_after"] = data[-1].get('id') + return list_data + + def _get_xml(self, url, params=None): + """GET call to SimpleMDM API""" + resp = requests.get(url, params, auth=(self.api_key, ""), proxies=self.proxyDict) + return resp.content def _patch_data(self, url, data, files=None): """PATCH call to SimpleMDM API""" diff --git a/SimpleMDMpy/__init__.py b/SimpleMDMpy/__init__.py index 6c867f3..9599c5c 100644 --- a/SimpleMDMpy/__init__.py +++ b/SimpleMDMpy/__init__.py @@ -16,3 +16,5 @@ from SimpleMDMpy.LostMode import LostMode from SimpleMDMpy.ManagedAppConfigs import ManagedAppConfigs from SimpleMDMpy.PushCertificate import PushCertificate +from SimpleMDMpy.ScriptJobs import ScriptJobs +from SimpleMDMpy.Scripts import Scripts diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..fed528d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..34ef91c --- /dev/null +++ b/setup.cfg @@ -0,0 +1,21 @@ +[metadata] +name = SimpleMDMpy +version = 3.0.6 +author = Steve Küng +maintainer = MacAdmins +description = A Python Library for SimpleMDM. +long_description = file: README.md +long_description_content_type = text/markdown +license = MIT +license_file = LICENSE +url = https://github.com/macadmins/simpleMDMpy +classifiers = + Development Status :: 3 - Alpha + Intended Audience :: System Administrators + License :: OSI Approved :: MIT License + Operating System :: OS Independent + Programming Language :: Python :: 3 + +[options] +packages = SimpleMDMpy +install_requires = requests diff --git a/tests/readme.md b/tests/readme.md new file mode 100644 index 0000000..c86c9b5 --- /dev/null +++ b/tests/readme.md @@ -0,0 +1,15 @@ +# Tests + +This is my first foray into unit tests. If you see any issues or have any recommendations, please open a [Github issue](https://github.com/macadmins/simpleMDMpy/issues) or reach out to me on the [Mac Admins](https://www.macadmins.org) Slack @bheinz - @bryanheinz + +Because our testing currently has to be done on our own SimpleMDM instances, please be extra cautious and review all code before running. This is also why the tests largely don't test for specific data. + +## Testing Steps +- Duplicate `settings-sample.py` as `settings.py` and fill in the variables +- Create virtual environments folder if it doesn't already exist `mkdir ~/.env` +- Create the virtual env `python3 -m venv ~/.env/smdm-tests` +- Activate the environment `source ~/.env/smdm-tests/bin/activate` +- Install the simpleMDMpy module version that you'd like to test `pip install -e /path/to/cloned/simpleMDMpy` (requires pyproject.toml and setup.cfg files, and pip v22+) +- Run the tests `python3 -m unittest discover -s /path/to/cloned/simpleMDMpy/tests` +- Uninstall the test module `pip uninstall SimpleMDMpy` +- Deactivate the Python virtual environment `deactivate` diff --git a/tests/settings-sample.py b/tests/settings-sample.py new file mode 100644 index 0000000..f1a6299 --- /dev/null +++ b/tests/settings-sample.py @@ -0,0 +1,10 @@ +#!/usr/bin/env python3 + +# add a SimpleMDM API key to use for tests +api_key = '' + +# add the ID of a profile in your instance to test profile functions +profile_id = '' # note what profile this is + +# add the id of a device in your instance to test functions against +device_id = '' # note what device this is diff --git a/tests/test_Account.py b/tests/test_Account.py new file mode 100644 index 0000000..7304ccd --- /dev/null +++ b/tests/test_Account.py @@ -0,0 +1,16 @@ +#!/usr/bin/env python3 + +import settings +import unittest +import SimpleMDMpy + + +class TestCustomConfigurationProfiles(unittest.TestCase): + def test_get_account_details(self): + account_details = SimpleMDMpy.Account(settings.api_key) \ + .get_account_details() + account_name = account_details.get('attributes', {}).get('name') + self.assertIsNotNone(account_name) + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_CustomConfigurationProfiles.py b/tests/test_CustomConfigurationProfiles.py new file mode 100644 index 0000000..a2125e1 --- /dev/null +++ b/tests/test_CustomConfigurationProfiles.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 + +import plistlib +import settings +import unittest +import SimpleMDMpy + + +@unittest.skipIf(settings.profile_id == '', + "profile_id not specified in settings.py.") +class TestCustomConfigurationProfiles(unittest.TestCase): + def test_get_profile(self): + profile_found = False + all_profiles = SimpleMDMpy.CustomConfigurationProfiles( + settings.api_key).get_profiles() + for prof in all_profiles: + prof_id = str(prof.get('id', '')) + if prof_id == settings.profile_id: profile_found = True + self.assertTrue(profile_found) + + def test_download_profile(self): + # if settings.profile_id == '': + profile = SimpleMDMpy.CustomConfigurationProfiles(settings.api_key) \ + .download_profile(settings.profile_id) + self.assertIsInstance(plistlib.loads(profile), dict) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_Devices.py b/tests/test_Devices.py new file mode 100644 index 0000000..1e40051 --- /dev/null +++ b/tests/test_Devices.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +import settings +import unittest +import SimpleMDMpy + +class TestDevices(unittest.TestCase): + def test_get_device(self): + all_devices = SimpleMDMpy.Devices( + settings.api_key).get_device(include_awaiting_enrollment=True) + self.assertGreaterEqual(len(all_devices), 1) + # print(len(all_devices)) + cid = all_devices[0].get('id') + self.assertIsNotNone(cid) + single_device = SimpleMDMpy.Devices(settings.api_key) \ + .get_device(device_id=cid) + self.assertEqual(single_device.get('id'), cid) + + def test_list_profiles(self): + device_profiles = SimpleMDMpy.Devices(settings.api_key) \ + .list_profiles(settings.device_id) + self.assertGreaterEqual(len(device_profiles), 1) + + def test_list_users(self): + device_users = SimpleMDMpy.Devices(settings.api_key) \ + .list_users(settings.device_id) + self.assertGreaterEqual(len(device_users), 1) + + +if __name__ == '__main__': + unittest.main()