Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ publish.sh
venv_*
tests/backup_biscuits/
tests/latest_test_log.txt
tests/user_saves


# C extensions
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
7 changes: 5 additions & 2 deletions src/spaceandtime/apiversions.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
61 changes: 58 additions & 3 deletions src/spaceandtime/sxtbaseapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down Expand Up @@ -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]


Expand Down Expand Up @@ -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__':

Expand Down
4 changes: 3 additions & 1 deletion src/spaceandtime/sxtresource.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
136 changes: 124 additions & 12 deletions src/spaceandtime/sxtuser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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() ] )

Expand Down Expand Up @@ -235,14 +267,29 @@ 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()

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)
Expand All @@ -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


Expand Down Expand Up @@ -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__':

Expand Down
66 changes: 66 additions & 0 deletions tests/test_create_users_and_join_sub.py
Original file line number Diff line number Diff line change
@@ -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
Loading