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
« 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
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_
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
53EXPORT_FORMAT_VERSION = 1
56def _ts(value: datetime | None) -> Any:
57 return Timestamp_from_datetime(value) if value is not None else None
60def _date(value: date | None) -> str | None:
61 return value.isoformat() if value is not None else None
64def _enum(value: enum.Enum | None) -> str | None:
65 return value.name if value is not None else None
68def _bool(value: bool | None) -> BoolValue | None:
69 return BoolValue(value=value) if value is not None else None
72def _int32(value: int | None) -> Int32Value | None:
73 return Int32Value(value=value) if value is not None else None
76def _double(value: float | None) -> DoubleValue | None:
77 return DoubleValue(value=value) if value is not None else None
80def _string(value: str | None) -> StringValue | None:
81 return StringValue(value=value) if value is not None else None
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}
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)
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]
110 def is_visible(self, user_id: int) -> bool:
111 return self._username(user_id) is not None
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))
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
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))
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 )
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 )
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 )
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 )
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())
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
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
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 ]
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 )
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 )
324 refs.prefetch([reference.to_user_id for reference in written])
325 refs.prefetch([reference.from_user_id for reference in received])
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 )
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
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 )
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)
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
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)
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 )
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)
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)
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
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 )
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 )
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 )
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
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()
631 contexts = _thread_contexts(
632 session, {comment.thread_id for comment in comments} | {thread_id for _, thread_id in replies}
633 )
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 )
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 ]
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 ]
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 ]
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 )
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 ]
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)
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 )
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 )