Coverage for app / backend / src / couchers / servicers / account.py: 92%
324 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-19 14:14 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-19 14:14 +0000
1import json
2import logging
3from datetime import timedelta
4from urllib.parse import urlencode
6import grpc
7import requests
8from google.protobuf import empty_pb2
9from sqlalchemy import select
10from sqlalchemy.orm import Session
11from sqlalchemy.sql import func, update
12from user_agents import parse as user_agents_parse
14from couchers import urls
15from couchers.config import config
16from couchers.constants import DONATION_DRIVE_START, PHONE_REVERIFICATION_INTERVAL, SMS_CODE_ATTEMPTS, SMS_CODE_LIFETIME
17from couchers.context import CouchersContext
18from couchers.crypto import (
19 b64decode,
20 b64encode,
21 generate_invite_code,
22 hash_password,
23 simple_decrypt,
24 simple_encrypt,
25 urlsafe_secure_token,
26 verify_password,
27 verify_token,
28)
29from couchers.event_log import log_event
30from couchers.experimentation import check_gate
31from couchers.helpers.completed_profile import has_completed_profile
32from couchers.helpers.geoip import geoip_approximate_location
33from couchers.helpers.strong_verification import get_strong_verification_fields, has_strong_verification
34from couchers.jobs.enqueue import queue_job
35from couchers.jobs.handlers import finalize_strong_verification
36from couchers.materialized_views import LiteUser
37from couchers.metrics import (
38 account_deletion_initiations_counter,
39 strong_verification_data_deletions_counter,
40 strong_verification_initiations_counter,
41)
42from couchers.models import (
43 AccountDeletionReason,
44 AccountDeletionToken,
45 ContributeOption,
46 ContributorForm,
47 HostRequest,
48 HostRequestStatus,
49 InviteCode,
50 ModNote,
51 ProfilePublicVisibility,
52 StrongVerificationAttempt,
53 StrongVerificationAttemptStatus,
54 StrongVerificationCallbackEvent,
55 User,
56 UserSession,
57 Volunteer,
58)
59from couchers.models.notifications import NotificationTopicAction
60from couchers.notifications.notify import notify
61from couchers.phone import sms
62from couchers.phone.check import is_e164_format, is_known_operator
63from couchers.proto import account_pb2, account_pb2_grpc, auth_pb2, iris_pb2_grpc, notification_data_pb2
64from couchers.proto.google.api import httpbody_pb2
65from couchers.proto.internal import internal_pb2, jobs_pb2
66from couchers.servicers.api import lite_user_to_pb
67from couchers.servicers.public import format_volunteer_link
68from couchers.servicers.references import get_pending_references_to_write, reftype2api
69from couchers.sql import where_moderated_content_visible, where_users_column_visible
70from couchers.tasks import (
71 maybe_send_contributor_form_email,
72 send_account_deletion_report_email,
73 send_email_changed_confirmation_to_new_email,
74)
75from couchers.utils import (
76 Timestamp_from_datetime,
77 create_lang_cookie,
78 date_to_api,
79 dt_from_page_token,
80 dt_to_page_token,
81 is_valid_email,
82 now,
83 to_aware_datetime,
84)
86logger = logging.getLogger(__name__)
87logger.setLevel(logging.DEBUG)
89contributeoption2sql = {
90 auth_pb2.CONTRIBUTE_OPTION_UNSPECIFIED: None,
91 auth_pb2.CONTRIBUTE_OPTION_YES: ContributeOption.yes,
92 auth_pb2.CONTRIBUTE_OPTION_MAYBE: ContributeOption.maybe,
93 auth_pb2.CONTRIBUTE_OPTION_NO: ContributeOption.no,
94}
96contributeoption2api = {
97 None: auth_pb2.CONTRIBUTE_OPTION_UNSPECIFIED,
98 ContributeOption.yes: auth_pb2.CONTRIBUTE_OPTION_YES,
99 ContributeOption.maybe: auth_pb2.CONTRIBUTE_OPTION_MAYBE,
100 ContributeOption.no: auth_pb2.CONTRIBUTE_OPTION_NO,
101}
103profilepublicitysetting2sql = {
104 account_pb2.PROFILE_PUBLIC_VISIBILITY_UNKNOWN: None,
105 account_pb2.PROFILE_PUBLIC_VISIBILITY_NOTHING: ProfilePublicVisibility.nothing,
106 account_pb2.PROFILE_PUBLIC_VISIBILITY_MAP_ONLY: ProfilePublicVisibility.map_only,
107 account_pb2.PROFILE_PUBLIC_VISIBILITY_LIMITED: ProfilePublicVisibility.limited,
108 account_pb2.PROFILE_PUBLIC_VISIBILITY_MOST: ProfilePublicVisibility.most,
109 account_pb2.PROFILE_PUBLIC_VISIBILITY_FULL: ProfilePublicVisibility.full,
110}
112profilepublicitysetting2api = {
113 None: account_pb2.PROFILE_PUBLIC_VISIBILITY_UNKNOWN,
114 ProfilePublicVisibility.nothing: account_pb2.PROFILE_PUBLIC_VISIBILITY_NOTHING,
115 ProfilePublicVisibility.map_only: account_pb2.PROFILE_PUBLIC_VISIBILITY_MAP_ONLY,
116 ProfilePublicVisibility.limited: account_pb2.PROFILE_PUBLIC_VISIBILITY_LIMITED,
117 ProfilePublicVisibility.most: account_pb2.PROFILE_PUBLIC_VISIBILITY_MOST,
118 ProfilePublicVisibility.full: account_pb2.PROFILE_PUBLIC_VISIBILITY_FULL,
119}
121MAX_PAGINATION_LENGTH = 50
124def mod_note_to_pb(note: ModNote) -> account_pb2.ModNote:
125 return account_pb2.ModNote(
126 note_id=note.id,
127 note_content=note.note_content,
128 created=Timestamp_from_datetime(note.created),
129 acknowledged=Timestamp_from_datetime(note.acknowledged) if note.acknowledged else None,
130 )
133def abort_on_invalid_password(password: str, context: CouchersContext) -> None:
134 """
135 Internal utility function: given a password, aborts if password is unforgivably insecure
136 """
137 if len(password) < 8:
138 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "password_too_short")
140 if len(password) > 256:
141 # Hey, what are you trying to do? Give us a DDOS attack?
142 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "password_too_long")
144 # check for the most common weak passwords (not meant to be an exhaustive check!)
145 if password.lower() in ("password", "12345678", "couchers", "couchers1"):
146 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "insecure_password")
149def _volunteer_info_to_pb(volunteer: Volunteer, username: str) -> account_pb2.GetMyVolunteerInfoRes:
150 return account_pb2.GetMyVolunteerInfoRes(
151 display_name=volunteer.display_name,
152 display_location=volunteer.display_location,
153 role=volunteer.role,
154 started_volunteering=date_to_api(volunteer.started_volunteering),
155 stopped_volunteering=date_to_api(volunteer.stopped_volunteering) if volunteer.stopped_volunteering else None,
156 show_on_team_page=volunteer.show_on_team_page,
157 **format_volunteer_link(volunteer, username),
158 )
161class Account(account_pb2_grpc.AccountServicer):
162 def GetAccountInfo(
163 self, request: empty_pb2.Empty, context: CouchersContext, session: Session
164 ) -> account_pb2.GetAccountInfoRes:
165 user, volunteer = session.execute(
166 select(User, Volunteer).outerjoin(Volunteer, Volunteer.user_id == User.id).where(User.id == context.user_id)
167 ).one()
169 # Test experimentation integration - check if user is in the test gate
170 # Create 'test_statsig_integration' in Statsig console to test
171 test_gate = check_gate(context, "test_statsig_integration")
172 logger.info(f"Experimentation gate 'test_statsig_integration' for user {user.id}: {test_gate}")
174 should_show_donation_banner = DONATION_DRIVE_START is not None and (
175 user.last_donated is None or user.last_donated < DONATION_DRIVE_START
176 )
178 return account_pb2.GetAccountInfoRes(
179 username=user.username,
180 email=user.email,
181 phone=user.phone if (user.phone_is_verified or not user.phone_code_expired) else None,
182 has_donated=user.last_donated is not None,
183 phone_verified=user.phone_is_verified,
184 profile_complete=has_completed_profile(session, user),
185 my_home_complete=user.has_completed_my_home,
186 timezone=user.timezone,
187 is_superuser=user.is_superuser,
188 ui_language_preference=user.ui_language_preference,
189 profile_public_visibility=profilepublicitysetting2api[user.public_visibility],
190 is_volunteer=volunteer is not None,
191 should_show_donation_banner=should_show_donation_banner,
192 **get_strong_verification_fields(session, user),
193 )
195 def ChangePasswordV2(
196 self, request: account_pb2.ChangePasswordV2Req, context: CouchersContext, session: Session
197 ) -> empty_pb2.Empty:
198 """
199 Changes the user's password. They have to confirm their old password just in case.
201 If they didn't have an old password previously, then we don't check that.
202 """
203 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
205 if not verify_password(user.hashed_password, request.old_password):
206 # wrong password
207 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_password")
209 abort_on_invalid_password(request.new_password, context)
210 user.hashed_password = hash_password(request.new_password)
212 session.commit()
214 notify(
215 session,
216 user_id=user.id,
217 topic_action=NotificationTopicAction.password__change,
218 key="",
219 )
220 log_event(context, session, "account.password_changed", {})
222 return empty_pb2.Empty()
224 def ChangeEmailV2(
225 self, request: account_pb2.ChangeEmailV2Req, context: CouchersContext, session: Session
226 ) -> empty_pb2.Empty:
227 """
228 Change the user's email address.
230 If the user has a password, a notification is sent to the old email, and a confirmation is sent to the new one.
232 Otherwise they need to confirm twice, via an email sent to each of their old and new emails.
234 In all confirmation emails, the user must click on the confirmation link.
235 """
236 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
238 # check password first
239 if not verify_password(user.hashed_password, request.password):
240 # wrong password
241 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_password")
243 # not a valid email
244 if not is_valid_email(request.new_email):
245 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_email")
247 # email already in use (possibly by this user)
248 if session.execute(select(User).where(User.email == request.new_email)).scalar_one_or_none():
249 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_email")
251 user.new_email = request.new_email
252 user.new_email_token = urlsafe_secure_token()
253 user.new_email_token_created = now()
254 user.new_email_token_expiry = now() + timedelta(hours=2)
256 send_email_changed_confirmation_to_new_email(session, user)
258 # will still go into old email
259 notify(
260 session,
261 user_id=user.id,
262 topic_action=NotificationTopicAction.email_address__change,
263 key="",
264 data=notification_data_pb2.EmailAddressChange(
265 new_email=request.new_email,
266 ),
267 )
269 log_event(context, session, "account.email_change_initiated", {})
271 # session autocommit
272 return empty_pb2.Empty()
274 def ChangeLanguagePreference(
275 self, request: account_pb2.ChangeLanguagePreferenceReq, context: CouchersContext, session: Session
276 ) -> empty_pb2.Empty:
277 # select the user from the db
278 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
280 # update the user's preference
281 user.ui_language_preference = request.ui_language_preference
282 context.set_cookies(create_lang_cookie(request.ui_language_preference))
284 return empty_pb2.Empty()
286 def FillContributorForm(
287 self, request: account_pb2.FillContributorFormReq, context: CouchersContext, session: Session
288 ) -> empty_pb2.Empty:
289 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
291 form = request.contributor_form
293 form = ContributorForm(
294 user_id=user.id,
295 ideas=form.ideas or None,
296 features=form.features or None,
297 experience=form.experience or None,
298 contribute=contributeoption2sql[form.contribute],
299 contribute_ways=form.contribute_ways,
300 expertise=form.expertise or None,
301 )
303 session.add(form)
304 session.flush()
305 maybe_send_contributor_form_email(session, form)
307 user.filled_contributor_form = True
308 log_event(context, session, "contributor.form_submitted", {"is_filled": form.is_filled})
310 return empty_pb2.Empty()
312 def GetContributorFormInfo(
313 self, request: empty_pb2.Empty, context: CouchersContext, session: Session
314 ) -> account_pb2.GetContributorFormInfoRes:
315 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
317 return account_pb2.GetContributorFormInfoRes(
318 filled_contributor_form=user.filled_contributor_form,
319 )
321 def ChangePhone(
322 self, request: account_pb2.ChangePhoneReq, context: CouchersContext, session: Session
323 ) -> empty_pb2.Empty:
324 phone = request.phone
325 # early quick validation
326 if phone and not is_e164_format(phone):
327 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_phone")
329 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
330 if user.last_donated is None:
331 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "not_donated")
333 if not phone:
334 user.phone = None
335 user.phone_verification_verified = None
336 user.phone_verification_token = None
337 user.phone_verification_attempts = 0
338 return empty_pb2.Empty()
340 if not is_known_operator(phone):
341 context.abort_with_error_code(grpc.StatusCode.UNIMPLEMENTED, "unrecognized_phone_number")
343 if now() - user.phone_verification_sent < PHONE_REVERIFICATION_INTERVAL:
344 context.abort_with_error_code(grpc.StatusCode.RESOURCE_EXHAUSTED, "reverification_too_early")
346 token = sms.generate_random_code()
347 result = sms.send_sms(phone, sms.format_message(token))
349 if result == "success":
350 user.phone = phone
351 user.phone_verification_verified = None
352 user.phone_verification_token = token
353 user.phone_verification_sent = now()
354 user.phone_verification_attempts = 0
356 notify(
357 session,
358 user_id=user.id,
359 topic_action=NotificationTopicAction.phone_number__change,
360 key="",
361 data=notification_data_pb2.PhoneNumberChange(
362 phone=phone,
363 ),
364 )
366 return empty_pb2.Empty()
368 context.abort(grpc.StatusCode.UNIMPLEMENTED, result)
370 def VerifyPhone(
371 self, request: account_pb2.VerifyPhoneReq, context: CouchersContext, session: Session
372 ) -> empty_pb2.Empty:
373 if not sms.looks_like_a_code(request.token): 373 ↛ 374line 373 didn't jump to line 374 because the condition on line 373 was never true
374 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "wrong_sms_code")
376 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
377 if user.phone_verification_token is None:
378 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "no_pending_verification")
380 if now() - user.phone_verification_sent > SMS_CODE_LIFETIME: 380 ↛ 381line 380 didn't jump to line 381 because the condition on line 380 was never true
381 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "no_pending_verification")
383 if user.phone_verification_attempts > SMS_CODE_ATTEMPTS:
384 context.abort_with_error_code(grpc.StatusCode.RESOURCE_EXHAUSTED, "too_many_sms_code_attempts")
386 if not verify_token(request.token, user.phone_verification_token):
387 user.phone_verification_attempts += 1
388 session.commit()
389 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "wrong_sms_code")
391 # Delete verifications from everyone else that has this number
392 session.execute(
393 update(User)
394 .where(User.phone == user.phone)
395 .where(User.id != context.user_id)
396 .values(
397 {
398 "phone_verification_verified": None,
399 "phone_verification_attempts": 0,
400 "phone_verification_token": None,
401 "phone": None,
402 }
403 )
404 .execution_options(synchronize_session=False)
405 )
407 user.phone_verification_token = None
408 user.phone_verification_verified = now()
409 user.phone_verification_attempts = 0
411 notify(
412 session,
413 user_id=user.id,
414 topic_action=NotificationTopicAction.phone_number__verify,
415 key="",
416 data=notification_data_pb2.PhoneNumberVerify(
417 phone=user.phone,
418 ),
419 )
421 return empty_pb2.Empty()
423 def InitiateStrongVerification(
424 self, request: empty_pb2.Empty, context: CouchersContext, session: Session
425 ) -> account_pb2.InitiateStrongVerificationRes:
426 if not config["ENABLE_STRONG_VERIFICATION"]:
427 context.abort_with_error_code(grpc.StatusCode.UNAVAILABLE, "strong_verification_disabled")
429 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
430 existing_verification = session.execute(
431 select(StrongVerificationAttempt)
432 .where(StrongVerificationAttempt.user_id == user.id)
433 .where(StrongVerificationAttempt.is_valid)
434 ).scalar_one_or_none()
435 if existing_verification: 435 ↛ 436line 435 didn't jump to line 436 because the condition on line 435 was never true
436 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "strong_verification_already_verified")
438 strong_verification_initiations_counter.labels(user.gender).inc()
439 log_event(context, session, "verification.strong_initiated", {"gender": user.gender})
441 verification_attempt_token = urlsafe_secure_token()
442 # this is the iris reference data, they will return this on every callback, it also doubles as webhook auth given lack of it otherwise
443 reference = b64encode(
444 simple_encrypt(
445 "iris_callback",
446 internal_pb2.VerificationReferencePayload(
447 verification_attempt_token=verification_attempt_token,
448 user_id=user.id,
449 ).SerializeToString(),
450 )
451 )
452 response = requests.post(
453 "https://passportreader.app/api/v1/session.create",
454 auth=(config["IRIS_ID_PUBKEY"], config["IRIS_ID_SECRET"]),
455 json={
456 "callback_url": f"{config['BACKEND_BASE_URL']}/iris/webhook",
457 "face_verification": False,
458 "passport_only": True,
459 "reference": reference,
460 },
461 timeout=10,
462 verify="/etc/ssl/certs/ca-certificates.crt",
463 )
465 if response.status_code != 200: 465 ↛ 466line 465 didn't jump to line 466 because the condition on line 465 was never true
466 raise Exception(f"Iris didn't return 200: {response.text}")
468 iris_session_id = response.json()["id"]
469 token = response.json()["token"]
470 session.add(
471 StrongVerificationAttempt(
472 user_id=user.id,
473 verification_attempt_token=verification_attempt_token,
474 iris_session_id=iris_session_id,
475 iris_token=token,
476 )
477 )
479 redirect_params = {
480 "token": token,
481 "redirect_url": urls.complete_strong_verification_url(
482 verification_attempt_token=verification_attempt_token
483 ),
484 }
485 redirect_url = "https://passportreader.app/open?" + urlencode(redirect_params)
487 return account_pb2.InitiateStrongVerificationRes(
488 verification_attempt_token=verification_attempt_token,
489 redirect_url=redirect_url,
490 )
492 def GetStrongVerificationAttemptStatus(
493 self, request: account_pb2.GetStrongVerificationAttemptStatusReq, context: CouchersContext, session: Session
494 ) -> account_pb2.GetStrongVerificationAttemptStatusRes:
495 verification_attempt = session.execute(
496 select(StrongVerificationAttempt)
497 .where(StrongVerificationAttempt.user_id == context.user_id)
498 .where(StrongVerificationAttempt.is_visible)
499 .where(StrongVerificationAttempt.verification_attempt_token == request.verification_attempt_token)
500 ).scalar_one_or_none()
501 if not verification_attempt: 501 ↛ 502line 501 didn't jump to line 502 because the condition on line 501 was never true
502 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "strong_verification_attempt_not_found")
503 status_to_pb = {
504 StrongVerificationAttemptStatus.succeeded: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_SUCCEEDED,
505 StrongVerificationAttemptStatus.in_progress_waiting_on_user_to_open_app: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_IN_PROGRESS_WAITING_ON_USER_TO_OPEN_APP,
506 StrongVerificationAttemptStatus.in_progress_waiting_on_user_in_app: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_IN_PROGRESS_WAITING_ON_USER_IN_APP,
507 StrongVerificationAttemptStatus.in_progress_waiting_on_backend: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_IN_PROGRESS_WAITING_ON_BACKEND,
508 StrongVerificationAttemptStatus.failed: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_FAILED,
509 }
510 return account_pb2.GetStrongVerificationAttemptStatusRes(
511 status=status_to_pb.get(
512 verification_attempt.status, account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_UNKNOWN
513 ),
514 )
516 def DeleteStrongVerificationData(
517 self, request: empty_pb2.Empty, context: CouchersContext, session: Session
518 ) -> empty_pb2.Empty:
519 verification_attempts = (
520 session.execute(
521 select(StrongVerificationAttempt)
522 .where(StrongVerificationAttempt.user_id == context.user_id)
523 .where(StrongVerificationAttempt.has_full_data)
524 )
525 .scalars()
526 .all()
527 )
528 for verification_attempt in verification_attempts:
529 verification_attempt.status = StrongVerificationAttemptStatus.deleted
530 verification_attempt.has_full_data = False
531 verification_attempt.passport_encrypted_data = None
532 verification_attempt.passport_date_of_birth = None
533 verification_attempt.passport_sex = None
534 session.flush()
535 # double check:
536 verification_attempts = (
537 session.execute(
538 select(StrongVerificationAttempt)
539 .where(StrongVerificationAttempt.user_id == context.user_id)
540 .where(StrongVerificationAttempt.has_full_data)
541 )
542 .scalars()
543 .all()
544 )
545 assert len(verification_attempts) == 0
547 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
548 strong_verification_data_deletions_counter.labels(user.gender).inc()
549 log_event(context, session, "verification.strong_data_deleted", {"gender": user.gender})
551 return empty_pb2.Empty()
553 def DeleteAccount(
554 self, request: account_pb2.DeleteAccountReq, context: CouchersContext, session: Session
555 ) -> empty_pb2.Empty:
556 """
557 Triggers email with token to confirm deletion
559 Frontend should confirm via unique string (i.e. username) before this is called
560 """
561 if not request.confirm:
562 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "must_confirm_account_delete")
564 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
566 reason = request.reason.strip()
567 if reason:
568 deletion_reason = AccountDeletionReason(user_id=user.id, reason=reason)
569 session.add(deletion_reason)
570 session.flush()
571 send_account_deletion_report_email(session, deletion_reason)
573 token = AccountDeletionToken(token=urlsafe_secure_token(), user_id=user.id, expiry=now() + timedelta(hours=2))
575 notify(
576 session,
577 user_id=user.id,
578 topic_action=NotificationTopicAction.account_deletion__start,
579 key="",
580 data=notification_data_pb2.AccountDeletionStart(
581 deletion_token=token.token,
582 ),
583 )
584 session.add(token)
586 account_deletion_initiations_counter.labels(user.gender).inc()
587 log_event(context, session, "account.deletion_initiated", {"gender": user.gender, "has_reason": bool(reason)})
589 return empty_pb2.Empty()
591 def ListModNotes(
592 self, request: empty_pb2.Empty, context: CouchersContext, session: Session
593 ) -> account_pb2.ListModNotesRes:
594 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
596 notes = (
597 session.execute(select(ModNote).where(ModNote.user_id == user.id).order_by(ModNote.created.asc()))
598 .scalars()
599 .all()
600 )
602 return account_pb2.ListModNotesRes(mod_notes=[mod_note_to_pb(note) for note in notes])
604 def ListActiveSessions(
605 self, request: account_pb2.ListActiveSessionsReq, context: CouchersContext, session: Session
606 ) -> account_pb2.ListActiveSessionsRes:
607 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
608 page_token = dt_from_page_token(request.page_token) if request.page_token else now()
610 user_sessions = (
611 session.execute(
612 select(UserSession)
613 .where(UserSession.user_id == context.user_id)
614 .where(UserSession.is_valid)
615 .where(UserSession.is_api_key == False)
616 .where(UserSession.last_seen <= page_token)
617 .order_by(UserSession.last_seen.desc())
618 .limit(page_size + 1)
619 )
620 .scalars()
621 .all()
622 )
624 def _active_session_to_pb(user_session: UserSession) -> account_pb2.ActiveSession:
625 user_agent = user_agents_parse(user_session.user_agent or "")
626 return account_pb2.ActiveSession(
627 created=Timestamp_from_datetime(user_session.created),
628 expiry=Timestamp_from_datetime(user_session.expiry),
629 last_seen=Timestamp_from_datetime(user_session.last_seen),
630 operating_system=user_agent.os.family,
631 browser=user_agent.browser.family,
632 device=user_agent.device.family,
633 approximate_location=geoip_approximate_location(user_session.ip_address) or "Unknown",
634 is_current_session=user_session.token == context.token,
635 )
637 return account_pb2.ListActiveSessionsRes(
638 active_sessions=list(map(_active_session_to_pb, user_sessions[:page_size])),
639 next_page_token=dt_to_page_token(user_sessions[-1].last_seen) if len(user_sessions) > page_size else None,
640 )
642 def LogOutSession(
643 self, request: account_pb2.LogOutSessionReq, context: CouchersContext, session: Session
644 ) -> empty_pb2.Empty:
645 session.execute(
646 update(UserSession)
647 .where(UserSession.token != context.token)
648 .where(UserSession.user_id == context.user_id)
649 .where(UserSession.is_valid)
650 .where(UserSession.is_api_key == False)
651 .where(UserSession.created == to_aware_datetime(request.created))
652 .values(expiry=func.now())
653 .execution_options(synchronize_session=False)
654 )
655 return empty_pb2.Empty()
657 def LogOutOtherSessions(
658 self, request: account_pb2.LogOutOtherSessionsReq, context: CouchersContext, session: Session
659 ) -> empty_pb2.Empty:
660 if not request.confirm:
661 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "must_confirm_logout_other_sessions")
663 session.execute(
664 update(UserSession)
665 .where(UserSession.token != context.token)
666 .where(UserSession.user_id == context.user_id)
667 .where(UserSession.is_valid)
668 .where(UserSession.is_api_key == False)
669 .values(expiry=func.now())
670 .execution_options(synchronize_session=False)
671 )
672 return empty_pb2.Empty()
674 def SetProfilePublicVisibility(
675 self, request: account_pb2.SetProfilePublicVisibilityReq, context: CouchersContext, session: Session
676 ) -> empty_pb2.Empty:
677 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
678 user.public_visibility = profilepublicitysetting2sql[request.profile_public_visibility] # type: ignore[assignment]
679 user.has_modified_public_visibility = True
680 return empty_pb2.Empty()
682 def CreateInviteCode(
683 self, request: empty_pb2.Empty, context: CouchersContext, session: Session
684 ) -> account_pb2.CreateInviteCodeRes:
685 code = generate_invite_code()
686 session.add(InviteCode(id=code, creator_user_id=context.user_id))
688 return account_pb2.CreateInviteCodeRes(
689 code=code,
690 url=urls.invite_code_link(code=code),
691 )
693 def DisableInviteCode(
694 self, request: account_pb2.DisableInviteCodeReq, context: CouchersContext, session: Session
695 ) -> empty_pb2.Empty:
696 invite = session.execute(
697 select(InviteCode).where(InviteCode.id == request.code, InviteCode.creator_user_id == context.user_id)
698 ).scalar_one_or_none()
700 if not invite: 700 ↛ 701line 700 didn't jump to line 701 because the condition on line 700 was never true
701 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "not_found")
703 invite.disabled = func.now()
704 session.commit()
706 return empty_pb2.Empty()
708 def ListInviteCodes(
709 self, request: empty_pb2.Empty, context: CouchersContext, session: Session
710 ) -> account_pb2.ListInviteCodesRes:
711 results = session.execute(
712 select(
713 InviteCode.id,
714 InviteCode.created,
715 InviteCode.disabled,
716 func.count(User.id).label("num_users"),
717 )
718 .outerjoin(User, User.invite_code_id == InviteCode.id)
719 .where(InviteCode.creator_user_id == context.user_id)
720 .group_by(InviteCode.id, InviteCode.disabled)
721 .order_by(func.count(User.id).desc(), InviteCode.disabled)
722 ).all()
724 return account_pb2.ListInviteCodesRes(
725 invite_codes=[
726 account_pb2.InviteCodeInfo(
727 code=code_id,
728 created=Timestamp_from_datetime(created),
729 disabled=Timestamp_from_datetime(disabled) if disabled else None,
730 uses=len_users,
731 url=urls.invite_code_link(code=code_id),
732 )
733 for code_id, created, disabled, len_users in results
734 ]
735 )
737 def GetReminders(
738 self, request: empty_pb2.Empty, context: CouchersContext, session: Session
739 ) -> account_pb2.GetRemindersRes:
740 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
742 # responding to reqs comes first in desc order of when they were received
743 query = select(HostRequest.conversation_id, LiteUser).join(LiteUser, LiteUser.id == HostRequest.surfer_user_id)
744 query = where_users_column_visible(query, context, HostRequest.surfer_user_id)
745 query = where_moderated_content_visible(query, context, HostRequest, is_list_operation=True)
746 pending_host_requests = session.execute(
747 query.where(HostRequest.host_user_id == context.user_id)
748 .where(HostRequest.status == HostRequestStatus.pending)
749 .where(HostRequest.start_time > func.now())
750 .order_by(HostRequest.conversation_id.asc())
751 ).all()
752 reminders = [
753 account_pb2.Reminder(
754 respond_to_host_request_reminder=account_pb2.RespondToHostRequestReminder(
755 host_request_id=host_request_id,
756 surfer_user=lite_user_to_pb(session, lite_user, context),
757 )
758 )
759 for host_request_id, lite_user in pending_host_requests
760 ]
762 # references come second, in order of deadline, desc
763 reminders += [
764 account_pb2.Reminder(
765 write_reference_reminder=account_pb2.WriteReferenceReminder(
766 host_request_id=host_request_id,
767 reference_type=reftype2api[reference_type],
768 other_user=lite_user_to_pb(session, lite_user, context),
769 )
770 )
771 for host_request_id, reference_type, _, lite_user in get_pending_references_to_write(session, context)
772 ]
774 if not has_completed_profile(session, user):
775 reminders.append(account_pb2.Reminder(complete_profile_reminder=account_pb2.CompleteProfileReminder()))
777 if not has_strong_verification(session, user):
778 reminders.append(
779 account_pb2.Reminder(complete_verification_reminder=account_pb2.CompleteVerificationReminder())
780 )
782 return account_pb2.GetRemindersRes(reminders=reminders)
784 def GetMyVolunteerInfo(
785 self, request: empty_pb2.Empty, context: CouchersContext, session: Session
786 ) -> account_pb2.GetMyVolunteerInfoRes:
787 user, volunteer = session.execute(
788 select(User, Volunteer).outerjoin(Volunteer, Volunteer.user_id == User.id).where(User.id == context.user_id)
789 ).one()
790 if not volunteer:
791 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "not_a_volunteer")
792 return _volunteer_info_to_pb(volunteer, user.username)
794 def UpdateMyVolunteerInfo(
795 self, request: account_pb2.UpdateMyVolunteerInfoReq, context: CouchersContext, session: Session
796 ) -> account_pb2.GetMyVolunteerInfoRes:
797 user, volunteer = session.execute(
798 select(User, Volunteer).outerjoin(Volunteer, Volunteer.user_id == User.id).where(User.id == context.user_id)
799 ).one()
800 if not volunteer:
801 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "not_a_volunteer")
803 if request.HasField("display_name"): 803 ↛ 806line 803 didn't jump to line 806 because the condition on line 803 was always true
804 volunteer.display_name = request.display_name.value or None
806 if request.HasField("display_location"):
807 volunteer.display_location = request.display_location.value or None
809 if request.HasField("show_on_team_page"): 809 ↛ 810line 809 didn't jump to line 810 because the condition on line 809 was never true
810 volunteer.show_on_team_page = request.show_on_team_page.value
812 if request.HasField("link_type") or request.HasField("link_text") or request.HasField("link_url"): 812 ↛ 838line 812 didn't jump to line 838 because the condition on line 812 was always true
813 link_type = request.link_type.value or volunteer.link_type
814 link_text = request.link_text.value or volunteer.link_text
815 link_url = request.link_url.value or volunteer.link_url
816 if link_type == "couchers": 816 ↛ 818line 816 didn't jump to line 818 because the condition on line 816 was never true
817 # this is the default
818 link_type = None
819 link_text = None
820 link_url = None
821 elif link_type == "linkedin":
822 # this is the username
823 link_text = link_text
824 link_url = f"https://www.linkedin.com/in/{link_text}/"
825 elif link_type == "email":
826 if not is_valid_email(link_text): 826 ↛ 827line 826 didn't jump to line 827 because the condition on line 826 was never true
827 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_email")
828 link_url = f"mailto:{link_text}"
829 elif link_type == "website": 829 ↛ 833line 829 didn't jump to line 833 because the condition on line 829 was always true
830 if not link_url.startswith("https://") or "/" in link_text or link_text not in link_url: 830 ↛ 831line 830 didn't jump to line 831 because the condition on line 830 was never true
831 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_website_url")
832 else:
833 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_link_type")
834 volunteer.link_type = link_type
835 volunteer.link_text = link_text
836 volunteer.link_url = link_url
838 session.flush()
840 return _volunteer_info_to_pb(volunteer, user.username)
843class Iris(iris_pb2_grpc.IrisServicer):
844 def Webhook(
845 self, request: httpbody_pb2.HttpBody, context: CouchersContext, session: Session
846 ) -> httpbody_pb2.HttpBody:
847 json_data = json.loads(request.data)
848 reference_payload = internal_pb2.VerificationReferencePayload.FromString(
849 simple_decrypt("iris_callback", b64decode(json_data["session_reference"]))
850 )
851 # if we make it past the decrypt, we consider this webhook authenticated
852 verification_attempt_token = reference_payload.verification_attempt_token
853 user_id = reference_payload.user_id
855 verification_attempt = session.execute(
856 select(StrongVerificationAttempt)
857 .where(StrongVerificationAttempt.user_id == reference_payload.user_id)
858 .where(StrongVerificationAttempt.verification_attempt_token == reference_payload.verification_attempt_token)
859 .where(StrongVerificationAttempt.iris_session_id == json_data["session_id"])
860 ).scalar_one()
861 iris_status = json_data["session_state"]
862 session.add(
863 StrongVerificationCallbackEvent(
864 verification_attempt_id=verification_attempt.id,
865 iris_status=iris_status,
866 )
867 )
868 if iris_status == "INITIATED":
869 # the user opened the session in the app
870 verification_attempt.status = StrongVerificationAttemptStatus.in_progress_waiting_on_user_in_app
871 elif iris_status == "COMPLETED":
872 verification_attempt.status = StrongVerificationAttemptStatus.in_progress_waiting_on_backend
873 elif iris_status == "APPROVED": 873 ↛ 883line 873 didn't jump to line 883 because the condition on line 873 was always true
874 verification_attempt.status = StrongVerificationAttemptStatus.in_progress_waiting_on_backend
875 session.commit()
876 # background worker will go and sort this one out
877 queue_job(
878 session,
879 job=finalize_strong_verification,
880 payload=jobs_pb2.FinalizeStrongVerificationPayload(verification_attempt_id=verification_attempt.id),
881 priority=8,
882 )
883 elif iris_status in ["FAILED", "ABORTED", "REJECTED"]:
884 verification_attempt.status = StrongVerificationAttemptStatus.failed
886 return httpbody_pb2.HttpBody(
887 content_type="application/json",
888 # json.dumps escapes non-ascii characters
889 data=json.dumps({"success": True}).encode("ascii"),
890 )