Skip to content
Draft
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
3 changes: 3 additions & 0 deletions app/backend/src/couchers/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
bugs_pb2_grpc,
communities_pb2_grpc,
conversations_pb2_grpc,
dashboard_pb2_grpc,
discussions_pb2_grpc,
donations_pb2_grpc,
editor_pb2_grpc,
Expand Down Expand Up @@ -53,6 +54,7 @@
from couchers.servicers.bugs import Bugs
from couchers.servicers.communities import Communities
from couchers.servicers.conversations import Conversations
from couchers.servicers.dashboard import Dashboard
from couchers.servicers.discussions import Discussions
from couchers.servicers.donations import Donations, Stripe
from couchers.servicers.editor import Editor
Expand Down Expand Up @@ -108,6 +110,7 @@ def create_main_server(port: int, start_resource_sampler: bool = False) -> grpc.
bugs_pb2_grpc.add_BugsServicer_to_server(Bugs(), server)
communities_pb2_grpc.add_CommunitiesServicer_to_server(Communities(), server)
conversations_pb2_grpc.add_ConversationsServicer_to_server(Conversations(), server)
dashboard_pb2_grpc.add_DashboardServicer_to_server(Dashboard(), server)
discussions_pb2_grpc.add_DiscussionsServicer_to_server(Discussions(), server)
donations_pb2_grpc.add_DonationsServicer_to_server(Donations(), server)
editor_pb2_grpc.add_EditorServicer_to_server(Editor(), server)
Expand Down
74 changes: 74 additions & 0 deletions app/backend/src/couchers/servicers/dashboard.py
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,
),
Comment on lines +28 to +53

Copy link
Copy Markdown
Member

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_sent vs only_received. You could extract a small helper like _upcoming_host_requests_req(surfing: bool) so the parameters can't drift apart between the two?

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,
),
)
9 changes: 9 additions & 0 deletions app/backend/src/tests/fixtures/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
bugs_pb2_grpc,
communities_pb2_grpc,
conversations_pb2_grpc,
dashboard_pb2_grpc,
discussions_pb2_grpc,
donations_pb2_grpc,
editor_pb2_grpc,
Expand Down Expand Up @@ -59,6 +60,7 @@
from couchers.servicers.bugs import Bugs
from couchers.servicers.communities import Communities
from couchers.servicers.conversations import Conversations
from couchers.servicers.dashboard import Dashboard
from couchers.servicers.discussions import Discussions
from couchers.servicers.donations import Donations, Stripe
from couchers.servicers.editor import Editor
Expand Down Expand Up @@ -418,6 +420,13 @@ def discussions_session(token: str):
yield discussions_pb2_grpc.DiscussionsStub(channel)


@contextmanager
def dashboard_session(token: str):
channel = FakeChannel(token)
dashboard_pb2_grpc.add_DashboardServicer_to_server(Dashboard(), channel)
yield dashboard_pb2_grpc.DashboardStub(channel)


@contextmanager
def donations_session(token: str):
channel = FakeChannel(token)
Expand Down
197 changes: 197 additions & 0 deletions app/backend/src/tests/test_dashboard.py
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
29 changes: 29 additions & 0 deletions app/proto/dashboard.proto
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;
}
Loading