Coverage for src/couchers/servicers/public.py: 70%
67 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-12-20 11:53 +0000
« prev ^ index » next coverage.py v7.11.0, created at 2025-12-20 11:53 +0000
1import logging
3import grpc
4from cachetools import TTLCache, cached
5from sqlalchemy.sql import func, union_all
7from couchers import urls
8from couchers.constants import DONATION_GOAL_USD, DONATION_OFFSET_USD
9from couchers.materialized_views import LiteUser
10from couchers.models import Cluster, Invoice, InvoiceType, Node, ProfilePublicVisibility, Reference, User, Volunteer
11from couchers.proto import api_pb2, public_pb2, public_pb2_grpc
12from couchers.resources import get_static_badge_dict
13from couchers.servicers.api import fluency2api, hostingstatus2api, meetupstatus2api, user_model_to_pb
14from couchers.servicers.gis import _statement_to_geojson_response
15from couchers.sql import couchers_select as select
16from couchers.utils import Timestamp_from_datetime, make_logged_out_context, now
18logger = logging.getLogger(__name__)
21def format_volunteer_link(volunteer: Volunteer, username: str) -> dict[str, str]:
22 """Format volunteer link information into a dict with link_type, link_text, and link_url."""
23 if volunteer.link_type:
24 return dict(
25 link_type=volunteer.link_type,
26 link_text=volunteer.link_text,
27 link_url=volunteer.link_url,
28 )
29 else:
30 return dict(
31 link_type="couchers",
32 link_text=f"@{username}",
33 link_url=urls.user_link(username=username),
34 )
37@cached(cache=TTLCache(maxsize=1, ttl=600), key=lambda _: None)
38def _get_public_users(session):
39 with_geom = (
40 select(User.username, User.geom)
41 .where(User.is_visible)
42 .where(User.public_visibility != ProfilePublicVisibility.nothing)
43 .where(User.public_visibility != ProfilePublicVisibility.map_only)
44 )
46 without_geom = (
47 select(None, User.randomized_geom)
48 .where(User.is_visible)
49 .where(User.randomized_geom != None)
50 .where(User.public_visibility == ProfilePublicVisibility.map_only)
51 )
52 return _statement_to_geojson_response(session, union_all(with_geom, without_geom))
55@cached(cache=TTLCache(maxsize=1, ttl=60), key=lambda _: None)
56def _get_signup_page_info(session):
57 # last user who signed up
58 last_signup, geom = session.execute(
59 select(User.joined, User.geom).where(User.is_visible).order_by(User.id.desc()).limit(1)
60 ).one_or_none()
62 communities = (
63 session.execute(
64 select(Cluster.name)
65 .join(Node, Node.id == Cluster.parent_node_id)
66 .where(Cluster.is_official_cluster)
67 .where(func.ST_Contains(Node.geom, geom))
68 .order_by(Cluster.id.asc())
69 )
70 .scalars()
71 .all()
72 )
74 if len(communities) <= 1:
75 # either no community or just global community
76 last_location = "The World"
77 elif len(communities) == 3:
78 # probably global, continent, region, so let's just return the region
79 last_location = communities[-1]
80 else:
81 # probably global, continent, region, city
82 last_location = f"{communities[-1]}, {communities[-2]}"
84 user_count = session.execute(select(func.count()).select_from(User).where(User.is_visible)).scalar_one()
86 return public_pb2.GetSignupPageInfoRes(
87 last_signup=Timestamp_from_datetime(last_signup.replace(second=0, microsecond=0)),
88 last_location=last_location,
89 user_count=user_count,
90 )
93@cached(cache=TTLCache(maxsize=1, ttl=60), key=lambda _: None)
94def _get_donation_stats(session):
95 """Get year-to-date donation statistics, excluding merch purchases."""
96 start_of_year = now().replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0)
98 total_donated = session.execute(
99 select(func.coalesce(func.sum(Invoice.amount), 0))
100 .where(Invoice.invoice_type == InvoiceType.on_platform)
101 .where(Invoice.created >= start_of_year)
102 ).scalar_one()
104 return public_pb2.GetDonationStatsRes(
105 total_donated_ytd=max(int(total_donated - DONATION_OFFSET_USD), 0),
106 goal=DONATION_GOAL_USD,
107 )
110@cached(cache=TTLCache(maxsize=1, ttl=5), key=lambda _: None)
111def _get_volunteers(session):
112 volunteers = session.execute(
113 select(Volunteer, LiteUser)
114 .join(LiteUser, LiteUser.id == Volunteer.user_id)
115 .where(LiteUser.is_visible)
116 .where(Volunteer.show_on_team_page)
117 .order_by(
118 Volunteer.sort_key.asc().nulls_last(),
119 Volunteer.stopped_volunteering.desc().nulls_first(),
120 Volunteer.started_volunteering.asc(),
121 )
122 ).all()
124 board_members = set(get_static_badge_dict()["board_member"])
126 def format_volunteer(volunteer, lite_user):
127 return public_pb2.Volunteer(
128 name=volunteer.display_name or lite_user.name,
129 username=lite_user.username,
130 is_board_member=lite_user.id in board_members,
131 role=volunteer.role,
132 location=volunteer.display_location or lite_user.city,
133 img=urls.media_url(filename=lite_user.avatar_filename, size="thumbnail")
134 if lite_user.avatar_filename
135 else None,
136 **format_volunteer_link(volunteer, lite_user.username),
137 )
139 return public_pb2.GetVolunteersRes(
140 current_volunteers=[
141 format_volunteer(volunteer, lite_user)
142 for volunteer, lite_user in volunteers
143 if volunteer.stopped_volunteering is None
144 ],
145 past_volunteers=[
146 format_volunteer(volunteer, lite_user)
147 for volunteer, lite_user in volunteers
148 if volunteer.stopped_volunteering is not None
149 ],
150 )
153class Public(public_pb2_grpc.PublicServicer):
154 """
155 Public (logged out) APIs for getting public info
156 """
158 def GetPublicUsers(self, request, context, session):
159 return _get_public_users(session)
161 def GetPublicUser(self, request, context, session):
162 user = session.execute(
163 select(User)
164 .where(User.is_visible)
165 .where(User.username == request.user)
166 .where(
167 User.public_visibility.in_(
168 [ProfilePublicVisibility.limited, ProfilePublicVisibility.most, ProfilePublicVisibility.full]
169 )
170 )
171 ).scalar_one_or_none()
173 if not user:
174 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "user_not_found")
176 if user.public_visibility == ProfilePublicVisibility.full:
177 return public_pb2.GetPublicUserRes(full_user=user_model_to_pb(user, session, make_logged_out_context()))
179 num_references = session.execute(
180 select(func.count())
181 .select_from(Reference)
182 .join(User, User.id == Reference.from_user_id)
183 .where(User.is_visible)
184 .where(Reference.to_user_id == user.id)
185 ).scalar_one()
187 if user.public_visibility == ProfilePublicVisibility.limited:
188 return public_pb2.GetPublicUserRes(
189 limited_user=public_pb2.LimitedUser(
190 username=user.username,
191 name=user.name,
192 city=user.city,
193 hometown=user.hometown,
194 num_references=num_references,
195 joined=Timestamp_from_datetime(user.display_joined),
196 hosting_status=hostingstatus2api[user.hosting_status],
197 meetup_status=meetupstatus2api[user.meetup_status],
198 badges=[badge.badge_id for badge in user.badges],
199 )
200 )
202 if user.public_visibility == ProfilePublicVisibility.most:
203 return public_pb2.GetPublicUserRes(
204 most_user=public_pb2.MostUser(
205 username=user.username,
206 name=user.name,
207 city=user.city,
208 hometown=user.hometown,
209 timezone=user.timezone,
210 num_references=num_references,
211 gender=user.gender,
212 pronouns=user.pronouns,
213 age=user.age,
214 joined=Timestamp_from_datetime(user.display_joined),
215 last_active=Timestamp_from_datetime(user.display_last_active),
216 hosting_status=hostingstatus2api[user.hosting_status],
217 meetup_status=meetupstatus2api[user.meetup_status],
218 occupation=user.occupation,
219 education=user.education,
220 about_me=user.about_me,
221 things_i_like=user.things_i_like,
222 language_abilities=[
223 api_pb2.LanguageAbility(code=ability.language_code, fluency=fluency2api[ability.fluency])
224 for ability in user.language_abilities
225 ],
226 regions_visited=[region.code for region in user.regions_visited],
227 regions_lived=[region.code for region in user.regions_lived],
228 avatar_url=user.avatar.full_url if user.avatar else None,
229 avatar_thumbnail_url=user.avatar.thumbnail_url if user.avatar else None,
230 badges=[badge.badge_id for badge in user.badges],
231 )
232 )
234 def GetSignupPageInfo(self, request, context, session):
235 return _get_signup_page_info(session)
237 def GetVolunteers(self, request, context, session):
238 return _get_volunteers(session)
240 def GetDonationStats(self, request, context, session):
241 return _get_donation_stats(session)