-
Notifications
You must be signed in to change notification settings - Fork 100
Add GetDashboardV2 RPC to aggregate dashboard content #9147
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
aapeliv
wants to merge
3
commits into
develop
Choose a base branch
from
backend/feature/get-dashboard-v2
base: develop
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,74 @@ | ||
| from google.protobuf import empty_pb2 | ||
| from sqlalchemy.orm import Session | ||
|
|
||
| from couchers.context import CouchersContext | ||
| from couchers.proto import ( | ||
| conversations_pb2, | ||
| dashboard_pb2, | ||
| dashboard_pb2_grpc, | ||
| discussions_pb2, | ||
| events_pb2, | ||
| requests_pb2, | ||
| ) | ||
| from couchers.servicers.account import Account | ||
| from couchers.servicers.discussions import Discussions | ||
| from couchers.servicers.events import Events | ||
| from couchers.servicers.requests import Requests | ||
|
|
||
| # the dashboard shows a small preview of each section | ||
| DASHBOARD_PAGE_SIZE = 3 | ||
|
|
||
|
|
||
| class Dashboard(dashboard_pb2_grpc.DashboardServicer): | ||
| def GetDashboardV2( | ||
| self, request: dashboard_pb2.GetDashboardV2Req, context: CouchersContext, session: Session | ||
| ) -> dashboard_pb2.GetDashboardV2Res: | ||
| return dashboard_pb2.GetDashboardV2Res( | ||
| reminders=Account().GetReminders(empty_pb2.Empty(), context, session), | ||
| surfing=Requests().ListHostRequests( | ||
| requests_pb2.ListHostRequestsReq( | ||
| only_sent=True, | ||
| only_active=True, | ||
| status_in=[ | ||
| conversations_pb2.HOST_REQUEST_STATUS_ACCEPTED, | ||
| conversations_pb2.HOST_REQUEST_STATUS_CONFIRMED, | ||
| ], | ||
| sort_by=requests_pb2.HOST_REQUEST_SORT_BY_FROM_DATE, | ||
| ), | ||
| context, | ||
| session, | ||
| ), | ||
| hosting=Requests().ListHostRequests( | ||
| requests_pb2.ListHostRequestsReq( | ||
| only_received=True, | ||
| only_active=True, | ||
| status_in=[ | ||
| conversations_pb2.HOST_REQUEST_STATUS_ACCEPTED, | ||
| conversations_pb2.HOST_REQUEST_STATUS_CONFIRMED, | ||
| ], | ||
| sort_by=requests_pb2.HOST_REQUEST_SORT_BY_FROM_DATE, | ||
| ), | ||
| context, | ||
| session, | ||
| ), | ||
| my_events=Events().ListMyEvents( | ||
| events_pb2.ListMyEventsReq(page_size=DASHBOARD_PAGE_SIZE), | ||
| context, | ||
| session, | ||
| ), | ||
| community_events=Events().ListMyEvents( | ||
| events_pb2.ListMyEventsReq( | ||
| page_size=DASHBOARD_PAGE_SIZE, | ||
| my_communities=True, | ||
| my_communities_exclude_global=True, | ||
| exclude_attending=True, | ||
| ), | ||
| context, | ||
| session, | ||
| ), | ||
| discussions=Discussions().ListMyCommunitiesDiscussions( | ||
| discussions_pb2.ListMyCommunitiesDiscussionsReq(page_size=DASHBOARD_PAGE_SIZE), | ||
| context, | ||
| session, | ||
| ), | ||
| ) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,197 @@ | ||
| from datetime import timedelta | ||
|
|
||
| import pytest | ||
| from google.protobuf import empty_pb2 | ||
|
|
||
| from couchers.constants import HOST_REQUEST_MIN_LENGTH_UTF16 | ||
| from couchers.db import session_scope | ||
| from couchers.proto import conversations_pb2, dashboard_pb2, discussions_pb2, events_pb2, requests_pb2 | ||
| from couchers.utils import Timestamp_from_datetime, now, today | ||
| from tests.fixtures.db import generate_user | ||
| from tests.fixtures.sessions import ( | ||
| account_session, | ||
| dashboard_session, | ||
| discussions_session, | ||
| events_session, | ||
| requests_session, | ||
| ) | ||
| from tests.test_communities import create_community | ||
|
|
||
|
|
||
| @pytest.fixture(autouse=True) | ||
| def _(testconfig): | ||
| pass | ||
|
|
||
|
|
||
| UPCOMING_STATUSES = [ | ||
| conversations_pb2.HOST_REQUEST_STATUS_ACCEPTED, | ||
| conversations_pb2.HOST_REQUEST_STATUS_CONFIRMED, | ||
| ] | ||
|
|
||
|
|
||
| def valid_request_text(text: str = "Test request") -> str: | ||
| """Pads a request text to a valid length (measured in utf-16 code units, matching the frontend).""" | ||
| utf16_length = len(text.encode("utf-16-le")) // 2 | ||
| if utf16_length >= HOST_REQUEST_MIN_LENGTH_UTF16: | ||
| return text | ||
| return text + ("_" * (HOST_REQUEST_MIN_LENGTH_UTF16 - utf16_length)) | ||
|
|
||
|
|
||
| def _setup_accepted_host_request(token_surfer, host_user_id, moderator): | ||
| from_date = today() + timedelta(days=2) | ||
| to_date = today() + timedelta(days=3) | ||
| with requests_session(token_surfer) as api: | ||
| host_request_id = api.CreateHostRequest( | ||
| requests_pb2.CreateHostRequestReq( | ||
| host_user_id=host_user_id, | ||
| from_date=from_date.isoformat(), | ||
| to_date=to_date.isoformat(), | ||
| text=valid_request_text(), | ||
| ) | ||
| ).host_request_id | ||
| moderator.approve_host_request(host_request_id) | ||
| return host_request_id | ||
|
|
||
|
|
||
| def test_GetDashboardV2_matches_individual_rpcs(db, moderator): | ||
| user1, token1 = generate_user() | ||
| user2, token2 = generate_user() | ||
|
|
||
| host_request_id = _setup_accepted_host_request(token1, user2.id, moderator) | ||
| with requests_session(token2) as api: | ||
| api.RespondHostRequest( | ||
| requests_pb2.RespondHostRequestReq( | ||
| host_request_id=host_request_id, | ||
| status=conversations_pb2.HOST_REQUEST_STATUS_ACCEPTED, | ||
| text="Sure, come on over!", | ||
| ) | ||
| ) | ||
|
|
||
| # the dashboard response must be identical to fanning out to the individual RPCs with the | ||
| # same parameters the web frontend uses | ||
| with requests_session(token1) as api: | ||
| surfing = api.ListHostRequests( | ||
| requests_pb2.ListHostRequestsReq( | ||
| only_sent=True, | ||
| only_active=True, | ||
| status_in=UPCOMING_STATUSES, | ||
| sort_by=requests_pb2.HOST_REQUEST_SORT_BY_FROM_DATE, | ||
| ) | ||
| ) | ||
| hosting = api.ListHostRequests( | ||
| requests_pb2.ListHostRequestsReq( | ||
| only_received=True, | ||
| only_active=True, | ||
| status_in=UPCOMING_STATUSES, | ||
| sort_by=requests_pb2.HOST_REQUEST_SORT_BY_FROM_DATE, | ||
| ) | ||
| ) | ||
| with events_session(token1) as api: | ||
| my_events = api.ListMyEvents(events_pb2.ListMyEventsReq(page_size=3)) | ||
| community_events = api.ListMyEvents( | ||
| events_pb2.ListMyEventsReq( | ||
| page_size=3, my_communities=True, my_communities_exclude_global=True, exclude_attending=True | ||
| ) | ||
| ) | ||
| with discussions_session(token1) as api: | ||
| discussions = api.ListMyCommunitiesDiscussions(discussions_pb2.ListMyCommunitiesDiscussionsReq(page_size=3)) | ||
| with account_session(token1) as api: | ||
| reminders = api.GetReminders(empty_pb2.Empty()) | ||
|
|
||
| with dashboard_session(token1) as api: | ||
| res = api.GetDashboardV2(dashboard_pb2.GetDashboardV2Req()) | ||
|
|
||
| assert res.reminders == reminders | ||
| assert res.surfing == surfing | ||
| assert res.hosting == hosting | ||
| assert res.my_events == my_events | ||
| assert res.community_events == community_events | ||
| assert res.discussions == discussions | ||
|
|
||
| # the surfer sees their upcoming trip under surfing, nothing under hosting | ||
| assert len(res.surfing.host_requests) == 1 | ||
| assert res.surfing.host_requests[0].host_request_id == host_request_id | ||
| assert res.surfing.host_requests[0].status == conversations_pb2.HOST_REQUEST_STATUS_ACCEPTED | ||
| assert len(res.hosting.host_requests) == 0 | ||
|
|
||
|
|
||
| def test_GetDashboardV2_buckets_by_role(db, moderator): | ||
| user1, token1 = generate_user() | ||
| user2, token2 = generate_user() | ||
|
|
||
| host_request_id = _setup_accepted_host_request(token1, user2.id, moderator) | ||
| with requests_session(token2) as api: | ||
| api.RespondHostRequest( | ||
| requests_pb2.RespondHostRequestReq( | ||
| host_request_id=host_request_id, | ||
| status=conversations_pb2.HOST_REQUEST_STATUS_ACCEPTED, | ||
| text="Sure, come on over!", | ||
| ) | ||
| ) | ||
|
|
||
| # the host sees the upcoming stay under hosting, nothing under surfing | ||
| with dashboard_session(token2) as api: | ||
| res = api.GetDashboardV2(dashboard_pb2.GetDashboardV2Req()) | ||
| assert len(res.hosting.host_requests) == 1 | ||
| assert res.hosting.host_requests[0].host_request_id == host_request_id | ||
| assert len(res.surfing.host_requests) == 0 | ||
|
|
||
|
|
||
| def test_GetDashboardV2_community_events_excludes_attending(db, moderator): | ||
| # Pins the exclude_attending parameter the web frontend sends: an event the user is | ||
| # attending shows under my_events and must not also duplicate into community_events. | ||
| user1, token1 = generate_user() | ||
| user2, token2 = generate_user() | ||
|
|
||
| with session_scope() as session: | ||
| # community_events excludes global-level communities, so nest down to a subregion | ||
| world = create_community(session, 0, 100, "World", [user1, user2], [], None) | ||
| macroregion = create_community(session, 0, 100, "Macroregion", [user1, user2], [], world) | ||
| region = create_community(session, 0, 100, "Region", [user1, user2], [], macroregion) | ||
| subregion = create_community(session, 0, 100, "Subregion", [user1, user2], [], region) | ||
| community_id = subregion.id | ||
|
|
||
| start = now() | ||
|
|
||
| def make_event(hours: int) -> events_pb2.CreateEventReq: | ||
| return events_pb2.CreateEventReq( | ||
| title="Test Event", | ||
| content="Test content.", | ||
| location=events_pb2.EventLocation(address="Near Null Island", lat=0.1, lng=0.2), | ||
| parent_community_id=community_id, | ||
| timezone="UTC", | ||
| start_time=Timestamp_from_datetime(start + timedelta(hours=hours)), | ||
| end_time=Timestamp_from_datetime(start + timedelta(hours=hours + 1)), | ||
| ) | ||
|
|
||
| with events_session(token2) as api: | ||
| e_attending = api.CreateEvent(make_event(1)).event_id | ||
| e_community_only = api.CreateEvent(make_event(2)).event_id | ||
|
|
||
| moderator.approve_event_occurrence(e_attending) | ||
| moderator.approve_event_occurrence(e_community_only) | ||
|
|
||
| with events_session(token1) as api: | ||
| api.SetEventAttendance( | ||
| events_pb2.SetEventAttendanceReq(event_id=e_attending, attendance_state=events_pb2.ATTENDANCE_STATE_GOING) | ||
| ) | ||
|
|
||
| with dashboard_session(token1) as api: | ||
| res = api.GetDashboardV2(dashboard_pb2.GetDashboardV2Req()) | ||
|
|
||
| # the attended event shows under my_events (which with no flags includes all relationships)... | ||
| assert e_attending in {e.event_id for e in res.my_events.events} | ||
| # ...while community_events must exclude it (exclude_attending), showing only the rest | ||
| assert [e.event_id for e in res.community_events.events] == [e_community_only] | ||
|
|
||
|
|
||
| def test_GetDashboardV2_empty(db): | ||
| user, token = generate_user() | ||
| with dashboard_session(token) as api: | ||
| res = api.GetDashboardV2(dashboard_pb2.GetDashboardV2Req()) | ||
| assert len(res.surfing.host_requests) == 0 | ||
| assert len(res.hosting.host_requests) == 0 | ||
| assert len(res.my_events.events) == 0 | ||
| assert len(res.community_events.events) == 0 | ||
| assert len(res.discussions.discussions) == 0 | ||
| assert len(res.reminders.reminders) == 0 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| syntax = "proto3"; | ||
|
|
||
| package org.couchers.api.dashboard; | ||
|
|
||
| import "annotations.proto"; | ||
| import "account.proto"; | ||
| import "discussions.proto"; | ||
| import "events.proto"; | ||
| import "requests.proto"; | ||
|
|
||
| service Dashboard { | ||
| option (auth_level) = AUTH_LEVEL_SECURE; | ||
|
|
||
| rpc GetDashboardV2(GetDashboardV2Req) returns (GetDashboardV2Res) { | ||
| // Fetch all the content needed to render the dashboard in a single call, instead of fanning out | ||
| // to the individual RPCs. Each field mirrors the response of the RPC that previously populated it. | ||
| } | ||
| } | ||
|
|
||
| message GetDashboardV2Req {} | ||
|
|
||
| message GetDashboardV2Res { | ||
| org.couchers.api.account.GetRemindersRes reminders = 1; | ||
| org.couchers.api.requests.ListHostRequestsRes surfing = 2; | ||
| org.couchers.api.requests.ListHostRequestsRes hosting = 3; | ||
| org.couchers.api.events.ListMyEventsRes my_events = 4; | ||
| org.couchers.api.events.ListMyEventsRes community_events = 5; | ||
| org.couchers.api.discussions.ListMyCommunitiesDiscussionsRes discussions = 6; | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These two blocks are identical except
only_sentvsonly_received. You could extract a small helper like_upcoming_host_requests_req(surfing: bool)so the parameters can't drift apart between the two?