Browse Source

feat(account): preserve and merge guest user data on registration, login, and support chat

- Add guest_merge.py to resolve guest accounts via token, device_id, and web IP+UA fingerprint
- Implement merge_guest_user_data for support rooms, messages, bookmarks, and ratings
- Trigger guest merging in UserVerifyView on verification and UserLoginView on authentication
- Automatically merge guest support conversations in resolve_user_support_room for logged-in users
master
Mohsen Taba 2 weeks ago
parent
commit
d9067e6a9f
  1. 165
      apps/account/guest_merge.py
  2. 26
      apps/account/views/user.py
  3. 27
      apps/support/views.py

165
apps/account/guest_merge.py

@ -0,0 +1,165 @@
import hashlib
import logging
from django.contrib.auth import get_user_model
from django.db import transaction
from rest_framework.authtoken.models import Token
from utils.ip_helper import get_client_ip
logger = logging.getLogger(__name__)
User = get_user_model()
def _extract_token_from_auth_header(request) -> str:
auth_header = (request.META.get('HTTP_AUTHORIZATION') or '').strip()
if not auth_header:
return ''
parts = auth_header.split()
if len(parts) == 2 and parts[0].lower() in ('token', 'bearer'):
return parts[1].strip()
return ''
def compute_web_guest_device_id(request) -> str:
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
if x_forwarded_for:
client_ip = x_forwarded_for.split(',')[0]
else:
client_ip = request.META.get('REMOTE_ADDR') or get_client_ip(request) or ''
user_agent = request.META.get('HTTP_USER_AGENT', '')
ua_hash = hashlib.md5(user_agent.encode('utf-8')).hexdigest()[:8]
return f"{client_ip}_{ua_hash}"
def resolve_guest_users_from_request(request, device_id: str = None) -> list:
"""
Finds any Guest User(s) (`email__isnull=True`) associated with the current request
via X-Guest-Token header, Authorization token header, explicit device_id, or Web IP+UA device_id.
Returns a deduplicated list of guest User instances in priority order.
"""
candidates = []
seen_ids = set()
def _add(u):
if u and getattr(u, 'email', None) is None and u.id not in seen_ids:
seen_ids.add(u.id)
candidates.append(u)
# 1. Explicit X-Guest-Token header
x_guest_key = (request.headers.get('X-Guest-Token') or '').strip()
if x_guest_key:
token_obj = Token.objects.select_related('user').filter(key=x_guest_key).first()
if token_obj:
_add(token_obj.user)
# 2. Authorization: token <guest_token> header (sent automatically by frontend before auth_token is set)
auth_key = _extract_token_from_auth_header(request)
if auth_key and auth_key != x_guest_key:
token_obj = Token.objects.select_related('user').filter(key=auth_key).first()
if token_obj:
_add(token_obj.user)
# 3. Explicit device_id parameter
if device_id:
guest_by_device = User.objects.filter(device_id=device_id, email__isnull=True).first()
_add(guest_by_device)
# 4. Web IP + User-Agent hash fallback (matches WebUserGuestView)
try:
web_device_id = compute_web_guest_device_id(request)
if web_device_id and web_device_id != device_id:
guest_by_web_id = User.objects.filter(device_id=web_device_id, email__isnull=True).first()
_add(guest_by_web_id)
except Exception as exc:
logger.debug("Could not compute web guest device_id fallback: %s", exc)
return candidates
@transaction.atomic
def merge_guest_user_data(guest_user, target_user) -> None:
"""
Merges all guest activity (SupportRoom, SupportMessage, Bookmark, Rate)
from `guest_user` into `target_user`.
Safe to call multiple times (idempotent).
"""
if not guest_user or not target_user or guest_user.id == target_user.id:
return
# 1. Merge Support Chat (SupportRoom & SupportMessage)
try:
from apps.support.models import SupportRoom, SupportMessage
guest_room = SupportRoom.objects.select_for_update().filter(user=guest_user).first()
if guest_room:
target_room = SupportRoom.objects.select_for_update().filter(user=target_user).first()
if not target_room:
guest_room.user = target_user
guest_room.save(update_fields=['user', 'updated_at'])
SupportMessage.objects.filter(room=guest_room, sender=guest_user).update(sender=target_user)
else:
# Both guest and target user already have support rooms: merge messages into target_room
SupportMessage.objects.filter(room=guest_room).update(room=target_room)
SupportMessage.objects.filter(room=target_room, sender=guest_user).update(sender=target_user)
target_room.unread_by_admin = (target_room.unread_by_admin or 0) + (guest_room.unread_by_admin or 0)
target_room.unread_by_user = (target_room.unread_by_user or 0) + (guest_room.unread_by_user or 0)
last_msg = target_room.messages.order_by('-id').first()
if last_msg:
target_room.last_message_at = last_msg.created_at
if last_msg.sender_type == SupportMessage.SenderType.USER:
target_room.status = SupportRoom.StatusChoices.PENDING
else:
target_room.status = SupportRoom.StatusChoices.ANSWERED
target_room.save(
update_fields=[
'unread_by_admin',
'unread_by_user',
'last_message_at',
'status',
'updated_at',
]
)
guest_room.delete()
except Exception as exc:
logger.error("Error merging support chat from guest %s to user %s: %s", guest_user.id, target_user.id, exc)
# 2. Merge Bookmarks
try:
from apps.bookmark.models import Bookmark
for bm in Bookmark.objects.filter(user=guest_user):
existing = Bookmark.objects.filter(
user=target_user,
service=bm.service,
content_id=bm.content_id,
).first()
if existing:
if bm.status and not existing.status:
existing.status = True
existing.save(update_fields=['status', 'updated_at'])
bm.delete()
else:
bm.user = target_user
bm.save(update_fields=['user', 'updated_at'])
except Exception as exc:
logger.error("Error merging bookmarks from guest %s to user %s: %s", guest_user.id, target_user.id, exc)
# 3. Merge Rates
try:
from apps.bookmark.models import Rate
for rt in Rate.objects.filter(user=guest_user):
existing = Rate.objects.filter(
user=target_user,
service=rt.service,
content_id=rt.content_id,
).first()
if existing:
rt.delete()
else:
rt.user = target_user
rt.save(update_fields=['user', 'updated_at'])
except Exception as exc:
logger.error("Error merging rates from guest %s to user %s: %s", guest_user.id, target_user.id, exc)

26
apps/account/views/user.py

@ -338,6 +338,8 @@ class UserVerifyView(CreateAPIView):
return current_code return current_code
def perform_create(self, *args, **kwargs): def perform_create(self, *args, **kwargs):
from apps.account.guest_merge import resolve_guest_users_from_request, merge_guest_user_data
email = kwargs.get('email') email = kwargs.get('email')
device_id = kwargs.get('device_id') device_id = kwargs.get('device_id')
password = kwargs.get('password') password = kwargs.get('password')
@ -353,6 +355,8 @@ class UserVerifyView(CreateAPIView):
create_kwargs.pop('user_agent', None) create_kwargs.pop('user_agent', None)
create_kwargs.pop('device_os', None) create_kwargs.pop('device_os', None)
guest_candidates = resolve_guest_users_from_request(self.request, device_id=device_id)
user = User.objects.filter(email=email).first() user = User.objects.filter(email=email).first()
if user: if user:
if password and str(password).lower() != 'none': if password and str(password).lower() != 'none':
@ -369,12 +373,13 @@ class UserVerifyView(CreateAPIView):
user.device_os = device_os user.device_os = device_os
user.last_login = timezone.now() user.last_login = timezone.now()
user.save() user.save()
for guest_user in guest_candidates:
if guest_user.id != user.id:
merge_guest_user_data(guest_user, user)
else: else:
# If device_id is provided, try to find existing user with that device_id
if device_id:
user = User.objects.filter(device_id=device_id, email__isnull=True).first()
else:
user = None
# Upgrade the primary guest user in-place if one exists (from guest_token, device_id, or Web IP+UA)
user = guest_candidates[0] if guest_candidates else None
if not user: if not user:
user = User(**create_kwargs) user = User(**create_kwargs)
@ -401,6 +406,11 @@ class UserVerifyView(CreateAPIView):
user.deleted_at = None user.deleted_at = None
user.save() user.save()
# Merge any remaining guest candidates into the newly upgraded user
for extra_guest in guest_candidates[1:]:
if extra_guest.id != user.id:
merge_guest_user_data(extra_guest, user)
user.login_history.create( user.login_history.create(
ip=client_ip, ip=client_ip,
user_agent=user_agent, user_agent=user_agent,
@ -490,6 +500,12 @@ class UserLoginView(CreateAPIView):
device_os = detect_device_os(user_agent) device_os = detect_device_os(user_agent)
device_id = request.data.get('device_id') device_id = request.data.get('device_id')
from apps.account.guest_merge import resolve_guest_users_from_request, merge_guest_user_data
guest_candidates = resolve_guest_users_from_request(request, device_id=device_id)
for guest_user in guest_candidates:
if guest_user.id != user.id:
merge_guest_user_data(guest_user, user)
user_timezone = serializer.validated_data.pop('timezone', None) user_timezone = serializer.validated_data.pop('timezone', None)
user.last_login = timezone.now() user.last_login = timezone.now()
user.is_active = True user.is_active = True

27
apps/support/views.py

@ -48,29 +48,22 @@ def get_total_admin_unread_count() -> int:
def resolve_user_support_room(request, create_if_missing: bool = False): def resolve_user_support_room(request, create_if_missing: bool = False):
""" """
Resolves the SupportRoom for request.user (works for both logged-in and guest users). Resolves the SupportRoom for request.user (works for both logged-in and guest users).
If a logged-in user does not yet have a room, checks the optional X-Guest-Token header
to seamlessly migrate their existing guest support conversation.
If the user is logged in, automatically checks for and merges any unmerged guest support room
(from X-Guest-Token or Web IP+UA device_id).
""" """
user = request.user user = request.user
if not getattr(user, 'is_guest', False) and getattr(user, 'email', None):
from apps.account.guest_merge import resolve_guest_users_from_request, merge_guest_user_data
guest_candidates = resolve_guest_users_from_request(request)
for guest_user in guest_candidates:
if guest_user.id != user.id:
merge_guest_user_data(guest_user, user)
room = SupportRoom.objects.filter(user=user).first() room = SupportRoom.objects.filter(user=user).first()
if room: if room:
return room return room
# Check if user recently logged in from a guest session that already had a support chat
guest_token_key = request.headers.get('X-Guest-Token', '').strip()
if guest_token_key and not getattr(user, 'is_guest', False):
guest_token_obj = (
Token.objects.select_related('user')
.filter(key=guest_token_key)
.first()
)
if guest_token_obj and guest_token_obj.user_id != user.id:
guest_room = SupportRoom.objects.filter(user=guest_token_obj.user).first()
if guest_room:
guest_room.user = user
guest_room.save(update_fields=['user', 'updated_at'])
return guest_room
if create_if_missing: if create_if_missing:
room, _ = SupportRoom.objects.get_or_create(user=user) room, _ = SupportRoom.objects.get_or_create(user=user)
return room return room

Loading…
Cancel
Save