mirror of https://github.com/zulip/zulip.git
hotspot: Add backend changes for non-intro hotspots.
This commit introduces non-intro hotspots. They are a bit different than intro hotspots in the following ways: * All the non-intro hotspots are sent at once instead of sending them one by one like intro hotspots. * They only activate when a specific event occurs, unlike intro hotspot where they activate after the previous hotspot is read.
This commit is contained in:
parent
0c159c5f47
commit
8d633cc368
|
@ -20,6 +20,12 @@ format used by the Zulip server that they are interacting with.
|
|||
|
||||
## Changes in Zulip 8.0
|
||||
|
||||
**Feature level 230**
|
||||
|
||||
* [`GET /events`](/api/get-events): Added `has_trigger` field in
|
||||
hotspots events to identify if a hotspot will activate only when
|
||||
some specific event occurs.
|
||||
|
||||
**Feature level 229**
|
||||
|
||||
* [`PATCH /messages/{message_id}`](/api/update-message), [`POST
|
||||
|
|
|
@ -33,7 +33,7 @@ DESKTOP_WARNING_VERSION = "5.9.3"
|
|||
# Changes should be accompanied by documentation explaining what the
|
||||
# new level means in api_docs/changelog.md, as well as "**Changes**"
|
||||
# entries in the endpoint's documentation in `zulip.yaml`.
|
||||
API_FEATURE_LEVEL = 229
|
||||
API_FEATURE_LEVEL = 230
|
||||
|
||||
# Bump the minor PROVISION_VERSION to indicate that folks should provision
|
||||
# only when going from an old version of the code to a newer version. Bump
|
||||
|
|
|
@ -177,12 +177,14 @@ exports.fixtures = {
|
|||
title: "About topics",
|
||||
description: "Topics are good.",
|
||||
delay: 1.5,
|
||||
has_trigger: false,
|
||||
},
|
||||
{
|
||||
name: "compose",
|
||||
title: "Compose box",
|
||||
description: "This is where you compose messages.",
|
||||
delay: 3.14159,
|
||||
has_trigger: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
|
@ -338,6 +338,7 @@ _hotspot = DictType(
|
|||
("title", str),
|
||||
("description", str),
|
||||
("delay", NumberType()),
|
||||
("has_trigger", bool),
|
||||
]
|
||||
)
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
# See https://zulip.readthedocs.io/en/latest/subsystems/hotspots.html
|
||||
# for documentation on this subsystem.
|
||||
from typing import Dict, List
|
||||
from typing import Dict, List, Union
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils.translation import gettext_lazy
|
||||
|
@ -8,7 +8,7 @@ from django_stubs_ext import StrPromise
|
|||
|
||||
from zerver.models import UserHotspot, UserProfile
|
||||
|
||||
INTRO_HOTSPOTS: Dict[str, Dict[str, StrPromise]] = {
|
||||
INTRO_HOTSPOTS: Dict[str, Dict[str, Union[StrPromise, str]]] = {
|
||||
"intro_streams": {
|
||||
"title": gettext_lazy("Catch up on a stream"),
|
||||
"description": gettext_lazy(
|
||||
|
@ -39,11 +39,21 @@ INTRO_HOTSPOTS: Dict[str, Dict[str, StrPromise]] = {
|
|||
},
|
||||
}
|
||||
|
||||
|
||||
NON_INTRO_HOTSPOTS: Dict[str, Dict[str, Union[StrPromise, str]]] = {}
|
||||
|
||||
# We would most likely implement new hotspots in the future that aren't
|
||||
# a part of the initial tutorial. To that end, classifying them into
|
||||
# categories which are aggregated in ALL_HOTSPOTS, seems like a good start.
|
||||
ALL_HOTSPOTS: Dict[str, Dict[str, StrPromise]] = {
|
||||
**INTRO_HOTSPOTS,
|
||||
ALL_HOTSPOTS: Dict[str, Dict[str, Union[StrPromise, str, bool]]] = {
|
||||
**{
|
||||
hotspot_name: {**INTRO_HOTSPOTS[hotspot_name], "has_trigger": False}
|
||||
for hotspot_name in INTRO_HOTSPOTS
|
||||
},
|
||||
**{
|
||||
hotspot_name: {**NON_INTRO_HOTSPOTS[hotspot_name], "has_trigger": True} # type: ignore[arg-type] # reason: Its a temporary hack
|
||||
for hotspot_name in NON_INTRO_HOTSPOTS
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
@ -59,38 +69,61 @@ def get_next_hotspots(user: UserProfile) -> List[Dict[str, object]]:
|
|||
if settings.ALWAYS_SEND_ALL_HOTSPOTS:
|
||||
return [
|
||||
{
|
||||
"name": hotspot,
|
||||
"title": str(ALL_HOTSPOTS[hotspot]["title"]),
|
||||
"description": str(ALL_HOTSPOTS[hotspot]["description"]),
|
||||
**base_hotspot,
|
||||
"name": name,
|
||||
"title": str(base_hotspot["title"]),
|
||||
"description": str(base_hotspot["description"]),
|
||||
"delay": 0,
|
||||
}
|
||||
for hotspot in ALL_HOTSPOTS
|
||||
for name, base_hotspot in ALL_HOTSPOTS.items()
|
||||
]
|
||||
|
||||
# If a Zulip server has disabled the tutorial, never send hotspots.
|
||||
if not settings.TUTORIAL_ENABLED:
|
||||
return []
|
||||
|
||||
if user.tutorial_status == UserProfile.TUTORIAL_FINISHED:
|
||||
return []
|
||||
|
||||
seen_hotspots = frozenset(
|
||||
UserHotspot.objects.filter(user=user).values_list("hotspot", flat=True)
|
||||
)
|
||||
for hotspot in INTRO_HOTSPOTS:
|
||||
if hotspot not in seen_hotspots:
|
||||
return [
|
||||
{
|
||||
"name": hotspot,
|
||||
"title": str(INTRO_HOTSPOTS[hotspot]["title"]),
|
||||
"description": str(INTRO_HOTSPOTS[hotspot]["description"]),
|
||||
"delay": 0.5,
|
||||
|
||||
hotspots = []
|
||||
|
||||
for name, base_hotspot in NON_INTRO_HOTSPOTS.items():
|
||||
if name in seen_hotspots:
|
||||
continue
|
||||
|
||||
hotspot = {
|
||||
**base_hotspot,
|
||||
"name": name,
|
||||
"title": str(base_hotspot["title"]),
|
||||
"description": str(base_hotspot["description"]),
|
||||
"delay": 0,
|
||||
"has_trigger": True,
|
||||
}
|
||||
]
|
||||
hotspots.append(hotspot)
|
||||
|
||||
if user.tutorial_status == UserProfile.TUTORIAL_FINISHED:
|
||||
return hotspots
|
||||
|
||||
for name, base_hotspot in INTRO_HOTSPOTS.items():
|
||||
if name in seen_hotspots:
|
||||
continue
|
||||
|
||||
# Make a copy to set delay and finalize i18n strings.
|
||||
hotspot = {
|
||||
**base_hotspot,
|
||||
"name": name,
|
||||
"title": str(base_hotspot["title"]),
|
||||
"description": str(base_hotspot["description"]),
|
||||
"delay": 0.5,
|
||||
"has_trigger": False,
|
||||
}
|
||||
hotspots.append(hotspot)
|
||||
return hotspots
|
||||
|
||||
user.tutorial_status = UserProfile.TUTORIAL_FINISHED
|
||||
user.save(update_fields=["tutorial_status"])
|
||||
return []
|
||||
return hotspots
|
||||
|
||||
|
||||
def copy_hotspots(source_profile: UserProfile, target_profile: UserProfile) -> None:
|
||||
|
|
|
@ -2050,6 +2050,10 @@ class UserProfile(AbstractBaseUser, PermissionsMixin, UserBaseSettings): # type
|
|||
avatar_version = models.PositiveSmallIntegerField(default=1)
|
||||
avatar_hash = models.CharField(null=True, max_length=64)
|
||||
|
||||
# TODO: TUTORIAL_STATUS was originally an optimization designed to
|
||||
# allow us to skip querying the UserHotspot table when loading
|
||||
# /. This optimization is no longer effective, so it's possible we
|
||||
# should delete it.
|
||||
TUTORIAL_WAITING = "W"
|
||||
TUTORIAL_STARTED = "S"
|
||||
TUTORIAL_FINISHED = "F"
|
||||
|
|
|
@ -2286,6 +2286,7 @@ paths:
|
|||
"title": "Catch up on a stream",
|
||||
"description": "Messages sent to a stream are seen by everyone subscribed to that stream. Try clicking on one of the stream links below.",
|
||||
"delay": 0.5,
|
||||
"has_trigger": false,
|
||||
},
|
||||
],
|
||||
"id": 0,
|
||||
|
@ -18496,6 +18497,13 @@ components:
|
|||
description: |
|
||||
The description of the hotspot, as will be displayed to the
|
||||
user.
|
||||
has_trigger:
|
||||
type: boolean
|
||||
description: |
|
||||
The has_trigger is used to identify if the hotspot will activate
|
||||
only when some specific event occurs.
|
||||
|
||||
**Changes**: New in Zulip 8.0 (feature level 230).
|
||||
RealmEmoji:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
|
|
|
@ -1249,7 +1249,7 @@ class FetchQueriesTest(ZulipTestCase):
|
|||
|
||||
self.login_user(user)
|
||||
|
||||
with self.assert_database_query_count(39):
|
||||
with self.assert_database_query_count(40):
|
||||
with mock.patch("zerver.lib.events.always_want") as want_mock:
|
||||
fetch_initial_state_data(user)
|
||||
|
||||
|
@ -1259,7 +1259,7 @@ class FetchQueriesTest(ZulipTestCase):
|
|||
default_streams=1,
|
||||
default_stream_groups=1,
|
||||
drafts=1,
|
||||
hotspots=0,
|
||||
hotspots=1,
|
||||
message=1,
|
||||
muted_topics=1,
|
||||
muted_users=1,
|
||||
|
|
|
@ -256,7 +256,7 @@ class HomeTest(ZulipTestCase):
|
|||
self.client_post("/json/bots", bot_info)
|
||||
|
||||
# Verify succeeds once logged-in
|
||||
with self.assert_database_query_count(49):
|
||||
with self.assert_database_query_count(50):
|
||||
with patch("zerver.lib.cache.cache_set") as cache_mock:
|
||||
result = self._get_home_page(stream="Denmark")
|
||||
self.check_rendered_logged_in_app(result)
|
||||
|
@ -445,7 +445,7 @@ class HomeTest(ZulipTestCase):
|
|||
def test_num_queries_for_realm_admin(self) -> None:
|
||||
# Verify number of queries for Realm admin isn't much higher than for normal users.
|
||||
self.login("iago")
|
||||
with self.assert_database_query_count(50):
|
||||
with self.assert_database_query_count(51):
|
||||
with patch("zerver.lib.cache.cache_set") as cache_mock:
|
||||
result = self._get_home_page()
|
||||
self.check_rendered_logged_in_app(result)
|
||||
|
@ -476,7 +476,7 @@ class HomeTest(ZulipTestCase):
|
|||
self._get_home_page()
|
||||
|
||||
# Then for the second page load, measure the number of queries.
|
||||
with self.assert_database_query_count(44):
|
||||
with self.assert_database_query_count(45):
|
||||
result = self._get_home_page()
|
||||
|
||||
# Do a sanity check that our new streams were in the payload.
|
||||
|
|
|
@ -2,7 +2,7 @@ from typing_extensions import override
|
|||
|
||||
from zerver.actions.create_user import do_create_user
|
||||
from zerver.actions.hotspots import do_mark_hotspot_as_read
|
||||
from zerver.lib.hotspots import ALL_HOTSPOTS, INTRO_HOTSPOTS, get_next_hotspots
|
||||
from zerver.lib.hotspots import ALL_HOTSPOTS, INTRO_HOTSPOTS, NON_INTRO_HOTSPOTS, get_next_hotspots
|
||||
from zerver.lib.test_classes import ZulipTestCase
|
||||
from zerver.models import UserHotspot, UserProfile, get_realm
|
||||
|
||||
|
@ -22,6 +22,22 @@ class TestGetNextHotspots(ZulipTestCase):
|
|||
self.assert_length(hotspots, 1)
|
||||
self.assertEqual(hotspots[0]["name"], "intro_streams")
|
||||
|
||||
# Remove this test once we have a non-intro hotspot.
|
||||
def test_non_intro_hotspots(self) -> None:
|
||||
NON_INTRO_HOTSPOTS["dummy_hotspot"] = {
|
||||
"title": "Dummy Hotspot",
|
||||
"description": "This is a dummy hotspot",
|
||||
}
|
||||
hotspots = get_next_hotspots(self.user)
|
||||
self.assert_length(hotspots, 2)
|
||||
self.assertEqual(hotspots[0]["name"], "dummy_hotspot")
|
||||
self.assertEqual(hotspots[1]["name"], "intro_streams")
|
||||
do_mark_hotspot_as_read(self.user, "dummy_hotspot")
|
||||
hotspots = get_next_hotspots(self.user)
|
||||
self.assert_length(hotspots, 1)
|
||||
self.assertEqual(hotspots[0]["name"], "intro_streams")
|
||||
NON_INTRO_HOTSPOTS.pop("dummy_hotspot")
|
||||
|
||||
def test_some_done_some_not(self) -> None:
|
||||
do_mark_hotspot_as_read(self.user, "intro_streams")
|
||||
do_mark_hotspot_as_read(self.user, "intro_compose")
|
||||
|
@ -29,11 +45,16 @@ class TestGetNextHotspots(ZulipTestCase):
|
|||
self.assert_length(hotspots, 1)
|
||||
self.assertEqual(hotspots[0]["name"], "intro_topics")
|
||||
|
||||
def test_all_intro_hotspots_done(self) -> None:
|
||||
def test_all_hotspots_done(self) -> None:
|
||||
with self.settings(TUTORIAL_ENABLED=True):
|
||||
self.assertNotEqual(self.user.tutorial_status, UserProfile.TUTORIAL_FINISHED)
|
||||
for hotspot in NON_INTRO_HOTSPOTS:
|
||||
do_mark_hotspot_as_read(self.user, hotspot)
|
||||
|
||||
self.assertNotEqual(self.user.tutorial_status, UserProfile.TUTORIAL_FINISHED)
|
||||
for hotspot in INTRO_HOTSPOTS:
|
||||
do_mark_hotspot_as_read(self.user, hotspot)
|
||||
|
||||
self.assertEqual(self.user.tutorial_status, UserProfile.TUTORIAL_FINISHED)
|
||||
self.assertEqual(get_next_hotspots(self.user), [])
|
||||
|
||||
|
|
Loading…
Reference in New Issue