Coverage for app/backend/src/couchers/servicers/public.py: 91%

76 statements  

« prev     ^ index     » next       coverage.py v7.14.2, created at 2026-06-21 09:29 +0000

1import logging 

2import threading 

3 

4import grpc 

5from cachetools import TTLCache, cached 

6from google.protobuf import empty_pb2 

7from sqlalchemy import null, select 

8from sqlalchemy.orm import Session, selectinload 

9from sqlalchemy.sql import func, union_all 

10 

11from couchers import experimentation, urls 

12from couchers.context import CouchersContext, make_logged_out_context 

13from couchers.materialized_views import LiteUser 

14from couchers.models import ( 

15 Cluster, 

16 Invoice, 

17 InvoiceType, 

18 Node, 

19 ProfilePublicVisibility, 

20 Reference, 

21 User, 

22 Volunteer, 

23) 

24from couchers.models.uploads import get_avatar_upload 

25from couchers.proto import api_pb2, public_pb2, public_pb2_grpc 

26from couchers.proto.google.api import httpbody_pb2 

27from couchers.resources import get_static_badge_dict 

28from couchers.servicers.api import fluency2api, hostingstatus2api, meetupstatus2api, user_model_to_pb 

29from couchers.servicers.gis import _statement_to_geojson_response 

30from couchers.utils import Timestamp_from_datetime, not_none, now 

31 

32logger = logging.getLogger(__name__) 

33 

34 

35def format_volunteer_link(volunteer: Volunteer, username: str) -> dict[str, str]: 

36 """Format volunteer link information into a dict with link_type, link_text, and link_url.""" 

37 if volunteer.link_type: 

38 return dict( 

39 link_type=volunteer.link_type, 

40 link_text=not_none(volunteer.link_text), 

41 link_url=not_none(volunteer.link_url), 

42 ) 

43 else: 

44 return dict( 

45 link_type="couchers", 

46 link_text=f"@{username}", 

47 link_url=urls.user_link(username=username), 

48 ) 

49 

50 

51@cached(cache=TTLCache(maxsize=1, ttl=600), key=lambda _: None, lock=threading.Lock()) 

52def _get_public_users(session: Session) -> httpbody_pb2.HttpBody: 

53 with_geom = ( 

54 select(User.username, User.geom) 

55 .where(User.is_visible) 

56 .where(User.public_visibility != ProfilePublicVisibility.nothing) 

57 .where(User.public_visibility != ProfilePublicVisibility.map_only) 

58 ) 

59 

60 without_geom = ( 

61 select(null(), User.randomized_geom) 

62 .where(User.is_visible) 

63 .where(User.randomized_geom != None) 

64 .where(User.public_visibility == ProfilePublicVisibility.map_only) 

65 ) 

66 return _statement_to_geojson_response(session, union_all(with_geom, without_geom)) 

67 

68 

69@cached(cache=TTLCache(maxsize=1, ttl=60), key=lambda _: None, lock=threading.Lock()) 

70def _get_signup_page_info(session: Session) -> public_pb2.GetSignupPageInfoRes: 

71 # last user who signed up 

72 last_signup, geom = session.execute( 

73 select(User.joined, User.geom).where(User.is_visible).order_by(User.id.desc()).limit(1) 

74 ).one() 

75 

76 communities = ( 

77 session.execute( 

78 select(Cluster.name) 

79 .join(Node, Node.id == Cluster.parent_node_id) 

80 .where(Cluster.is_official_cluster) 

81 .where(func.ST_Contains(Node.geom, geom)) 

82 .order_by(Cluster.id.asc()) 

83 ) 

84 .scalars() 

85 .all() 

86 ) 

87 

88 if len(communities) <= 1: 88 ↛ 91line 88 didn't jump to line 91 because the condition on line 88 was always true

89 # either no community or just global community 

90 last_location = "The World" 

91 elif len(communities) == 3: 

92 # probably global, continent, region, so let's just return the region 

93 last_location = communities[-1] 

94 else: 

95 # probably global, continent, region, city 

96 last_location = f"{communities[-1]}, {communities[-2]}" 

97 

98 user_count = session.execute(select(func.count()).select_from(User).where(User.is_visible)).scalar_one() 

99 

100 return public_pb2.GetSignupPageInfoRes( 

101 last_signup=Timestamp_from_datetime(last_signup.replace(second=0, microsecond=0)), 

102 last_location=last_location, 

103 user_count=user_count, 

104 ) 

105 

106 

107@cached(cache=TTLCache(maxsize=1, ttl=60), key=lambda _: None, lock=threading.Lock()) 

108def _get_donation_stats(session: Session) -> public_pb2.GetDonationStatsRes: 

109 """Get year-to-date donation statistics, excluding merch purchases.""" 

110 start_of_year = now().replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0) 

111 

112 total_donated = session.execute( 

113 select(func.coalesce(func.sum(Invoice.amount), 0)) 

114 .where(Invoice.invoice_type == InvoiceType.on_platform) 

115 .where(Invoice.created >= start_of_year) 

116 ).scalar_one() 

117 

118 # No request user here (public, cached endpoint), so evaluate the drive's goal/offset globally. 

119 # The defaults reproduce the historical drive config; the offset excludes large one-off donations. 

120 goal = experimentation.get_global_integer_value("donation_goal_usd", 5000) 

121 offset = experimentation.get_global_integer_value("donation_offset_usd", 2000) 

122 

123 return public_pb2.GetDonationStatsRes( 

124 total_donated_ytd=max(int(total_donated - offset), 0), 

125 goal=goal, 

126 ) 

127 

128 

129@cached(cache=TTLCache(maxsize=1, ttl=5), key=lambda _: None, lock=threading.Lock()) 

130def _get_volunteers(session: Session) -> public_pb2.GetVolunteersRes: 

131 volunteers = session.execute( 

132 select(Volunteer, LiteUser) 

133 .join(LiteUser, LiteUser.id == Volunteer.user_id) 

134 .where(LiteUser.is_visible) 

135 .where(Volunteer.show_on_team_page) 

136 .order_by( 

137 Volunteer.sort_key.asc().nulls_last(), 

138 Volunteer.stopped_volunteering.desc().nulls_first(), 

139 Volunteer.started_volunteering.asc(), 

140 ) 

141 ).all() 

142 

143 board_members = set(get_static_badge_dict()["board_member"]) 

144 

145 def format_volunteer(volunteer: Volunteer, lite_user: LiteUser) -> public_pb2.Volunteer: 

146 return public_pb2.Volunteer( 

147 name=volunteer.display_name or lite_user.name, 

148 username=lite_user.username, 

149 is_board_member=lite_user.id in board_members, 

150 role=volunteer.role, 

151 location=volunteer.display_location or lite_user.city, 

152 img=urls.media_url(filename=lite_user.avatar_filename, size="thumbnail") 

153 if lite_user.avatar_filename 

154 else None, 

155 **format_volunteer_link(volunteer, lite_user.username), 

156 ) 

157 

158 return public_pb2.GetVolunteersRes( 

159 current_volunteers=[ 

160 format_volunteer(volunteer, lite_user) 

161 for volunteer, lite_user in volunteers 

162 if volunteer.stopped_volunteering is None 

163 ], 

164 past_volunteers=[ 

165 format_volunteer(volunteer, lite_user) 

166 for volunteer, lite_user in volunteers 

167 if volunteer.stopped_volunteering is not None 

168 ], 

169 ) 

170 

171 

172class Public(public_pb2_grpc.PublicServicer): 

173 """ 

174 Public (logged-out) APIs for getting public info 

175 """ 

176 

177 def GetPublicUsers( 

178 self, request: empty_pb2.Empty, context: CouchersContext, session: Session 

179 ) -> httpbody_pb2.HttpBody: 

180 return _get_public_users(session) 

181 

182 def GetPublicUser( 

183 self, request: public_pb2.GetPublicUserReq, context: CouchersContext, session: Session 

184 ) -> public_pb2.GetPublicUserRes: 

185 user = session.execute( 

186 select(User) 

187 .where(User.is_visible) 

188 .where(User.username == request.user) 

189 .where( 

190 User.public_visibility.in_( 

191 [ProfilePublicVisibility.limited, ProfilePublicVisibility.most, ProfilePublicVisibility.full] 

192 ) 

193 ) 

194 .options( 

195 selectinload(User.badges), 

196 selectinload(User.regions_visited), 

197 selectinload(User.regions_lived), 

198 selectinload(User.language_abilities), 

199 ) 

200 ).scalar_one_or_none() 

201 

202 if not user: 

203 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "user_not_found") 

204 

205 if user.public_visibility == ProfilePublicVisibility.full: 

206 return public_pb2.GetPublicUserRes( 

207 full_user=user_model_to_pb(user, session, make_logged_out_context(localization=context.localization)) 

208 ) 

209 

210 num_references = session.execute( 

211 select(func.count()) 

212 .select_from(Reference) 

213 .join(User, User.id == Reference.from_user_id) 

214 .where(User.is_visible) 

215 .where(Reference.to_user_id == user.id) 

216 ).scalar_one() 

217 

218 if user.public_visibility == ProfilePublicVisibility.limited: 

219 return public_pb2.GetPublicUserRes( 

220 limited_user=public_pb2.LimitedUser( 

221 username=user.username, 

222 name=user.name, 

223 city=user.city, 

224 hometown=user.hometown, 

225 num_references=num_references, 

226 joined=Timestamp_from_datetime(user.display_joined), 

227 hosting_status=hostingstatus2api[user.hosting_status], 

228 meetup_status=meetupstatus2api[user.meetup_status], 

229 badges=[badge.badge_id for badge in user.badges], 

230 ) 

231 ) 

232 

233 if user.public_visibility == ProfilePublicVisibility.most: 233 ↛ 266line 233 didn't jump to line 266 because the condition on line 233 was always true

234 avatar_upload = get_avatar_upload(session, user) 

235 

236 return public_pb2.GetPublicUserRes( 

237 most_user=public_pb2.MostUser( 

238 username=user.username, 

239 name=user.name, 

240 city=user.city, 

241 hometown=user.hometown, 

242 timezone=user.timezone, 

243 num_references=num_references, 

244 gender=user.gender, 

245 pronouns=user.pronouns, 

246 age=int(user.age), 

247 joined=Timestamp_from_datetime(user.display_joined), 

248 last_active=Timestamp_from_datetime(user.display_last_active), 

249 hosting_status=hostingstatus2api[user.hosting_status], 

250 meetup_status=meetupstatus2api[user.meetup_status], 

251 occupation=user.occupation, 

252 education=user.education, 

253 about_me=user.about_me, 

254 things_i_like=user.things_i_like, 

255 language_abilities=[ 

256 api_pb2.LanguageAbility(code=ability.language_code, fluency=fluency2api[ability.fluency]) 

257 for ability in user.language_abilities 

258 ], 

259 regions_visited=[region.code for region in user.regions_visited], 

260 regions_lived=[region.code for region in user.regions_lived], 

261 avatar_url=avatar_upload.full_url if avatar_upload else None, 

262 avatar_thumbnail_url=avatar_upload.thumbnail_url if avatar_upload else None, 

263 badges=[badge.badge_id for badge in user.badges], 

264 ) 

265 ) 

266 raise RuntimeError(user.public_visibility) 

267 

268 def GetSignupPageInfo( 

269 self, request: empty_pb2.Empty, context: CouchersContext, session: Session 

270 ) -> public_pb2.GetSignupPageInfoRes: 

271 return _get_signup_page_info(session) 

272 

273 def GetVolunteers( 

274 self, request: empty_pb2.Empty, context: CouchersContext, session: Session 

275 ) -> public_pb2.GetVolunteersRes: 

276 return _get_volunteers(session) 

277 

278 def GetDonationStats( 

279 self, request: empty_pb2.Empty, context: CouchersContext, session: Session 

280 ) -> public_pb2.GetDonationStatsRes: 

281 return _get_donation_stats(session)