Skip to content

Commit d582db7

Browse files
aapelivclaude
andcommitted
Add GetDashboardV2 RPC to aggregate dashboard content
The dashboard fires ~6 separate RPCs on load (reminders, surfing/hosting host requests, my events, community events, community discussions). Add a single GetDashboardV2 RPC on a new Dashboard service that returns all of them in one call, removing the per-request round-trip and interceptor overhead. The servicer methods that power each section take no instance state, so the aggregator just instantiates the existing servicers and calls their methods directly — no logic is duplicated or moved. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 69f4cde commit d582db7

5 files changed

Lines changed: 259 additions & 0 deletions

File tree

app/backend/src/couchers/server.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
bugs_pb2_grpc,
2222
communities_pb2_grpc,
2323
conversations_pb2_grpc,
24+
dashboard_pb2_grpc,
2425
discussions_pb2_grpc,
2526
donations_pb2_grpc,
2627
editor_pb2_grpc,
@@ -53,6 +54,7 @@
5354
from couchers.servicers.bugs import Bugs
5455
from couchers.servicers.communities import Communities
5556
from couchers.servicers.conversations import Conversations
57+
from couchers.servicers.dashboard import Dashboard
5658
from couchers.servicers.discussions import Discussions
5759
from couchers.servicers.donations import Donations, Stripe
5860
from couchers.servicers.editor import Editor
@@ -108,6 +110,7 @@ def create_main_server(port: int, start_resource_sampler: bool = False) -> grpc.
108110
bugs_pb2_grpc.add_BugsServicer_to_server(Bugs(), server)
109111
communities_pb2_grpc.add_CommunitiesServicer_to_server(Communities(), server)
110112
conversations_pb2_grpc.add_ConversationsServicer_to_server(Conversations(), server)
113+
dashboard_pb2_grpc.add_DashboardServicer_to_server(Dashboard(), server)
111114
discussions_pb2_grpc.add_DiscussionsServicer_to_server(Discussions(), server)
112115
donations_pb2_grpc.add_DonationsServicer_to_server(Donations(), server)
113116
editor_pb2_grpc.add_EditorServicer_to_server(Editor(), server)
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
from google.protobuf import empty_pb2
2+
from sqlalchemy.orm import Session
3+
4+
from couchers.context import CouchersContext
5+
from couchers.proto import (
6+
conversations_pb2,
7+
dashboard_pb2,
8+
dashboard_pb2_grpc,
9+
discussions_pb2,
10+
events_pb2,
11+
requests_pb2,
12+
)
13+
from couchers.servicers.account import Account
14+
from couchers.servicers.discussions import Discussions
15+
from couchers.servicers.events import Events
16+
from couchers.servicers.requests import Requests
17+
18+
# the dashboard shows a small preview of each section
19+
DASHBOARD_PAGE_SIZE = 3
20+
21+
22+
class Dashboard(dashboard_pb2_grpc.DashboardServicer):
23+
def GetDashboardV2(
24+
self, request: dashboard_pb2.GetDashboardV2Req, context: CouchersContext, session: Session
25+
) -> dashboard_pb2.GetDashboardV2Res:
26+
return dashboard_pb2.GetDashboardV2Res(
27+
reminders=Account().GetReminders(empty_pb2.Empty(), context, session),
28+
surfing=Requests().ListHostRequests(
29+
requests_pb2.ListHostRequestsReq(
30+
only_sent=True,
31+
only_active=True,
32+
status_in=[
33+
conversations_pb2.HOST_REQUEST_STATUS_ACCEPTED,
34+
conversations_pb2.HOST_REQUEST_STATUS_CONFIRMED,
35+
],
36+
sort_by=requests_pb2.HOST_REQUEST_SORT_BY_FROM_DATE,
37+
),
38+
context,
39+
session,
40+
),
41+
hosting=Requests().ListHostRequests(
42+
requests_pb2.ListHostRequestsReq(
43+
only_received=True,
44+
only_active=True,
45+
status_in=[
46+
conversations_pb2.HOST_REQUEST_STATUS_ACCEPTED,
47+
conversations_pb2.HOST_REQUEST_STATUS_CONFIRMED,
48+
],
49+
sort_by=requests_pb2.HOST_REQUEST_SORT_BY_FROM_DATE,
50+
),
51+
context,
52+
session,
53+
),
54+
my_events=Events().ListMyEvents(
55+
events_pb2.ListMyEventsReq(page_size=DASHBOARD_PAGE_SIZE),
56+
context,
57+
session,
58+
),
59+
community_events=Events().ListMyEvents(
60+
events_pb2.ListMyEventsReq(
61+
page_size=DASHBOARD_PAGE_SIZE,
62+
my_communities=True,
63+
my_communities_exclude_global=True,
64+
),
65+
context,
66+
session,
67+
),
68+
discussions=Discussions().ListMyCommunitiesDiscussions(
69+
discussions_pb2.ListMyCommunitiesDiscussionsReq(page_size=DASHBOARD_PAGE_SIZE),
70+
context,
71+
session,
72+
),
73+
)

app/backend/src/tests/fixtures/sessions.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
bugs_pb2_grpc,
2828
communities_pb2_grpc,
2929
conversations_pb2_grpc,
30+
dashboard_pb2_grpc,
3031
discussions_pb2_grpc,
3132
donations_pb2_grpc,
3233
editor_pb2_grpc,
@@ -59,6 +60,7 @@
5960
from couchers.servicers.bugs import Bugs
6061
from couchers.servicers.communities import Communities
6162
from couchers.servicers.conversations import Conversations
63+
from couchers.servicers.dashboard import Dashboard
6264
from couchers.servicers.discussions import Discussions
6365
from couchers.servicers.donations import Donations, Stripe
6466
from couchers.servicers.editor import Editor
@@ -417,6 +419,13 @@ def discussions_session(token: str):
417419
yield discussions_pb2_grpc.DiscussionsStub(channel)
418420

419421

422+
@contextmanager
423+
def dashboard_session(token: str):
424+
channel = FakeChannel(token)
425+
dashboard_pb2_grpc.add_DashboardServicer_to_server(Dashboard(), channel)
426+
yield dashboard_pb2_grpc.DashboardStub(channel)
427+
428+
420429
@contextmanager
421430
def donations_session(token: str):
422431
channel = FakeChannel(token)
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
from datetime import timedelta
2+
3+
import pytest
4+
from google.protobuf import empty_pb2
5+
6+
from couchers.constants import HOST_REQUEST_MIN_LENGTH_UTF16
7+
from couchers.proto import conversations_pb2, dashboard_pb2, discussions_pb2, events_pb2, requests_pb2
8+
from couchers.utils import today
9+
from tests.fixtures.db import generate_user
10+
from tests.fixtures.sessions import (
11+
account_session,
12+
dashboard_session,
13+
discussions_session,
14+
events_session,
15+
requests_session,
16+
)
17+
18+
19+
@pytest.fixture(autouse=True)
20+
def _(testconfig):
21+
pass
22+
23+
24+
UPCOMING_STATUSES = [
25+
conversations_pb2.HOST_REQUEST_STATUS_ACCEPTED,
26+
conversations_pb2.HOST_REQUEST_STATUS_CONFIRMED,
27+
]
28+
29+
30+
def valid_request_text(text: str = "Test request") -> str:
31+
"""Pads a request text to a valid length (measured in utf-16 code units, matching the frontend)."""
32+
utf16_length = len(text.encode("utf-16-le")) // 2
33+
if utf16_length >= HOST_REQUEST_MIN_LENGTH_UTF16:
34+
return text
35+
return text + ("_" * (HOST_REQUEST_MIN_LENGTH_UTF16 - utf16_length))
36+
37+
38+
def _setup_accepted_host_request(token_surfer, host_user_id, moderator):
39+
from_date = today() + timedelta(days=2)
40+
to_date = today() + timedelta(days=3)
41+
with requests_session(token_surfer) as api:
42+
host_request_id = api.CreateHostRequest(
43+
requests_pb2.CreateHostRequestReq(
44+
host_user_id=host_user_id,
45+
from_date=from_date.isoformat(),
46+
to_date=to_date.isoformat(),
47+
text=valid_request_text(),
48+
)
49+
).host_request_id
50+
moderator.approve_host_request(host_request_id)
51+
return host_request_id
52+
53+
54+
def test_GetDashboardV2_matches_individual_rpcs(db, moderator):
55+
user1, token1 = generate_user()
56+
user2, token2 = generate_user()
57+
58+
host_request_id = _setup_accepted_host_request(token1, user2.id, moderator)
59+
with requests_session(token2) as api:
60+
api.RespondHostRequest(
61+
requests_pb2.RespondHostRequestReq(
62+
host_request_id=host_request_id,
63+
status=conversations_pb2.HOST_REQUEST_STATUS_ACCEPTED,
64+
text="Sure, come on over!",
65+
)
66+
)
67+
68+
# the dashboard response must be identical to fanning out to the individual RPCs with the
69+
# same parameters the web frontend uses
70+
with requests_session(token1) as api:
71+
surfing = api.ListHostRequests(
72+
requests_pb2.ListHostRequestsReq(
73+
only_sent=True,
74+
only_active=True,
75+
status_in=UPCOMING_STATUSES,
76+
sort_by=requests_pb2.HOST_REQUEST_SORT_BY_FROM_DATE,
77+
)
78+
)
79+
hosting = api.ListHostRequests(
80+
requests_pb2.ListHostRequestsReq(
81+
only_received=True,
82+
only_active=True,
83+
status_in=UPCOMING_STATUSES,
84+
sort_by=requests_pb2.HOST_REQUEST_SORT_BY_FROM_DATE,
85+
)
86+
)
87+
with events_session(token1) as api:
88+
my_events = api.ListMyEvents(events_pb2.ListMyEventsReq(page_size=3))
89+
community_events = api.ListMyEvents(
90+
events_pb2.ListMyEventsReq(page_size=3, my_communities=True, my_communities_exclude_global=True)
91+
)
92+
with discussions_session(token1) as api:
93+
discussions = api.ListMyCommunitiesDiscussions(discussions_pb2.ListMyCommunitiesDiscussionsReq(page_size=3))
94+
with account_session(token1) as api:
95+
reminders = api.GetReminders(empty_pb2.Empty())
96+
97+
with dashboard_session(token1) as api:
98+
res = api.GetDashboardV2(dashboard_pb2.GetDashboardV2Req())
99+
100+
assert res.reminders == reminders
101+
assert res.surfing == surfing
102+
assert res.hosting == hosting
103+
assert res.my_events == my_events
104+
assert res.community_events == community_events
105+
assert res.discussions == discussions
106+
107+
# the surfer sees their upcoming trip under surfing, nothing under hosting
108+
assert len(res.surfing.host_requests) == 1
109+
assert res.surfing.host_requests[0].host_request_id == host_request_id
110+
assert res.surfing.host_requests[0].status == conversations_pb2.HOST_REQUEST_STATUS_ACCEPTED
111+
assert len(res.hosting.host_requests) == 0
112+
113+
114+
def test_GetDashboardV2_buckets_by_role(db, moderator):
115+
user1, token1 = generate_user()
116+
user2, token2 = generate_user()
117+
118+
host_request_id = _setup_accepted_host_request(token1, user2.id, moderator)
119+
with requests_session(token2) as api:
120+
api.RespondHostRequest(
121+
requests_pb2.RespondHostRequestReq(
122+
host_request_id=host_request_id,
123+
status=conversations_pb2.HOST_REQUEST_STATUS_ACCEPTED,
124+
text="Sure, come on over!",
125+
)
126+
)
127+
128+
# the host sees the upcoming stay under hosting, nothing under surfing
129+
with dashboard_session(token2) as api:
130+
res = api.GetDashboardV2(dashboard_pb2.GetDashboardV2Req())
131+
assert len(res.hosting.host_requests) == 1
132+
assert res.hosting.host_requests[0].host_request_id == host_request_id
133+
assert len(res.surfing.host_requests) == 0
134+
135+
136+
def test_GetDashboardV2_empty(db):
137+
user, token = generate_user()
138+
with dashboard_session(token) as api:
139+
res = api.GetDashboardV2(dashboard_pb2.GetDashboardV2Req())
140+
assert len(res.surfing.host_requests) == 0
141+
assert len(res.hosting.host_requests) == 0
142+
assert len(res.my_events.events) == 0
143+
assert len(res.community_events.events) == 0
144+
assert len(res.discussions.discussions) == 0
145+
assert len(res.reminders.reminders) == 0

app/proto/dashboard.proto

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
syntax = "proto3";
2+
3+
package org.couchers.api.dashboard;
4+
5+
import "annotations.proto";
6+
import "account.proto";
7+
import "discussions.proto";
8+
import "events.proto";
9+
import "requests.proto";
10+
11+
service Dashboard {
12+
option (auth_level) = AUTH_LEVEL_SECURE;
13+
14+
rpc GetDashboardV2(GetDashboardV2Req) returns (GetDashboardV2Res) {
15+
// Fetch all the content needed to render the dashboard in a single call, instead of fanning out
16+
// to the individual RPCs. Each field mirrors the response of the RPC that previously populated it.
17+
}
18+
}
19+
20+
message GetDashboardV2Req {}
21+
22+
message GetDashboardV2Res {
23+
org.couchers.api.account.GetRemindersRes reminders = 1;
24+
org.couchers.api.requests.ListHostRequestsRes surfing = 2;
25+
org.couchers.api.requests.ListHostRequestsRes hosting = 3;
26+
org.couchers.api.events.ListMyEventsRes my_events = 4;
27+
org.couchers.api.events.ListMyEventsRes community_events = 5;
28+
org.couchers.api.discussions.ListMyCommunitiesDiscussionsRes discussions = 6;
29+
}

0 commit comments

Comments
 (0)