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

1import logging 

2 

3import grpc 

4from cachetools import TTLCache, cached 

5from sqlalchemy.sql import func, union_all 

6 

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 

17 

18logger = logging.getLogger(__name__) 

19 

20 

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 ) 

35 

36 

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 ) 

45 

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)) 

53 

54 

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() 

61 

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 ) 

73 

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]}" 

83 

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

85 

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 ) 

91 

92 

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) 

97 

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() 

103 

104 return public_pb2.GetDonationStatsRes( 

105 total_donated_ytd=max(int(total_donated - DONATION_OFFSET_USD), 0), 

106 goal=DONATION_GOAL_USD, 

107 ) 

108 

109 

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() 

123 

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

125 

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 ) 

138 

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 ) 

151 

152 

153class Public(public_pb2_grpc.PublicServicer): 

154 """ 

155 Public (logged out) APIs for getting public info 

156 """ 

157 

158 def GetPublicUsers(self, request, context, session): 

159 return _get_public_users(session) 

160 

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() 

172 

173 if not user: 

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

175 

176 if user.public_visibility == ProfilePublicVisibility.full: 

177 return public_pb2.GetPublicUserRes(full_user=user_model_to_pb(user, session, make_logged_out_context())) 

178 

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() 

186 

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 ) 

201 

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 ) 

233 

234 def GetSignupPageInfo(self, request, context, session): 

235 return _get_signup_page_info(session) 

236 

237 def GetVolunteers(self, request, context, session): 

238 return _get_volunteers(session) 

239 

240 def GetDonationStats(self, request, context, session): 

241 return _get_donation_stats(session)