From e3bdcea15ae73412b3b00277ae9b7ae9f1d1f046 Mon Sep 17 00:00:00 2001 From: Quentame Date: Sat, 4 Apr 2020 00:48:32 +0200 Subject: [PATCH] Add account family + storage services (#250) --- pyicloud/services/account.py | 314 ++++++++++++++++++++++++++++++++-- tests/__init__.py | 11 +- tests/const.py | 2 +- tests/const_account.py | 42 +++++ tests/const_account_family.py | 97 +++++++++++ tests/const_findmyiphone.py | 28 +-- tests/const_login.py | 19 +- tests/test_account.py | 51 ++++++ tests/test_cmdline.py | 4 +- 9 files changed, 523 insertions(+), 45 deletions(-) create mode 100644 tests/const_account_family.py diff --git a/pyicloud/services/account.py b/pyicloud/services/account.py index 0551d5c..f771036 100644 --- a/pyicloud/services/account.py +++ b/pyicloud/services/account.py @@ -13,34 +13,68 @@ class AccountService(object): self.session = session self.params = params self._service_root = service_root + self._devices = [] + self._family = [] + self._storage = None - self._acc_endpoint = "%s/setup/web/device" % self._service_root - self._account_devices_url = "%s/getDevices" % self._acc_endpoint - - req = self.session.get(self._account_devices_url, params=self.params) - self.response = req.json() - - for device_info in self.response["devices"]: - # device_id = device_info['udid'] - # self._devices[device_id] = AccountDevice(device_info) - self._devices.append(AccountDevice(device_info)) + self._acc_endpoint = "%s/setup/web" % self._service_root + self._acc_devices_url = "%s/device/getDevices" % self._acc_endpoint + self._acc_family_details_url = "%s/family/getFamilyDetails" % self._acc_endpoint + self._acc_family_member_photo_url = ( + "%s/family/getMemberPhoto" % self._acc_endpoint + ) + self._acc_storage_url = "https://setup.icloud.com/setup/ws/1/storageUsageInfo" @property def devices(self): - """Gets the account devices.""" + """Returns current paired devices.""" + if not self._devices: + req = self.session.get(self._acc_devices_url, params=self.params) + response = req.json() + + for device_info in response["devices"]: + self._devices.append(AccountDevice(device_info)) + return self._devices + @property + def family(self): + """Returns family members.""" + if not self._family: + req = self.session.get(self._acc_family_details_url, params=self.params) + response = req.json() + + for member_info in response["familyMembers"]: + self._family.append( + FamilyMember( + member_info, + self.session, + self.params, + self._acc_family_member_photo_url, + ) + ) + + return self._family + + @property + def storage(self): + """Returns storage infos.""" + if not self._storage: + req = self.session.get(self._acc_storage_url, params=self.params) + response = req.json() + + self._storage = AccountStorage(response) + + return self._storage + @six.python_2_unicode_compatible class AccountDevice(dict): """Account device.""" - def __getattr__(self, name): - try: - return self[underscore_to_camelcase(name)] - except KeyError: - raise AttributeError(name) + def __getattr__(self, key): + return self[underscore_to_camelcase(key)] def __str__(self): return u"{display_name}: {name}".format( @@ -55,3 +89,251 @@ class AccountDevice(dict): else six.text_type(self).encode("utf8", "replace") ) ) + + +class FamilyMember(object): + """A family member.""" + + def __init__(self, member_info, session, params, acc_family_member_photo_url): + self._attrs = member_info + self._session = session + self._params = params + self._acc_family_member_photo_url = acc_family_member_photo_url + + @property + def last_name(self): + """Gets the last name.""" + return self._attrs.get("lastName") + + @property + def dsid(self): + """Gets the dsid.""" + return self._attrs.get("dsid") + + @property + def original_invitation_email(self): + """Gets the original invitation.""" + return self._attrs.get("originalInvitationEmail") + + @property + def full_name(self): + """Gets the full name.""" + return self._attrs.get("fullName") + + @property + def age_classification(self): + """Gets the age classification.""" + return self._attrs.get("ageClassification") + + @property + def apple_id_for_purchases(self): + """Gets the apple id for purchases.""" + return self._attrs.get("appleIdForPurchases") + + @property + def apple_id(self): + """Gets the apple id.""" + return self._attrs.get("appleId") + + @property + def family_id(self): + """Gets the family id.""" + return self._attrs.get("familyId") + + @property + def first_name(self): + """Gets the first name.""" + return self._attrs.get("firstName") + + @property + def has_parental_privileges(self): + """Has parental privileges.""" + return self._attrs.get("hasParentalPrivileges") + + @property + def has_screen_time_enabled(self): + """Has screen time enabled.""" + return self._attrs.get("hasScreenTimeEnabled") + + @property + def has_ask_to_buy_enabled(self): + """Has to ask for buying.""" + return self._attrs.get("hasAskToBuyEnabled") + + @property + def has_share_purchases_enabled(self): + """Has share purshases.""" + return self._attrs.get("hasSharePurchasesEnabled") + + @property + def share_my_location_enabled_family_members(self): + """Has share my location with family.""" + return self._attrs.get("shareMyLocationEnabledFamilyMembers") + + @property + def has_share_my_location_enabled(self): + """Has share my location.""" + return self._attrs.get("hasShareMyLocationEnabled") + + @property + def dsid_for_purchases(self): + """Gets the dsid for purchases.""" + return self._attrs.get("dsidForPurchases") + + def get_photo(self): + """Returns the photo.""" + params_photo = dict(self._params) + params_photo.update({"memberId": self.dsid}) + return self._session.get( + self._acc_family_member_photo_url, params=params_photo, stream=True + ) + + def __getitem__(self, key): + if self._attrs.get(key): + return self._attrs[key] + return getattr(self, key) + + def __str__(self): + return u"{full_name}: {age_classification}".format( + full_name=self.full_name, age_classification=self.age_classification, + ) + + def __repr__(self): + return "<{display}>".format( + display=( + six.text_type(self) + if sys.version_info[0] >= 3 + else six.text_type(self).encode("utf8", "replace") + ) + ) + + +class AccountStorageUsageForMedia(object): + """Storage used for a specific media type into the account.""" + + def __init__(self, usage_data): + self.usage_data = usage_data + + @property + def key(self): + """Gets the key.""" + return self.usage_data["mediaKey"] + + @property + def label(self): + """Gets the label.""" + return self.usage_data["displayLabel"] + + @property + def color(self): + """Gets the HEX color.""" + return self.usage_data["displayColor"] + + @property + def usage_in_bytes(self): + """Gets the usage in bytes.""" + return self.usage_data["usageInBytes"] + + def __str__(self): + return u"{key}: {usage}".format(key=self.key, usage=self.usage_in_bytes) + + def __repr__(self): + return "<{display}>".format( + display=( + six.text_type(self) + if sys.version_info[0] >= 3 + else six.text_type(self).encode("utf8", "replace") + ) + ) + + +class AccountStorageUsage(object): + """Storage used for a specific media type into the account.""" + + def __init__(self, usage_data, quota_data): + self.usage_data = usage_data + self.quota_data = quota_data + + @property + def comp_storage_in_bytes(self): + """Gets the comp storage in bytes.""" + return self.usage_data["compStorageInBytes"] + + @property + def used_storage_in_bytes(self): + """Gets the used storage in bytes.""" + return self.usage_data["usedStorageInBytes"] + + @property + def used_storage_in_percent(self): + """Gets the used storage in percent.""" + return self.used_storage_in_bytes * 100 / self.total_storage_in_bytes + + @property + def available_storage_in_bytes(self): + """Gets the available storage in bytes.""" + return self.total_storage_in_bytes - self.used_storage_in_bytes + + @property + def available_storage_in_percent(self): + """Gets the available storage in percent.""" + return self.available_storage_in_bytes * 100 / self.total_storage_in_bytes + + @property + def total_storage_in_bytes(self): + """Gets the total storage in bytes.""" + return self.usage_data["totalStorageInBytes"] + + @property + def commerce_storage_in_bytes(self): + """Gets the commerce storage in bytes.""" + return self.usage_data["commerceStorageInBytes"] + + @property + def quota_over(self): + """Gets the over quota.""" + return self.quota_data["overQuota"] + + @property + def quota_tier_max(self): + """Gets the max tier quota.""" + return self.quota_data["haveMaxQuotaTier"] + + @property + def quota_almost_full(self): + """Gets the almost full quota.""" + return self.quota_data["almost-full"] + + @property + def quota_paid(self): + """Gets the paid quota.""" + return self.quota_data["paidQuota"] + + def __str__(self): + return u"{used_percent}%% used of {total} bytes".format( + used_percent=self.used_storage_in_percent, total=self.total_storage_in_bytes + ) + + def __repr__(self): + return "<{display}>".format( + display=( + six.text_type(self) + if sys.version_info[0] >= 3 + else six.text_type(self).encode("utf8", "replace") + ) + ) + + +class AccountStorage(object): + """Storage of the account.""" + + def __init__(self, storage_data): + self.usage = AccountStorageUsage( + storage_data.get("storageUsageInfo"), storage_data.get("quotaStatus") + ) + self.usages_by_media = {} + + for usage_media in storage_data.get("storageUsageByMedia"): + self.usages_by_media[usage_media["mediaKey"]] = AccountStorageUsageForMedia( + usage_media + ) diff --git a/tests/__init__.py b/tests/__init__.py index 14be3e8..dd35c17 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -20,8 +20,9 @@ from .const_login import ( VERIFICATION_CODE_OK, VERIFICATION_CODE_KO, ) -from .const_account import ACCOUNT_DEVICES_WORKING -from .const_findmyiphone import FMI_FMLY_WORKING +from .const_account import ACCOUNT_DEVICES_WORKING, ACCOUNT_STORAGE_WORKING +from .const_account_family import ACCOUNT_FAMILY_WORKING +from .const_findmyiphone import FMI_FAMILY_WORKING class ResponseMock(Response): @@ -76,10 +77,14 @@ class PyiCloudSessionMock(base.PyiCloudSession): # Account if "device/getDevices" in url and method == "GET": return ResponseMock(ACCOUNT_DEVICES_WORKING) + if "family/getFamilyDetails" in url and method == "GET": + return ResponseMock(ACCOUNT_FAMILY_WORKING) + if "setup/ws/1/storageUsageInfo" in url and method == "GET": + return ResponseMock(ACCOUNT_STORAGE_WORKING) # Find My iPhone if "fmi" in url and method == "POST": - return ResponseMock(FMI_FMLY_WORKING) + return ResponseMock(FMI_FAMILY_WORKING) return None diff --git a/tests/const.py b/tests/const.py index 9aa8b5a..7c56937 100644 --- a/tests/const.py +++ b/tests/const.py @@ -1,5 +1,5 @@ """Test constants.""" -from .const_login import PRIMARY_EMAIL, APPLE_ID_EMAIL, ICLOUD_ID_EMAIL +from .const_account_family import PRIMARY_EMAIL, APPLE_ID_EMAIL, ICLOUD_ID_EMAIL # Base AUTHENTICATED_USER = PRIMARY_EMAIL diff --git a/tests/const_account.py b/tests/const_account.py index c6d7219..75aef9a 100644 --- a/tests/const_account.py +++ b/tests/const_account.py @@ -75,3 +75,45 @@ ACCOUNT_DEVICES_WORKING = { }, ], } + + +ACCOUNT_STORAGE_WORKING = { + "storageUsageByMedia": [ + { + "mediaKey": "photos", + "displayLabel": "Photos et vidéos", + "displayColor": "ffcc00", + "usageInBytes": 0, + }, + { + "mediaKey": "backup", + "displayLabel": "Sauvegarde", + "displayColor": "5856d6", + "usageInBytes": 799008186, + }, + { + "mediaKey": "docs", + "displayLabel": "Documents", + "displayColor": "ff9500", + "usageInBytes": 449092146, + }, + { + "mediaKey": "mail", + "displayLabel": "Mail", + "displayColor": "007aff", + "usageInBytes": 1101522944, + }, + ], + "storageUsageInfo": { + "compStorageInBytes": 0, + "usedStorageInBytes": 2348632876, + "totalStorageInBytes": 5368709120, + "commerceStorageInBytes": 0, + }, + "quotaStatus": { + "overQuota": False, + "haveMaxQuotaTier": False, + "almost-full": False, + "paidQuota": False, + }, +} diff --git a/tests/const_account_family.py b/tests/const_account_family.py new file mode 100644 index 0000000..c097f10 --- /dev/null +++ b/tests/const_account_family.py @@ -0,0 +1,97 @@ +"""Account family test constants.""" + +# Fakers +FIRST_NAME = "Quentin" +LAST_NAME = "TARANTINO" +FULL_NAME = FIRST_NAME + " " + LAST_NAME +PERSON_ID = (FIRST_NAME + LAST_NAME).lower() +PRIMARY_EMAIL = PERSON_ID + "@hotmail.fr" +APPLE_ID_EMAIL = PERSON_ID + "@me.com" +ICLOUD_ID_EMAIL = PERSON_ID + "@icloud.com" + +MEMBER_1_FIRST_NAME = "John" +MEMBER_1_LAST_NAME = "TRAVOLTA" +MEMBER_1_FULL_NAME = MEMBER_1_FIRST_NAME + " " + MEMBER_1_LAST_NAME +MEMBER_1_PERSON_ID = (MEMBER_1_FIRST_NAME + MEMBER_1_LAST_NAME).lower() +MEMBER_1_APPLE_ID = MEMBER_1_PERSON_ID + "@icloud.com" + +MEMBER_2_FIRST_NAME = "Uma" +MEMBER_2_LAST_NAME = "THURMAN" +MEMBER_2_FULL_NAME = MEMBER_2_FIRST_NAME + " " + MEMBER_2_LAST_NAME +MEMBER_2_PERSON_ID = (MEMBER_2_FIRST_NAME + MEMBER_2_LAST_NAME).lower() +MEMBER_2_APPLE_ID = MEMBER_2_PERSON_ID + "@outlook.fr" + +FAMILY_ID = "family_" + PERSON_ID + +# Data +ACCOUNT_FAMILY_WORKING = { + "status-message": "Member of a family.", + "familyInvitations": [], + "outgoingTransferRequests": [], + "isMemberOfFamily": True, + "family": { + "familyId": FAMILY_ID, + "transferRequests": [], + "invitations": [], + "organizer": PERSON_ID, + "members": [PERSON_ID, MEMBER_2_PERSON_ID, MEMBER_1_PERSON_ID], + "outgoingTransferRequests": [], + "etag": "12", + }, + "familyMembers": [ + { + "lastName": LAST_NAME, + "dsid": PERSON_ID, + "originalInvitationEmail": PRIMARY_EMAIL, + "fullName": FULL_NAME, + "ageClassification": "ADULT", + "appleIdForPurchases": PRIMARY_EMAIL, + "appleId": PRIMARY_EMAIL, + "familyId": FAMILY_ID, + "firstName": FIRST_NAME, + "hasParentalPrivileges": True, + "hasScreenTimeEnabled": False, + "hasAskToBuyEnabled": False, + "hasSharePurchasesEnabled": True, + "shareMyLocationEnabledFamilyMembers": [], + "hasShareMyLocationEnabled": True, + "dsidForPurchases": PERSON_ID, + }, + { + "lastName": MEMBER_2_LAST_NAME, + "dsid": MEMBER_2_PERSON_ID, + "originalInvitationEmail": MEMBER_2_APPLE_ID, + "fullName": MEMBER_2_FULL_NAME, + "ageClassification": "ADULT", + "appleIdForPurchases": MEMBER_2_APPLE_ID, + "appleId": MEMBER_2_APPLE_ID, + "familyId": FAMILY_ID, + "firstName": MEMBER_2_FIRST_NAME, + "hasParentalPrivileges": False, + "hasScreenTimeEnabled": False, + "hasAskToBuyEnabled": False, + "hasSharePurchasesEnabled": False, + "hasShareMyLocationEnabled": False, + "dsidForPurchases": MEMBER_2_PERSON_ID, + }, + { + "lastName": MEMBER_1_LAST_NAME, + "dsid": MEMBER_1_PERSON_ID, + "originalInvitationEmail": MEMBER_1_APPLE_ID, + "fullName": MEMBER_1_FULL_NAME, + "ageClassification": "ADULT", + "appleIdForPurchases": MEMBER_1_APPLE_ID, + "appleId": MEMBER_1_APPLE_ID, + "familyId": FAMILY_ID, + "firstName": MEMBER_1_FIRST_NAME, + "hasParentalPrivileges": False, + "hasScreenTimeEnabled": False, + "hasAskToBuyEnabled": False, + "hasSharePurchasesEnabled": True, + "hasShareMyLocationEnabled": True, + "dsidForPurchases": MEMBER_1_PERSON_ID, + }, + ], + "status": 0, + "showAddMemberButton": True, +} diff --git a/tests/const_findmyiphone.py b/tests/const_findmyiphone.py index 959bc86..06dcf64 100644 --- a/tests/const_findmyiphone.py +++ b/tests/const_findmyiphone.py @@ -1,17 +1,19 @@ """Find my iPhone test constants.""" from .const import CLIENT_ID -from .const_login import FIRST_NAME, LAST_NAME, PERSON_ID, FULL_NAME - -# Base -MEMBER_1_FIRST_NAME = "John" -MEMBER_1_LAST_NAME = "TRAVOLTA" -MEMBER_1_PERSON_ID = (MEMBER_1_FIRST_NAME + MEMBER_1_LAST_NAME).lower() -MEMBER_1_APPLE_ID = MEMBER_1_PERSON_ID + "@icloud.com" - -MEMBER_2_FIRST_NAME = "Uma" -MEMBER_2_LAST_NAME = "THURMAN" -MEMBER_2_PERSON_ID = (MEMBER_2_FIRST_NAME + MEMBER_2_LAST_NAME).lower() -MEMBER_2_APPLE_ID = MEMBER_2_PERSON_ID + "@outlook.fr" +from .const_account_family import ( + FIRST_NAME, + LAST_NAME, + PERSON_ID, + FULL_NAME, + MEMBER_1_FIRST_NAME, + MEMBER_1_APPLE_ID, + MEMBER_1_LAST_NAME, + MEMBER_1_PERSON_ID, + MEMBER_2_APPLE_ID, + MEMBER_2_FIRST_NAME, + MEMBER_2_LAST_NAME, + MEMBER_2_PERSON_ID, +) # Fakers UUID = "ABCDEFGH-1234-5678-1234-ABCDEFGHIJKL" @@ -23,7 +25,7 @@ LOCATION_LONGITUDE = 6.1234567890123456 # id = rawDeviceModel + prsId (if not None) # baUUID = UUID + id # So they can still be faked and unique -FMI_FMLY_WORKING = { +FMI_FAMILY_WORKING = { "userInfo": { "accountFormatter": 0, "firstName": FIRST_NAME, diff --git a/tests/const_login.py b/tests/const_login.py index bd54b04..d206cbd 100644 --- a/tests/const_login.py +++ b/tests/const_login.py @@ -1,20 +1,19 @@ """Login test constants.""" - -# Base -FIRST_NAME = "Quentin" -LAST_NAME = "TARANTINO" -FULL_NAME = FIRST_NAME + " " + LAST_NAME +from .const_account_family import ( + FIRST_NAME, + LAST_NAME, + PERSON_ID, + FULL_NAME, + PRIMARY_EMAIL, + APPLE_ID_EMAIL, + ICLOUD_ID_EMAIL, +) PERSON_ID = (FIRST_NAME + LAST_NAME).lower() NOTIFICATION_ID = "12345678-1234-1234-1234-123456789012" + PERSON_ID A_DS_ID = "123456-12-12345678-1234-1234-1234-123456789012" + PERSON_ID WIDGET_KEY = "widget_key" + PERSON_ID -PRIMARY_EMAIL = PERSON_ID + "@hotmail.fr" -APPLE_ID_EMAIL = PERSON_ID + "@me.com" -ICLOUD_ID_EMAIL = PERSON_ID + "@icloud.com" - - # Data LOGIN_WORKING = { "dsInfo": { diff --git a/tests/test_account.py b/tests/test_account.py index e626fa0..1e6bbc3 100644 --- a/tests/test_account.py +++ b/tests/test_account.py @@ -14,6 +14,7 @@ class AccountServiceTest(TestCase): def test_devices(self): """Tests devices.""" + assert self.service.devices assert len(self.service.devices) == 2 for device in self.service.devices: @@ -31,3 +32,53 @@ class AccountServiceTest(TestCase): assert device["modelSmallPhotoURL2x"] assert device["modelSmallPhotoURL1x"] assert device["modelDisplayName"] + + def test_family(self): + """Tests family members.""" + assert self.service.family + assert len(self.service.family) == 3 + + for member in self.service.family: + assert member.last_name + assert member.dsid + assert member.original_invitation_email + assert member.full_name + assert member.age_classification + assert member.apple_id_for_purchases + assert member.apple_id + assert member.first_name + assert not member.has_screen_time_enabled + assert not member.has_ask_to_buy_enabled + assert not member.share_my_location_enabled_family_members + assert member.dsid_for_purchases + + def test_storage(self): + """Tests storage.""" + assert self.service.storage + + assert self.service.storage.usage + assert ( + self.service.storage.usage.comp_storage_in_bytes + or self.service.storage.usage.comp_storage_in_bytes == 0 + ) + assert self.service.storage.usage.used_storage_in_bytes + assert self.service.storage.usage.used_storage_in_percent + assert self.service.storage.usage.available_storage_in_bytes + assert self.service.storage.usage.available_storage_in_percent + assert self.service.storage.usage.total_storage_in_bytes + assert ( + self.service.storage.usage.commerce_storage_in_bytes + or self.service.storage.usage.commerce_storage_in_bytes == 0 + ) + assert not self.service.storage.usage.quota_over + assert not self.service.storage.usage.quota_tier_max + assert not self.service.storage.usage.quota_almost_full + assert not self.service.storage.usage.quota_paid + + assert self.service.storage.usages_by_media + + for usage_media in self.service.storage.usages_by_media.values(): + assert usage_media.key + assert usage_media.label + assert usage_media.color + assert usage_media.usage_in_bytes or usage_media.usage_in_bytes == 0 diff --git a/tests/test_cmdline.py b/tests/test_cmdline.py index 57a0da3..7f02f31 100644 --- a/tests/test_cmdline.py +++ b/tests/test_cmdline.py @@ -2,7 +2,7 @@ from pyicloud import cmdline from . import PyiCloudServiceMock from .const import AUTHENTICATED_USER, REQUIRES_2SA_USER, VALID_PASSWORD -from .const_findmyiphone import FMI_FMLY_WORKING +from .const_findmyiphone import FMI_FAMILY_WORKING import os import sys @@ -103,7 +103,7 @@ class TestCmdline(TestCase): ]) # fmt: on - devices = FMI_FMLY_WORKING.get("content") + devices = FMI_FAMILY_WORKING.get("content") for device in devices: file_name = device.get("name").strip().lower() + ".fmip_snapshot"