fix(users): sync google profile data to user records
Some checks failed
Backend CI/CD / test (push) Has been cancelled
Backend CI/CD / deploy (push) Has been cancelled

This commit is contained in:
2026-05-14 21:39:47 +03:30
parent 388d4e0e7f
commit 3019f59d3a
3 changed files with 86 additions and 7 deletions

View File

@@ -65,6 +65,7 @@ from apps.users.services.google_oauth import (
find_social_account_for_profile, find_social_account_for_profile,
get_google_flow, get_google_flow,
send_google_claim_otp, send_google_claim_otp,
sync_user_from_google_profile,
verify_google_claim, verify_google_claim,
) )
@@ -190,6 +191,7 @@ class GoogleOAuthCallbackView(APIView):
social_account = find_social_account_for_profile(profile) social_account = find_social_account_for_profile(profile)
if social_account: if social_account:
sync_user_from_google_profile(social_account.user, profile)
flow_payload = build_authenticated_flow_payload(social_account.user) flow_payload = build_authenticated_flow_payload(social_account.user)
else: else:
flow_payload = build_pending_google_flow_payload(profile) flow_payload = build_pending_google_flow_payload(profile)

View File

@@ -4,10 +4,12 @@ import secrets
from dataclasses import asdict, dataclass, is_dataclass from dataclasses import asdict, dataclass, is_dataclass
from typing import Any from typing import Any
from urllib.parse import urlencode from urllib.parse import urlencode
from urllib.parse import urlparse
import requests import requests
from django.conf import settings from django.conf import settings
from django.core.cache import cache from django.core.cache import cache
from django.core.files.base import ContentFile
from rest_framework.exceptions import APIException, ValidationError from rest_framework.exceptions import APIException, ValidationError
from apps.users.email_identity import mask_mobile, normalize_email_identity from apps.users.email_identity import mask_mobile, normalize_email_identity
@@ -111,6 +113,46 @@ def _normalize_mobile(mobile: str) -> str:
return normalized return normalized
def _avatar_file_extension(profile: GoogleProfile) -> str:
path = urlparse(profile.avatar_url or "").path
if "." in path:
suffix = path.rsplit(".", 1)[-1].lower()
if suffix in {"jpg", "jpeg", "png", "webp", "gif"}:
return suffix
return "jpg"
def sync_user_from_google_profile(user: User, profile: GoogleProfile) -> None:
update_fields: list[str] = []
if not user.first_name and profile.first_name:
user.first_name = profile.first_name
update_fields.append("first_name")
if not user.last_name and profile.last_name:
user.last_name = profile.last_name
update_fields.append("last_name")
if normalize_email_identity(user.email) is None and profile.email:
user.email = profile.email
update_fields.append("email")
if not user.profile_picture and profile.avatar_url:
try:
avatar_response = requests.get(profile.avatar_url, timeout=10)
avatar_response.raise_for_status()
except requests.RequestException:
avatar_response = None
if avatar_response and avatar_response.content:
filename = f"google-{profile.provider_user_id}.{_avatar_file_extension(profile)}"
user.profile_picture.save(filename, ContentFile(avatar_response.content), save=False)
update_fields.append("profile_picture")
if update_fields:
user.save(update_fields=update_fields)
def _build_claim_required_payload( def _build_claim_required_payload(
*, *,
profile: GoogleProfile, profile: GoogleProfile,
@@ -379,6 +421,7 @@ def complete_google_signup(flow: str, mobile: str) -> dict[str, Any]:
) )
user.set_unusable_password() user.set_unusable_password()
user.save(update_fields=["password"]) user.save(update_fields=["password"])
sync_user_from_google_profile(user, profile)
UserSocialAccount.objects.create( UserSocialAccount.objects.create(
user=user, user=user,
@@ -457,6 +500,8 @@ def verify_google_claim(flow: str, code: str) -> dict[str, Any]:
user.email = profile.email user.email = profile.email
user.save(update_fields=["email"]) user.save(update_fields=["email"])
sync_user_from_google_profile(user, profile)
if existing_link: if existing_link:
existing_link.email = profile.email existing_link.email = profile.email
existing_link.email_verified = profile.email_verified existing_link.email_verified = profile.email_verified

View File

@@ -1,5 +1,5 @@
from io import StringIO from io import StringIO
from unittest.mock import patch from unittest.mock import Mock, patch
from django.conf import settings from django.conf import settings
from django.core.cache import cache from django.core.cache import cache
@@ -512,8 +512,9 @@ class GoogleOAuthApiTests(APITestCase):
cls.linked_user = User.objects.create_user( cls.linked_user = User.objects.create_user(
mobile="09125550001", mobile="09125550001",
password="secret123", password="secret123",
first_name="Google", first_name="",
last_name="Linked", last_name="",
email=None,
) )
cls.email_matched_user = User.objects.create_user( cls.email_matched_user = User.objects.create_user(
mobile="09125550002", mobile="09125550002",
@@ -525,8 +526,8 @@ class GoogleOAuthApiTests(APITestCase):
cls.blank_email_user = User.objects.create_user( cls.blank_email_user = User.objects.create_user(
mobile="09125550003", mobile="09125550003",
password="secret123", password="secret123",
first_name="Blank", first_name="",
last_name="Email", last_name="",
email=None, email=None,
) )
cls.other_email_user = User.objects.create_user( cls.other_email_user = User.objects.create_user(
@@ -550,11 +551,18 @@ class GoogleOAuthApiTests(APITestCase):
self.assertIn("accounts.google.com", response["Location"]) self.assertIn("accounts.google.com", response["Location"])
self.assertIn("state=", response["Location"]) self.assertIn("state=", response["Location"])
@patch("apps.users.services.google_oauth.requests.get")
@patch("apps.users.api.views.exchange_code_for_google_profile") @patch("apps.users.api.views.exchange_code_for_google_profile")
def test_google_callback_redirects_with_authenticated_flow_for_linked_account( def test_google_callback_redirects_with_authenticated_flow_for_linked_account(
self, self,
exchange_code_for_google_profile, exchange_code_for_google_profile,
requests_get,
): ):
avatar_response = Mock()
avatar_response.content = b"avatar-bytes"
avatar_response.headers = {"Content-Type": "image/png"}
avatar_response.raise_for_status.return_value = None
requests_get.return_value = avatar_response
exchange_code_for_google_profile.return_value = GoogleProfile( exchange_code_for_google_profile.return_value = GoogleProfile(
provider_user_id="google-sub-1", provider_user_id="google-sub-1",
email="linked@example.com", email="linked@example.com",
@@ -589,6 +597,11 @@ class GoogleOAuthApiTests(APITestCase):
self.assertEqual(flow_response.data["status"], "authenticated") self.assertEqual(flow_response.data["status"], "authenticated")
self.assertIn("access", flow_response.data) self.assertIn("access", flow_response.data)
self.assertIn("refresh", flow_response.data) self.assertIn("refresh", flow_response.data)
self.linked_user.refresh_from_db()
self.assertEqual(self.linked_user.email, "linked@example.com")
self.assertEqual(self.linked_user.first_name, "Google")
self.assertEqual(self.linked_user.last_name, "Linked")
self.assertTrue(bool(self.linked_user.profile_picture))
@patch("apps.users.api.views.exchange_code_for_google_profile") @patch("apps.users.api.views.exchange_code_for_google_profile")
def test_google_callback_redirects_with_mobile_collection_flow_for_new_account( def test_google_callback_redirects_with_mobile_collection_flow_for_new_account(
@@ -720,7 +733,13 @@ class GoogleOAuthApiTests(APITestCase):
self.assertEqual(response.data["code"], "google_email_mobile_conflict") self.assertEqual(response.data["code"], "google_email_mobile_conflict")
self.assertEqual(response.data["mobile_hint"], "09*****0002") self.assertEqual(response.data["mobile_hint"], "09*****0002")
def test_google_complete_new_mobile_creates_user_and_link(self): @patch("apps.users.services.google_oauth.requests.get")
def test_google_complete_new_mobile_creates_user_and_link(self, requests_get):
avatar_response = Mock()
avatar_response.content = b"avatar-bytes"
avatar_response.headers = {"Content-Type": "image/png"}
avatar_response.raise_for_status.return_value = None
requests_get.return_value = avatar_response
cache.set( cache.set(
"google_oauth_flow:new-flow", "google_oauth_flow:new-flow",
{ {
@@ -751,6 +770,10 @@ class GoogleOAuthApiTests(APITestCase):
self.assertEqual(response.data["status"], "authenticated") self.assertEqual(response.data["status"], "authenticated")
created_user = User.objects.get(mobile="09125550009") created_user = User.objects.get(mobile="09125550009")
self.assertFalse(created_user.has_usable_password()) self.assertFalse(created_user.has_usable_password())
self.assertEqual(created_user.email, "created@example.com")
self.assertEqual(created_user.first_name, "Created")
self.assertEqual(created_user.last_name, "User")
self.assertTrue(bool(created_user.profile_picture))
self.assertTrue( self.assertTrue(
UserSocialAccount.objects.filter( UserSocialAccount.objects.filter(
user=created_user, user=created_user,
@@ -858,9 +881,15 @@ class GoogleOAuthApiTests(APITestCase):
self.assertEqual(response.data["access"], "a") self.assertEqual(response.data["access"], "a")
verify_google_claim.assert_called_once_with(flow="claim-flow", code="12345") verify_google_claim.assert_called_once_with(flow="claim-flow", code="12345")
@patch("apps.users.services.google_oauth.requests.get")
@patch("apps.users.services.google_oauth.get_tokens_for_user") @patch("apps.users.services.google_oauth.get_tokens_for_user")
def test_google_claim_verify_sets_blank_user_email_from_google(self, get_tokens_for_user): def test_google_claim_verify_sets_blank_user_email_from_google(self, get_tokens_for_user, requests_get):
get_tokens_for_user.return_value = {"access": "a", "refresh": "r"} get_tokens_for_user.return_value = {"access": "a", "refresh": "r"}
avatar_response = Mock()
avatar_response.content = b"avatar-bytes"
avatar_response.headers = {"Content-Type": "image/png"}
avatar_response.raise_for_status.return_value = None
requests_get.return_value = avatar_response
cache.set( cache.set(
"google_oauth_flow:claim-verify-flow", "google_oauth_flow:claim-verify-flow",
{ {
@@ -897,6 +926,9 @@ class GoogleOAuthApiTests(APITestCase):
self.assertEqual(response.data["status"], "authenticated") self.assertEqual(response.data["status"], "authenticated")
self.blank_email_user.refresh_from_db() self.blank_email_user.refresh_from_db()
self.assertEqual(self.blank_email_user.email, "claimed@example.com") self.assertEqual(self.blank_email_user.email, "claimed@example.com")
self.assertEqual(self.blank_email_user.first_name, "Claimed")
self.assertEqual(self.blank_email_user.last_name, "User")
self.assertTrue(bool(self.blank_email_user.profile_picture))
self.assertTrue( self.assertTrue(
UserSocialAccount.objects.filter( UserSocialAccount.objects.filter(
user=self.blank_email_user, user=self.blank_email_user,