Skip to content
This repository has been archived by the owner on Apr 26, 2024. It is now read-only.

Add last_seen_ts to the admin users API #16218

Merged
merged 3 commits into from
Sep 4, 2023
Merged
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 changelog.d/16218.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add `last_seen_ts` to the admin users API.
2 changes: 2 additions & 0 deletions docs/admin_api/user_admin_api.md
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,7 @@ The following parameters should be set in the URL:
- `displayname` - Users are ordered alphabetically by `displayname`.
- `avatar_url` - Users are ordered alphabetically by avatar URL.
- `creation_ts` - Users are ordered by when the users was created in ms.
- `last_seen_ts` - Users are ordered by when the user was lastly seen in ms.

- `dir` - Direction of media order. Either `f` for forwards or `b` for backwards.
Setting this value to `b` will reverse the above sort order. Defaults to `f`.
Expand Down Expand Up @@ -272,6 +273,7 @@ The following fields are returned in the JSON response body:
- `displayname` - string - The user's display name if they have set one.
- `avatar_url` - string - The user's avatar URL if they have set one.
- `creation_ts` - integer - The user's creation timestamp in ms.
- `last_seen_ts` - integer - The user's last activity timestamp in ms.

- `next_token`: string representing a positive integer - Indication for pagination. See above.
- `total` - integer - Total number of media.
Expand Down
1 change: 1 addition & 0 deletions synapse/handlers/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ async def get_user(self, user: UserID) -> Optional[JsonDict]:
"consent_ts",
"user_type",
"is_guest",
"last_seen_ts",
}

if self._msc3866_enabled:
Expand Down
1 change: 1 addition & 0 deletions synapse/rest/admin/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ async def on_GET(self, request: SynapseRequest) -> Tuple[int, JsonDict]:
UserSortOrder.AVATAR_URL.value,
UserSortOrder.SHADOW_BANNED.value,
UserSortOrder.CREATION_TS.value,
UserSortOrder.LAST_SEEN_TS.value,
),
)

Expand Down
6 changes: 5 additions & 1 deletion synapse/storage/databases/main/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,10 @@ def get_users_paginate_txn(
FROM users as u
LEFT JOIN profiles AS p ON u.name = p.full_user_id
LEFT JOIN erased_users AS eu ON u.name = eu.user_id
LEFT JOIN (
SELECT user_id, MAX(last_seen) AS last_seen_ts
FROM user_ips GROUP BY user_id
) ls ON u.name = ls.user_id
{where_clause}
"""
sql = "SELECT COUNT(*) as total_users " + sql_base
Expand All @@ -286,7 +290,7 @@ def get_users_paginate_txn(
sql = f"""
SELECT name, user_type, is_guest, admin, deactivated, shadow_banned,
displayname, avatar_url, creation_ts * 1000 as creation_ts, approved,
eu.user_id is not null as erased
eu.user_id is not null as erased, last_seen_ts
{sql_base}
ORDER BY {order_by_column} {order}, u.name ASC
LIMIT ? OFFSET ?
Expand Down
7 changes: 6 additions & 1 deletion synapse/storage/databases/main/registration.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,8 +206,12 @@ def get_user_by_id_txn(txn: LoggingTransaction) -> Optional[Dict[str, Any]]:
consent_server_notice_sent, appservice_id, creation_ts, user_type,
deactivated, COALESCE(shadow_banned, FALSE) AS shadow_banned,
COALESCE(approved, TRUE) AS approved,
COALESCE(locked, FALSE) AS locked
COALESCE(locked, FALSE) AS locked, last_seen_ts
FROM users
LEFT JOIN (
SELECT user_id, MAX(last_seen) AS last_seen_ts
FROM user_ips GROUP BY user_id
) ls ON users.name = ls.user_id
WHERE name = ?
""",
(user_id,),
Expand Down Expand Up @@ -268,6 +272,7 @@ async def get_userinfo_by_id(self, user_id: str) -> Optional[UserInfo]:
is_shadow_banned=bool(user_data["shadow_banned"]),
user_id=UserID.from_string(user_data["name"]),
user_type=user_data["user_type"],
last_seen_ts=user_data["last_seen_ts"],
)

async def is_trial_user(self, user_id: str) -> bool:
Expand Down
1 change: 1 addition & 0 deletions synapse/storage/databases/main/stats.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ class UserSortOrder(Enum):
AVATAR_URL = "avatar_url"
SHADOW_BANNED = "shadow_banned"
CREATION_TS = "creation_ts"
LAST_SEEN_TS = "last_seen_ts"


class StatsStore(StateDeltasStore):
Expand Down
2 changes: 2 additions & 0 deletions synapse/types/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -946,6 +946,7 @@ class UserInfo:
is_guest: True if the user is a guest user.
is_shadow_banned: True if the user has been shadow-banned.
user_type: User type (None for normal user, 'support' and 'bot' other options).
last_seen_ts: Last activity timestamp of the user.
"""

user_id: UserID
Expand All @@ -958,6 +959,7 @@ class UserInfo:
is_deactivated: bool
is_guest: bool
is_shadow_banned: bool
last_seen_ts: Optional[int]


class UserProfile(TypedDict):
Expand Down
60 changes: 60 additions & 0 deletions tests/rest/admin/test_user.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
user_directory,
)
from synapse.server import HomeServer
from synapse.storage.databases.main.client_ips import LAST_SEEN_GRANULARITY
from synapse.types import JsonDict, UserID, create_requester
from synapse.util import Clock

Expand Down Expand Up @@ -456,6 +457,7 @@ class UsersListTestCase(unittest.HomeserverTestCase):
servlets = [
synapse.rest.admin.register_servlets,
login.register_servlets,
room.register_servlets,
]
url = "/_synapse/admin/v2/users"

Expand Down Expand Up @@ -506,6 +508,62 @@ def test_all_users(self) -> None:
# Check that all fields are available
self._check_fields(channel.json_body["users"])

def test_last_seen(self) -> None:
"""
Test that last_seen_ts field is properly working.
"""
user1 = self.register_user("u1", "pass")
user1_token = self.login("u1", "pass")
user2 = self.register_user("u2", "pass")
user2_token = self.login("u2", "pass")
user3 = self.register_user("u3", "pass")
user3_token = self.login("u3", "pass")

self.helper.create_room_as(self.admin_user, tok=self.admin_user_tok)
self.reactor.advance(10)
self.helper.create_room_as(user2, tok=user2_token)
self.reactor.advance(10)
self.helper.create_room_as(user1, tok=user1_token)
self.reactor.advance(10)
self.helper.create_room_as(user3, tok=user3_token)
self.reactor.advance(10)

channel = self.make_request(
"GET",
self.url,
access_token=self.admin_user_tok,
)

self.assertEqual(200, channel.code, msg=channel.json_body)
self.assertEqual(4, len(channel.json_body["users"]))
self.assertEqual(4, channel.json_body["total"])

admin_last_seen = channel.json_body["users"][0]["last_seen_ts"]
user1_last_seen = channel.json_body["users"][1]["last_seen_ts"]
user2_last_seen = channel.json_body["users"][2]["last_seen_ts"]
user3_last_seen = channel.json_body["users"][3]["last_seen_ts"]
self.assertTrue(admin_last_seen > 0 and admin_last_seen < 10000)
self.assertTrue(user2_last_seen > 10000 and user2_last_seen < 20000)
self.assertTrue(user1_last_seen > 20000 and user1_last_seen < 30000)
self.assertTrue(user3_last_seen > 30000 and user3_last_seen < 40000)

self._order_test([self.admin_user, user2, user1, user3], "last_seen_ts")

self.reactor.advance(LAST_SEEN_GRANULARITY / 1000)
self.helper.create_room_as(user1, tok=user1_token)
self.reactor.advance(10)

channel = self.make_request(
"GET",
self.url + "/" + user1,
access_token=self.admin_user_tok,
)
self.assertTrue(
channel.json_body["last_seen_ts"] > 40000 + LAST_SEEN_GRANULARITY
)

self._order_test([self.admin_user, user2, user3, user1], "last_seen_ts")

def test_search_term(self) -> None:
"""Test that searching for a users works correctly"""

Expand Down Expand Up @@ -1135,6 +1193,7 @@ def _check_fields(self, content: List[JsonDict]) -> None:
self.assertIn("displayname", u)
self.assertIn("avatar_url", u)
self.assertIn("creation_ts", u)
self.assertIn("last_seen_ts", u)

def _create_users(self, number_users: int) -> None:
"""
Expand Down Expand Up @@ -3035,6 +3094,7 @@ def _check_fields(self, content: JsonDict) -> None:
self.assertIn("consent_version", content)
self.assertIn("consent_ts", content)
self.assertIn("external_ids", content)
self.assertIn("last_seen_ts", content)

# This key was removed intentionally. Ensure it is not accidentally re-included.
self.assertNotIn("password_hash", content)
Expand Down
1 change: 1 addition & 0 deletions tests/storage/test_registration.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ def test_register(self) -> None:
"locked": 0,
"shadow_banned": 0,
"approved": 1,
"last_seen_ts": None,
},
(self.get_success(self.store.get_user_by_id(self.user_id))),
)
Expand Down
Loading