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
4 changes: 3 additions & 1 deletion hashtopolis/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@
ObjectDoesNotExist,
MultipleObjectsReturned,
ModelBase,
Model
Model,
Helper
)
Comment on lines 12 to 15
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Helper is now imported in the "base stuff" import list, but it is also imported again later in the "action utilities" import list. Consider removing one of these imports to avoid redundancy and potential confusion during maintenance.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As it's mentioned by Copilot, the helper class is already imported below.


# models
from .hashtopolis import (
ApiToken,
AccessGroup,
Agent,
AgentStat,
Expand Down
55 changes: 39 additions & 16 deletions hashtopolis/hashtopolis.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -106,22 +116,26 @@ def __init__(self, model_uri, config):
self._hashtopolis_uri = config._hashtopolis_uri
self.config = config

def authenticate(self):
if self._api_endpoint not in HashtopolisConnector.token:
# Request access TOKEN, used throughout the test

logger.info("Start authentication")
def authenticate(self, auth=None):
if auth is not None:
logger.info("Start authentication with provided credentials")
auth_uri = self._api_endpoint + '/auth/token'
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']
Comment on lines +119 to +127
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HashtopolisConnector.authenticate(auth=...) bypasses the class-level token cache and will POST to /auth/token on every call when auth is provided. This can cause unnecessary authentication traffic and makes behavior inconsistent with the no-arg path. Consider caching tokens per (api_endpoint, username) (or per full auth identity) or alternatively accepting a bearer token directly so repeated requests don't re-authenticate.

Copilot uses AI. Check for mistakes.
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
Expand Down Expand Up @@ -215,8 +229,8 @@ 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):
self.authenticate()
def filter(self, include, ordering, filter, start_offset, auth=None):
self.authenticate(auth=auth)
headers = self._headers
Comment on lines +232 to 234
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Auth support is only threaded through HashtopolisConnector.filter(..., auth=...). Other connector methods invoked by the ORM-style API (get_single_page, get_one, create, delete, etc.) still call authenticate() without a way to supply alternate credentials, so "log in as a different user" will be incomplete depending on which QuerySet/Manager method is used. Consider consistently plumbing an auth (or token) parameter through connector methods, or storing the per-request auth context on the connector/QuerySet and using it across all request types.

Copilot uses AI. Check for mistakes.

after_dict = {"primary": {"id": start_offset}}
Expand Down Expand Up @@ -394,12 +408,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))
Expand Down Expand Up @@ -431,7 +446,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
Expand Down Expand Up @@ -469,6 +484,10 @@ def page(self, **pages):
def all(self):
# yield from self
return self

def authenticate(self, auth):
self.auth = auth
return self

Comment on lines +488 to 491
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

QuerySet.authenticate() doesn't actually perform authentication; it only sets self.auth for later use. This name can be misleading for callers (it reads like a network operation). Consider renaming it to something that reflects intent (e.g., with_auth/using_credentials) and/or documenting the expected auth type (requests auth tuple).

Suggested change
def authenticate(self, auth):
self.auth = auth
return self
def with_auth(self, auth):
"""Set authentication credentials for subsequent requests.
This only stores the value on the QuerySet so it can be passed to
later HTTP requests. It does not perform authentication immediately.
Args:
auth: Authentication object understood by requests, typically a
``(username, password)`` tuple.
"""
self.auth = auth
return self
def authenticate(self, auth):
"""Backward-compatible alias for :meth:`with_auth`."""
return self.with_auth(auth)

Copilot uses AI. Check for mistakes.
def get(self, **filters):
if filters:
Expand Down Expand Up @@ -760,6 +779,10 @@ def uri(self):
##
# Begin of API objects
#
class ApiToken(Model, uri="/ui/apiTokens"):
pass


class AccessGroup(Model, uri="/ui/accessgroups"):
pass

Expand Down