Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions django_email_learning/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ def _wrapped_view(request, *view_args, **view_kwargs) -> JsonResponse: # type:
return decorator


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

if not user.is_superuser:
has_access = OrganizationUser.objects.filter( # type: ignore[misc]
qs = OrganizationUser.objects.filter( # type: ignore[misc]
user=user
).exists()
)
if only_admin:
qs = qs.filter(role="admin")
has_access = qs.exists()
if not has_access:
return JsonResponse({"error": "Forbidden"}, status=403)
return view_func(request, *view_args, **view_kwargs)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Generated by Django 6.0.2 on 2026-02-08 14:18

from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
(
"django_email_learning",
"0006_alter_apikey_salt_alter_imapconnection_salt_and_more",
),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.AlterField(
model_name="apikey",
name="salt",
field=models.CharField(
default="647b192f563e4a47a23d9cd7d7331e1b",
editable=False,
max_length=32,
),
),
migrations.AlterField(
model_name="imapconnection",
name="salt",
field=models.CharField(
default="647b192f563e4a47a23d9cd7d7331e1b",
editable=False,
max_length=32,
),
),
migrations.AlterUniqueTogether(
name="organizationuser",
unique_together={("user", "organization")},
),
]
3 changes: 3 additions & 0 deletions django_email_learning/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,9 @@ class OrganizationUser(models.Model):
def __str__(self) -> str:
return f"{self.user.username} - {self.organization.name}"

class Meta:
unique_together = [["user", "organization"]]


class EncryptionMixin(models.Model):
salt = models.CharField(max_length=32, editable=False, default=uuid.uuid4().hex)
Expand Down
42 changes: 39 additions & 3 deletions django_email_learning/platform/api/serializers.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import re
from pydantic import (
BaseModel,
ConfigDict,
Expand All @@ -23,6 +24,7 @@
QuizSelectionStrategy,
Enrollment,
EnrollmentStatus,
OrganizationUser,
)
from django_email_learning.services.jwt_service import generate_jwt
import enum
Expand Down Expand Up @@ -52,6 +54,25 @@ def from_django_model(api_key: ApiKey) -> "ApiKeyResponse":
)


class GetOrCreateUserRequest(BaseModel):
email: str = Field(min_length=1, examples=["user@example.com"])
organization_id: int = Field(gt=0, examples=[1])

@field_validator("email")
def validate_email(cls, email: str) -> str:
email_regex = r"^[\w\.-]+@[\w\.-]+\.\w+$"
if not re.match(email_regex, email):
raise ValueError("Invalid email format")
return email


class UserResponse(BaseModel):
id: int
email: str

model_config = ConfigDict(from_attributes=True)


class CreateCourseRequest(BaseModel):
title: str = Field(min_length=1, examples=["Introduction to Python"])
slug: str = Field(
Expand Down Expand Up @@ -273,20 +294,35 @@ class UpdateOrganizationRequest(BaseModel):

class UserRole(enum.StrEnum):
ADMIN = "admin"
Editor = "editor"
Viewer = "viewer"
EDITOR = "editor"
VIEWER = "viewer"


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


class UpdateOrganizationUserRoleRequest(BaseModel):
role: UserRole = Field(min_length=1, examples=[UserRole.ADMIN])


class OrganizationUserResponse(BaseModel):
id: int
user_id: int
organization_id: int
email: str
role: UserRole

model_config = ConfigDict(from_attributes=True)
@staticmethod
def from_django_model(org_user: OrganizationUser) -> "OrganizationUserResponse":
return OrganizationUserResponse(
id=org_user.id,
user_id=org_user.user.id,
organization_id=org_user.organization.id,
email=org_user.user.email,
role=UserRole(org_user.role),
)


class UpdateSessionRequest(BaseModel):
Expand Down
12 changes: 12 additions & 0 deletions django_email_learning/platform/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from django.views.defaults import page_not_found
from django_email_learning.platform.api.views import (
ApiKeyView,
GetOrCreateUserByEmail,
SingleApiKeyView,
CourseView,
EnrollmentView,
Expand All @@ -10,6 +11,7 @@
ImapConnectionView,
OrganizationsView,
OrganizationUsersView,
SingleOrganizationUserView,
SingleOrganizationView,
SingleCourseView,
CourseContentView,
Expand All @@ -34,6 +36,11 @@
OrganizationUsersView.as_view(),
name="organization_users_view",
),
path(
"organizations/<int:organization_id>/users/<int:user_id>/",
SingleOrganizationUserView.as_view(),
name="single_organization_user_view",
),
path(
"organizations/<int:organization_id>/imap-connections/",
ImapConnectionView.as_view(),
Expand Down Expand Up @@ -97,6 +104,11 @@
SingleApiKeyView.as_view(),
name="single_api_key_view",
),
path(
"users/get-or-create-by-email/",
GetOrCreateUserByEmail.as_view(),
name="get_or_create_user_by_email_view",
),
path("session", UpdateSessionView.as_view(), name="update_session_view"),
path("", page_not_found, name="root"),
]
116 changes: 115 additions & 1 deletion django_email_learning/platform/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
from django.conf import settings
from django.core.files.storage import default_storage
from django.utils import timezone
from django.contrib.auth.models import User
from django.contrib.auth.forms import PasswordResetForm
from datetime import timedelta, datetime
from pydantic import ValidationError
from enum import StrEnum
Expand All @@ -34,6 +36,7 @@
is_platform_admin,
)
from typing import Any
import uuid
import json
import logging

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


@method_decorator(accessible_for(roles={"admin"}), name="post")
@method_decorator(accessible_for(roles={"admin"}), name="get")
class OrganizationUsersView(View):
def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def]
try:
Expand All @@ -430,7 +434,7 @@ def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unt
)
org_user.save()
return JsonResponse(
serializers.OrganizationUserResponse.model_validate(
serializers.OrganizationUserResponse.from_django_model(
org_user
).model_dump(),
status=201,
Expand All @@ -442,9 +446,64 @@ def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unt
except IntegrityError as e:
return JsonResponse({"error": str(e)}, status=409)

def get(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def]
organization_users = OrganizationUser.objects.filter(
organization_id=kwargs["organization_id"]
)
response_list = []
for org_user in organization_users:
response_list.append(
serializers.OrganizationUserResponse.from_django_model(
org_user
).model_dump()
)
return JsonResponse({"organization_users": response_list}, status=200)


@method_decorator(accessible_for(roles={"admin"}), name="delete")
class SingleOrganizationUserView(View):
def delete(self, request, *args, **kwargs): # type: ignore[no-untyped-def]
try:
org_user = OrganizationUser.objects.get(id=kwargs["user_id"])
org_user.delete()
return JsonResponse(
{"message": "Organization user removed successfully"}, status=200
)
except OrganizationUser.DoesNotExist:
return JsonResponse({"error": "Organization user not found"}, status=404)
except ValidationError as e:
return JsonResponse({"error": e.json()}, status=400)
except IntegrityError as e:
return JsonResponse({"error": str(e)}, status=409)

def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def]
try:
payload = json.loads(request.body)
serializer = serializers.UpdateOrganizationUserRoleRequest.model_validate(
payload
)
org_user = OrganizationUser.objects.get(
organization_id=kwargs["organization_id"], user_id=kwargs["user_id"]
)
org_user.role = serializer.role
org_user.save()
return JsonResponse(
serializers.OrganizationUserResponse.from_django_model(
org_user
).model_dump(),
status=200,
)
except OrganizationUser.DoesNotExist:
return JsonResponse({"error": "Organization user not found"}, status=404)
except ValidationError as e:
return JsonResponse({"error": e.json()}, status=400)
except IntegrityError as e:
return JsonResponse({"error": str(e)}, status=409)


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

def get(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def]
try:
organization = Organization.objects.get(id=kwargs["organization_id"])
return JsonResponse(
serializers.OrganizationResponse.from_django_model(
organization,
request.build_absolute_uri,
).model_dump(),
status=200,
)
except Organization.DoesNotExist:
return JsonResponse({"error": "Organization not found"}, status=404)
except ValidationError as e:
return JsonResponse({"error": e.json()}, status=400)


@method_decorator((is_an_organization_member(only_admin=True)), name="post")
class GetOrCreateUserByEmail(View):
def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def]
payload = json.loads(request.body)
serializer = serializers.GetOrCreateUserRequest.model_validate(payload)
try:
email = serializer.email
organization_id = serializer.organization_id
user = User.objects.filter(email=email).first()
if not user:
user = User.objects.create_user(
username=email, email=email, password=uuid.uuid4().hex
)
form = PasswordResetForm(data={"email": email})
if form.is_valid():
form.save(
request=request,
use_https=True,
from_email=settings.DJANGO_EMAIL_LEARNING["FROM_EMAIL"],
email_template_name="emails/password_reset.txt",
html_email_template_name="emails/password_reset.html",
extra_email_context={
"organization": Organization.objects.get(
id=organization_id
).name
},
)
else:
raise ValueError(
"Failed to send password reset email to the new user."
)
return JsonResponse(
serializers.UserResponse.model_validate(user).model_dump(), status=200
)
except ValidationError as e:
return JsonResponse({"error": e.json()}, status=400)
except IntegrityError as e:
return JsonResponse({"error": str(e)}, status=409)


@method_decorator(accessible_for(roles={"admin", "editor"}), name="post")
class FileView(View):
Expand Down
6 changes: 6 additions & 0 deletions django_email_learning/platform/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
CourseView,
Courses,
Organizations,
SingleOrganization,
Learners,
ApiKeys,
)
Expand All @@ -14,6 +15,11 @@
path("courses/", Courses.as_view(), name="courses_view"),
path("courses/<int:course_id>/", CourseView.as_view(), name="course_detail_view"),
path("organizations/", Organizations.as_view(), name="organizations_view"),
path(
"organizations/<int:organization_id>/",
SingleOrganization.as_view(),
name="organization_detail_view",
),
path("learners/", Learners.as_view(), name="learners_view"),
path("settings/api_keys/", ApiKeys.as_view(), name="api_keys_view"),
path(
Expand Down
Loading