snapshot of https://github.com/zulip/zulip.git @ 48c69911c7dd4e03b20552eb3bd77ad8151c1357 (2026-09-15)
Some checks failed
Code scanning / CodeQL (push) Failing after 15s
Zulip production suite / Ubuntu 22.04 production build (push) Failing after 26s
Zulip production suite / Debian 12 production install with custom db name and user (push) Has been skipped
Zulip production suite / Ubuntu 22.04 production install and PostgreSQL upgrade with pgroonga (push) Has been skipped
Zulip production suite / Ubuntu 24.04 production install (push) Has been skipped
Zulip production suite / Ubuntu 26.04 production install (push) Has been skipped
Zulip production suite / Debian 13 production install (push) Has been skipped
Zulip production suite / 7.0 Version Upgrade (push) Has been skipped
Zulip production suite / 8.0 Version Upgrade (push) Has been skipped
Zulip production suite / 6.0 Version Upgrade (push) Has been skipped
Zulip production suite / 10.0 Version Upgrade (push) Has been skipped
Zulip production suite / 9.0 Version Upgrade (push) Has been skipped
Zulip production suite / 12.0 Version Upgrade (push) Has been skipped
Zulip production suite / 11.0 Version Upgrade (push) Has been skipped
GitHub Actions Security Analysis with zizmor / zizmor (push) Failing after 3s
Zulip CI / Debian 12 (Python 3.11, backend + documentation) (push) Failing after 26s
Zulip CI / Ubuntu 22.04 (Python 3.10, backend + frontend) (push) Failing after 3s
Zulip CI / Ubuntu 24.04 (Python 3.12, backend) (push) Failing after 25s
Zulip CI / Ubuntu 26.04 (Python 3.14, backend) (push) Failing after 25s
Zulip CI / Debian 13 (Python 3.13, backend) (push) Failing after 28s
Zulip production suite / Required jobs (push) Has been cancelled
Zulip CI / Required jobs (push) Has been cancelled
API Documentation Update Check / check-feature-level-updated (push) Has been cancelled
API Documentation Update Check / notify-if-api-docs-changed (push) Has been cancelled
Some checks failed
Code scanning / CodeQL (push) Failing after 15s
Zulip production suite / Ubuntu 22.04 production build (push) Failing after 26s
Zulip production suite / Debian 12 production install with custom db name and user (push) Has been skipped
Zulip production suite / Ubuntu 22.04 production install and PostgreSQL upgrade with pgroonga (push) Has been skipped
Zulip production suite / Ubuntu 24.04 production install (push) Has been skipped
Zulip production suite / Ubuntu 26.04 production install (push) Has been skipped
Zulip production suite / Debian 13 production install (push) Has been skipped
Zulip production suite / 7.0 Version Upgrade (push) Has been skipped
Zulip production suite / 8.0 Version Upgrade (push) Has been skipped
Zulip production suite / 6.0 Version Upgrade (push) Has been skipped
Zulip production suite / 10.0 Version Upgrade (push) Has been skipped
Zulip production suite / 9.0 Version Upgrade (push) Has been skipped
Zulip production suite / 12.0 Version Upgrade (push) Has been skipped
Zulip production suite / 11.0 Version Upgrade (push) Has been skipped
GitHub Actions Security Analysis with zizmor / zizmor (push) Failing after 3s
Zulip CI / Debian 12 (Python 3.11, backend + documentation) (push) Failing after 26s
Zulip CI / Ubuntu 22.04 (Python 3.10, backend + frontend) (push) Failing after 3s
Zulip CI / Ubuntu 24.04 (Python 3.12, backend) (push) Failing after 25s
Zulip CI / Ubuntu 26.04 (Python 3.14, backend) (push) Failing after 25s
Zulip CI / Debian 13 (Python 3.13, backend) (push) Failing after 28s
Zulip production suite / Required jobs (push) Has been cancelled
Zulip CI / Required jobs (push) Has been cancelled
API Documentation Update Check / check-feature-level-updated (push) Has been cancelled
API Documentation Update Check / notify-if-api-docs-changed (push) Has been cancelled
This commit is contained in:
279
zerver/lib/presence.py
Normal file
279
zerver/lib/presence.py
Normal file
@@ -0,0 +1,279 @@
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from collections.abc import Mapping, Sequence
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils.timezone import now as timezone_now
|
||||
|
||||
from zerver.lib.timestamp import datetime_to_timestamp
|
||||
from zerver.lib.users import check_user_can_access_all_users, get_accessible_user_ids
|
||||
from zerver.models import Realm, UserPresence, UserProfile
|
||||
|
||||
|
||||
def get_presence_dicts_for_rows(
|
||||
all_rows: Sequence[Mapping[str, Any]], slim_presence: bool
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
# This function takes the presence data fetched from the database and
|
||||
# turn it into an appropriate format for the API to return to clients.
|
||||
# Used by the endpoints that fetch presence data:
|
||||
# 1) `POST /users/me/presence`: https://zulip.com/api/update-presence
|
||||
# 2) `POST /register` when presence data is requested:
|
||||
# https://zulip.com/api/register-queue
|
||||
# 3) `GET /users/{user_id_or_email}/presence`: https://zulip.com/api/get-user-presence
|
||||
if slim_presence:
|
||||
# Stringify user_id here, since it's gonna be turned
|
||||
# into a string anyway by JSON, and it keeps mypy happy.
|
||||
get_user_key = lambda row: str(row["user_profile_id"])
|
||||
get_user_presence_info = get_modern_user_presence_info
|
||||
else:
|
||||
get_user_key = lambda row: row["user_profile__email"]
|
||||
get_user_presence_info = get_legacy_user_presence_info
|
||||
|
||||
user_statuses: dict[str, dict[str, Any]] = {}
|
||||
|
||||
for presence_row in all_rows:
|
||||
user_key = get_user_key(presence_row)
|
||||
|
||||
last_active_time = user_presence_datetime_with_date_joined_default(
|
||||
presence_row["last_active_time"], presence_row["user_profile__date_joined"]
|
||||
)
|
||||
last_connected_time = user_presence_datetime_with_date_joined_default(
|
||||
presence_row["last_connected_time"], presence_row["user_profile__date_joined"]
|
||||
)
|
||||
|
||||
info = get_user_presence_info(
|
||||
last_active_time,
|
||||
last_connected_time,
|
||||
)
|
||||
user_statuses[user_key] = info
|
||||
|
||||
return user_statuses
|
||||
|
||||
|
||||
def user_presence_datetime_with_date_joined_default(
|
||||
dt: datetime | None, date_joined: datetime
|
||||
) -> datetime:
|
||||
"""
|
||||
Our data models support UserPresence objects not having None
|
||||
values for last_active_time/last_connected_time. The legacy API
|
||||
however has always sent timestamps, so for backward
|
||||
compatibility we cannot send such values through the API and need
|
||||
to default to a sane
|
||||
|
||||
This helper functions expects to take a last_active_time or
|
||||
last_connected_time value and the date_joined of the user, which
|
||||
will serve as the default value if the first argument is None.
|
||||
"""
|
||||
if dt is None:
|
||||
return date_joined
|
||||
|
||||
return dt
|
||||
|
||||
|
||||
def get_modern_user_presence_info(
|
||||
last_active_time: datetime, last_connected_time: datetime
|
||||
) -> dict[str, Any]:
|
||||
# TODO: Do further bandwidth optimizations to this structure.
|
||||
result = {}
|
||||
result["active_timestamp"] = datetime_to_timestamp(last_active_time)
|
||||
result["idle_timestamp"] = datetime_to_timestamp(last_connected_time)
|
||||
return result
|
||||
|
||||
|
||||
def get_legacy_user_presence_info(
|
||||
last_active_time: datetime, last_connected_time: datetime
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Reformats the modern UserPresence data structure so that legacy
|
||||
API clients can still access presence data.
|
||||
We expect this code to remain mostly unchanged until we can delete it.
|
||||
"""
|
||||
|
||||
# Now we put things together in the legacy presence format with
|
||||
# one client + an `aggregated` field.
|
||||
#
|
||||
# TODO: Look at whether we can drop to just the "aggregated" field
|
||||
# if no clients look at the rest.
|
||||
most_recent_info = format_legacy_presence_dict(last_active_time, last_connected_time)
|
||||
|
||||
result = {}
|
||||
|
||||
# The word "aggregated" here is possibly misleading.
|
||||
# It's really just the most recent client's info.
|
||||
result["aggregated"] = dict(
|
||||
client=most_recent_info["client"],
|
||||
status=most_recent_info["status"],
|
||||
timestamp=most_recent_info["timestamp"],
|
||||
)
|
||||
|
||||
result["website"] = most_recent_info
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def format_legacy_presence_dict(
|
||||
last_active_time: datetime, last_connected_time: datetime
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
This function assumes it's being called right after the presence object was updated,
|
||||
and is not meant to be used on old presence data.
|
||||
"""
|
||||
if (
|
||||
last_active_time
|
||||
+ timedelta(seconds=settings.PRESENCE_LEGACY_EVENT_OFFSET_FOR_ACTIVITY_SECONDS)
|
||||
>= last_connected_time
|
||||
):
|
||||
status = UserPresence.LEGACY_STATUS_ACTIVE
|
||||
timestamp = datetime_to_timestamp(last_active_time)
|
||||
else:
|
||||
status = UserPresence.LEGACY_STATUS_IDLE
|
||||
timestamp = datetime_to_timestamp(last_connected_time)
|
||||
|
||||
# This field was never used by clients of the legacy API, so we
|
||||
# just set it to a fixed value for API format compatibility.
|
||||
pushable = False
|
||||
|
||||
return dict(client="website", status=status, timestamp=timestamp, pushable=pushable)
|
||||
|
||||
|
||||
def get_presence_for_user(
|
||||
user_profile_id: int, slim_presence: bool = False
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
query = UserPresence.objects.filter(user_profile_id=user_profile_id).values(
|
||||
"last_active_time",
|
||||
"last_connected_time",
|
||||
"user_profile__email",
|
||||
"user_profile_id",
|
||||
"user_profile__enable_offline_push_notifications",
|
||||
"user_profile__date_joined",
|
||||
)
|
||||
presence_rows = list(query)
|
||||
|
||||
return get_presence_dicts_for_rows(presence_rows, slim_presence)
|
||||
|
||||
|
||||
def get_presence_dict_by_realm(
|
||||
realm: Realm,
|
||||
slim_presence: bool = False,
|
||||
last_update_id_fetched_by_client: int | None = None,
|
||||
history_limit_days: int | None = None,
|
||||
requesting_user_profile: UserProfile | None = None,
|
||||
) -> tuple[dict[str, dict[str, Any]], int]:
|
||||
now = timezone_now()
|
||||
if history_limit_days is not None:
|
||||
fetch_since_datetime = now - timedelta(days=history_limit_days)
|
||||
else:
|
||||
# The original behavior for this API was to return last two weeks
|
||||
# of data at most, so we preserve that when the history_limit_days
|
||||
# param is not provided.
|
||||
fetch_since_datetime = now - timedelta(days=14)
|
||||
|
||||
kwargs: dict[str, object] = dict()
|
||||
if last_update_id_fetched_by_client is not None:
|
||||
kwargs["last_update_id__gt"] = last_update_id_fetched_by_client
|
||||
|
||||
if last_update_id_fetched_by_client is None or last_update_id_fetched_by_client <= 0:
|
||||
# If the client already has fetched some presence data, as indicated by
|
||||
# last_update_id_fetched_by_client, then filtering by last_connected_time
|
||||
# is redundant, as it shouldn't affect the results.
|
||||
kwargs["last_connected_time__gte"] = fetch_since_datetime
|
||||
|
||||
if history_limit_days != 0:
|
||||
query = UserPresence.objects.filter(
|
||||
realm_id=realm.id,
|
||||
user_profile__is_active=True,
|
||||
user_profile__is_bot=False,
|
||||
**kwargs,
|
||||
)
|
||||
else:
|
||||
# If history_limit_days is 0, the client doesn't want any presence data.
|
||||
# Explicitly return an empty QuerySet to avoid a query or races which
|
||||
# might cause a UserPresence row to get fetched if it gets updated
|
||||
# during the execution of this function.
|
||||
query = UserPresence.objects.none()
|
||||
|
||||
if settings.CAN_ACCESS_ALL_USERS_GROUP_LIMITS_PRESENCE and not check_user_can_access_all_users(
|
||||
requesting_user_profile
|
||||
):
|
||||
assert requesting_user_profile is not None
|
||||
accessible_user_ids = get_accessible_user_ids(requesting_user_profile)
|
||||
query = query.filter(user_profile_id__in=accessible_user_ids)
|
||||
|
||||
presence_rows = list(
|
||||
query.values(
|
||||
"last_active_time",
|
||||
"last_connected_time",
|
||||
"user_profile__email",
|
||||
"user_profile_id",
|
||||
"user_profile__enable_offline_push_notifications",
|
||||
"user_profile__date_joined",
|
||||
"last_update_id",
|
||||
)
|
||||
)
|
||||
# Get max last_update_id from the list.
|
||||
if presence_rows:
|
||||
last_update_id_fetched_by_server: int | None = max(
|
||||
row["last_update_id"] for row in presence_rows
|
||||
)
|
||||
elif last_update_id_fetched_by_client is not None:
|
||||
# If there are no results, that means that are no new updates to presence
|
||||
# since what the client has last seen. Therefore, returning the same
|
||||
# last_update_id that the client provided is correct.
|
||||
last_update_id_fetched_by_server = last_update_id_fetched_by_client
|
||||
else:
|
||||
# If the client didn't specify a last_update_id, we return -1 to indicate
|
||||
# the lack of any data fetched, while sticking to the convention of
|
||||
# returning an integer.
|
||||
last_update_id_fetched_by_server = -1
|
||||
|
||||
assert last_update_id_fetched_by_server is not None
|
||||
return get_presence_dicts_for_rows(
|
||||
presence_rows, slim_presence
|
||||
), last_update_id_fetched_by_server
|
||||
|
||||
|
||||
def get_presences_for_realm(
|
||||
realm: Realm,
|
||||
slim_presence: bool,
|
||||
last_update_id_fetched_by_client: int | None,
|
||||
history_limit_days: int | None,
|
||||
requesting_user_profile: UserProfile,
|
||||
) -> tuple[dict[str, dict[str, dict[str, Any]]], int]:
|
||||
if realm.presence_disabled: # nocoverage
|
||||
# Return an empty dict if presence is disabled in this realm
|
||||
return defaultdict(dict), -1
|
||||
|
||||
return get_presence_dict_by_realm(
|
||||
realm,
|
||||
slim_presence,
|
||||
last_update_id_fetched_by_client,
|
||||
history_limit_days,
|
||||
requesting_user_profile=requesting_user_profile,
|
||||
)
|
||||
|
||||
|
||||
def get_presence_response(
|
||||
requesting_user_profile: UserProfile,
|
||||
slim_presence: bool,
|
||||
last_update_id_fetched_by_client: int | None = None,
|
||||
history_limit_days: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
realm = requesting_user_profile.realm
|
||||
server_timestamp = time.time()
|
||||
presences, last_update_id_fetched_by_server = get_presences_for_realm(
|
||||
realm,
|
||||
slim_presence,
|
||||
last_update_id_fetched_by_client,
|
||||
history_limit_days,
|
||||
requesting_user_profile=requesting_user_profile,
|
||||
)
|
||||
|
||||
response_dict = dict(
|
||||
presences=presences,
|
||||
server_timestamp=server_timestamp,
|
||||
presence_last_update_id=last_update_id_fetched_by_server,
|
||||
)
|
||||
|
||||
return response_dict
|
||||
Reference in New Issue
Block a user