Skip to content

Commit bd488bb

Browse files
authored
Merge pull request #1965 from dudanogueira/expose-include-last-user-time-and-created-at
expose include_last_used_time parameter, return created_at and api_key_first_letters
2 parents afc9f16 + f144d5e commit bd488bb

6 files changed

Lines changed: 132 additions & 9 deletions

File tree

integration/test_users.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,3 +214,82 @@ def test_list_all_users(client_factory: ClientFactory) -> None:
214214
assert len(dynamic_users) == 5
215215
for i in range(5):
216216
client.users.db.delete(user_id=f"list-all-{i}")
217+
218+
219+
def test_get_user_created_at_and_api_key_first_letters(client_factory: ClientFactory) -> None:
220+
with client_factory(ports=RBAC_PORTS, auth_credentials=Auth.api_key("admin-key")) as client:
221+
if client._connection._weaviate_version.is_lower_than(1, 30, 0):
222+
pytest.skip("This test requires Weaviate 1.30.0 or higher")
223+
224+
randomUserName = "new-user" + str(random.randint(1, 1000))
225+
client.users.db.create(user_id=randomUserName)
226+
try:
227+
user = client.users.db.get(user_id=randomUserName)
228+
assert user is not None
229+
assert user.created_at is not None
230+
assert user.api_key_first_letters is not None
231+
assert len(user.api_key_first_letters) > 0
232+
finally:
233+
client.users.db.delete(user_id=randomUserName)
234+
235+
236+
def test_get_user_include_last_used_time(client_factory: ClientFactory) -> None:
237+
with client_factory(ports=RBAC_PORTS, auth_credentials=Auth.api_key("admin-key")) as client:
238+
if client._connection._weaviate_version.is_lower_than(1, 30, 0):
239+
pytest.skip("This test requires Weaviate 1.30.0 or higher")
240+
241+
randomUserName = "new-user" + str(random.randint(1, 1000))
242+
apiKey = client.users.db.create(user_id=randomUserName)
243+
try:
244+
# log in with the new user to generate a lastUsedAt timestamp
245+
with weaviate.connect_to_local(
246+
port=RBAC_PORTS[0],
247+
grpc_port=RBAC_PORTS[1],
248+
auth_credentials=Auth.api_key(apiKey),
249+
) as client2:
250+
assert client2.users.get_my_user().user_id == randomUserName
251+
252+
# without include_last_used_time, last_used_time should be None
253+
user = client.users.db.get(user_id=randomUserName)
254+
assert user is not None
255+
assert user.last_used_time is None
256+
257+
# with include_last_used_time=True, last_used_time should be populated
258+
user = client.users.db.get(user_id=randomUserName, include_last_used_time=True)
259+
assert user is not None
260+
assert user.last_used_time is not None
261+
finally:
262+
client.users.db.delete(user_id=randomUserName)
263+
264+
265+
def test_list_all_include_last_used_time(client_factory: ClientFactory) -> None:
266+
with client_factory(ports=RBAC_PORTS, auth_credentials=Auth.api_key("admin-key")) as client:
267+
if client._connection._weaviate_version.is_lower_than(1, 30, 0):
268+
pytest.skip("This test requires Weaviate 1.30.0 or higher")
269+
270+
randomUserName = "new-user" + str(random.randint(1, 1000))
271+
apiKey = client.users.db.create(user_id=randomUserName)
272+
try:
273+
# log in with the new user to generate a lastUsedAt timestamp
274+
with weaviate.connect_to_local(
275+
port=RBAC_PORTS[0],
276+
grpc_port=RBAC_PORTS[1],
277+
auth_credentials=Auth.api_key(apiKey),
278+
) as client2:
279+
assert client2.users.get_my_user().user_id == randomUserName
280+
281+
# without include_last_used_time, last_used_time should be None
282+
users = client.users.db.list_all()
283+
target = next((u for u in users if u.user_id == randomUserName), None)
284+
assert target is not None
285+
assert target.created_at is not None
286+
assert target.api_key_first_letters is not None
287+
assert target.last_used_time is None
288+
289+
# with include_last_used_time=True, last_used_time should be populated
290+
users = client.users.db.list_all(include_last_used_time=True)
291+
target = next((u for u in users if u.user_id == randomUserName), None)
292+
assert target is not None
293+
assert target.last_used_time is not None
294+
finally:
295+
client.users.db.delete(user_id=randomUserName)

weaviate/rbac/models.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,9 @@ class WeaviateDBUserRoleNames(TypedDict):
140140
groups: List[str]
141141
active: bool
142142
dbUserType: str
143+
createdAt: NotRequired[str]
144+
lastUsedAt: NotRequired[str]
145+
apiKeyFirstLetters: NotRequired[str]
143146

144147

145148
class _Action:

weaviate/users/async_.pyi

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,10 @@ class _UsersDBAsync(_UsersDBExecutor[ConnectionAsync]):
5757
async def rotate_key(self, *, user_id: str) -> str: ...
5858
async def activate(self, *, user_id: str) -> bool: ...
5959
async def deactivate(self, *, user_id: str, revoke_key: bool = False) -> bool: ...
60-
async def get(self, *, user_id: str) -> Optional[UserDB]: ...
61-
async def list_all(self) -> List[UserDB]: ...
60+
async def get(
61+
self, *, user_id: str, include_last_used_time: bool = False
62+
) -> Optional[UserDB]: ...
63+
async def list_all(self, *, include_last_used_time: bool = False) -> List[UserDB]: ...
6264

6365
class _UsersAsync(_UsersExecutor[ConnectionAsync]):
6466
async def get_my_user(self) -> OwnUser: ...

weaviate/users/base.py

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from datetime import datetime, timezone
12
from typing import Any, Dict, Generic, List, Literal, Optional, Union, cast, overload
23

34
from httpx import Response
@@ -20,6 +21,16 @@
2021
)
2122
from weaviate.util import _decode_json_response_dict, escape_string
2223

24+
# Go's zero time value, returned by the server when a timestamp is not set
25+
_GO_ZERO_TIME = datetime(1, 1, 1, 0, 0, tzinfo=timezone.utc)
26+
27+
28+
def _parse_last_used_at(value: Optional[str]) -> Optional[datetime]:
29+
if value is None:
30+
return None
31+
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
32+
return None if dt == _GO_ZERO_TIME else dt
33+
2334

2435
class _BaseExecutor(Generic[ConnectionType]):
2536
def __init__(self, connection: ConnectionType):
@@ -451,35 +462,51 @@ def resp(res: Response) -> bool:
451462
status_codes=_ExpectedStatusCodes(ok_in=[200, 409], error="Deactivate user"),
452463
)
453464

454-
def get(self, *, user_id: str) -> executor.Result[Optional[UserDB]]:
465+
def get(
466+
self, *, user_id: str, include_last_used_time: bool = False
467+
) -> executor.Result[Optional[UserDB]]:
455468
"""Get all information about an user.
456469
457470
Args:
458471
user_id: The id of the user.
472+
include_last_used_time: If True, the last used time of the user will be included.
459473
"""
460474

461475
def resp(res: Response) -> Optional[UserDB]:
462476
if res.status_code == 404:
463477
return None
464478
parsed = _decode_json_response_dict(res, "Get user")
465479
assert parsed is not None
480+
created_at = parsed.get("createdAt")
466481
return UserDB(
467482
user_id=parsed["userId"],
468483
role_names=parsed["roles"],
469484
active=parsed["active"],
470485
user_type=UserTypes(parsed["dbUserType"]),
486+
created_at=(
487+
datetime.fromisoformat(created_at.replace("Z", "+00:00"))
488+
if created_at is not None
489+
else None
490+
),
491+
last_used_time=_parse_last_used_at(parsed.get("lastUsedAt")),
492+
api_key_first_letters=parsed.get("apiKeyFirstLetters"),
471493
)
472494

473495
return executor.execute(
474496
response_callback=resp,
475497
method=self._connection.get,
476498
path=f"/users/db/{user_id}",
499+
params={"includeLastUsedTime": include_last_used_time},
477500
error_msg=f"Could not get user '{user_id}'",
478501
status_codes=_ExpectedStatusCodes(ok_in=[200, 404], error="get user"),
479502
)
480503

481-
def list_all(self) -> executor.Result[List[UserDB]]:
482-
"""List all DB users."""
504+
def list_all(self, *, include_last_used_time: bool = False) -> executor.Result[List[UserDB]]:
505+
"""List all DB users.
506+
507+
Args:
508+
include_last_used_time: If True, the last used time of each user will be included.
509+
"""
483510

484511
def resp(res: Response) -> List[UserDB]:
485512
parsed = _decode_json_response_dict(res, "Get user")
@@ -490,6 +517,13 @@ def resp(res: Response) -> List[UserDB]:
490517
role_names=user["roles"],
491518
active=user["active"],
492519
user_type=UserTypes(user["dbUserType"]),
520+
created_at=(
521+
datetime.fromisoformat(ca.replace("Z", "+00:00"))
522+
if (ca := user.get("createdAt")) is not None
523+
else None
524+
),
525+
last_used_time=_parse_last_used_at(user.get("lastUsedAt")),
526+
api_key_first_letters=user.get("apiKeyFirstLetters"),
493527
)
494528
for user in cast(List[WeaviateDBUserRoleNames], parsed)
495529
]
@@ -498,6 +532,7 @@ def resp(res: Response) -> List[UserDB]:
498532
response_callback=resp,
499533
method=self._connection.get,
500534
path="/users/db",
535+
params={"includeLastUsedTime": include_last_used_time},
501536
error_msg="Could not list all users",
502537
status_codes=_ExpectedStatusCodes(ok_in=[200], error="list all users"),
503538
)

weaviate/users/sync.pyi

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,8 @@ class _UsersDB(_UsersDBExecutor[ConnectionSync]):
5757
def rotate_key(self, *, user_id: str) -> str: ...
5858
def activate(self, *, user_id: str) -> bool: ...
5959
def deactivate(self, *, user_id: str, revoke_key: bool = False) -> bool: ...
60-
def get(self, *, user_id: str) -> Optional[UserDB]: ...
61-
def list_all(self) -> List[UserDB]: ...
60+
def get(self, *, user_id: str, include_last_used_time: bool = False) -> Optional[UserDB]: ...
61+
def list_all(self, *, include_last_used_time: bool = False) -> List[UserDB]: ...
6262

6363
class _Users(_UsersExecutor[ConnectionSync]):
6464
def get_my_user(self) -> OwnUser: ...

weaviate/users/users.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
from dataclasses import dataclass
2-
from typing import Dict, Final, List, Literal
1+
from dataclasses import dataclass, field
2+
from datetime import datetime
3+
from typing import Dict, Final, List, Literal, Optional
34

45
from weaviate.rbac.models import (
56
Role,
@@ -29,6 +30,9 @@ class UserBase:
2930
class UserDB(UserBase):
3031
user_type: UserTypes
3132
active: bool
33+
created_at: Optional[datetime] = field(default=None)
34+
last_used_time: Optional[datetime] = field(default=None)
35+
api_key_first_letters: Optional[str] = field(default=None)
3236

3337

3438
@dataclass

0 commit comments

Comments
 (0)