django-clerk-users 0.0.1__py3-none-any.whl → 0.1.0__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.
Files changed (47) hide show
  1. django_clerk_users/__init__.py +78 -7
  2. django_clerk_users/apps.py +20 -0
  3. django_clerk_users/authentication/__init__.py +24 -0
  4. django_clerk_users/authentication/backends.py +89 -0
  5. django_clerk_users/authentication/drf.py +111 -0
  6. django_clerk_users/authentication/utils.py +171 -0
  7. django_clerk_users/caching.py +161 -0
  8. django_clerk_users/checks.py +127 -0
  9. django_clerk_users/client.py +32 -0
  10. django_clerk_users/decorators.py +181 -0
  11. django_clerk_users/exceptions.py +51 -0
  12. django_clerk_users/management/__init__.py +0 -0
  13. django_clerk_users/management/commands/__init__.py +0 -0
  14. django_clerk_users/management/commands/migrate_users_to_clerk.py +223 -0
  15. django_clerk_users/management/commands/sync_clerk_organizations.py +191 -0
  16. django_clerk_users/management/commands/sync_clerk_users.py +114 -0
  17. django_clerk_users/managers.py +120 -0
  18. django_clerk_users/middleware/__init__.py +9 -0
  19. django_clerk_users/middleware/auth.py +230 -0
  20. django_clerk_users/migrations/0001_initial.py +174 -0
  21. django_clerk_users/migrations/0002_make_clerk_id_nullable.py +24 -0
  22. django_clerk_users/migrations/__init__.py +0 -0
  23. django_clerk_users/models.py +180 -0
  24. django_clerk_users/organizations/__init__.py +8 -0
  25. django_clerk_users/organizations/admin.py +81 -0
  26. django_clerk_users/organizations/apps.py +8 -0
  27. django_clerk_users/organizations/middleware.py +130 -0
  28. django_clerk_users/organizations/migrations/0001_initial.py +349 -0
  29. django_clerk_users/organizations/migrations/__init__.py +0 -0
  30. django_clerk_users/organizations/models.py +314 -0
  31. django_clerk_users/organizations/webhooks.py +417 -0
  32. django_clerk_users/settings.py +37 -0
  33. django_clerk_users/testing.py +381 -0
  34. django_clerk_users/utils.py +210 -0
  35. django_clerk_users/webhooks/__init__.py +26 -0
  36. django_clerk_users/webhooks/handlers.py +346 -0
  37. django_clerk_users/webhooks/security.py +108 -0
  38. django_clerk_users/webhooks/signals.py +42 -0
  39. django_clerk_users/webhooks/views.py +76 -0
  40. django_clerk_users-0.1.0.dist-info/METADATA +311 -0
  41. django_clerk_users-0.1.0.dist-info/RECORD +43 -0
  42. {django_clerk_users-0.0.1.dist-info → django_clerk_users-0.1.0.dist-info}/WHEEL +1 -2
  43. django_clerk_users/main.py +0 -2
  44. django_clerk_users-0.0.1.dist-info/METADATA +0 -24
  45. django_clerk_users-0.0.1.dist-info/RECORD +0 -7
  46. django_clerk_users-0.0.1.dist-info/top_level.txt +0 -1
  47. {django_clerk_users-0.0.1.dist-info → django_clerk_users-0.1.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,349 @@
1
+ # Generated by Django 5.2.8 on 2026-01-13 18:24
2
+
3
+ import django.db.models.deletion
4
+ import django.utils.timezone
5
+ import uuid
6
+ from django.conf import settings
7
+ from django.db import migrations, models
8
+
9
+
10
+ class Migration(migrations.Migration):
11
+ initial = True
12
+
13
+ dependencies = [
14
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
15
+ ]
16
+
17
+ operations = [
18
+ migrations.CreateModel(
19
+ name="Organization",
20
+ fields=[
21
+ (
22
+ "id",
23
+ models.BigAutoField(
24
+ auto_created=True,
25
+ primary_key=True,
26
+ serialize=False,
27
+ verbose_name="ID",
28
+ ),
29
+ ),
30
+ (
31
+ "uid",
32
+ models.UUIDField(
33
+ db_index=True,
34
+ default=uuid.uuid4,
35
+ editable=False,
36
+ help_text="Public unique identifier for the organization.",
37
+ ),
38
+ ),
39
+ (
40
+ "clerk_id",
41
+ models.CharField(
42
+ db_index=True,
43
+ help_text="Unique identifier from Clerk.",
44
+ max_length=255,
45
+ unique=True,
46
+ ),
47
+ ),
48
+ (
49
+ "name",
50
+ models.CharField(help_text="Organization name.", max_length=255),
51
+ ),
52
+ (
53
+ "slug",
54
+ models.SlugField(
55
+ help_text="URL-friendly organization identifier.",
56
+ max_length=255,
57
+ ),
58
+ ),
59
+ (
60
+ "image_url",
61
+ models.URLField(
62
+ blank=True,
63
+ default="",
64
+ help_text="URL to organization logo from Clerk.",
65
+ max_length=500,
66
+ ),
67
+ ),
68
+ (
69
+ "public_metadata",
70
+ models.JSONField(
71
+ blank=True,
72
+ default=dict,
73
+ help_text="Public metadata from Clerk.",
74
+ ),
75
+ ),
76
+ (
77
+ "private_metadata",
78
+ models.JSONField(
79
+ blank=True,
80
+ default=dict,
81
+ help_text="Private metadata from Clerk.",
82
+ ),
83
+ ),
84
+ (
85
+ "is_active",
86
+ models.BooleanField(
87
+ db_index=True,
88
+ default=True,
89
+ help_text="Whether the organization is active.",
90
+ ),
91
+ ),
92
+ (
93
+ "members_count",
94
+ models.PositiveIntegerField(
95
+ default=0, help_text="Number of members in the organization."
96
+ ),
97
+ ),
98
+ (
99
+ "pending_invitations_count",
100
+ models.PositiveIntegerField(
101
+ default=0, help_text="Number of pending invitations."
102
+ ),
103
+ ),
104
+ (
105
+ "max_allowed_memberships",
106
+ models.PositiveIntegerField(
107
+ default=0,
108
+ help_text="Maximum allowed memberships (0 = unlimited).",
109
+ ),
110
+ ),
111
+ (
112
+ "created_at",
113
+ models.DateTimeField(
114
+ default=django.utils.timezone.now,
115
+ help_text="When the organization was created in Clerk.",
116
+ ),
117
+ ),
118
+ (
119
+ "updated_at",
120
+ models.DateTimeField(
121
+ auto_now=True,
122
+ help_text="When the organization was last updated.",
123
+ ),
124
+ ),
125
+ ],
126
+ options={
127
+ "verbose_name": "Organization",
128
+ "verbose_name_plural": "Organizations",
129
+ "ordering": ["-created_at"],
130
+ "indexes": [
131
+ models.Index(
132
+ fields=["clerk_id"], name="clerk_organ_clerk_i_b2b811_idx"
133
+ ),
134
+ models.Index(fields=["slug"], name="clerk_organ_slug_6c2124_idx"),
135
+ models.Index(
136
+ fields=["is_active"], name="clerk_organ_is_acti_67b48f_idx"
137
+ ),
138
+ ],
139
+ },
140
+ ),
141
+ migrations.CreateModel(
142
+ name="OrganizationInvitation",
143
+ fields=[
144
+ (
145
+ "id",
146
+ models.BigAutoField(
147
+ auto_created=True,
148
+ primary_key=True,
149
+ serialize=False,
150
+ verbose_name="ID",
151
+ ),
152
+ ),
153
+ (
154
+ "clerk_invitation_id",
155
+ models.CharField(
156
+ db_index=True,
157
+ help_text="Unique invitation identifier from Clerk.",
158
+ max_length=255,
159
+ unique=True,
160
+ ),
161
+ ),
162
+ (
163
+ "email_address",
164
+ models.EmailField(
165
+ help_text="Email address of the invitee.", max_length=254
166
+ ),
167
+ ),
168
+ (
169
+ "role",
170
+ models.CharField(
171
+ default="member",
172
+ help_text="Role the user will have upon accepting.",
173
+ max_length=100,
174
+ ),
175
+ ),
176
+ (
177
+ "status",
178
+ models.CharField(
179
+ choices=[
180
+ ("pending", "Pending"),
181
+ ("accepted", "Accepted"),
182
+ ("revoked", "Revoked"),
183
+ ],
184
+ db_index=True,
185
+ default="pending",
186
+ help_text="Invitation status.",
187
+ max_length=20,
188
+ ),
189
+ ),
190
+ (
191
+ "public_metadata",
192
+ models.JSONField(
193
+ blank=True,
194
+ default=dict,
195
+ help_text="Public metadata from Clerk.",
196
+ ),
197
+ ),
198
+ (
199
+ "private_metadata",
200
+ models.JSONField(
201
+ blank=True,
202
+ default=dict,
203
+ help_text="Private metadata from Clerk.",
204
+ ),
205
+ ),
206
+ (
207
+ "created_at",
208
+ models.DateTimeField(
209
+ default=django.utils.timezone.now,
210
+ help_text="When the invitation was created.",
211
+ ),
212
+ ),
213
+ (
214
+ "updated_at",
215
+ models.DateTimeField(
216
+ auto_now=True, help_text="When the invitation was last updated."
217
+ ),
218
+ ),
219
+ (
220
+ "inviter",
221
+ models.ForeignKey(
222
+ blank=True,
223
+ help_text="The user who sent the invitation.",
224
+ null=True,
225
+ on_delete=django.db.models.deletion.SET_NULL,
226
+ related_name="sent_invitations",
227
+ to=settings.AUTH_USER_MODEL,
228
+ ),
229
+ ),
230
+ (
231
+ "organization",
232
+ models.ForeignKey(
233
+ help_text="The organization.",
234
+ on_delete=django.db.models.deletion.CASCADE,
235
+ related_name="invitations",
236
+ to="clerk_organizations.organization",
237
+ ),
238
+ ),
239
+ ],
240
+ options={
241
+ "verbose_name": "Organization Invitation",
242
+ "verbose_name_plural": "Organization Invitations",
243
+ "ordering": ["-created_at"],
244
+ "indexes": [
245
+ models.Index(
246
+ fields=["clerk_invitation_id"],
247
+ name="clerk_organ_clerk_i_53d078_idx",
248
+ ),
249
+ models.Index(
250
+ fields=["status"], name="clerk_organ_status_bef241_idx"
251
+ ),
252
+ models.Index(
253
+ fields=["email_address"], name="clerk_organ_email_a_b3359c_idx"
254
+ ),
255
+ ],
256
+ },
257
+ ),
258
+ migrations.CreateModel(
259
+ name="OrganizationMember",
260
+ fields=[
261
+ (
262
+ "id",
263
+ models.BigAutoField(
264
+ auto_created=True,
265
+ primary_key=True,
266
+ serialize=False,
267
+ verbose_name="ID",
268
+ ),
269
+ ),
270
+ (
271
+ "clerk_membership_id",
272
+ models.CharField(
273
+ db_index=True,
274
+ help_text="Unique membership identifier from Clerk.",
275
+ max_length=255,
276
+ unique=True,
277
+ ),
278
+ ),
279
+ (
280
+ "role",
281
+ models.CharField(
282
+ default="member",
283
+ help_text="User's role in the organization (e.g., 'admin', 'member').",
284
+ max_length=100,
285
+ ),
286
+ ),
287
+ (
288
+ "public_metadata",
289
+ models.JSONField(
290
+ blank=True,
291
+ default=dict,
292
+ help_text="Public metadata from Clerk.",
293
+ ),
294
+ ),
295
+ (
296
+ "private_metadata",
297
+ models.JSONField(
298
+ blank=True,
299
+ default=dict,
300
+ help_text="Private metadata from Clerk.",
301
+ ),
302
+ ),
303
+ (
304
+ "joined_at",
305
+ models.DateTimeField(
306
+ default=django.utils.timezone.now,
307
+ help_text="When the user joined the organization.",
308
+ ),
309
+ ),
310
+ (
311
+ "updated_at",
312
+ models.DateTimeField(
313
+ auto_now=True, help_text="When the membership was last updated."
314
+ ),
315
+ ),
316
+ (
317
+ "organization",
318
+ models.ForeignKey(
319
+ help_text="The organization.",
320
+ on_delete=django.db.models.deletion.CASCADE,
321
+ related_name="cached_members",
322
+ to="clerk_organizations.organization",
323
+ ),
324
+ ),
325
+ (
326
+ "user",
327
+ models.ForeignKey(
328
+ help_text="The user.",
329
+ on_delete=django.db.models.deletion.CASCADE,
330
+ related_name="organization_memberships",
331
+ to=settings.AUTH_USER_MODEL,
332
+ ),
333
+ ),
334
+ ],
335
+ options={
336
+ "verbose_name": "Organization Member",
337
+ "verbose_name_plural": "Organization Members",
338
+ "ordering": ["-joined_at"],
339
+ "indexes": [
340
+ models.Index(
341
+ fields=["clerk_membership_id"],
342
+ name="clerk_organ_clerk_m_10f2c5_idx",
343
+ ),
344
+ models.Index(fields=["role"], name="clerk_organ_role_645ce1_idx"),
345
+ ],
346
+ "unique_together": {("organization", "user")},
347
+ },
348
+ ),
349
+ ]
@@ -0,0 +1,314 @@
1
+ """
2
+ Organization models for django-clerk-users.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import uuid
8
+ from typing import TYPE_CHECKING
9
+
10
+ from django.conf import settings
11
+ from django.db import models
12
+ from django.utils import timezone
13
+
14
+ if TYPE_CHECKING:
15
+ from django_clerk_users.models import AbstractClerkUser
16
+
17
+
18
+ class Organization(models.Model):
19
+ """
20
+ Represents a Clerk organization.
21
+
22
+ Organizations are synced from Clerk and stored locally for
23
+ efficient querying and relationships.
24
+ """
25
+
26
+ # Public identifier (use in URLs and APIs)
27
+ uid = models.UUIDField(
28
+ default=uuid.uuid4,
29
+ editable=False,
30
+ db_index=True,
31
+ help_text="Public unique identifier for the organization.",
32
+ )
33
+
34
+ # Clerk-specific fields
35
+ clerk_id = models.CharField(
36
+ max_length=255,
37
+ unique=True,
38
+ db_index=True,
39
+ help_text="Unique identifier from Clerk.",
40
+ )
41
+
42
+ # Organization fields
43
+ name = models.CharField(
44
+ max_length=255,
45
+ help_text="Organization name.",
46
+ )
47
+ slug = models.SlugField(
48
+ max_length=255,
49
+ db_index=True,
50
+ help_text="URL-friendly organization identifier.",
51
+ )
52
+ image_url = models.URLField(
53
+ max_length=500,
54
+ blank=True,
55
+ default="",
56
+ help_text="URL to organization logo from Clerk.",
57
+ )
58
+
59
+ # Metadata
60
+ public_metadata = models.JSONField(
61
+ default=dict,
62
+ blank=True,
63
+ help_text="Public metadata from Clerk.",
64
+ )
65
+ private_metadata = models.JSONField(
66
+ default=dict,
67
+ blank=True,
68
+ help_text="Private metadata from Clerk.",
69
+ )
70
+
71
+ # Status
72
+ is_active = models.BooleanField(
73
+ default=True,
74
+ db_index=True,
75
+ help_text="Whether the organization is active.",
76
+ )
77
+
78
+ # Stats (synced from Clerk)
79
+ members_count = models.PositiveIntegerField(
80
+ default=0,
81
+ help_text="Number of members in the organization.",
82
+ )
83
+ pending_invitations_count = models.PositiveIntegerField(
84
+ default=0,
85
+ help_text="Number of pending invitations.",
86
+ )
87
+ max_allowed_memberships = models.PositiveIntegerField(
88
+ default=0,
89
+ help_text="Maximum allowed memberships (0 = unlimited).",
90
+ )
91
+
92
+ # Timestamps
93
+ created_at = models.DateTimeField(
94
+ default=timezone.now,
95
+ help_text="When the organization was created in Clerk.",
96
+ )
97
+ updated_at = models.DateTimeField(
98
+ auto_now=True,
99
+ help_text="When the organization was last updated.",
100
+ )
101
+
102
+ class Meta:
103
+ ordering = ["-created_at"]
104
+ indexes = [
105
+ models.Index(fields=["clerk_id"]),
106
+ models.Index(fields=["slug"]),
107
+ models.Index(fields=["is_active"]),
108
+ ]
109
+ verbose_name = "Organization"
110
+ verbose_name_plural = "Organizations"
111
+
112
+ def __str__(self) -> str:
113
+ return self.name
114
+
115
+ @property
116
+ def public_id(self) -> str:
117
+ """Return the public UUID as a string for API responses."""
118
+ return str(self.uid)
119
+
120
+ @property
121
+ def handle(self) -> str:
122
+ """Return the organization slug (alias for compatibility)."""
123
+ return self.slug
124
+
125
+ def get_member_count(self) -> int:
126
+ """Get current member count from cached members."""
127
+ return self.cached_members.count()
128
+
129
+ def sync_from_clerk(self) -> tuple[bool, str]:
130
+ """
131
+ Sync organization data from Clerk.
132
+
133
+ Returns:
134
+ Tuple of (success, message)
135
+ """
136
+ try:
137
+ from django_clerk_users.organizations.webhooks import (
138
+ update_or_create_organization,
139
+ )
140
+
141
+ org, created = update_or_create_organization(self.clerk_id)
142
+ action = "created" if created else "updated"
143
+ return True, f"Organization {action} successfully"
144
+ except Exception as e:
145
+ return False, str(e)
146
+
147
+
148
+ class OrganizationMember(models.Model):
149
+ """
150
+ Represents a membership in a Clerk organization.
151
+
152
+ This is a cache of Clerk's organization memberships for efficient
153
+ local queries.
154
+ """
155
+
156
+ # Clerk-specific fields
157
+ clerk_membership_id = models.CharField(
158
+ max_length=255,
159
+ unique=True,
160
+ db_index=True,
161
+ help_text="Unique membership identifier from Clerk.",
162
+ )
163
+
164
+ # Relationships
165
+ organization = models.ForeignKey(
166
+ Organization,
167
+ on_delete=models.CASCADE,
168
+ related_name="cached_members",
169
+ help_text="The organization.",
170
+ )
171
+ user = models.ForeignKey(
172
+ settings.AUTH_USER_MODEL,
173
+ on_delete=models.CASCADE,
174
+ related_name="organization_memberships",
175
+ help_text="The user.",
176
+ )
177
+
178
+ # Role
179
+ role = models.CharField(
180
+ max_length=100,
181
+ default="member",
182
+ help_text="User's role in the organization (e.g., 'admin', 'member').",
183
+ )
184
+
185
+ # Metadata
186
+ public_metadata = models.JSONField(
187
+ default=dict,
188
+ blank=True,
189
+ help_text="Public metadata from Clerk.",
190
+ )
191
+ private_metadata = models.JSONField(
192
+ default=dict,
193
+ blank=True,
194
+ help_text="Private metadata from Clerk.",
195
+ )
196
+
197
+ # Timestamps
198
+ joined_at = models.DateTimeField(
199
+ default=timezone.now,
200
+ help_text="When the user joined the organization.",
201
+ )
202
+ updated_at = models.DateTimeField(
203
+ auto_now=True,
204
+ help_text="When the membership was last updated.",
205
+ )
206
+
207
+ class Meta:
208
+ ordering = ["-joined_at"]
209
+ unique_together = [("organization", "user")]
210
+ indexes = [
211
+ models.Index(fields=["clerk_membership_id"]),
212
+ models.Index(fields=["role"]),
213
+ ]
214
+ verbose_name = "Organization Member"
215
+ verbose_name_plural = "Organization Members"
216
+
217
+ def __str__(self) -> str:
218
+ return f"{self.user.email} in {self.organization.name} ({self.role})"
219
+
220
+ @property
221
+ def is_admin(self) -> bool:
222
+ """Check if this member has admin role."""
223
+ return self.role.lower() in ("admin", "org:admin", "owner")
224
+
225
+ def can_invite_members(self) -> bool:
226
+ """Check if this member can invite others to the organization."""
227
+ return self.is_admin
228
+
229
+
230
+ class OrganizationInvitation(models.Model):
231
+ """
232
+ Represents a pending invitation to a Clerk organization.
233
+ """
234
+
235
+ class Status(models.TextChoices):
236
+ PENDING = "pending", "Pending"
237
+ ACCEPTED = "accepted", "Accepted"
238
+ REVOKED = "revoked", "Revoked"
239
+
240
+ # Clerk-specific fields
241
+ clerk_invitation_id = models.CharField(
242
+ max_length=255,
243
+ unique=True,
244
+ db_index=True,
245
+ help_text="Unique invitation identifier from Clerk.",
246
+ )
247
+
248
+ # Relationships
249
+ organization = models.ForeignKey(
250
+ Organization,
251
+ on_delete=models.CASCADE,
252
+ related_name="invitations",
253
+ help_text="The organization.",
254
+ )
255
+ inviter = models.ForeignKey(
256
+ settings.AUTH_USER_MODEL,
257
+ on_delete=models.SET_NULL,
258
+ null=True,
259
+ blank=True,
260
+ related_name="sent_invitations",
261
+ help_text="The user who sent the invitation.",
262
+ )
263
+
264
+ # Invitation details
265
+ email_address = models.EmailField(
266
+ help_text="Email address of the invitee.",
267
+ )
268
+ role = models.CharField(
269
+ max_length=100,
270
+ default="member",
271
+ help_text="Role the user will have upon accepting.",
272
+ )
273
+ status = models.CharField(
274
+ max_length=20,
275
+ choices=Status.choices,
276
+ default=Status.PENDING,
277
+ db_index=True,
278
+ help_text="Invitation status.",
279
+ )
280
+
281
+ # Metadata
282
+ public_metadata = models.JSONField(
283
+ default=dict,
284
+ blank=True,
285
+ help_text="Public metadata from Clerk.",
286
+ )
287
+ private_metadata = models.JSONField(
288
+ default=dict,
289
+ blank=True,
290
+ help_text="Private metadata from Clerk.",
291
+ )
292
+
293
+ # Timestamps
294
+ created_at = models.DateTimeField(
295
+ default=timezone.now,
296
+ help_text="When the invitation was created.",
297
+ )
298
+ updated_at = models.DateTimeField(
299
+ auto_now=True,
300
+ help_text="When the invitation was last updated.",
301
+ )
302
+
303
+ class Meta:
304
+ ordering = ["-created_at"]
305
+ indexes = [
306
+ models.Index(fields=["clerk_invitation_id"]),
307
+ models.Index(fields=["status"]),
308
+ models.Index(fields=["email_address"]),
309
+ ]
310
+ verbose_name = "Organization Invitation"
311
+ verbose_name_plural = "Organization Invitations"
312
+
313
+ def __str__(self) -> str:
314
+ return f"Invitation to {self.email_address} for {self.organization.name}"