django-clerk-users 0.0.1__py3-none-any.whl → 0.0.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- django_clerk_users/__init__.py +78 -7
- django_clerk_users/apps.py +20 -0
- django_clerk_users/authentication/__init__.py +24 -0
- django_clerk_users/authentication/backends.py +89 -0
- django_clerk_users/authentication/drf.py +111 -0
- django_clerk_users/authentication/utils.py +171 -0
- django_clerk_users/caching.py +161 -0
- django_clerk_users/checks.py +127 -0
- django_clerk_users/client.py +32 -0
- django_clerk_users/decorators.py +181 -0
- django_clerk_users/exceptions.py +51 -0
- django_clerk_users/management/__init__.py +0 -0
- django_clerk_users/management/commands/__init__.py +0 -0
- django_clerk_users/management/commands/migrate_users_to_clerk.py +223 -0
- django_clerk_users/management/commands/sync_clerk_organizations.py +191 -0
- django_clerk_users/management/commands/sync_clerk_users.py +114 -0
- django_clerk_users/managers.py +121 -0
- django_clerk_users/middleware/__init__.py +9 -0
- django_clerk_users/middleware/auth.py +201 -0
- django_clerk_users/migrations/0001_initial.py +174 -0
- django_clerk_users/migrations/__init__.py +0 -0
- django_clerk_users/models.py +174 -0
- django_clerk_users/organizations/__init__.py +8 -0
- django_clerk_users/organizations/admin.py +81 -0
- django_clerk_users/organizations/apps.py +8 -0
- django_clerk_users/organizations/middleware.py +130 -0
- django_clerk_users/organizations/models.py +316 -0
- django_clerk_users/organizations/webhooks.py +417 -0
- django_clerk_users/settings.py +37 -0
- django_clerk_users/testing.py +381 -0
- django_clerk_users/utils.py +210 -0
- django_clerk_users/webhooks/__init__.py +26 -0
- django_clerk_users/webhooks/handlers.py +346 -0
- django_clerk_users/webhooks/security.py +108 -0
- django_clerk_users/webhooks/signals.py +42 -0
- django_clerk_users/webhooks/views.py +76 -0
- django_clerk_users-0.0.2.dist-info/METADATA +228 -0
- django_clerk_users-0.0.2.dist-info/RECORD +41 -0
- django_clerk_users/main.py +0 -2
- django_clerk_users-0.0.1.dist-info/METADATA +0 -24
- django_clerk_users-0.0.1.dist-info/RECORD +0 -7
- {django_clerk_users-0.0.1.dist-info → django_clerk_users-0.0.2.dist-info}/WHEEL +0 -0
- {django_clerk_users-0.0.1.dist-info → django_clerk_users-0.0.2.dist-info}/licenses/LICENSE +0 -0
- {django_clerk_users-0.0.1.dist-info → django_clerk_users-0.0.2.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Testing utilities for django-clerk-users.
|
|
3
|
+
|
|
4
|
+
Provides helpers for creating test users via Clerk's Backend API,
|
|
5
|
+
useful for integration testing and local development.
|
|
6
|
+
|
|
7
|
+
See: https://clerk.com/docs/testing/overview
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
from django_clerk_users.testing import ClerkTestClient
|
|
11
|
+
|
|
12
|
+
# In your test setup
|
|
13
|
+
client = ClerkTestClient()
|
|
14
|
+
user_data = client.create_test_user(email="test+clerk_test@example.com")
|
|
15
|
+
token = client.get_session_token(user_data["id"])
|
|
16
|
+
|
|
17
|
+
# Make authenticated requests
|
|
18
|
+
response = self.client.get(
|
|
19
|
+
"/api/protected/",
|
|
20
|
+
HTTP_AUTHORIZATION=f"Bearer {token}"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
Test Email/Phone Patterns:
|
|
24
|
+
- Test emails: Use `+clerk_test` suffix (e.g., "jane+clerk_test@example.com")
|
|
25
|
+
- Test phones: Use `+1 (XXX) 555-0100` through `+1 (XXX) 555-0199`
|
|
26
|
+
- Fixed OTP code for both: 424242
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import uuid
|
|
32
|
+
from dataclasses import dataclass
|
|
33
|
+
from typing import TYPE_CHECKING, Any
|
|
34
|
+
|
|
35
|
+
from django_clerk_users.client import get_clerk_client
|
|
36
|
+
|
|
37
|
+
if TYPE_CHECKING:
|
|
38
|
+
from clerk_backend_api import Clerk
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# Test mode constants
|
|
42
|
+
TEST_OTP_CODE = "424242"
|
|
43
|
+
TEST_EMAIL_SUFFIX = "+clerk_test"
|
|
44
|
+
TEST_PHONE_PREFIX = "+1"
|
|
45
|
+
TEST_PHONE_PATTERN = "555-01" # 555-0100 through 555-0199
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def make_test_email(base: str = "testuser", domain: str = "example.com") -> str:
|
|
49
|
+
"""
|
|
50
|
+
Generate a test email address with the +clerk_test suffix.
|
|
51
|
+
|
|
52
|
+
These emails won't trigger actual email delivery in test mode
|
|
53
|
+
and use the fixed OTP code 424242.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
base: The base username (default: "testuser")
|
|
57
|
+
domain: The email domain (default: "example.com")
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
A test email like "testuser+clerk_test@example.com"
|
|
61
|
+
"""
|
|
62
|
+
unique_id = uuid.uuid4().hex[:8]
|
|
63
|
+
return f"{base}+clerk_test_{unique_id}@{domain}"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def make_test_phone(area_code: str = "201", suffix: int = 0) -> str:
|
|
67
|
+
"""
|
|
68
|
+
Generate a test phone number using Clerk's reserved test range.
|
|
69
|
+
|
|
70
|
+
These phone numbers won't trigger actual SMS delivery in test mode
|
|
71
|
+
and use the fixed OTP code 424242.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
area_code: US area code (default: "201")
|
|
75
|
+
suffix: Number from 0-99 for the 555-01XX range (default: 0)
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
A test phone like "+12015550100"
|
|
79
|
+
"""
|
|
80
|
+
suffix = max(0, min(99, suffix)) # Clamp to 0-99
|
|
81
|
+
return f"+1{area_code}55501{suffix:02d}"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass
|
|
85
|
+
class TestUserData:
|
|
86
|
+
"""Data returned when creating a test user."""
|
|
87
|
+
|
|
88
|
+
id: str
|
|
89
|
+
email: str | None
|
|
90
|
+
first_name: str | None
|
|
91
|
+
last_name: str | None
|
|
92
|
+
phone_number: str | None
|
|
93
|
+
raw_response: dict[str, Any]
|
|
94
|
+
|
|
95
|
+
@classmethod
|
|
96
|
+
def from_clerk_response(cls, response: Any) -> "TestUserData":
|
|
97
|
+
"""Create TestUserData from a Clerk API response."""
|
|
98
|
+
# Handle both dict and object responses
|
|
99
|
+
if hasattr(response, "id"):
|
|
100
|
+
return cls(
|
|
101
|
+
id=response.id,
|
|
102
|
+
email=getattr(response, "email_addresses", [{}])[0].get("email_address")
|
|
103
|
+
if getattr(response, "email_addresses", None)
|
|
104
|
+
else None,
|
|
105
|
+
first_name=getattr(response, "first_name", None),
|
|
106
|
+
last_name=getattr(response, "last_name", None),
|
|
107
|
+
phone_number=getattr(response, "phone_numbers", [{}])[0].get(
|
|
108
|
+
"phone_number"
|
|
109
|
+
)
|
|
110
|
+
if getattr(response, "phone_numbers", None)
|
|
111
|
+
else None,
|
|
112
|
+
raw_response=response.__dict__
|
|
113
|
+
if hasattr(response, "__dict__")
|
|
114
|
+
else {},
|
|
115
|
+
)
|
|
116
|
+
else:
|
|
117
|
+
# Dict response
|
|
118
|
+
return cls(
|
|
119
|
+
id=response.get("id", ""),
|
|
120
|
+
email=response.get("email_addresses", [{}])[0].get("email_address"),
|
|
121
|
+
first_name=response.get("first_name"),
|
|
122
|
+
last_name=response.get("last_name"),
|
|
123
|
+
phone_number=response.get("phone_numbers", [{}])[0].get("phone_number")
|
|
124
|
+
if response.get("phone_numbers")
|
|
125
|
+
else None,
|
|
126
|
+
raw_response=response,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class ClerkTestClient:
|
|
131
|
+
"""
|
|
132
|
+
Client for creating test users and sessions via Clerk's Backend API.
|
|
133
|
+
|
|
134
|
+
This client wraps Clerk's Backend API to provide convenient methods
|
|
135
|
+
for testing scenarios. It's designed for use in development and test
|
|
136
|
+
environments only.
|
|
137
|
+
|
|
138
|
+
Example:
|
|
139
|
+
client = ClerkTestClient()
|
|
140
|
+
|
|
141
|
+
# Create a test user
|
|
142
|
+
user = client.create_test_user()
|
|
143
|
+
|
|
144
|
+
# Get a session token for authenticated requests
|
|
145
|
+
token = client.get_session_token(user.id)
|
|
146
|
+
|
|
147
|
+
# Use in Django test client
|
|
148
|
+
response = self.client.get(
|
|
149
|
+
"/api/protected/",
|
|
150
|
+
HTTP_AUTHORIZATION=f"Bearer {token}"
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
# Cleanup after test
|
|
154
|
+
client.delete_user(user.id)
|
|
155
|
+
"""
|
|
156
|
+
|
|
157
|
+
def __init__(self, clerk_client: "Clerk | None" = None):
|
|
158
|
+
"""
|
|
159
|
+
Initialize the test client.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
clerk_client: Optional Clerk client instance. If not provided,
|
|
163
|
+
uses the configured client from django settings.
|
|
164
|
+
"""
|
|
165
|
+
self._client = clerk_client
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def client(self) -> "Clerk":
|
|
169
|
+
"""Get the Clerk client, initializing if needed."""
|
|
170
|
+
if self._client is None:
|
|
171
|
+
self._client = get_clerk_client()
|
|
172
|
+
return self._client
|
|
173
|
+
|
|
174
|
+
def create_test_user(
|
|
175
|
+
self,
|
|
176
|
+
email: str | None = None,
|
|
177
|
+
first_name: str = "Test",
|
|
178
|
+
last_name: str = "User",
|
|
179
|
+
password: str | None = None,
|
|
180
|
+
phone_number: str | None = None,
|
|
181
|
+
**kwargs: Any,
|
|
182
|
+
) -> TestUserData:
|
|
183
|
+
"""
|
|
184
|
+
Create a test user via Clerk's Backend API.
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
email: Email address. If None, generates a test email.
|
|
188
|
+
first_name: User's first name (default: "Test")
|
|
189
|
+
last_name: User's last name (default: "User")
|
|
190
|
+
password: Optional password. If not provided, user is passwordless.
|
|
191
|
+
phone_number: Optional phone number for SMS auth.
|
|
192
|
+
**kwargs: Additional fields to pass to Clerk API.
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
TestUserData with the created user's information.
|
|
196
|
+
|
|
197
|
+
Example:
|
|
198
|
+
# Create user with auto-generated test email
|
|
199
|
+
user = client.create_test_user()
|
|
200
|
+
|
|
201
|
+
# Create user with specific email (use +clerk_test for test mode)
|
|
202
|
+
user = client.create_test_user(email="admin+clerk_test@example.com")
|
|
203
|
+
|
|
204
|
+
# Create user with password for email/password auth
|
|
205
|
+
user = client.create_test_user(password="testpass123")
|
|
206
|
+
"""
|
|
207
|
+
if email is None:
|
|
208
|
+
email = make_test_email()
|
|
209
|
+
|
|
210
|
+
create_params: dict[str, Any] = {
|
|
211
|
+
"email_address": [email],
|
|
212
|
+
"first_name": first_name,
|
|
213
|
+
"last_name": last_name,
|
|
214
|
+
**kwargs,
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if password:
|
|
218
|
+
create_params["password"] = password
|
|
219
|
+
|
|
220
|
+
if phone_number:
|
|
221
|
+
create_params["phone_number"] = [phone_number]
|
|
222
|
+
|
|
223
|
+
response = self.client.users.create(**create_params)
|
|
224
|
+
return TestUserData.from_clerk_response(response)
|
|
225
|
+
|
|
226
|
+
def create_session(self, user_id: str) -> dict[str, Any]:
|
|
227
|
+
"""
|
|
228
|
+
Create a session for a user.
|
|
229
|
+
|
|
230
|
+
Args:
|
|
231
|
+
user_id: The Clerk user ID.
|
|
232
|
+
|
|
233
|
+
Returns:
|
|
234
|
+
Session data including the session ID.
|
|
235
|
+
"""
|
|
236
|
+
response = self.client.sessions.create(user_id=user_id)
|
|
237
|
+
if hasattr(response, "__dict__"):
|
|
238
|
+
return {"id": response.id, "user_id": response.user_id}
|
|
239
|
+
return response
|
|
240
|
+
|
|
241
|
+
def get_session_token(self, user_id: str, session_id: str | None = None) -> str:
|
|
242
|
+
"""
|
|
243
|
+
Get a session token for making authenticated API requests.
|
|
244
|
+
|
|
245
|
+
Creates a session if session_id is not provided, then generates
|
|
246
|
+
a session token.
|
|
247
|
+
|
|
248
|
+
Note: Clerk session tokens are short-lived (60 seconds). For longer
|
|
249
|
+
tests, you may need to refresh the token.
|
|
250
|
+
|
|
251
|
+
Args:
|
|
252
|
+
user_id: The Clerk user ID.
|
|
253
|
+
session_id: Optional existing session ID. If not provided,
|
|
254
|
+
creates a new session.
|
|
255
|
+
|
|
256
|
+
Returns:
|
|
257
|
+
A JWT session token for use in Authorization header.
|
|
258
|
+
|
|
259
|
+
Example:
|
|
260
|
+
token = client.get_session_token(user.id)
|
|
261
|
+
response = requests.get(
|
|
262
|
+
"http://localhost:8000/api/protected/",
|
|
263
|
+
headers={"Authorization": f"Bearer {token}"}
|
|
264
|
+
)
|
|
265
|
+
"""
|
|
266
|
+
if session_id is None:
|
|
267
|
+
session = self.create_session(user_id)
|
|
268
|
+
session_id = session["id"]
|
|
269
|
+
|
|
270
|
+
response = self.client.sessions.create_session_token(session_id=session_id)
|
|
271
|
+
|
|
272
|
+
if hasattr(response, "jwt"):
|
|
273
|
+
return response.jwt
|
|
274
|
+
return response.get("jwt", "")
|
|
275
|
+
|
|
276
|
+
def delete_user(self, user_id: str) -> bool:
|
|
277
|
+
"""
|
|
278
|
+
Delete a test user.
|
|
279
|
+
|
|
280
|
+
Call this in test teardown to clean up created users.
|
|
281
|
+
|
|
282
|
+
Args:
|
|
283
|
+
user_id: The Clerk user ID to delete.
|
|
284
|
+
|
|
285
|
+
Returns:
|
|
286
|
+
True if deletion was successful.
|
|
287
|
+
"""
|
|
288
|
+
try:
|
|
289
|
+
self.client.users.delete(user_id=user_id)
|
|
290
|
+
return True
|
|
291
|
+
except Exception:
|
|
292
|
+
return False
|
|
293
|
+
|
|
294
|
+
def get_testing_token(self) -> str:
|
|
295
|
+
"""
|
|
296
|
+
Get a testing token for bypassing bot detection.
|
|
297
|
+
|
|
298
|
+
Testing tokens are used to bypass Clerk's bot detection when
|
|
299
|
+
making Frontend API requests during automated testing.
|
|
300
|
+
|
|
301
|
+
Returns:
|
|
302
|
+
A testing token string.
|
|
303
|
+
|
|
304
|
+
See: https://clerk.com/docs/testing/testing-tokens
|
|
305
|
+
"""
|
|
306
|
+
response = self.client.testing_tokens.create()
|
|
307
|
+
if hasattr(response, "token"):
|
|
308
|
+
return response.token
|
|
309
|
+
return response.get("token", "")
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
class ClerkTestMixin:
|
|
313
|
+
"""
|
|
314
|
+
Mixin for Django TestCase classes that need Clerk test users.
|
|
315
|
+
|
|
316
|
+
Provides setUp/tearDown helpers for creating and cleaning up test users.
|
|
317
|
+
|
|
318
|
+
Example:
|
|
319
|
+
from django.test import TestCase
|
|
320
|
+
from django_clerk_users.testing import ClerkTestMixin
|
|
321
|
+
|
|
322
|
+
class MyAPITestCase(ClerkTestMixin, TestCase):
|
|
323
|
+
def test_protected_endpoint(self):
|
|
324
|
+
# self.test_user and self.session_token are available
|
|
325
|
+
response = self.client.get(
|
|
326
|
+
"/api/protected/",
|
|
327
|
+
HTTP_AUTHORIZATION=f"Bearer {self.session_token}"
|
|
328
|
+
)
|
|
329
|
+
self.assertEqual(response.status_code, 200)
|
|
330
|
+
"""
|
|
331
|
+
|
|
332
|
+
clerk_test_client: ClerkTestClient
|
|
333
|
+
test_user: TestUserData
|
|
334
|
+
session_token: str
|
|
335
|
+
_created_users: list[str]
|
|
336
|
+
|
|
337
|
+
def setUp(self) -> None:
|
|
338
|
+
"""Set up test fixtures including a test user."""
|
|
339
|
+
super().setUp() # type: ignore[misc]
|
|
340
|
+
self.clerk_test_client = ClerkTestClient()
|
|
341
|
+
self._created_users = []
|
|
342
|
+
|
|
343
|
+
# Create a default test user
|
|
344
|
+
self.test_user = self.create_test_user()
|
|
345
|
+
self.session_token = self.clerk_test_client.get_session_token(self.test_user.id)
|
|
346
|
+
|
|
347
|
+
def tearDown(self) -> None:
|
|
348
|
+
"""Clean up created test users."""
|
|
349
|
+
for user_id in self._created_users:
|
|
350
|
+
self.clerk_test_client.delete_user(user_id)
|
|
351
|
+
super().tearDown() # type: ignore[misc]
|
|
352
|
+
|
|
353
|
+
def create_test_user(self, **kwargs: Any) -> TestUserData:
|
|
354
|
+
"""
|
|
355
|
+
Create a test user and track it for cleanup.
|
|
356
|
+
|
|
357
|
+
Args:
|
|
358
|
+
**kwargs: Arguments to pass to ClerkTestClient.create_test_user()
|
|
359
|
+
|
|
360
|
+
Returns:
|
|
361
|
+
TestUserData for the created user.
|
|
362
|
+
"""
|
|
363
|
+
user = self.clerk_test_client.create_test_user(**kwargs)
|
|
364
|
+
self._created_users.append(user.id)
|
|
365
|
+
return user
|
|
366
|
+
|
|
367
|
+
def get_auth_header(self, user: TestUserData | None = None) -> dict[str, str]:
|
|
368
|
+
"""
|
|
369
|
+
Get an Authorization header dict for a user.
|
|
370
|
+
|
|
371
|
+
Args:
|
|
372
|
+
user: The user to get a token for. Defaults to self.test_user.
|
|
373
|
+
|
|
374
|
+
Returns:
|
|
375
|
+
Dict with HTTP_AUTHORIZATION key for use with Django test client.
|
|
376
|
+
"""
|
|
377
|
+
if user is None:
|
|
378
|
+
return {"HTTP_AUTHORIZATION": f"Bearer {self.session_token}"}
|
|
379
|
+
|
|
380
|
+
token = self.clerk_test_client.get_session_token(user.id)
|
|
381
|
+
return {"HTTP_AUTHORIZATION": f"Bearer {token}"}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Core utilities for django-clerk-users.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from typing import TYPE_CHECKING, Any
|
|
9
|
+
|
|
10
|
+
from django_clerk_users.caching import (
|
|
11
|
+
get_cached_user,
|
|
12
|
+
invalidate_clerk_user_cache,
|
|
13
|
+
set_cached_user,
|
|
14
|
+
)
|
|
15
|
+
from django_clerk_users.client import get_clerk_client
|
|
16
|
+
from django_clerk_users.exceptions import ClerkAPIError, ClerkUserNotFoundError
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from django_clerk_users.models import AbstractClerkUser
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def update_or_create_clerk_user(
|
|
25
|
+
clerk_user_id: str,
|
|
26
|
+
) -> tuple["AbstractClerkUser", bool]:
|
|
27
|
+
"""
|
|
28
|
+
Update or create a Django user from Clerk data.
|
|
29
|
+
|
|
30
|
+
Fetches user data from the Clerk API and creates or updates
|
|
31
|
+
the corresponding Django user.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
clerk_user_id: The Clerk user ID.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
A tuple of (user, created) where created is True if the user was
|
|
38
|
+
newly created.
|
|
39
|
+
|
|
40
|
+
Raises:
|
|
41
|
+
ClerkUserNotFoundError: If the user is not found in Clerk.
|
|
42
|
+
ClerkAPIError: If the Clerk API returns an error.
|
|
43
|
+
"""
|
|
44
|
+
from django.contrib.auth import get_user_model
|
|
45
|
+
|
|
46
|
+
User = get_user_model()
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
# Fetch user from Clerk API
|
|
50
|
+
clerk = get_clerk_client()
|
|
51
|
+
clerk_user = clerk.users.get(user_id=clerk_user_id)
|
|
52
|
+
|
|
53
|
+
if not clerk_user:
|
|
54
|
+
raise ClerkUserNotFoundError(f"User not found in Clerk: {clerk_user_id}")
|
|
55
|
+
|
|
56
|
+
# Extract email from email_addresses array
|
|
57
|
+
primary_email = None
|
|
58
|
+
email_addresses = getattr(clerk_user, "email_addresses", []) or []
|
|
59
|
+
for email_obj in email_addresses:
|
|
60
|
+
email_id = getattr(clerk_user, "primary_email_address_id", None)
|
|
61
|
+
if email_id and getattr(email_obj, "id", None) == email_id:
|
|
62
|
+
primary_email = getattr(email_obj, "email_address", None)
|
|
63
|
+
break
|
|
64
|
+
if not primary_email and email_addresses:
|
|
65
|
+
# Fallback to first email
|
|
66
|
+
primary_email = getattr(email_addresses[0], "email_address", None)
|
|
67
|
+
|
|
68
|
+
if not primary_email:
|
|
69
|
+
raise ClerkAPIError(f"User {clerk_user_id} has no email address")
|
|
70
|
+
|
|
71
|
+
# Prepare user data
|
|
72
|
+
user_data = {
|
|
73
|
+
"email": primary_email,
|
|
74
|
+
"first_name": getattr(clerk_user, "first_name", "") or "",
|
|
75
|
+
"last_name": getattr(clerk_user, "last_name", "") or "",
|
|
76
|
+
"image_url": getattr(clerk_user, "image_url", "") or "",
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
# Update or create the Django user
|
|
80
|
+
user, created = User.objects.update_or_create(
|
|
81
|
+
clerk_id=clerk_user_id,
|
|
82
|
+
defaults=user_data,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
# Update cache
|
|
86
|
+
set_cached_user(clerk_user_id, user)
|
|
87
|
+
|
|
88
|
+
return user, created
|
|
89
|
+
|
|
90
|
+
except ClerkUserNotFoundError:
|
|
91
|
+
raise
|
|
92
|
+
except Exception as e:
|
|
93
|
+
logger.error(f"Failed to fetch/create user from Clerk: {e}")
|
|
94
|
+
raise ClerkAPIError(f"Failed to fetch user from Clerk: {e}") from e
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def get_clerk_user(clerk_user_id: str) -> "AbstractClerkUser | None":
|
|
98
|
+
"""
|
|
99
|
+
Get a Django user by their Clerk ID.
|
|
100
|
+
|
|
101
|
+
Checks the cache first, then the database.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
clerk_user_id: The Clerk user ID.
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
The user instance or None if not found.
|
|
108
|
+
"""
|
|
109
|
+
from django.contrib.auth import get_user_model
|
|
110
|
+
|
|
111
|
+
User = get_user_model()
|
|
112
|
+
|
|
113
|
+
# Check cache first
|
|
114
|
+
cached = get_cached_user(clerk_user_id)
|
|
115
|
+
if cached is not None:
|
|
116
|
+
if cached is False:
|
|
117
|
+
return None # Cached as "not found"
|
|
118
|
+
return cached
|
|
119
|
+
|
|
120
|
+
# Query database
|
|
121
|
+
user = User.objects.filter(clerk_id=clerk_user_id).first()
|
|
122
|
+
|
|
123
|
+
# Update cache
|
|
124
|
+
set_cached_user(clerk_user_id, user)
|
|
125
|
+
|
|
126
|
+
return user
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def sync_user_from_clerk(clerk_user_id: str) -> "AbstractClerkUser | None":
|
|
130
|
+
"""
|
|
131
|
+
Force sync a user from Clerk, ignoring cache.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
clerk_user_id: The Clerk user ID.
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
The synced user or None if sync failed.
|
|
138
|
+
"""
|
|
139
|
+
# Invalidate cache
|
|
140
|
+
invalidate_clerk_user_cache(clerk_user_id)
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
user, _ = update_or_create_clerk_user(clerk_user_id)
|
|
144
|
+
return user
|
|
145
|
+
except Exception as e:
|
|
146
|
+
logger.error(f"Failed to sync user: {e}")
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def get_user_metadata(clerk_user_id: str) -> dict[str, Any]:
|
|
151
|
+
"""
|
|
152
|
+
Get user metadata from Clerk.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
clerk_user_id: The Clerk user ID.
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
A dict containing public and private metadata.
|
|
159
|
+
"""
|
|
160
|
+
try:
|
|
161
|
+
clerk = get_clerk_client()
|
|
162
|
+
clerk_user = clerk.users.get(user_id=clerk_user_id)
|
|
163
|
+
|
|
164
|
+
if not clerk_user:
|
|
165
|
+
return {"public": {}, "private": {}}
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
"public": getattr(clerk_user, "public_metadata", {}) or {},
|
|
169
|
+
"private": getattr(clerk_user, "private_metadata", {}) or {},
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
except Exception as e:
|
|
173
|
+
logger.error(f"Failed to get user metadata: {e}")
|
|
174
|
+
return {"public": {}, "private": {}}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def update_user_metadata(
|
|
178
|
+
clerk_user_id: str,
|
|
179
|
+
public_metadata: dict[str, Any] | None = None,
|
|
180
|
+
private_metadata: dict[str, Any] | None = None,
|
|
181
|
+
) -> bool:
|
|
182
|
+
"""
|
|
183
|
+
Update user metadata in Clerk.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
clerk_user_id: The Clerk user ID.
|
|
187
|
+
public_metadata: Public metadata to merge (optional).
|
|
188
|
+
private_metadata: Private metadata to merge (optional).
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
True if update succeeded, False otherwise.
|
|
192
|
+
"""
|
|
193
|
+
try:
|
|
194
|
+
clerk = get_clerk_client()
|
|
195
|
+
|
|
196
|
+
update_data = {}
|
|
197
|
+
if public_metadata is not None:
|
|
198
|
+
update_data["public_metadata"] = public_metadata
|
|
199
|
+
if private_metadata is not None:
|
|
200
|
+
update_data["private_metadata"] = private_metadata
|
|
201
|
+
|
|
202
|
+
if not update_data:
|
|
203
|
+
return True
|
|
204
|
+
|
|
205
|
+
clerk.users.update(user_id=clerk_user_id, **update_data)
|
|
206
|
+
return True
|
|
207
|
+
|
|
208
|
+
except Exception as e:
|
|
209
|
+
logger.error(f"Failed to update user metadata: {e}")
|
|
210
|
+
return False
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Webhook handling for Clerk events.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from django_clerk_users.webhooks.security import (
|
|
6
|
+
clerk_webhook_required,
|
|
7
|
+
verify_clerk_webhook,
|
|
8
|
+
)
|
|
9
|
+
from django_clerk_users.webhooks.signals import (
|
|
10
|
+
clerk_user_created,
|
|
11
|
+
clerk_user_deleted,
|
|
12
|
+
clerk_user_updated,
|
|
13
|
+
)
|
|
14
|
+
from django_clerk_users.webhooks.views import clerk_webhook_view
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
# Security
|
|
18
|
+
"clerk_webhook_required",
|
|
19
|
+
"verify_clerk_webhook",
|
|
20
|
+
# Signals
|
|
21
|
+
"clerk_user_created",
|
|
22
|
+
"clerk_user_updated",
|
|
23
|
+
"clerk_user_deleted",
|
|
24
|
+
# Views
|
|
25
|
+
"clerk_webhook_view",
|
|
26
|
+
]
|