Coverage for app/backend/src/couchers/data_export.py: 97%

209 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-19 17:13 +0000

1import enum 

2from collections.abc import Collection, Iterable 

3from datetime import date, datetime 

4from typing import Any 

5 

6from google.protobuf.wrappers_pb2 import BoolValue, DoubleValue, Int32Value, StringValue 

7from sqlalchemy import case, select 

8from sqlalchemy.orm import Session, joinedload, selectinload 

9from sqlalchemy.sql import or_ 

10 

11from couchers import urls 

12from couchers.context import CouchersContext, make_background_user_context 

13from couchers.helpers.group_chats import was_subscribed_at 

14from couchers.helpers.references import where_reference_user_visible, where_references_not_hidden_by_reciprocity 

15from couchers.models import ( 

16 AccountDeletionReason, 

17 ActivenessProbe, 

18 ClusterSubscription, 

19 Comment, 

20 Discussion, 

21 Event, 

22 EventOccurrence, 

23 EventOccurrenceAttendee, 

24 EventOrganizer, 

25 EventSubscription, 

26 FriendRelationship, 

27 FriendStatus, 

28 GroupChat, 

29 GroupChatSubscription, 

30 HostRequest, 

31 HostRequestFeedback, 

32 InviteCode, 

33 Invoice, 

34 Message, 

35 ModNote, 

36 Page, 

37 PhotoGallery, 

38 PhotoGalleryItem, 

39 PostalVerificationAttempt, 

40 PublicTrip, 

41 Reference, 

42 Reply, 

43 StrongVerificationAttempt, 

44 Upload, 

45 User, 

46 UserBlock, 

47 UserSession, 

48) 

49from couchers.proto.internal import user_data_export_pb2 

50from couchers.sql import users_visible, where_moderated_content_visible_to_user_column, where_users_column_visible 

51from couchers.utils import Timestamp_from_datetime, get_coordinates 

52 

53EXPORT_FORMAT_VERSION = 1 

54 

55 

56def _ts(value: datetime | None) -> Any: 

57 return Timestamp_from_datetime(value) if value is not None else None 

58 

59 

60def _date(value: date | None) -> str | None: 

61 return value.isoformat() if value is not None else None 

62 

63 

64def _enum(value: enum.Enum | None) -> str | None: 

65 return value.name if value is not None else None 

66 

67 

68def _bool(value: bool | None) -> BoolValue | None: 

69 return BoolValue(value=value) if value is not None else None 

70 

71 

72def _int32(value: int | None) -> Int32Value | None: 

73 return Int32Value(value=value) if value is not None else None 

74 

75 

76def _double(value: float | None) -> DoubleValue | None: 

77 return DoubleValue(value=value) if value is not None else None 

78 

79 

80def _string(value: str | None) -> StringValue | None: 

81 return StringValue(value=value) if value is not None else None 

82 

83 

84class _UserRefs: 

85 def __init__(self, session: Session, user: User, context: CouchersContext): 

86 self.session = session 

87 self.user = user 

88 self.context = context 

89 self.usernames: dict[int, str | None] = {user.id: user.username} 

90 

91 def prefetch(self, user_ids: Iterable[int | None]) -> None: 

92 """Resolve a batch of users in one query, so the per-id lookups below don't each cost a round trip.""" 

93 missing = {user_id for user_id in user_ids if user_id is not None and user_id not in self.usernames} 

94 if not missing: 

95 return 

96 found: dict[int, str] = { 

97 row.id: row.username 

98 for row in self.session.execute( 

99 select(User.id, User.username).where(User.id.in_(missing)).where(users_visible(self.context, User)) 

100 ) 

101 } 

102 for user_id in missing: 

103 self.usernames[user_id] = found.get(user_id) 

104 

105 def _username(self, user_id: int) -> str | None: 

106 if user_id not in self.usernames: 106 ↛ 107line 106 didn't jump to line 107 because the condition on line 106 was never true

107 self.prefetch([user_id]) 

108 return self.usernames[user_id] 

109 

110 def is_visible(self, user_id: int) -> bool: 

111 return self._username(user_id) is not None 

112 

113 def get_one(self, user_id: int) -> user_data_export_pb2.UserRef: 

114 username = self._username(user_id) 

115 return user_data_export_pb2.UserRef(user_id=user_id, username=_string(f"@{username}" if username else None)) 

116 

117 def get(self, user_id: int | None) -> user_data_export_pb2.UserRef | None: 

118 return self.get_one(user_id) if user_id is not None else None 

119 

120 def get_blockee(self, user_id: int) -> user_data_export_pb2.UserRef: 

121 username = self.session.execute( 

122 select(User.username).where(User.id == user_id).where(User.is_visible) 

123 ).scalar_one_or_none() 

124 return user_data_export_pb2.UserRef(user_id=user_id, username=_string(f"@{username}" if username else None)) 

125 

126 

127def _account(user: User) -> user_data_export_pb2.Account: 

128 lat, lng = user.coordinates 

129 return user_data_export_pb2.Account( 

130 user_id=user.id, 

131 username=user.username, 

132 email=user.email, 

133 name=user.name, 

134 birthdate=_date(user.birthdate), 

135 gender=user.gender, 

136 pronouns=user.pronouns, 

137 phone=user.phone, 

138 phone_is_verified=user.phone_is_verified, 

139 phone_verified=_ts(user.phone_verification_verified), 

140 pending_email_change=user.new_email, 

141 joined=_ts(user.joined), 

142 profile_last_updated=_ts(user.profile_last_updated), 

143 city=user.city, 

144 hometown=user.hometown, 

145 latitude=_double(lat), 

146 longitude=_double(lng), 

147 location_radius=user.geom_radius, 

148 ui_language_preference=user.ui_language_preference, 

149 accepted_tos_version=user.accepted_tos, 

150 accepted_community_guidelines_version=user.accepted_community_guidelines, 

151 do_not_email=user.do_not_email, 

152 opt_out_of_newsletter=user.opt_out_of_newsletter, 

153 digest_frequency_seconds=_int32( 

154 int(user.digest_frequency.total_seconds()) if user.digest_frequency is not None else None 

155 ), 

156 onboarding_emails_sent=user.onboarding_emails_sent, 

157 last_donated=_ts(user.last_donated), 

158 heard_about_couchers=user.heard_about_couchers, 

159 signup_motivations=user.signup_motivations or [], 

160 signed_up_with_invite_code=user.invite_code_id, 

161 profile_url=urls.user_link(username=user.username), 

162 ) 

163 

164 

165def _profile(user: User) -> user_data_export_pb2.Profile: 

166 return user_data_export_pb2.Profile( 

167 about_me=user.about_me, 

168 things_i_like=user.things_i_like, 

169 occupation=user.occupation, 

170 education=user.education, 

171 additional_information=user.additional_information, 

172 hosting_status=_enum(user.hosting_status), 

173 meetup_status=_enum(user.meetup_status), 

174 languages=[ability.language_code for ability in user.language_abilities], 

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

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

177 badges=[ 

178 user_data_export_pb2.Badge(badge_id=badge.badge_id, awarded=_ts(badge.created)) for badge in user.badges 

179 ], 

180 ) 

181 

182 

183def _home(user: User) -> user_data_export_pb2.Home: 

184 return user_data_export_pb2.Home( 

185 about_place=user.about_place, 

186 max_guests=_int32(user.max_guests), 

187 last_minute=_bool(user.last_minute), 

188 has_pets=_bool(user.has_pets), 

189 accepts_pets=_bool(user.accepts_pets), 

190 pet_details=user.pet_details, 

191 has_kids=_bool(user.has_kids), 

192 accepts_kids=_bool(user.accepts_kids), 

193 kid_details=user.kid_details, 

194 has_housemates=_bool(user.has_housemates), 

195 housemate_details=user.housemate_details, 

196 wheelchair_accessible=_bool(user.wheelchair_accessible), 

197 smoking_allowed=_enum(user.smoking_allowed), 

198 smokes_at_home=_bool(user.smokes_at_home), 

199 drinking_allowed=_bool(user.drinking_allowed), 

200 drinks_at_home=_bool(user.drinks_at_home), 

201 other_host_info=user.other_host_info, 

202 sleeping_arrangement=_enum(user.sleeping_arrangement), 

203 sleeping_details=user.sleeping_details, 

204 area=user.area, 

205 house_rules=user.house_rules, 

206 parking=_bool(user.parking), 

207 parking_details=_enum(user.parking_details), 

208 camping_ok=_bool(user.camping_ok), 

209 ) 

210 

211 

212def _photos(session: Session, user: User) -> user_data_export_pb2.Photos: 

213 galleries = ( 

214 session.execute( 

215 select(PhotoGallery) 

216 .where(PhotoGallery.owner_user_id == user.id) 

217 .options(selectinload(PhotoGallery.photos).joinedload(PhotoGalleryItem.upload)) 

218 .order_by(PhotoGallery.id) 

219 ) 

220 .scalars() 

221 .unique() 

222 .all() 

223 ) 

224 uploads = ( 

225 session.execute(select(Upload).where(Upload.creator_user_id == user.id).order_by(Upload.created, Upload.key)) 

226 .scalars() 

227 .all() 

228 ) 

229 return user_data_export_pb2.Photos( 

230 galleries=[ 

231 user_data_export_pb2.Gallery( 

232 is_profile_gallery=gallery.id == user.profile_gallery_id, 

233 created=_ts(gallery.created), 

234 last_updated=_ts(gallery.last_updated), 

235 photos=[ 

236 user_data_export_pb2.GalleryPhoto( 

237 caption=photo.caption, 

238 position=photo.position, 

239 created=_ts(photo.created), 

240 url=photo.upload.full_url, 

241 ) 

242 for photo in gallery.photos 

243 ], 

244 ) 

245 for gallery in galleries 

246 ], 

247 uploads=[ 

248 user_data_export_pb2.UploadedFile( 

249 credit=upload.credit, 

250 created=_ts(upload.created), 

251 url=upload.full_url, 

252 ) 

253 for upload in uploads 

254 ], 

255 ) 

256 

257 

258def _friend_relationships(session: Session, user: User, status: FriendStatus) -> list[FriendRelationship]: 

259 # As ListFriends/ListFriendRequests scope them: nothing about rejected or cancelled requests, 

260 # since how the other party responded is theirs and the app never shows it. 

261 statement = where_moderated_content_visible_to_user_column(select(FriendRelationship), FriendRelationship, user.id) 

262 statement = statement.where(FriendRelationship.status == status).where( 

263 or_(FriendRelationship.from_user_id == user.id, FriendRelationship.to_user_id == user.id) 

264 ) 

265 return list(session.execute(statement.order_by(FriendRelationship.id)).scalars()) 

266 

267 

268def _other_user_id(relationship: FriendRelationship, user: User) -> int: 

269 return relationship.to_user_id if relationship.from_user_id == user.id else relationship.from_user_id 

270 

271 

272def _friends(session: Session, user: User, refs: _UserRefs) -> list[user_data_export_pb2.Friend]: 

273 relationships = _friend_relationships(session, user, FriendStatus.accepted) + _friend_relationships( 

274 session, user, FriendStatus.pending 

275 ) 

276 refs.prefetch(_other_user_id(relationship, user) for relationship in relationships) 

277 out = [] 

278 for relationship in relationships: 

279 other_user_id = _other_user_id(relationship, user) 

280 if not refs.is_visible(other_user_id): 

281 continue 

282 sent = relationship.from_user_id == user.id 

283 pending = relationship.status == FriendStatus.pending 

284 out.append( 

285 user_data_export_pb2.Friend( 

286 user=refs.get(other_user_id), 

287 status=("request_sent" if sent else "request_received") if pending else "friends", 

288 time_sent=_ts(relationship.time_sent) if pending and sent else None, 

289 ) 

290 ) 

291 return out 

292 

293 

294def _blocked_users(session: Session, user: User, refs: _UserRefs) -> list[user_data_export_pb2.BlockedUser]: 

295 blocks = ( 

296 session.execute(select(UserBlock).where(UserBlock.blocking_user_id == user.id).order_by(UserBlock.id)) 

297 .scalars() 

298 .all() 

299 ) 

300 return [ 

301 user_data_export_pb2.BlockedUser( 

302 user=refs.get_blockee(block.blocked_user_id), time_blocked=_ts(block.time_blocked) 

303 ) 

304 for block in blocks 

305 ] 

306 

307 

308def _references( 

309 session: Session, user: User, refs: _UserRefs, context: CouchersContext 

310) -> user_data_export_pb2.References: 

311 written = list( 

312 session.execute(select(Reference).where(Reference.from_user_id == user.id).order_by(Reference.id)).scalars() 

313 ) 

314 

315 # Received ones are someone else's writing, so they're scoped exactly as ListReferences scopes 

316 # them: moderation-visible, and not still hidden by the reciprocal-reference rule. 

317 received_statement = where_moderated_content_visible_to_user_column(select(Reference), Reference, user.id) 

318 received_statement = where_references_not_hidden_by_reciprocity(received_statement) 

319 received_statement = where_reference_user_visible(received_statement, context, Reference.from_user_id) 

320 received = list( 

321 session.execute(received_statement.where(Reference.to_user_id == user.id).order_by(Reference.id)).scalars() 

322 ) 

323 

324 refs.prefetch([reference.to_user_id for reference in written]) 

325 refs.prefetch([reference.from_user_id for reference in received]) 

326 

327 return user_data_export_pb2.References( 

328 written=[ 

329 user_data_export_pb2.ReferenceWritten( 

330 to_user=refs.get(reference.to_user_id), 

331 reference_type=_enum(reference.reference_type), 

332 time=_ts(reference.time), 

333 text=reference.text, 

334 ) 

335 for reference in written 

336 ], 

337 received=[ 

338 user_data_export_pb2.ReferenceReceived( 

339 from_user=refs.get(reference.from_user_id), 

340 reference_type=_enum(reference.reference_type), 

341 time=_ts(reference.time.replace(hour=0, minute=0, second=0, microsecond=0)), 

342 text=reference.text, 

343 ) 

344 for reference in received 

345 ], 

346 ) 

347 

348 

349def _messages_by_conversation( 

350 session: Session, refs: _UserRefs, conversation_ids: Collection[int] 

351) -> dict[int, list[Message]]: 

352 """All the messages for a set of conversations in one query, rather than one query per conversation.""" 

353 by_conversation: dict[int, list[Message]] = {} 

354 if not conversation_ids: 

355 return by_conversation 

356 messages = list( 

357 session.execute( 

358 select(Message).where(Message.conversation_id.in_(conversation_ids)).order_by(Message.id) 

359 ).scalars() 

360 ) 

361 refs.prefetch(message.author_id for message in messages) 

362 refs.prefetch(message.target_id for message in messages) 

363 for message in messages: 

364 by_conversation.setdefault(message.conversation_id, []).append(message) 

365 return by_conversation 

366 

367 

368def _chat_message(message: Message, refs: _UserRefs) -> user_data_export_pb2.ChatMessage: 

369 return user_data_export_pb2.ChatMessage( 

370 author=refs.get(message.author_id), 

371 time=_ts(message.time), 

372 message_type=_enum(message.message_type), 

373 text=message.text, 

374 target=refs.get(message.target_id), 

375 host_request_status_target=_enum(message.host_request_status_target), 

376 ) 

377 

378 

379def _host_requests( 

380 session: Session, user: User, refs: _UserRefs, context: CouchersContext 

381) -> list[user_data_export_pb2.HostRequest]: 

382 statement = where_moderated_content_visible_to_user_column(select(HostRequest), HostRequest, user.id) 

383 statement = where_users_column_visible( 

384 statement, 

385 context, 

386 case((HostRequest.surfer_user_id == user.id, HostRequest.host_user_id), else_=HostRequest.surfer_user_id), 

387 ) 

388 host_requests = list( 

389 session.execute( 

390 statement.where(or_(HostRequest.surfer_user_id == user.id, HostRequest.host_user_id == user.id)) 

391 .options(joinedload(HostRequest.conversation)) 

392 .order_by(HostRequest.conversation_id) 

393 ).scalars() 

394 ) 

395 refs.prefetch(host_request.surfer_user_id for host_request in host_requests) 

396 refs.prefetch(host_request.host_user_id for host_request in host_requests) 

397 messages_by_conversation = _messages_by_conversation( 

398 session, refs, [host_request.conversation_id for host_request in host_requests] 

399 ) 

400 feedback_by_request: dict[int, list[HostRequestFeedback]] = {} 

401 for item in ( 401 ↛ 410line 401 didn't jump to line 410 because the loop on line 401 never started

402 session.execute( 

403 select(HostRequestFeedback) 

404 .where(HostRequestFeedback.from_user_id == user.id) 

405 .order_by(HostRequestFeedback.id) 

406 ) 

407 .scalars() 

408 .all() 

409 ): 

410 feedback_by_request.setdefault(item.host_request_id, []).append(item) 

411 

412 out = [] 

413 for host_request in host_requests: 

414 messages = messages_by_conversation.get(host_request.conversation_id, []) 

415 is_initiator = host_request.initiator_user_id == user.id 

416 out.append( 

417 user_data_export_pb2.HostRequest( 

418 role="surfer" if host_request.surfer_user_id == user.id else "host", 

419 surfer=refs.get(host_request.surfer_user_id), 

420 host=refs.get(host_request.host_user_id), 

421 status=_enum(host_request.status), 

422 from_date=_date(host_request.from_date), 

423 to_date=_date(host_request.to_date), 

424 hosting_city=host_request.hosting_city, 

425 created=_ts(host_request.conversation.created), 

426 my_reason_didnt_meetup=( 

427 host_request.initiator_reason_didnt_meetup 

428 if is_initiator 

429 else host_request.recipient_reason_didnt_meetup 

430 ), 

431 my_feedback=[ 

432 user_data_export_pb2.HostRequestFeedback( 

433 time=_ts(item.time), 

434 request_quality=_enum(item.request_quality), 

435 decline_reason=item.decline_reason, 

436 ) 

437 for item in feedback_by_request.get(host_request.conversation_id, []) 

438 ], 

439 messages=[_chat_message(message, refs) for message in messages], 

440 ) 

441 ) 

442 return out 

443 

444 

445def _group_chats(session: Session, user: User, refs: _UserRefs) -> list[user_data_export_pb2.GroupChat]: 

446 subscriptions_by_chat: dict[int, list[GroupChatSubscription]] = {} 

447 for subscription in ( 

448 session.execute( 

449 select(GroupChatSubscription) 

450 .where(GroupChatSubscription.user_id == user.id) 

451 .order_by(GroupChatSubscription.id) 

452 ) 

453 .scalars() 

454 .all() 

455 ): 

456 subscriptions_by_chat.setdefault(subscription.group_chat_id, []).append(subscription) 

457 

458 visible_chat_ids = set( 

459 session.execute( 

460 where_moderated_content_visible_to_user_column(select(GroupChat.conversation_id), GroupChat, user.id).where( 

461 GroupChat.conversation_id.in_(subscriptions_by_chat) 

462 ) 

463 ).scalars() 

464 ) 

465 

466 group_chats = { 

467 group_chat.conversation_id: group_chat 

468 for group_chat in session.execute( 

469 select(GroupChat) 

470 .where(GroupChat.conversation_id.in_(visible_chat_ids)) 

471 .options(joinedload(GroupChat.conversation)) 

472 ).scalars() 

473 } 

474 refs.prefetch(group_chat.creator_id for group_chat in group_chats.values()) 

475 messages_by_conversation = _messages_by_conversation(session, refs, visible_chat_ids) 

476 

477 members_by_chat: dict[int, list[int]] = {} 

478 for chat_id, chat_subscriptions in subscriptions_by_chat.items(): 

479 if chat_id not in visible_chat_ids: 

480 continue 

481 latest_subscription = chat_subscriptions[-1] 

482 members_statement = select(GroupChatSubscription.user_id).where(GroupChatSubscription.group_chat_id == chat_id) 

483 if latest_subscription.left is not None: 483 ↛ 484line 483 didn't jump to line 484 because the condition on line 483 was never true

484 members_statement = members_statement.where( 

485 was_subscribed_at(GroupChatSubscription, latest_subscription.left) 

486 ) 

487 else: 

488 members_statement = members_statement.where(GroupChatSubscription.left.is_(None)) 

489 members_by_chat[chat_id] = list(session.execute(members_statement.order_by(GroupChatSubscription.id)).scalars()) 

490 refs.prefetch(member_id for members in members_by_chat.values() for member_id in members) 

491 

492 out = [] 

493 for chat_id, chat_subscriptions in sorted(subscriptions_by_chat.items()): 

494 if chat_id not in visible_chat_ids: 

495 continue 

496 group_chat = group_chats[chat_id] 

497 messages = messages_by_conversation.get(chat_id, []) 

498 # rejoining a chat doesn't hand them the backlog from while they were out of it 

499 windows = [(subscription.joined, subscription.left) for subscription in chat_subscriptions] 

500 in_window = [ 

501 message 

502 for message in messages 

503 if any(joined <= message.time and (left is None or message.time <= left) for joined, left in windows) 

504 ] 

505 out.append( 

506 user_data_export_pb2.GroupChat( 

507 title=group_chat.title, 

508 created=_ts(group_chat.conversation.created), 

509 creator=refs.get(group_chat.creator_id), 

510 members=[refs.get_one(member_id) for member_id in members_by_chat.get(chat_id, [])], 

511 messages=[_chat_message(message, refs) for message in in_window], 

512 ) 

513 ) 

514 return out 

515 

516 

517def _occurrence( 

518 occurrence: EventOccurrence, attendee_status: str | None = None 

519) -> user_data_export_pb2.EventOccurrence: 

520 coordinates = get_coordinates(occurrence.geom) 

521 lat, lng = coordinates if coordinates else (None, None) 

522 return user_data_export_pb2.EventOccurrence( 

523 title=occurrence.event.title, 

524 content=occurrence.content, 

525 address=occurrence.address, 

526 latitude=_double(lat), 

527 longitude=_double(lng), 

528 timezone=occurrence.timezone, 

529 start=_ts(occurrence.during.lower), 

530 end=_ts(occurrence.during.upper), 

531 is_cancelled=occurrence.is_cancelled, 

532 is_deleted=occurrence.is_deleted, 

533 created=_ts(occurrence.created), 

534 url=urls.event_link(occurrence_id=occurrence.id, slug=occurrence.event.slug), 

535 attendee_status=attendee_status, 

536 ) 

537 

538 

539def _events(session: Session, user: User) -> user_data_export_pb2.Events: 

540 created = ( 

541 session.execute( 

542 select(EventOccurrence) 

543 .where(EventOccurrence.creator_user_id == user.id) 

544 .options(joinedload(EventOccurrence.event)) 

545 .order_by(EventOccurrence.id) 

546 ) 

547 .scalars() 

548 .all() 

549 ) 

550 # ListMyEvents scopes all of organizing/subscribed/attending by occurrence visibility 

551 visible_event_ids = where_moderated_content_visible_to_user_column( 

552 select(EventOccurrence.event_id), EventOccurrence, user.id 

553 ) 

554 organizing = ( 

555 session.execute( 

556 select(Event) 

557 .join(EventOrganizer, EventOrganizer.event_id == Event.id) 

558 .where(EventOrganizer.user_id == user.id) 

559 .where(Event.id.in_(visible_event_ids)) 

560 .order_by(Event.id) 

561 ) 

562 .scalars() 

563 .all() 

564 ) 

565 attending_statement = where_moderated_content_visible_to_user_column( 

566 select(EventOccurrence, EventOccurrenceAttendee.attendee_status), EventOccurrence, user.id 

567 ) 

568 attending = session.execute( 

569 attending_statement.join(EventOccurrenceAttendee, EventOccurrenceAttendee.occurrence_id == EventOccurrence.id) 

570 .where(EventOccurrenceAttendee.user_id == user.id) 

571 .options(joinedload(EventOccurrence.event)) 

572 .order_by(EventOccurrence.id) 

573 ).all() 

574 subscribed = ( 

575 session.execute( 

576 select(Event) 

577 .join(EventSubscription, EventSubscription.event_id == Event.id) 

578 .where(EventSubscription.user_id == user.id) 

579 .where(Event.id.in_(visible_event_ids)) 

580 .order_by(Event.id) 

581 ) 

582 .scalars() 

583 .all() 

584 ) 

585 

586 return user_data_export_pb2.Events( 

587 created=[_occurrence(occurrence) for occurrence in created], 

588 organizing=[user_data_export_pb2.EventRef(title=event.title) for event in organizing], 

589 attending=[ 

590 _occurrence(occurrence, attendee_status=_enum(attendee_status)) for occurrence, attendee_status in attending 

591 ], 

592 subscribed=[user_data_export_pb2.EventRef(title=event.title) for event in subscribed], 

593 ) 

594 

595 

596def _thread_contexts(session: Session, thread_ids: set[int]) -> dict[int, user_data_export_pb2.ThreadContext]: 

597 if not thread_ids: 

598 return {} 

599 contexts: dict[int, user_data_export_pb2.ThreadContext] = {} 

600 for discussion_id, thread_id, title in session.execute( 

601 select(Discussion.id, Discussion.thread_id, Discussion.title).where(Discussion.thread_id.in_(thread_ids)) 

602 ): 

603 contexts[thread_id] = user_data_export_pb2.ThreadContext(type="discussion", title=title) 

604 for thread_id, title in session.execute( 

605 select(EventOccurrence.thread_id, Event.title) 

606 .join(Event, Event.id == EventOccurrence.event_id) 

607 .where(EventOccurrence.thread_id.in_(thread_ids)) 

608 ): 

609 contexts[thread_id] = user_data_export_pb2.ThreadContext(type="event", title=title) 

610 for (thread_id,) in session.execute(select(Page.thread_id).where(Page.thread_id.in_(thread_ids))): 610 ↛ 611line 610 didn't jump to line 611 because the loop on line 610 never started

611 contexts[thread_id] = user_data_export_pb2.ThreadContext(type="page") 

612 return contexts 

613 

614 

615def _discussions(session: Session, user: User) -> user_data_export_pb2.Discussions: 

616 discussions = ( 

617 session.execute(select(Discussion).where(Discussion.creator_user_id == user.id).order_by(Discussion.id)) 

618 .scalars() 

619 .all() 

620 ) 

621 comments = ( 

622 session.execute(select(Comment).where(Comment.author_user_id == user.id).order_by(Comment.id)).scalars().all() 

623 ) 

624 replies = session.execute( 

625 select(Reply, Comment.thread_id) 

626 .join(Comment, Comment.id == Reply.comment_id) 

627 .where(Reply.author_user_id == user.id) 

628 .order_by(Reply.id) 

629 ).all() 

630 

631 contexts = _thread_contexts( 

632 session, {comment.thread_id for comment in comments} | {thread_id for _, thread_id in replies} 

633 ) 

634 

635 return user_data_export_pb2.Discussions( 

636 created=[ 

637 user_data_export_pb2.DiscussionCreated( 

638 title=discussion.title, 

639 content=discussion.content, 

640 created=_ts(discussion.created), 

641 last_edited=_ts(discussion.last_edited), 

642 deleted=_ts(discussion.deleted), 

643 url=urls.discussion_link(discussion_id=str(discussion.id), slug=discussion.slug), 

644 ) 

645 for discussion in discussions 

646 ], 

647 comments=[ 

648 user_data_export_pb2.Comment( 

649 posted_in=contexts.get(comment.thread_id), 

650 content=comment.content, 

651 created=_ts(comment.created), 

652 last_edited=_ts(comment.last_edited), 

653 deleted=_ts(comment.deleted), 

654 ) 

655 for comment in comments 

656 ], 

657 replies=[ 

658 user_data_export_pb2.Reply( 

659 posted_in=contexts.get(thread_id), 

660 content=reply.content, 

661 created=_ts(reply.created), 

662 last_edited=_ts(reply.last_edited), 

663 deleted=_ts(reply.deleted), 

664 ) 

665 for reply, thread_id in replies 

666 ], 

667 ) 

668 

669 

670def _communities(session: Session, user: User) -> list[user_data_export_pb2.CommunityMembership]: 

671 subscriptions = ( 

672 session.execute( 

673 select(ClusterSubscription) 

674 .where(ClusterSubscription.user_id == user.id) 

675 .options(joinedload(ClusterSubscription.cluster)) 

676 .order_by(ClusterSubscription.id) 

677 ) 

678 .scalars() 

679 .all() 

680 ) 

681 return [ 

682 user_data_export_pb2.CommunityMembership( 

683 name=subscription.cluster.name, 

684 ) 

685 for subscription in subscriptions 

686 ] 

687 

688 

689def _public_trips(session: Session, user: User) -> list[user_data_export_pb2.PublicTrip]: 

690 trips = ( 

691 session.execute(select(PublicTrip).where(PublicTrip.user_id == user.id).order_by(PublicTrip.id)).scalars().all() 

692 ) 

693 return [ 

694 user_data_export_pb2.PublicTrip( 

695 from_date=_date(trip.from_date), 

696 to_date=_date(trip.to_date), 

697 description=trip.description, 

698 status=_enum(trip.status), 

699 same_gender_only=trip.same_gender_only, 

700 created=_ts(trip.created), 

701 ) 

702 for trip in trips 

703 ] 

704 

705 

706def _donations(session: Session, user: User) -> list[user_data_export_pb2.Invoice]: 

707 invoices = session.execute(select(Invoice).where(Invoice.user_id == user.id).order_by(Invoice.id)).scalars().all() 

708 return [ 

709 user_data_export_pb2.Invoice( 

710 created=_ts(invoice.created), 

711 amount=invoice.amount, 

712 invoice_type=_enum(invoice.invoice_type), 

713 receipt_url=invoice.stripe_receipt_url, 

714 ) 

715 for invoice in invoices 

716 ] 

717 

718 

719def _verification(session: Session, user: User) -> user_data_export_pb2.Verification: 

720 strong = ( 

721 session.execute( 

722 select(StrongVerificationAttempt) 

723 .where(StrongVerificationAttempt.user_id == user.id) 

724 .order_by(StrongVerificationAttempt.id) 

725 ) 

726 .scalars() 

727 .all() 

728 ) 

729 postal = ( 

730 session.execute( 

731 select(PostalVerificationAttempt) 

732 .where(PostalVerificationAttempt.user_id == user.id) 

733 .order_by(PostalVerificationAttempt.id) 

734 ) 

735 .scalars() 

736 .all() 

737 ) 

738 return user_data_export_pb2.Verification( 

739 strong_verification=[ 

740 user_data_export_pb2.StrongVerificationAttempt( 

741 created=_ts(attempt.created), 

742 status=_enum(attempt.status), 

743 passport_date_of_birth=_date(attempt.passport_date_of_birth), 

744 passport_sex=_enum(attempt.passport_sex), 

745 passport_expiry_date=_date(attempt.passport_expiry_date), 

746 passport_nationality=attempt.passport_nationality, 

747 passport_last_three_document_chars=attempt.passport_last_three_document_chars, 

748 ) 

749 for attempt in strong 

750 ], 

751 postal_verification=[ 

752 user_data_export_pb2.PostalVerificationAttempt( 

753 created=_ts(attempt.created), 

754 status=_enum(attempt.status), 

755 address_line_1=attempt.address_line_1, 

756 address_line_2=attempt.address_line_2, 

757 city=attempt.city, 

758 state=attempt.state, 

759 postal_code=attempt.postal_code, 

760 country_code=attempt.country_code, 

761 address_confirmed=_ts(attempt.address_confirmed_at), 

762 postcard_sent=_ts(attempt.postcard_sent_at), 

763 verified=_ts(attempt.verified_at), 

764 ) 

765 for attempt in postal 

766 ], 

767 ) 

768 

769 

770def _logins(session: Session, user: User) -> list[user_data_export_pb2.Login]: 

771 sessions = ( 

772 session.execute(select(UserSession).where(UserSession.user_id == user.id).order_by(UserSession.created)) 

773 .scalars() 

774 .all() 

775 ) 

776 return [ 

777 user_data_export_pb2.Login( 

778 created=_ts(user_session.created), 

779 logged_out=_ts(user_session.deleted), 

780 user_agent=user_session.user_agent, 

781 ) 

782 for user_session in sessions 

783 ] 

784 

785 

786def generate_user_data_export(session: Session, user: User, generated: datetime) -> user_data_export_pb2.UserDataExport: 

787 context = make_background_user_context(user.id) 

788 refs = _UserRefs(session, user, context) 

789 

790 mod_notes = session.execute(select(ModNote).where(ModNote.user_id == user.id).order_by(ModNote.id)).scalars().all() 

791 probes = ( 

792 session.execute(select(ActivenessProbe).where(ActivenessProbe.user_id == user.id).order_by(ActivenessProbe.id)) 

793 .scalars() 

794 .all() 

795 ) 

796 deletion_reasons = ( 

797 session.execute( 

798 select(AccountDeletionReason) 

799 .where(AccountDeletionReason.user_id == user.id) 

800 .order_by(AccountDeletionReason.id) 

801 ) 

802 .scalars() 

803 .all() 

804 ) 

805 invite_codes = ( 

806 session.execute(select(InviteCode).where(InviteCode.creator_user_id == user.id).order_by(InviteCode.created)) 

807 .scalars() 

808 .all() 

809 ) 

810 

811 return user_data_export_pb2.UserDataExport( 

812 export_format_version=EXPORT_FORMAT_VERSION, 

813 generated=_ts(generated), 

814 account=_account(user), 

815 profile=_profile(user), 

816 home=_home(user), 

817 photos=_photos(session, user), 

818 friends=_friends(session, user, refs), 

819 blocked_users=_blocked_users(session, user, refs), 

820 references=_references(session, user, refs, context), 

821 host_requests=_host_requests(session, user, refs, context), 

822 group_chats=_group_chats(session, user, refs), 

823 events=_events(session, user), 

824 discussions=_discussions(session, user), 

825 communities=_communities(session, user), 

826 public_trips=_public_trips(session, user), 

827 donations=_donations(session, user), 

828 verification=_verification(session, user), 

829 logins=_logins(session, user), 

830 mod_notes_received=[ 

831 user_data_export_pb2.ModNoteReceived( 

832 created=_ts(note.created), 

833 acknowledged=_ts(note.acknowledged), 

834 content=note.note_content, 

835 ) 

836 for note in mod_notes 

837 ], 

838 activeness_probes=[ 

839 user_data_export_pb2.ActivenessProbe( 

840 probe_initiated=_ts(probe.probe_initiated), 

841 responded=_ts(probe.responded), 

842 response=_enum(probe.response), 

843 ) 

844 for probe in probes 

845 ], 

846 account_deletion_reasons=[ 

847 user_data_export_pb2.AccountDeletionReason(created=_ts(reason.created), reason=reason.reason) 

848 for reason in deletion_reasons 

849 ], 

850 invite_codes_created=[ 

851 user_data_export_pb2.InviteCode(code=code.id, created=_ts(code.created), disabled=_ts(code.disabled)) 

852 for code in invite_codes 

853 ], 

854 )