diff --git a/.gitignore b/.gitignore index 8aca1fb..f025580 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ publish.sh venv_* tests/backup_biscuits/ tests/latest_test_log.txt +tests/user_saves # C extensions diff --git a/pyproject.toml b/pyproject.toml index 4e5d261..5b31e2e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta" [project] name = "spaceandtime" -version = "1.1.24" +version = "1.1.29" description = "SDK for Space and Time verifiable database" authors = [{ name = "Stephen Hilton", email = "stephen.hilton@spaceandtime.io" }] readme = "README.md" diff --git a/src/spaceandtime/apiversions.json b/src/spaceandtime/apiversions.json index df74537..8b099ab 100644 --- a/src/spaceandtime/apiversions.json +++ b/src/spaceandtime/apiversions.json @@ -29,7 +29,10 @@ "discover/blockchains/{chainId}/schemas": "v2", "discover/blockchains/{chainId}/meta": "v2", "subscription": "v1", - "subscription/users": "v1", "subscription/invite": "v1", - "subscription/invite/{joinCode}": "v1" + "subscription/invite/{joinCode}": "v1", + "subscription/leave": "v1", + "subscription/remove/{userId}": "v1", + "subscription/setrole/{userId}": "v1", + "subscription/users": "v1" } \ No newline at end of file diff --git a/src/spaceandtime/sxtbaseapi.py b/src/spaceandtime/sxtbaseapi.py index 0e688fd..951987f 100644 --- a/src/spaceandtime/sxtbaseapi.py +++ b/src/spaceandtime/sxtbaseapi.py @@ -262,7 +262,7 @@ def auth_code(self, user_id:str, prefix:str = None, joincode:str = None): """ dataparms = {"userId": user_id} if prefix: dataparms["prefix"] = prefix - if joincode: dataparms[joincode] = joincode + if joincode: dataparms["joincode"] = joincode success, rtn = self.call_api(endpoint = 'auth/code', auth_header = False, data_parms = dataparms) return success, rtn if success else [rtn] @@ -349,7 +349,7 @@ def auth_idexists(self, user_id:str ): bool: Success flag (True/False) indicating the api call worked as expected. object: Response information from the Space and Time network, as list or dict(json). """ - success, rtn = self.call_api(f'auth/idexists/{user_id}', False, SXTApiCallTypes.GET) + success, rtn = self.call_api('auth/idexists/{id}', False, SXTApiCallTypes.GET, path_parms={'id':user_id}) return success, rtn if success else [rtn] @@ -692,10 +692,65 @@ def subscription_join(self, joincode:str): endpoint = 'subscription/invite/{joinCode}' version = 'v2' if endpoint not in list(self.versions.keys()) else self.versions[endpoint] success, rtn = self.call_api(endpoint=endpoint, auth_header=True, request_type=SXTApiCallTypes.POST, - path_parms= {'{joinCode}': joincode} ) + path_parms= {'joinCode': joincode} ) return success, (rtn if success else [rtn]) + def subscription_leave(self): + """-------------------- + Allows the authenticated user to leave their subscription. + + Calls and returns data from API: subscription/leave. + + Args: + None + + Returns: + bool: Success flag (True/False) indicating the api call worked as expected. + object: Response information from the Space and Time network, as list or dict(json). + """ + endpoint = 'subscription/leave' + version = 'v2' if endpoint not in list(self.versions.keys()) else self.versions[endpoint] + success, rtn = self.call_api(endpoint=endpoint, auth_header=True, request_type=SXTApiCallTypes.POST ) + return success, (rtn if success else [rtn]) + + + def subscription_get_users(self) -> tuple[bool, dict]: + """ + Returns a list of all users in the current subscription. + + Args: + None + + Returns: + bool: Success flag (True/False) indicating the api call worked as expected. + object: Dictionary of User_IDs and User Permission level in the subscription, or error as json. + """ + endpoint = 'subscription/users' + version = 'v2' if endpoint not in list(self.versions.keys()) else self.versions[endpoint] + success, rtn = self.call_api(endpoint=endpoint, auth_header=True, request_type=SXTApiCallTypes.GET ) + if success: rtn = rtn['roleMap'] + return success, rtn + + + def subscription_remove(self, User_ID_to_Remove:str) -> tuple[bool, dict]: + """ + Removes another user from the current user's subscription. Current user must have more authority than the targeted user to remove. + + Args: + User_ID_to_Remove (str): ID of the user to remove from the current user's subscription. + + Returns: + bool: Success flag (True/False) indicating the api call worked as expected. + object: Response information from the Space and Time network, as list or dict(json). + """ + endpoint = 'subscription/remove/{userId}' + version = 'v2' if endpoint not in list(self.versions.keys()) else self.versions[endpoint] + success, rtn = self.call_api(endpoint=endpoint, auth_header=True, request_type=SXTApiCallTypes.POST, + path_parms= {'userId': User_ID_to_Remove} ) + return success, rtn + + if __name__ == '__main__': diff --git a/src/spaceandtime/sxtresource.py b/src/spaceandtime/sxtresource.py index 40f5315..ebe2536 100644 --- a/src/spaceandtime/sxtresource.py +++ b/src/spaceandtime/sxtresource.py @@ -205,10 +205,12 @@ def exists(self) -> bool: """Returns True of the resource appears on the Space and Time network, or False if it is missing. Returns None if a connection cannot be established or encountered an error.""" if self.user.access_expired: self.user.authenticate() + # __existfunc__ is defined by the interhiriting child class (table, view, etc.) which has the same signature, but called here success, resources = self.__existfunc__(schema=self.schema) if success: apiname = 'table' if self.resource_type.name.lower() == 'table' else 'view' - does_exist = f"{self.schema}.{self.name}".upper() in [ f"{r['schema']}.{r[apiname]}" for r in resources] + + does_exist = str(self.name).upper() in [ r[apiname] for r in resources] self.logger.debug(f'testing whether {self.resource_name} exists: {str(does_exist)}') return does_exist else: diff --git a/src/spaceandtime/sxtuser.py b/src/spaceandtime/sxtuser.py index 3320089..29eee9c 100644 --- a/src/spaceandtime/sxtuser.py +++ b/src/spaceandtime/sxtuser.py @@ -122,10 +122,42 @@ def user_type(self) -> str: def recommended_filename(self) -> Path: filename = f'./users/{self.user_id}.env' return Path(filename) + + + @property + def exists(self) -> bool: + """Returns whether the user_id exists on the network.""" + success, response = self.base_api.auth_idexists(self.user_id) + return True if str(response).lower() == 'true' else False + + + def __validtoken__(self, name:str) -> dict: + if self.access_expired: return 'disconnected - authenticate to retrieve' + success, response = self.base_api.auth_validtoken() + if success and name in response: return response[name] + else: return 'error - could not retrieve' + + @property + def subscription_id(self) -> str: + return self.__validtoken__('subscriptionId') + + @property + def is_trial(self) -> str: + return self.__validtoken__('trial') + + @property + def is_restricted(self) -> str: + return self.__validtoken__('restricted') + + @property + def is_quota_exceeded(self) -> str: + return self.__validtoken__('quotaExceeded') + + def __str__(self): - flds = {fld: getattr(self, fld) for fld in ['api_url','user_id','private_key','public_key','encoding']} + flds = {fld: getattr(self, fld) for fld in ['api_url','user_id','exists','private_key','public_key','encoding', 'subscription_id', 'is_trial', 'is_restricted', 'is_quota_exceeded']} flds['private_key'] = flds['private_key'][:6]+'...' return '\n'.join( [ f'\t{n} = {v}' for n,v in flds.items() ] ) @@ -235,7 +267,15 @@ def replace_all(self, mainstr:str, replace_map:dict = None) -> str: mainstr = str(mainstr).replace('{'+str(findname)+'}', str(replaceval)) return mainstr - + + + def __settokens__(self, access_token:str, refresh_token:str, access_token_expire_epoch:int, refresh_token_expire_epoch:int): + self.access_token = access_token + self.refresh_token = refresh_token + self.access_token_expire_epoch = access_token_expire_epoch + self.refresh_token_expire_epoch = refresh_token_expire_epoch + self.base_api.access_token = self.access_token + def authenticate(self) -> str: return self.register_new_user() @@ -243,6 +283,13 @@ def authenticate(self) -> str: def register_new_user(self, join_code:str = None) -> str: """-------------------- Authenticate to the Space and Time network, and store access_token and refresh_token. + + Args: + join_code (str): Optional to create a new user within an existing subscription. + + Returns: + bool: Success flag (True/False) indicating the call worked as expected. + object: Access_Token if successful, otherwise an error object. """ if not (self.user_id and self.private_key): raise SxTArgumentError('Must have valid UserID and Private Key to authenticate.', logger=self.logger) @@ -264,11 +311,11 @@ def register_new_user(self, join_code:str = None) -> str: raise SxTAuthenticationError('Authentication produced incorrect / incomplete output', logger=self.logger) except SxTAuthenticationError as ex: return False, [ex] - self.access_token = tokens['accessToken'] - self.refresh_token = tokens['refreshToken'] - self.access_token_expire_epoch = tokens['accessTokenExpires'] - self.refresh_token_expire_epoch = tokens['refreshTokenExpires'] - self.base_api.access_token = tokens['accessToken'] + + self.__settokens__(tokens['accessToken'], tokens['refreshToken'], tokens['accessTokenExpires'], tokens['refreshTokenExpires']) + if join_code and ('disconnected' in str(self.subscription_id).strip() or str(self.subscription_id).strip() == ''): + success, response = self.join_subscription(join_code) + return True, self.access_token @@ -301,21 +348,86 @@ def execute_sql(self, sql_text:str, biscuits:list = None, app_name:str = None): def execute_query(self, sql_text:str, biscuits:list = None, app_name:str = None): return self.base_api.sql_exec(sql_text=sql_text, biscuits=biscuits, app_name=app_name) + def generate_joincode(self, role:str = 'member'): + """ + Generate an invite /joincode to join the inviting user's subscription. + + Args: + role (str): Role level to assign the new user. Can be member, admin, or owner. + + Returns: + str: Joincode + """ success, results = self.base_api.subscription_invite_user(role) if not success: self.logger.error(str(results)) return str(results) - self.logger.info('Generated {} joincode') + self.logger.info('Generated joincode') return results['text'] + def join_subscription(self, joincode:str): - success, results = self.base_api.subscription_join(joincode=joincode) + """ + Join an existing subscription to the Space and Time network, based on supplied JoinCode (expires after 24 hours). + Note, joining a subscription will refresh both the access_token and refresh_token. + """ + success, tokens = self.base_api.subscription_join(joincode=joincode) + if success: + self.__settokens__(tokens['accessToken'], tokens['refreshToken'], tokens['accessTokenExpires'], tokens['refreshTokenExpires']) + return True, 'Consumed join_code and joined subscription!' if not success: - self.logger.error(str(results)) - return False, str(results) - return True, 'Consumed join_code and joined subscription!' + self.logger.error(str(tokens)) + return False, str(tokens) + + + def leave_subscription(self) -> tuple[bool, dict]: + """ + Currently authenticated user leaves subscription. Fails if the user is not authenticated. + """ + if self.access_expired: return False, {"error":"disconnected - authenticate to leave subscription"} + return self.base_api.subscription_leave() + def remove_from_subscription(self, user_id_to_remove:str) -> tuple[bool, dict]: + """ + Removes another user from the current user's subscription. Current user must have more authority than the targeted user to remove. + + Args: + User_ID_to_Remove (str): ID of the user to remove from the current user's subscription. + + Returns: + bool: Success flag (True/False) indicating the api call worked as expected. + object: Response information from the Space and Time network, as list or dict(json). + """ + success, response = self.base_api.subscription_remove(user_id_to_remove) + if success: + msg =f"Removed {user_id_to_remove} from subscription." + self.logger.info(msg) + return True, {"text": msg} + else: + self.logger.error(str(response)) + return False, response + + + def get_subscription_users(self) -> tuple[bool, dict]: + """ + Returns a list of all users in the current subscription. + + Args: + None + + Returns: + bool: Success flag (True/False) indicating the api call worked as expected. + object: Dictionary of User_IDs and User Permission level in the subscription, or error as json. + """ + success, response = self.base_api.subscription_get_users() + if success: + return True, response + else: + self.logger.error(response) + return False, response + + if __name__ == '__main__': diff --git a/tests/test_create_users_and_join_sub.py b/tests/test_create_users_and_join_sub.py new file mode 100644 index 0000000..4789b83 --- /dev/null +++ b/tests/test_create_users_and_join_sub.py @@ -0,0 +1,66 @@ +import os, sys, pytest, pandas, random +from pathlib import Path + +# load local copy of libraries +sys.path.append(str( Path(Path(__file__).parents[1] / 'src').resolve() )) +from spaceandtime.spaceandtime import SXTUser + + +def test_remove_all_users_from_test_subscription(): + # login with admin + admin = SXTUser(dotenv_file='.env_loader_admin') + admin.authenticate() + assert admin.user_id == 'testuser_owner' + + # get list of all users in subscription + success, users = admin.get_subscription_users() + assert success + for userid, role in users.items(): + if userid == admin.user_id: continue # skip self + + # remove all other users from subscription + success, response = admin.remove_from_subscription(userid) + assert success + + success, users = admin.get_subscription_users() + assert len(users) == 1 + + +def test_adding_users_to_subscription(): + # login with admin + steve = SXTUser(dotenv_file='.env_loader_admin') + steve.authenticate() + assert steve.user_id == 'testuser_owner' + + sxtloader_users = [] + + # create N new load users + for i in range(0,5): + + # load keys, then override name + sxtloaderN = SXTUser(dotenv_file='.env_loader', + user_id=f'testuser_joincode{i}_{str(random.randint(0,999999)).zfill(6)}',) + assert 'disconnected' in sxtloaderN.subscription_id + assert sxtloaderN.exists == False + + # register new user and add to subscription + joincode = steve.generate_joincode(role='member') + assert len(joincode) > 0 + + success, response = sxtloaderN.register_new_user() + assert sxtloaderN.exists == True + assert sxtloaderN.subscription_id == '' + first_access_token = sxtloaderN.access_token + + if success: success, response = sxtloaderN.join_subscription(joincode) + assert sxtloaderN.subscription_id != '' + assert 'disconnected' not in sxtloaderN.subscription_id + assert first_access_token != sxtloaderN.access_token + + success, response = sxtloaderN.leave_subscription() + + +if __name__ == '__main__': + test_remove_all_users_from_test_subscription() + test_adding_users_to_subscription() + pass \ No newline at end of file diff --git a/tests/test_sxtresource.py b/tests/test_sxtresource.py index 3d83cf7..c39dd3a 100644 --- a/tests/test_sxtresource.py +++ b/tests/test_sxtresource.py @@ -64,7 +64,7 @@ def test_inserts_deletes_updates(): sxt = SpaceAndTime() sxt.authenticate() - tbl = SXTTable(name='SXTTemp.Test_DML', from_file='./.env', SpaceAndTime_parent=sxt) + tbl = SXTTable(name='SXTTemp.Test_DML1', from_file='./.env', SpaceAndTime_parent=sxt) tbl.create_ddl = """ CREATE TABLE {table_name} ( MyID int @@ -77,14 +77,14 @@ def test_inserts_deletes_updates(): if not tbl.exists: tbl.create() else: - tbl.delete(where='') + tbl.delete(where='1=1') - data = [ {'MyID':1, 'MyName':'Abby', 'MyNumber':6} + data_in = [ {'MyID':1, 'MyName':'Abby', 'MyNumber':6} ,{'MyID':2, 'MyName':'Bob', 'MyNumber':6} ,{'MyID':3, 'MyName':'Chuck', 'MyNumber':6} ,{'MyID':4, 'MyName':'Daria', 'MyNumber':6} ] - tbl.insert.with_list_of_dicts(data) + tbl.insert.with_list_of_dicts(data_in) success, data = tbl.select() assert success assert [r['MYNUMBER'] for r in data] == [6, 6, 6, 6]