From 8222d7870b8387f81701058dd1541efc24f7c9a3 Mon Sep 17 00:00:00 2001 From: jessevz Date: Tue, 28 Apr 2026 15:10:13 +0200 Subject: [PATCH 1/7] Added API Token object and logging in as a different user --- hashtopolis/__init__.py | 4 +++- hashtopolis/hashtopolis.py | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/hashtopolis/__init__.py b/hashtopolis/__init__.py index f10fc27..c864c8c 100644 --- a/hashtopolis/__init__.py +++ b/hashtopolis/__init__.py @@ -10,11 +10,13 @@ ObjectDoesNotExist, MultipleObjectsReturned, ModelBase, - Model + Model, + Helper ) # models from .hashtopolis import ( + ApiToken, AccessGroup, Agent, AgentStat, diff --git a/hashtopolis/hashtopolis.py b/hashtopolis/hashtopolis.py index 8d5b223..d6ca45e 100644 --- a/hashtopolis/hashtopolis.py +++ b/hashtopolis/hashtopolis.py @@ -60,6 +60,16 @@ def __init__(self): self.username = self._cfg['username'] self.password = self._cfg['password'] + @classmethod + def with_credentials(cls, uri, username, password): + """Create a config with explicit credentials instead of reading from a config file.""" + config = cls.__new__(cls) + config._hashtopolis_uri = uri + config._api_endpoint = uri + '/api/v2' + config.username = username + config.password = password + return config + class HashtopolisResponseError(HashtopolisError): pass @@ -760,6 +770,10 @@ def uri(self): ## # Begin of API objects # +class ApiToken(Model, uri="/ui/apiTokens"): + pass + + class AccessGroup(Model, uri="/ui/accessgroups"): pass From 9252dd31f2f75d02ffca7aba552552945df40dcb Mon Sep 17 00:00:00 2001 From: jessevz Date: Wed, 29 Apr 2026 09:09:39 +0200 Subject: [PATCH 2/7] Made it possible to authenticate for filter in order to test easily with different accounts --- hashtopolis/hashtopolis.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/hashtopolis/hashtopolis.py b/hashtopolis/hashtopolis.py index d6ca45e..cdc8277 100644 --- a/hashtopolis/hashtopolis.py +++ b/hashtopolis/hashtopolis.py @@ -116,13 +116,16 @@ def __init__(self, model_uri, config): self._hashtopolis_uri = config._hashtopolis_uri self.config = config - def authenticate(self): + def authenticate(self, auth=None): if self._api_endpoint not in HashtopolisConnector.token: # Request access TOKEN, used throughout the test logger.info("Start authentication") auth_uri = self._api_endpoint + '/auth/token' - auth = (self.config.username, self.config.password) + if auth is not None: + auth = auth + else: + auth = (self.config.username, self.config.password) r = requests.post(auth_uri, auth=auth) self.validate_status_code(r, [201], "Authentication failed") @@ -225,7 +228,7 @@ def get_single_page(self, page, filter): return response["data"] # todo refactor start_offset into page variable - def filter(self, include, ordering, filter, start_offset): + def filter(self, include, ordering, filter, start_offset, auth=None): self.authenticate() headers = self._headers @@ -404,12 +407,13 @@ def count(self, filter): # Build Django ORM style django.query interface class QuerySet(): - def __init__(self, cls, include=None, ordering=None, filters=None, pages=None): + def __init__(self, cls, include=None, ordering=None, filters=None, pages=None, auth=None): self.cls = cls self.include = include self.ordering = ordering self.filters = filters self.pages = pages + self.auth = auth def __iter__(self): yield from self.__getitem__(slice(None, None, 1)) @@ -441,7 +445,7 @@ def filter_(self, start, stop, step): filters['id'] = filters['pk'] del filters['pk'] - filter_generator = self.cls.get_conn().filter(self.include, self.ordering, filters, start_offset=cursor) + filter_generator = self.cls.get_conn().filter(self.include, self.ordering, filters, start_offset=cursor, auth=self.auth) while index < stop: # Fetch new entries in chunks default to server @@ -479,6 +483,9 @@ def page(self, **pages): def all(self): # yield from self return self + + def auth(self, username=None, password=None): + self.auth = (username, password) def get(self, **filters): if filters: From f8587ff3de5064d3564d72c258c3e0fe8e47355a Mon Sep 17 00:00:00 2001 From: jessevz Date: Wed, 29 Apr 2026 09:21:14 +0200 Subject: [PATCH 3/7] Fixed name conflict --- hashtopolis/hashtopolis.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/hashtopolis/hashtopolis.py b/hashtopolis/hashtopolis.py index cdc8277..2683805 100644 --- a/hashtopolis/hashtopolis.py +++ b/hashtopolis/hashtopolis.py @@ -484,8 +484,9 @@ def all(self): # yield from self return self - def auth(self, username=None, password=None): + def authenticate(self, username=None, password=None): self.auth = (username, password) + return self def get(self, **filters): if filters: From af976ae5555c5b832e44cb0356e669181d412749 Mon Sep 17 00:00:00 2001 From: jessevz Date: Wed, 29 Apr 2026 09:44:19 +0200 Subject: [PATCH 4/7] Added authentication to filter --- hashtopolis/hashtopolis.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hashtopolis/hashtopolis.py b/hashtopolis/hashtopolis.py index 2683805..8bb4015 100644 --- a/hashtopolis/hashtopolis.py +++ b/hashtopolis/hashtopolis.py @@ -229,7 +229,7 @@ def get_single_page(self, page, filter): # todo refactor start_offset into page variable def filter(self, include, ordering, filter, start_offset, auth=None): - self.authenticate() + self.authenticate(auth=auth) headers = self._headers after_dict = {"primary": {"id": start_offset}} From 0985402136d3a45995603c90cfa2f69e7d315adc Mon Sep 17 00:00:00 2001 From: jessevz Date: Wed, 29 Apr 2026 10:15:50 +0200 Subject: [PATCH 5/7] Fixed caching issue when providing different authentication details --- hashtopolis/hashtopolis.py | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/hashtopolis/hashtopolis.py b/hashtopolis/hashtopolis.py index 8bb4015..0b3efbf 100644 --- a/hashtopolis/hashtopolis.py +++ b/hashtopolis/hashtopolis.py @@ -117,24 +117,25 @@ def __init__(self, model_uri, config): self.config = config def authenticate(self, auth=None): - if self._api_endpoint not in HashtopolisConnector.token: - # Request access TOKEN, used throughout the test - - logger.info("Start authentication") + if auth is not None: + logger.info("Start authentication with provided credentials") auth_uri = self._api_endpoint + '/auth/token' - if auth is not None: - auth = auth - else: - auth = (self.config.username, self.config.password) r = requests.post(auth_uri, auth=auth) self.validate_status_code(r, [201], "Authentication failed") - r_json = self.resp_to_json(r) - HashtopolisConnector.token[self._api_endpoint] = r_json['token'] - HashtopolisConnector.token_expires[self._api_endpoint] = r_json['token'] - - self._token = HashtopolisConnector.token[self._api_endpoint] - self._token_expires = HashtopolisConnector.token_expires[self._api_endpoint] + self._token = r_json['token'] + self._token_expires = r_json['token'] + else: + if self._api_endpoint not in HashtopolisConnector.token: + logger.info("Start authentication") + auth_uri = self._api_endpoint + '/auth/token' + r = requests.post(auth_uri, auth=(self.config.username, self.config.password)) + self.validate_status_code(r, [201], "Authentication failed") + r_json = self.resp_to_json(r) + HashtopolisConnector.token[self._api_endpoint] = r_json['token'] + HashtopolisConnector.token_expires[self._api_endpoint] = r_json['token'] + self._token = HashtopolisConnector.token[self._api_endpoint] + self._token_expires = HashtopolisConnector.token_expires[self._api_endpoint] self._headers = { 'Authorization': 'Bearer ' + self._token From f9efc61b0e4e3ee65872aee51b4554bfffd8ec49 Mon Sep 17 00:00:00 2001 From: jessevz Date: Wed, 29 Apr 2026 10:35:40 +0200 Subject: [PATCH 6/7] Fixed mismatch in parameters --- hashtopolis/hashtopolis.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/hashtopolis/hashtopolis.py b/hashtopolis/hashtopolis.py index 0b3efbf..a8b02e0 100644 --- a/hashtopolis/hashtopolis.py +++ b/hashtopolis/hashtopolis.py @@ -485,8 +485,8 @@ def all(self): # yield from self return self - def authenticate(self, username=None, password=None): - self.auth = (username, password) + def authenticate(self, auth): + self.auth = auth return self def get(self, **filters): From 651c2320f4761c1ddcd75d63b7829e9f22c565ef Mon Sep 17 00:00:00 2001 From: jessevz Date: Wed, 6 May 2026 08:39:48 +0200 Subject: [PATCH 7/7] Fixed copilot suggestions --- hashtopolis/__init__.py | 3 +-- hashtopolis/hashtopolis.py | 48 ++++++++++++++++++++++---------------- 2 files changed, 29 insertions(+), 22 deletions(-) diff --git a/hashtopolis/__init__.py b/hashtopolis/__init__.py index c864c8c..35f30a9 100644 --- a/hashtopolis/__init__.py +++ b/hashtopolis/__init__.py @@ -10,8 +10,7 @@ ObjectDoesNotExist, MultipleObjectsReturned, ModelBase, - Model, - Helper + Model ) # models diff --git a/hashtopolis/hashtopolis.py b/hashtopolis/hashtopolis.py index a8b02e0..38cca7a 100644 --- a/hashtopolis/hashtopolis.py +++ b/hashtopolis/hashtopolis.py @@ -117,6 +117,14 @@ def __init__(self, model_uri, config): self.config = config def authenticate(self, auth=None): + """ + Authenticate with the API and store the token for future requests. + + Args: + auth: Authentication object understood by requests, typically a + ``(username, password)`` tuple. Is only used for one off authentication + that differ from the config. This authentication is not cached. + """ if auth is not None: logger.info("Start authentication with provided credentials") auth_uri = self._api_endpoint + '/auth/token' @@ -204,9 +212,9 @@ def validate_status_code(self, r, expected_status_code, error_msg): # query_params = urllib.parse.parse_qs(urllib.parse.urlparse(links["last"]).query) # TODO not really a straightforward way to validate the last link - def get_single_page(self, page, filter): + def get_single_page(self, page, filter, auth=None): """Gets a single page by using the page parameters""" - self.authenticate() + self.authenticate(auth=auth) headers = self._headers request_uri = self._api_endpoint + self._model_uri payload = {} @@ -267,8 +275,8 @@ def filter(self, include, ordering, filter, start_offset, auth=None): break request_uri = response['links']['next'] - def get_one(self, pk, include): - self.authenticate() + def get_one(self, pk, include, auth=None): + self.authenticate(auth=auth) uri = self._api_endpoint + self._model_uri + f'/{pk}' headers = self._headers @@ -280,8 +288,8 @@ def get_one(self, pk, include): self.validate_status_code(r, [200], "Get single object failed") return self.resp_to_json(r) - def delete_many(self, objects): - self.authenticate() + def delete_many(self, objects, auth=None): + self.authenticate(auth=auth) uri = self._api_endpoint + self._model_uri headers = self._headers headers['Content-Type'] = 'application/json' @@ -296,7 +304,7 @@ def delete_many(self, objects): r = requests.delete(uri, headers=headers, data=json.dumps(payload)) self.validate_status_code(r, [204], "deleting failed") - def patch_many(self, objects, attributes, field): + def patch_many(self, objects, attributes, field, auth=None): """ Used to test PATCH many endpoint. @@ -307,7 +315,7 @@ def patch_many(self, objects, attributes, field): patched with attributes[0] on the set field """ assert len(objects) == len(attributes) - self.authenticate() + self.authenticate(auth=auth) uri = self._api_endpoint + self._model_uri headers = self._headers headers['Content-Type'] = 'application/json' @@ -316,12 +324,12 @@ def patch_many(self, objects, attributes, field): r = requests.patch(uri, headers=headers, data=json.dumps(payload)) self.validate_status_code(r, [200], "Patching failed") - def patch_one(self, obj): + def patch_one(self, obj, auth=None): if not obj.has_changed(): logger.debug("Object '%s' has not changed, no PATCH required", obj) return - self.authenticate() + self.authenticate(auth=auth) uri = self._hashtopolis_uri + obj.uri headers = self._headers headers['Content-Type'] = 'application/json' @@ -339,15 +347,15 @@ def patch_one(self, obj): # TODO: Validate if return objects matches digital twin obj.set_initial(self.resp_to_json(r)['data'].copy()) - def send_patch(self, uri, data): - self.authenticate() + def send_patch(self, uri, data, auth=None): + self.authenticate(auth=auth) headers = self._headers headers['Content-Type'] = 'application/json' logger.debug("Sending PATCH payload: %s to %s", json.dumps(data), uri) r = requests.patch(uri, headers=headers, data=json.dumps(data)) self.validate_status_code(r, [204], "Patching failed") - def patch_to_many_relationships(self, obj): + def patch_to_many_relationships(self, obj, auth=None): for k, v in obj.diff_includes().items(): attributes = [] logger.debug("Going to patch object '%s' property '%s' from '%s' to '%s'", obj, k, v[0], v[1]) @@ -355,13 +363,13 @@ def patch_to_many_relationships(self, obj): attributes.append({"type": k, "id": include_id}) data = {"data": attributes} uri = self._hashtopolis_uri + obj.uri + "/relationships/" + k - self.send_patch(uri, data) + self.send_patch(uri, data, auth=auth) - def create(self, obj): + def create(self, obj, auth=None): # Check if object to be created is new assert obj._new_model is True - self.authenticate() + self.authenticate(auth=auth) uri = self._api_endpoint + self._model_uri headers = self._headers headers['Content-Type'] = 'application/json' @@ -376,12 +384,12 @@ def create(self, obj): # TODO: Validate if return objects matches digital twin obj.set_initial(self.resp_to_json(r)['data'].copy()) - def delete(self, obj): + def delete(self, obj, auth=None): """ Delete object from database """ # TODO: Check if object to be deleted actually exists assert obj._new_model is False - self.authenticate() + self.authenticate(auth=auth) uri = self._hashtopolis_uri + obj.uri headers = self._headers payload = {} @@ -391,8 +399,8 @@ def delete(self, obj): # TODO: Cleanup object to allow re-creation - def count(self, filter): - self.authenticate() + def count(self, filter, auth=None): + self.authenticate(auth=auth) uri = self._api_endpoint + self._model_uri + "/count" headers = self._headers payload = {}