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,127 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Django system checks for django-clerk-users configuration.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from django.conf import settings
|
|
6
|
+
from django.core.checks import Error, Warning, register
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@register()
|
|
10
|
+
def check_clerk_secret_key(app_configs, **kwargs):
|
|
11
|
+
"""Check that CLERK_SECRET_KEY is configured."""
|
|
12
|
+
errors = []
|
|
13
|
+
if not getattr(settings, "CLERK_SECRET_KEY", None):
|
|
14
|
+
errors.append(
|
|
15
|
+
Error(
|
|
16
|
+
"CLERK_SECRET_KEY is not configured.",
|
|
17
|
+
hint="Set CLERK_SECRET_KEY in your Django settings.",
|
|
18
|
+
id="django_clerk_users.E001",
|
|
19
|
+
)
|
|
20
|
+
)
|
|
21
|
+
return errors
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@register()
|
|
25
|
+
def check_clerk_webhook_signing_key(app_configs, **kwargs):
|
|
26
|
+
"""Check that CLERK_WEBHOOK_SIGNING_KEY is configured."""
|
|
27
|
+
warnings = []
|
|
28
|
+
if not getattr(settings, "CLERK_WEBHOOK_SIGNING_KEY", None):
|
|
29
|
+
warnings.append(
|
|
30
|
+
Warning(
|
|
31
|
+
"CLERK_WEBHOOK_SIGNING_KEY is not configured.",
|
|
32
|
+
hint=(
|
|
33
|
+
"Set CLERK_WEBHOOK_SIGNING_KEY in your Django settings "
|
|
34
|
+
"if you plan to use Clerk webhooks."
|
|
35
|
+
),
|
|
36
|
+
id="django_clerk_users.W001",
|
|
37
|
+
)
|
|
38
|
+
)
|
|
39
|
+
return warnings
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@register()
|
|
43
|
+
def check_auth_user_model(app_configs, **kwargs):
|
|
44
|
+
"""Check that AUTH_USER_MODEL is configured for Clerk."""
|
|
45
|
+
warnings = []
|
|
46
|
+
auth_user_model = getattr(settings, "AUTH_USER_MODEL", "auth.User")
|
|
47
|
+
|
|
48
|
+
# Check if using a Clerk-compatible user model
|
|
49
|
+
if not (
|
|
50
|
+
auth_user_model.startswith("django_clerk_users.")
|
|
51
|
+
or "clerk" in auth_user_model.lower()
|
|
52
|
+
):
|
|
53
|
+
warnings.append(
|
|
54
|
+
Warning(
|
|
55
|
+
f"AUTH_USER_MODEL is set to '{auth_user_model}'.",
|
|
56
|
+
hint=(
|
|
57
|
+
"Consider using 'django_clerk_users.ClerkUser' or a custom model "
|
|
58
|
+
"that extends AbstractClerkUser for full Clerk integration."
|
|
59
|
+
),
|
|
60
|
+
id="django_clerk_users.W002",
|
|
61
|
+
)
|
|
62
|
+
)
|
|
63
|
+
return warnings
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@register()
|
|
67
|
+
def check_middleware_installed(app_configs, **kwargs):
|
|
68
|
+
"""Check that ClerkAuthMiddleware is installed."""
|
|
69
|
+
warnings = []
|
|
70
|
+
middleware = getattr(settings, "MIDDLEWARE", [])
|
|
71
|
+
|
|
72
|
+
clerk_middleware = "django_clerk_users.middleware.ClerkAuthMiddleware"
|
|
73
|
+
if clerk_middleware not in middleware:
|
|
74
|
+
warnings.append(
|
|
75
|
+
Warning(
|
|
76
|
+
"ClerkAuthMiddleware is not in MIDDLEWARE.",
|
|
77
|
+
hint=(
|
|
78
|
+
f"Add '{clerk_middleware}' to MIDDLEWARE in your Django settings "
|
|
79
|
+
"for automatic Clerk authentication."
|
|
80
|
+
),
|
|
81
|
+
id="django_clerk_users.W003",
|
|
82
|
+
)
|
|
83
|
+
)
|
|
84
|
+
return warnings
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@register()
|
|
88
|
+
def check_authentication_backend(app_configs, **kwargs):
|
|
89
|
+
"""Check that ClerkBackend is in AUTHENTICATION_BACKENDS."""
|
|
90
|
+
warnings = []
|
|
91
|
+
backends = getattr(settings, "AUTHENTICATION_BACKENDS", [])
|
|
92
|
+
|
|
93
|
+
clerk_backend = "django_clerk_users.authentication.ClerkBackend"
|
|
94
|
+
if clerk_backend not in backends:
|
|
95
|
+
warnings.append(
|
|
96
|
+
Warning(
|
|
97
|
+
"ClerkBackend is not in AUTHENTICATION_BACKENDS.",
|
|
98
|
+
hint=(
|
|
99
|
+
f"Add '{clerk_backend}' to AUTHENTICATION_BACKENDS in your "
|
|
100
|
+
"Django settings."
|
|
101
|
+
),
|
|
102
|
+
id="django_clerk_users.W004",
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
return warnings
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@register()
|
|
109
|
+
def check_frontend_hosts(app_configs, **kwargs):
|
|
110
|
+
"""Check that CLERK_FRONTEND_HOSTS is configured."""
|
|
111
|
+
warnings = []
|
|
112
|
+
frontend_hosts = getattr(settings, "CLERK_FRONTEND_HOSTS", [])
|
|
113
|
+
auth_parties = getattr(settings, "CLERK_AUTH_PARTIES", [])
|
|
114
|
+
|
|
115
|
+
if not frontend_hosts and not auth_parties:
|
|
116
|
+
warnings.append(
|
|
117
|
+
Warning(
|
|
118
|
+
"CLERK_FRONTEND_HOSTS is not configured.",
|
|
119
|
+
hint=(
|
|
120
|
+
"Set CLERK_FRONTEND_HOSTS in your Django settings to the list of "
|
|
121
|
+
"frontend URLs that will be sending authenticated requests "
|
|
122
|
+
"(e.g., ['https://myapp.com', 'http://localhost:3000'])."
|
|
123
|
+
),
|
|
124
|
+
id="django_clerk_users.W005",
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
return warnings
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Clerk SDK client singleton.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from functools import lru_cache
|
|
6
|
+
|
|
7
|
+
from clerk_backend_api import Clerk
|
|
8
|
+
|
|
9
|
+
from django_clerk_users.exceptions import ClerkConfigurationError
|
|
10
|
+
from django_clerk_users.settings import CLERK_SECRET_KEY
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@lru_cache(maxsize=1)
|
|
14
|
+
def get_clerk_client() -> Clerk:
|
|
15
|
+
"""
|
|
16
|
+
Get the Clerk SDK client instance.
|
|
17
|
+
|
|
18
|
+
Returns a cached singleton instance of the Clerk client.
|
|
19
|
+
|
|
20
|
+
Raises:
|
|
21
|
+
ClerkConfigurationError: If CLERK_SECRET_KEY is not set.
|
|
22
|
+
"""
|
|
23
|
+
if not CLERK_SECRET_KEY:
|
|
24
|
+
raise ClerkConfigurationError(
|
|
25
|
+
"CLERK_SECRET_KEY is not set. Please configure it in your Django settings."
|
|
26
|
+
)
|
|
27
|
+
return Clerk(bearer_auth=CLERK_SECRET_KEY)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_clerk_sdk() -> Clerk:
|
|
31
|
+
"""Alias for get_clerk_client() for compatibility."""
|
|
32
|
+
return get_clerk_client()
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""
|
|
2
|
+
View decorators for django-clerk-users.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import functools
|
|
8
|
+
from typing import TYPE_CHECKING, Callable
|
|
9
|
+
|
|
10
|
+
from django.http import JsonResponse
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from django.http import HttpRequest, HttpResponse
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def clerk_user_required(view_func: Callable) -> Callable:
|
|
17
|
+
"""
|
|
18
|
+
Decorator that requires a Clerk-authenticated user.
|
|
19
|
+
|
|
20
|
+
Use this decorator on views that require authentication.
|
|
21
|
+
Returns a 401 response if the user is not authenticated.
|
|
22
|
+
|
|
23
|
+
Example:
|
|
24
|
+
from django_clerk_users.decorators import clerk_user_required
|
|
25
|
+
|
|
26
|
+
@clerk_user_required
|
|
27
|
+
def my_protected_view(request):
|
|
28
|
+
user = request.clerk_user
|
|
29
|
+
return JsonResponse({"email": user.email})
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
view_func: The view function to wrap.
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
The wrapped view function.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
@functools.wraps(view_func)
|
|
39
|
+
def wrapper(request: "HttpRequest", *args, **kwargs) -> "HttpResponse":
|
|
40
|
+
clerk_user = getattr(request, "clerk_user", None)
|
|
41
|
+
|
|
42
|
+
if not clerk_user or not clerk_user.is_authenticated:
|
|
43
|
+
return JsonResponse(
|
|
44
|
+
{"error": "Authentication required"},
|
|
45
|
+
status=401,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
return view_func(request, *args, **kwargs)
|
|
49
|
+
|
|
50
|
+
return wrapper
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def clerk_org_required(view_func: Callable) -> Callable:
|
|
54
|
+
"""
|
|
55
|
+
Decorator that requires an organization context.
|
|
56
|
+
|
|
57
|
+
Use this decorator on views that require both authentication
|
|
58
|
+
and an organization context. Returns a 401 response if not
|
|
59
|
+
authenticated, or a 403 response if no organization is set.
|
|
60
|
+
|
|
61
|
+
Example:
|
|
62
|
+
from django_clerk_users.decorators import clerk_org_required
|
|
63
|
+
|
|
64
|
+
@clerk_org_required
|
|
65
|
+
def my_org_view(request):
|
|
66
|
+
org_id = request.org
|
|
67
|
+
return JsonResponse({"org_id": org_id})
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
view_func: The view function to wrap.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
The wrapped view function.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
@functools.wraps(view_func)
|
|
77
|
+
def wrapper(request: "HttpRequest", *args, **kwargs) -> "HttpResponse":
|
|
78
|
+
clerk_user = getattr(request, "clerk_user", None)
|
|
79
|
+
|
|
80
|
+
if not clerk_user or not clerk_user.is_authenticated:
|
|
81
|
+
return JsonResponse(
|
|
82
|
+
{"error": "Authentication required"},
|
|
83
|
+
status=401,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
org = getattr(request, "org", None)
|
|
87
|
+
if not org:
|
|
88
|
+
return JsonResponse(
|
|
89
|
+
{"error": "Organization context required"},
|
|
90
|
+
status=403,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
return view_func(request, *args, **kwargs)
|
|
94
|
+
|
|
95
|
+
return wrapper
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def clerk_staff_required(view_func: Callable) -> Callable:
|
|
99
|
+
"""
|
|
100
|
+
Decorator that requires a staff user.
|
|
101
|
+
|
|
102
|
+
Use this decorator on views that require staff access.
|
|
103
|
+
Returns a 401 response if not authenticated, or a 403 response
|
|
104
|
+
if the user is not staff.
|
|
105
|
+
|
|
106
|
+
Example:
|
|
107
|
+
from django_clerk_users.decorators import clerk_staff_required
|
|
108
|
+
|
|
109
|
+
@clerk_staff_required
|
|
110
|
+
def admin_view(request):
|
|
111
|
+
return JsonResponse({"message": "Staff access granted"})
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
view_func: The view function to wrap.
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
The wrapped view function.
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
@functools.wraps(view_func)
|
|
121
|
+
def wrapper(request: "HttpRequest", *args, **kwargs) -> "HttpResponse":
|
|
122
|
+
clerk_user = getattr(request, "clerk_user", None)
|
|
123
|
+
|
|
124
|
+
if not clerk_user or not clerk_user.is_authenticated:
|
|
125
|
+
return JsonResponse(
|
|
126
|
+
{"error": "Authentication required"},
|
|
127
|
+
status=401,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
if not clerk_user.is_staff:
|
|
131
|
+
return JsonResponse(
|
|
132
|
+
{"error": "Staff access required"},
|
|
133
|
+
status=403,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
return view_func(request, *args, **kwargs)
|
|
137
|
+
|
|
138
|
+
return wrapper
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def clerk_superuser_required(view_func: Callable) -> Callable:
|
|
142
|
+
"""
|
|
143
|
+
Decorator that requires a superuser.
|
|
144
|
+
|
|
145
|
+
Use this decorator on views that require superuser access.
|
|
146
|
+
Returns a 401 response if not authenticated, or a 403 response
|
|
147
|
+
if the user is not a superuser.
|
|
148
|
+
|
|
149
|
+
Example:
|
|
150
|
+
from django_clerk_users.decorators import clerk_superuser_required
|
|
151
|
+
|
|
152
|
+
@clerk_superuser_required
|
|
153
|
+
def superuser_view(request):
|
|
154
|
+
return JsonResponse({"message": "Superuser access granted"})
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
view_func: The view function to wrap.
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
The wrapped view function.
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
@functools.wraps(view_func)
|
|
164
|
+
def wrapper(request: "HttpRequest", *args, **kwargs) -> "HttpResponse":
|
|
165
|
+
clerk_user = getattr(request, "clerk_user", None)
|
|
166
|
+
|
|
167
|
+
if not clerk_user or not clerk_user.is_authenticated:
|
|
168
|
+
return JsonResponse(
|
|
169
|
+
{"error": "Authentication required"},
|
|
170
|
+
status=401,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
if not clerk_user.is_superuser:
|
|
174
|
+
return JsonResponse(
|
|
175
|
+
{"error": "Superuser access required"},
|
|
176
|
+
status=403,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
return view_func(request, *args, **kwargs)
|
|
180
|
+
|
|
181
|
+
return wrapper
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Custom exceptions for django-clerk-users.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ClerkError(Exception):
|
|
7
|
+
"""Base exception for all Clerk-related errors."""
|
|
8
|
+
|
|
9
|
+
pass
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ClerkConfigurationError(ClerkError):
|
|
13
|
+
"""Raised when Clerk is not properly configured."""
|
|
14
|
+
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ClerkAuthenticationError(ClerkError):
|
|
19
|
+
"""Raised when authentication fails."""
|
|
20
|
+
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ClerkTokenError(ClerkAuthenticationError):
|
|
25
|
+
"""Raised when JWT token validation fails."""
|
|
26
|
+
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ClerkWebhookError(ClerkError):
|
|
31
|
+
"""Raised when webhook verification fails."""
|
|
32
|
+
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ClerkAPIError(ClerkError):
|
|
37
|
+
"""Raised when Clerk API returns an error."""
|
|
38
|
+
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ClerkUserNotFoundError(ClerkError):
|
|
43
|
+
"""Raised when a Clerk user cannot be found."""
|
|
44
|
+
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ClerkOrganizationNotFoundError(ClerkError):
|
|
49
|
+
"""Raised when a Clerk organization cannot be found."""
|
|
50
|
+
|
|
51
|
+
pass
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Management command to migrate existing Django users to Clerk.
|
|
3
|
+
|
|
4
|
+
This command creates users in Clerk from your existing Django user database,
|
|
5
|
+
allowing you to migrate to Clerk authentication without losing user data.
|
|
6
|
+
|
|
7
|
+
Note: Passwords cannot be migrated. Users will need to reset their password
|
|
8
|
+
or use OAuth/social login.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
|
|
13
|
+
from django.apps import apps
|
|
14
|
+
from django.core.management.base import BaseCommand, CommandError
|
|
15
|
+
|
|
16
|
+
from django_clerk_users.client import get_clerk_client
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Command(BaseCommand):
|
|
20
|
+
help = "Migrate existing Django users to Clerk"
|
|
21
|
+
|
|
22
|
+
def add_arguments(self, parser):
|
|
23
|
+
parser.add_argument(
|
|
24
|
+
"--source-model",
|
|
25
|
+
type=str,
|
|
26
|
+
default="auth.User",
|
|
27
|
+
help="Source user model in app.Model format (default: auth.User)",
|
|
28
|
+
)
|
|
29
|
+
parser.add_argument(
|
|
30
|
+
"--email",
|
|
31
|
+
type=str,
|
|
32
|
+
help="Migrate a specific user by email address",
|
|
33
|
+
)
|
|
34
|
+
parser.add_argument(
|
|
35
|
+
"--all",
|
|
36
|
+
action="store_true",
|
|
37
|
+
help="Migrate all users",
|
|
38
|
+
)
|
|
39
|
+
parser.add_argument(
|
|
40
|
+
"--created-before",
|
|
41
|
+
type=str,
|
|
42
|
+
help="Migrate users created before this date (YYYY-MM-DD)",
|
|
43
|
+
)
|
|
44
|
+
parser.add_argument(
|
|
45
|
+
"--skip-existing",
|
|
46
|
+
action="store_true",
|
|
47
|
+
help="Skip users that already exist in Clerk (by email)",
|
|
48
|
+
)
|
|
49
|
+
parser.add_argument(
|
|
50
|
+
"--skip-password-email",
|
|
51
|
+
action="store_true",
|
|
52
|
+
default=True,
|
|
53
|
+
help="Don't trigger password reset emails (default: True)",
|
|
54
|
+
)
|
|
55
|
+
parser.add_argument(
|
|
56
|
+
"--dry-run",
|
|
57
|
+
action="store_true",
|
|
58
|
+
help="Show what would be migrated without making changes",
|
|
59
|
+
)
|
|
60
|
+
parser.add_argument(
|
|
61
|
+
"--limit",
|
|
62
|
+
type=int,
|
|
63
|
+
default=100,
|
|
64
|
+
help="Limit number of users to migrate (default: 100)",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
def handle(self, *args, **options):
|
|
68
|
+
# Get the source model
|
|
69
|
+
source_model_path = options["source_model"]
|
|
70
|
+
try:
|
|
71
|
+
app_label, model_name = source_model_path.split(".")
|
|
72
|
+
SourceUser = apps.get_model(app_label, model_name)
|
|
73
|
+
except (ValueError, LookupError) as e:
|
|
74
|
+
raise CommandError(f"Invalid source model '{source_model_path}': {e}")
|
|
75
|
+
|
|
76
|
+
email = options["email"]
|
|
77
|
+
migrate_all = options["all"]
|
|
78
|
+
created_before = options["created_before"]
|
|
79
|
+
skip_existing = options["skip_existing"]
|
|
80
|
+
skip_password_email = options["skip_password_email"]
|
|
81
|
+
dry_run = options["dry_run"]
|
|
82
|
+
limit = options["limit"]
|
|
83
|
+
|
|
84
|
+
if not email and not migrate_all and not created_before:
|
|
85
|
+
raise CommandError(
|
|
86
|
+
"You must specify --email, --all, or --created-before"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
clerk = get_clerk_client()
|
|
90
|
+
|
|
91
|
+
# Build queryset
|
|
92
|
+
queryset = SourceUser.objects.all()
|
|
93
|
+
|
|
94
|
+
if email:
|
|
95
|
+
queryset = queryset.filter(email=email)
|
|
96
|
+
elif created_before:
|
|
97
|
+
try:
|
|
98
|
+
before_date = datetime.strptime(created_before, "%Y-%m-%d")
|
|
99
|
+
queryset = queryset.filter(date_joined__lt=before_date)
|
|
100
|
+
except ValueError:
|
|
101
|
+
raise CommandError(
|
|
102
|
+
"Invalid date format. Use YYYY-MM-DD"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
queryset = queryset[:limit]
|
|
106
|
+
|
|
107
|
+
created_count = 0
|
|
108
|
+
skipped_count = 0
|
|
109
|
+
error_count = 0
|
|
110
|
+
linked_count = 0
|
|
111
|
+
total_count = 0
|
|
112
|
+
|
|
113
|
+
self.stdout.write(f"Migrating users from {source_model_path}...")
|
|
114
|
+
self.stdout.write(f"Found {queryset.count()} users to process")
|
|
115
|
+
|
|
116
|
+
if dry_run:
|
|
117
|
+
self.stdout.write(self.style.WARNING("DRY RUN - No changes will be made"))
|
|
118
|
+
|
|
119
|
+
for source_user in queryset:
|
|
120
|
+
total_count += 1
|
|
121
|
+
user_email = getattr(source_user, "email", None)
|
|
122
|
+
|
|
123
|
+
if not user_email:
|
|
124
|
+
self.stderr.write(
|
|
125
|
+
self.style.WARNING(f" Skipping user {source_user.pk}: no email")
|
|
126
|
+
)
|
|
127
|
+
skipped_count += 1
|
|
128
|
+
continue
|
|
129
|
+
|
|
130
|
+
first_name = getattr(source_user, "first_name", "") or ""
|
|
131
|
+
last_name = getattr(source_user, "last_name", "") or ""
|
|
132
|
+
|
|
133
|
+
# Check if user exists in Clerk
|
|
134
|
+
if skip_existing:
|
|
135
|
+
try:
|
|
136
|
+
existing_users = clerk.users.list(email_address=[user_email])
|
|
137
|
+
users_data = existing_users.data if hasattr(existing_users, "data") else existing_users
|
|
138
|
+
if users_data:
|
|
139
|
+
if dry_run:
|
|
140
|
+
self.stdout.write(
|
|
141
|
+
f" Would skip (exists in Clerk): {user_email}"
|
|
142
|
+
)
|
|
143
|
+
else:
|
|
144
|
+
self.stdout.write(f" Skipping (exists in Clerk): {user_email}")
|
|
145
|
+
# Try to link the user
|
|
146
|
+
clerk_user = users_data[0]
|
|
147
|
+
self._link_user(source_user, clerk_user)
|
|
148
|
+
linked_count += 1
|
|
149
|
+
skipped_count += 1
|
|
150
|
+
continue
|
|
151
|
+
except Exception as e:
|
|
152
|
+
self.stderr.write(
|
|
153
|
+
self.style.WARNING(f" Error checking if {user_email} exists: {e}")
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
if dry_run:
|
|
157
|
+
self.stdout.write(f" Would create: {user_email}")
|
|
158
|
+
continue
|
|
159
|
+
|
|
160
|
+
# Create user in Clerk
|
|
161
|
+
try:
|
|
162
|
+
clerk_user = clerk.users.create(
|
|
163
|
+
email_address=[user_email],
|
|
164
|
+
first_name=first_name if first_name else None,
|
|
165
|
+
last_name=last_name if last_name else None,
|
|
166
|
+
skip_password_requirement=True,
|
|
167
|
+
skip_password_checks=True,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
# Link Django user to Clerk
|
|
171
|
+
self._link_user(source_user, clerk_user)
|
|
172
|
+
|
|
173
|
+
created_count += 1
|
|
174
|
+
self.stdout.write(
|
|
175
|
+
self.style.SUCCESS(f" Created: {user_email}")
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
except Exception as e:
|
|
179
|
+
error_str = str(e)
|
|
180
|
+
if "email_address" in error_str.lower() and "taken" in error_str.lower():
|
|
181
|
+
self.stdout.write(
|
|
182
|
+
self.style.WARNING(f" Email already exists in Clerk: {user_email}")
|
|
183
|
+
)
|
|
184
|
+
skipped_count += 1
|
|
185
|
+
else:
|
|
186
|
+
error_count += 1
|
|
187
|
+
self.stderr.write(
|
|
188
|
+
self.style.ERROR(f" Failed to create {user_email}: {e}")
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
self.stdout.write("")
|
|
192
|
+
self.stdout.write(self.style.SUCCESS("Migration complete!"))
|
|
193
|
+
self.stdout.write(f" Total processed: {total_count}")
|
|
194
|
+
if not dry_run:
|
|
195
|
+
self.stdout.write(f" Created in Clerk: {created_count}")
|
|
196
|
+
self.stdout.write(f" Linked existing: {linked_count}")
|
|
197
|
+
self.stdout.write(f" Skipped: {skipped_count}")
|
|
198
|
+
self.stdout.write(f" Errors: {error_count}")
|
|
199
|
+
|
|
200
|
+
if not dry_run and created_count > 0:
|
|
201
|
+
self.stdout.write("")
|
|
202
|
+
self.stdout.write(
|
|
203
|
+
self.style.WARNING(
|
|
204
|
+
"Note: Migrated users will need to reset their password "
|
|
205
|
+
"or use OAuth to sign in."
|
|
206
|
+
)
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
def _link_user(self, source_user, clerk_user):
|
|
210
|
+
"""
|
|
211
|
+
Link a Django user to their Clerk user.
|
|
212
|
+
|
|
213
|
+
If the source user has a clerk_id field, update it.
|
|
214
|
+
"""
|
|
215
|
+
clerk_id = getattr(clerk_user, "id", None)
|
|
216
|
+
if not clerk_id:
|
|
217
|
+
return
|
|
218
|
+
|
|
219
|
+
# Check if source user has clerk_id field
|
|
220
|
+
if hasattr(source_user, "clerk_id"):
|
|
221
|
+
source_user.clerk_id = clerk_id
|
|
222
|
+
source_user.save(update_fields=["clerk_id"])
|
|
223
|
+
self.stdout.write(f" Linked clerk_id: {clerk_id}")
|