Skip to content

Commit 9fad20c

Browse files
authored
Merge pull request #206 from AvaCodeSolutions/feat/142/org-user-frontend
Feat/142/org user frontend
2 parents 496d25b + 243285e commit 9fad20c

25 files changed

Lines changed: 840 additions & 21 deletions

File tree

django_email_learning/decorators.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ def _wrapped_view(request, *view_args, **view_kwargs) -> JsonResponse: # type:
5151
return decorator
5252

5353

54-
def is_an_organization_member() -> typing.Callable:
54+
def is_an_organization_member(only_admin: bool = False) -> typing.Callable:
5555
def decorator(view_func: typing.Callable) -> typing.Callable:
5656
@wraps(view_func)
5757
def _wrapped_view(request, *view_args, **view_kwargs) -> JsonResponse: # type: ignore[no-untyped-def]
@@ -60,9 +60,12 @@ def _wrapped_view(request, *view_args, **view_kwargs) -> JsonResponse: # type:
6060
return JsonResponse({"error": "Unauthorized"}, status=401)
6161

6262
if not user.is_superuser:
63-
has_access = OrganizationUser.objects.filter( # type: ignore[misc]
63+
qs = OrganizationUser.objects.filter( # type: ignore[misc]
6464
user=user
65-
).exists()
65+
)
66+
if only_admin:
67+
qs = qs.filter(role="admin")
68+
has_access = qs.exists()
6669
if not has_access:
6770
return JsonResponse({"error": "Forbidden"}, status=403)
6871
return view_func(request, *view_args, **view_kwargs)
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Generated by Django 6.0.2 on 2026-02-08 14:18
2+
3+
from django.conf import settings
4+
from django.db import migrations, models
5+
6+
7+
class Migration(migrations.Migration):
8+
dependencies = [
9+
(
10+
"django_email_learning",
11+
"0006_alter_apikey_salt_alter_imapconnection_salt_and_more",
12+
),
13+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
14+
]
15+
16+
operations = [
17+
migrations.AlterField(
18+
model_name="apikey",
19+
name="salt",
20+
field=models.CharField(
21+
default="647b192f563e4a47a23d9cd7d7331e1b",
22+
editable=False,
23+
max_length=32,
24+
),
25+
),
26+
migrations.AlterField(
27+
model_name="imapconnection",
28+
name="salt",
29+
field=models.CharField(
30+
default="647b192f563e4a47a23d9cd7d7331e1b",
31+
editable=False,
32+
max_length=32,
33+
),
34+
),
35+
migrations.AlterUniqueTogether(
36+
name="organizationuser",
37+
unique_together={("user", "organization")},
38+
),
39+
]

django_email_learning/models.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,9 @@ class OrganizationUser(models.Model):
106106
def __str__(self) -> str:
107107
return f"{self.user.username} - {self.organization.name}"
108108

109+
class Meta:
110+
unique_together = [["user", "organization"]]
111+
109112

110113
class EncryptionMixin(models.Model):
111114
salt = models.CharField(max_length=32, editable=False, default=uuid.uuid4().hex)

django_email_learning/platform/api/serializers.py

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import re
12
from pydantic import (
23
BaseModel,
34
ConfigDict,
@@ -23,6 +24,7 @@
2324
QuizSelectionStrategy,
2425
Enrollment,
2526
EnrollmentStatus,
27+
OrganizationUser,
2628
)
2729
from django_email_learning.services.jwt_service import generate_jwt
2830
import enum
@@ -52,6 +54,25 @@ def from_django_model(api_key: ApiKey) -> "ApiKeyResponse":
5254
)
5355

5456

57+
class GetOrCreateUserRequest(BaseModel):
58+
email: str = Field(min_length=1, examples=["user@example.com"])
59+
organization_id: int = Field(gt=0, examples=[1])
60+
61+
@field_validator("email")
62+
def validate_email(cls, email: str) -> str:
63+
email_regex = r"^[\w\.-]+@[\w\.-]+\.\w+$"
64+
if not re.match(email_regex, email):
65+
raise ValueError("Invalid email format")
66+
return email
67+
68+
69+
class UserResponse(BaseModel):
70+
id: int
71+
email: str
72+
73+
model_config = ConfigDict(from_attributes=True)
74+
75+
5576
class CreateCourseRequest(BaseModel):
5677
title: str = Field(min_length=1, examples=["Introduction to Python"])
5778
slug: str = Field(
@@ -273,20 +294,35 @@ class UpdateOrganizationRequest(BaseModel):
273294

274295
class UserRole(enum.StrEnum):
275296
ADMIN = "admin"
276-
Editor = "editor"
277-
Viewer = "viewer"
297+
EDITOR = "editor"
298+
VIEWER = "viewer"
278299

279300

280301
class AddOrganizationUserRequest(BaseModel):
281302
user_id: int = Field(gt=0, examples=[1])
282303
role: UserRole = Field(min_length=1, examples=[UserRole.ADMIN])
283304

284305

306+
class UpdateOrganizationUserRoleRequest(BaseModel):
307+
role: UserRole = Field(min_length=1, examples=[UserRole.ADMIN])
308+
309+
285310
class OrganizationUserResponse(BaseModel):
311+
id: int
286312
user_id: int
313+
organization_id: int
314+
email: str
287315
role: UserRole
288316

289-
model_config = ConfigDict(from_attributes=True)
317+
@staticmethod
318+
def from_django_model(org_user: OrganizationUser) -> "OrganizationUserResponse":
319+
return OrganizationUserResponse(
320+
id=org_user.id,
321+
user_id=org_user.user.id,
322+
organization_id=org_user.organization.id,
323+
email=org_user.user.email,
324+
role=UserRole(org_user.role),
325+
)
290326

291327

292328
class UpdateSessionRequest(BaseModel):

django_email_learning/platform/api/urls.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from django.views.defaults import page_not_found
33
from django_email_learning.platform.api.views import (
44
ApiKeyView,
5+
GetOrCreateUserByEmail,
56
SingleApiKeyView,
67
CourseView,
78
EnrollmentView,
@@ -10,6 +11,7 @@
1011
ImapConnectionView,
1112
OrganizationsView,
1213
OrganizationUsersView,
14+
SingleOrganizationUserView,
1315
SingleOrganizationView,
1416
SingleCourseView,
1517
CourseContentView,
@@ -34,6 +36,11 @@
3436
OrganizationUsersView.as_view(),
3537
name="organization_users_view",
3638
),
39+
path(
40+
"organizations/<int:organization_id>/users/<int:user_id>/",
41+
SingleOrganizationUserView.as_view(),
42+
name="single_organization_user_view",
43+
),
3744
path(
3845
"organizations/<int:organization_id>/imap-connections/",
3946
ImapConnectionView.as_view(),
@@ -97,6 +104,11 @@
97104
SingleApiKeyView.as_view(),
98105
name="single_api_key_view",
99106
),
107+
path(
108+
"users/get-or-create-by-email/",
109+
GetOrCreateUserByEmail.as_view(),
110+
name="get_or_create_user_by_email_view",
111+
),
100112
path("session", UpdateSessionView.as_view(), name="update_session_view"),
101113
path("", page_not_found, name="root"),
102114
]

django_email_learning/platform/api/views.py

Lines changed: 115 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
from django.conf import settings
1111
from django.core.files.storage import default_storage
1212
from django.utils import timezone
13+
from django.contrib.auth.models import User
14+
from django.contrib.auth.forms import PasswordResetForm
1315
from datetime import timedelta, datetime
1416
from pydantic import ValidationError
1517
from enum import StrEnum
@@ -34,6 +36,7 @@
3436
is_platform_admin,
3537
)
3638
from typing import Any
39+
import uuid
3740
import json
3841
import logging
3942

@@ -417,6 +420,7 @@ def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unt
417420

418421

419422
@method_decorator(accessible_for(roles={"admin"}), name="post")
423+
@method_decorator(accessible_for(roles={"admin"}), name="get")
420424
class OrganizationUsersView(View):
421425
def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def]
422426
try:
@@ -430,7 +434,7 @@ def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unt
430434
)
431435
org_user.save()
432436
return JsonResponse(
433-
serializers.OrganizationUserResponse.model_validate(
437+
serializers.OrganizationUserResponse.from_django_model(
434438
org_user
435439
).model_dump(),
436440
status=201,
@@ -442,9 +446,64 @@ def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unt
442446
except IntegrityError as e:
443447
return JsonResponse({"error": str(e)}, status=409)
444448

449+
def get(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def]
450+
organization_users = OrganizationUser.objects.filter(
451+
organization_id=kwargs["organization_id"]
452+
)
453+
response_list = []
454+
for org_user in organization_users:
455+
response_list.append(
456+
serializers.OrganizationUserResponse.from_django_model(
457+
org_user
458+
).model_dump()
459+
)
460+
return JsonResponse({"organization_users": response_list}, status=200)
461+
462+
463+
@method_decorator(accessible_for(roles={"admin"}), name="delete")
464+
class SingleOrganizationUserView(View):
465+
def delete(self, request, *args, **kwargs): # type: ignore[no-untyped-def]
466+
try:
467+
org_user = OrganizationUser.objects.get(id=kwargs["user_id"])
468+
org_user.delete()
469+
return JsonResponse(
470+
{"message": "Organization user removed successfully"}, status=200
471+
)
472+
except OrganizationUser.DoesNotExist:
473+
return JsonResponse({"error": "Organization user not found"}, status=404)
474+
except ValidationError as e:
475+
return JsonResponse({"error": e.json()}, status=400)
476+
except IntegrityError as e:
477+
return JsonResponse({"error": str(e)}, status=409)
478+
479+
def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def]
480+
try:
481+
payload = json.loads(request.body)
482+
serializer = serializers.UpdateOrganizationUserRoleRequest.model_validate(
483+
payload
484+
)
485+
org_user = OrganizationUser.objects.get(
486+
organization_id=kwargs["organization_id"], user_id=kwargs["user_id"]
487+
)
488+
org_user.role = serializer.role
489+
org_user.save()
490+
return JsonResponse(
491+
serializers.OrganizationUserResponse.from_django_model(
492+
org_user
493+
).model_dump(),
494+
status=200,
495+
)
496+
except OrganizationUser.DoesNotExist:
497+
return JsonResponse({"error": "Organization user not found"}, status=404)
498+
except ValidationError as e:
499+
return JsonResponse({"error": e.json()}, status=400)
500+
except IntegrityError as e:
501+
return JsonResponse({"error": str(e)}, status=409)
502+
445503

446504
@method_decorator(is_platform_admin(), name="post")
447505
@method_decorator(is_platform_admin(), name="delete")
506+
@method_decorator(accessible_for(roles={"admin", "editor", "viewer"}), name="get")
448507
class SingleOrganizationView(View):
449508
def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def]
450509
try:
@@ -488,6 +547,61 @@ def delete(self, request, *args, **kwargs): # type: ignore[no-untyped-def]
488547
except IntegrityError as e:
489548
return JsonResponse({"error": str(e)}, status=409)
490549

550+
def get(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def]
551+
try:
552+
organization = Organization.objects.get(id=kwargs["organization_id"])
553+
return JsonResponse(
554+
serializers.OrganizationResponse.from_django_model(
555+
organization,
556+
request.build_absolute_uri,
557+
).model_dump(),
558+
status=200,
559+
)
560+
except Organization.DoesNotExist:
561+
return JsonResponse({"error": "Organization not found"}, status=404)
562+
except ValidationError as e:
563+
return JsonResponse({"error": e.json()}, status=400)
564+
565+
566+
@method_decorator((is_an_organization_member(only_admin=True)), name="post")
567+
class GetOrCreateUserByEmail(View):
568+
def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def]
569+
payload = json.loads(request.body)
570+
serializer = serializers.GetOrCreateUserRequest.model_validate(payload)
571+
try:
572+
email = serializer.email
573+
organization_id = serializer.organization_id
574+
user = User.objects.filter(email=email).first()
575+
if not user:
576+
user = User.objects.create_user(
577+
username=email, email=email, password=uuid.uuid4().hex
578+
)
579+
form = PasswordResetForm(data={"email": email})
580+
if form.is_valid():
581+
form.save(
582+
request=request,
583+
use_https=True,
584+
from_email=settings.DJANGO_EMAIL_LEARNING["FROM_EMAIL"],
585+
email_template_name="emails/password_reset.txt",
586+
html_email_template_name="emails/password_reset.html",
587+
extra_email_context={
588+
"organization": Organization.objects.get(
589+
id=organization_id
590+
).name
591+
},
592+
)
593+
else:
594+
raise ValueError(
595+
"Failed to send password reset email to the new user."
596+
)
597+
return JsonResponse(
598+
serializers.UserResponse.model_validate(user).model_dump(), status=200
599+
)
600+
except ValidationError as e:
601+
return JsonResponse({"error": e.json()}, status=400)
602+
except IntegrityError as e:
603+
return JsonResponse({"error": str(e)}, status=409)
604+
491605

492606
@method_decorator(accessible_for(roles={"admin", "editor"}), name="post")
493607
class FileView(View):

django_email_learning/platform/urls.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
CourseView,
55
Courses,
66
Organizations,
7+
SingleOrganization,
78
Learners,
89
ApiKeys,
910
)
@@ -14,6 +15,11 @@
1415
path("courses/", Courses.as_view(), name="courses_view"),
1516
path("courses/<int:course_id>/", CourseView.as_view(), name="course_detail_view"),
1617
path("organizations/", Organizations.as_view(), name="organizations_view"),
18+
path(
19+
"organizations/<int:organization_id>/",
20+
SingleOrganization.as_view(),
21+
name="organization_detail_view",
22+
),
1723
path("learners/", Learners.as_view(), name="learners_view"),
1824
path("settings/api_keys/", ApiKeys.as_view(), name="api_keys_view"),
1925
path(

0 commit comments

Comments
 (0)