diff --git a/django_email_learning/migrations/0017_alter_organizationuser_role.py b/django_email_learning/migrations/0017_alter_organizationuser_role.py
new file mode 100644
index 0000000..e5e672d
--- /dev/null
+++ b/django_email_learning/migrations/0017_alter_organizationuser_role.py
@@ -0,0 +1,26 @@
+# Generated by Django 6.0.4 on 2026-04-29 07:18
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("django_email_learning", "0016_alter_jobexecution_job_name_and_more"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="organizationuser",
+ name="role",
+ field=models.CharField(
+ choices=[
+ ("admin", "Admin"),
+ ("editor", "Editor"),
+ ("instructor", "Instructor"),
+ ("viewer", "Viewer"),
+ ],
+ db_index=True,
+ max_length=50,
+ ),
+ ),
+ ]
diff --git a/django_email_learning/models.py b/django_email_learning/models.py
index 5344933..3d27f0a 100644
--- a/django_email_learning/models.py
+++ b/django_email_learning/models.py
@@ -124,6 +124,7 @@ class OrganizationUser(models.Model):
choices=[
("admin", "Admin"),
("editor", "Editor"),
+ ("instructor", "Instructor"),
("viewer", "Viewer"),
],
db_index=True,
diff --git a/django_email_learning/platform/api/serializers.py b/django_email_learning/platform/api/serializers.py
index 8bb85a9..d613ee4 100644
--- a/django_email_learning/platform/api/serializers.py
+++ b/django_email_learning/platform/api/serializers.py
@@ -430,6 +430,7 @@ class UpdateOrganizationRequest(BaseModel):
class UserRole(enum.StrEnum):
ADMIN = "admin"
EDITOR = "editor"
+ INSTRUCTOR = "instructor"
VIEWER = "viewer"
diff --git a/django_email_learning/platform/api/views.py b/django_email_learning/platform/api/views.py
index b10141b..699620a 100644
--- a/django_email_learning/platform/api/views.py
+++ b/django_email_learning/platform/api/views.py
@@ -67,8 +67,10 @@
@method_decorator(ensure_csrf_cookie, name="get")
-@method_decorator(accessible_for(roles={"admin", "editor"}), name="post")
-@method_decorator(accessible_for(roles={"admin", "editor", "viewer"}), name="get")
+@method_decorator(accessible_for(roles={"admin", "editor", "instructor"}), name="post")
+@method_decorator(
+ accessible_for(roles={"admin", "editor", "instructor", "viewer"}), name="get"
+)
class CourseView(View):
def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def]
payload = json.loads(request.body)
@@ -114,8 +116,10 @@ def get(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unty
return JsonResponse({"courses": response_list}, status=200)
-@method_decorator(accessible_for(roles={"admin", "editor"}), name="post")
-@method_decorator(accessible_for(roles={"admin", "editor", "viewer"}), name="get")
+@method_decorator(accessible_for(roles={"admin", "editor", "instructor"}), name="post")
+@method_decorator(
+ accessible_for(roles={"admin", "editor", "viewer", "instructor"}), name="get"
+)
class CourseContentView(View):
def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def]
payload = json.loads(request.body)
@@ -161,7 +165,7 @@ def get(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unty
return JsonResponse({"error": "Course not found"}, status=404)
-@method_decorator(accessible_for(roles={"admin", "editor"}), name="post")
+@method_decorator(accessible_for(roles={"admin", "editor", "instructor"}), name="post")
class ReorderCourseContentView(View):
def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def]
payload = json.loads(request.body)
@@ -207,9 +211,13 @@ def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unt
return JsonResponse({"error": str(e)}, status=409)
-@method_decorator(accessible_for(roles={"admin", "editor", "viewer"}), name="get")
-@method_decorator(accessible_for(roles={"admin", "editor"}), name="delete")
-@method_decorator(accessible_for(roles={"admin", "editor"}), name="post")
+@method_decorator(
+ accessible_for(roles={"admin", "editor", "instructor", "viewer"}), name="get"
+)
+@method_decorator(
+ accessible_for(roles={"admin", "editor", "instructor"}), name="delete"
+)
+@method_decorator(accessible_for(roles={"admin", "editor", "instructor"}), name="post")
class SingleCourseContentView(View):
def get(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def]
try:
@@ -344,9 +352,13 @@ def _update_course_content_atomic(
)
-@method_decorator(accessible_for(roles={"admin", "editor"}), name="post")
-@method_decorator(accessible_for(roles={"admin", "editor"}), name="delete")
-@method_decorator(accessible_for(roles={"admin", "editor", "viewer"}), name="get")
+@method_decorator(accessible_for(roles={"admin", "editor", "instructor"}), name="post")
+@method_decorator(
+ accessible_for(roles={"admin", "editor", "instructor"}), name="delete"
+)
+@method_decorator(
+ accessible_for(roles={"admin", "editor", "instructor", "viewer"}), name="get"
+)
class SingleCourseView(View):
def get(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def]
try:
@@ -496,8 +508,10 @@ def post(self, request, *args, **kwargs): # type: ignore[no-untyped-def]
return JsonResponse({"error": "Failed to enroll users"}, status=500)
-@method_decorator(accessible_for(roles={"admin", "editor"}), name="post")
-@method_decorator(accessible_for(roles={"admin", "editor", "viewer"}), name="get")
+@method_decorator(accessible_for(roles={"admin", "editor", "instructor"}), name="post")
+@method_decorator(
+ accessible_for(roles={"admin", "editor", "instructor", "viewer"}), name="get"
+)
class ImapConnectionView(View):
def get(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def]
response_list = []
@@ -661,7 +675,9 @@ def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unt
@method_decorator(is_platform_admin(), name="post")
@method_decorator(is_platform_admin(), name="delete")
-@method_decorator(accessible_for(roles={"admin", "editor", "viewer"}), name="get")
+@method_decorator(
+ accessible_for(roles={"admin", "editor", "instructor", "viewer"}), name="get"
+)
class SingleOrganizationView(View):
def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def]
try:
@@ -771,7 +787,7 @@ def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unt
return JsonResponse({"error": str(e)}, status=409)
-@method_decorator(accessible_for(roles={"admin", "editor"}), name="post")
+@method_decorator(accessible_for(roles={"admin", "editor", "instructor"}), name="post")
class SendLessonToPlatformUser(View):
def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def]
if not request.user.email:
@@ -802,8 +818,10 @@ def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unt
)
-@method_decorator(accessible_for(roles={"admin", "editor"}), name="post")
-@method_decorator(accessible_for(roles={"admin", "editor"}), name="delete")
+@method_decorator(accessible_for(roles={"admin", "editor", "instructor"}), name="post")
+@method_decorator(
+ accessible_for(roles={"admin", "editor", "instructor"}), name="delete"
+)
class FileView(View):
def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def]
uploaded_file = request.FILES.get("file")
@@ -891,7 +909,7 @@ def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unt
return JsonResponse(response_serializer.model_dump(), status=200)
-@method_decorator(accessible_for(roles={"admin", "editor", "viewer"}), name="get")
+@method_decorator(accessible_for(roles={"admin", "instructor"}), name="get")
class LearnersView(PaginatedApiMixin, View):
def get_query_set(self, request: Any) -> models.QuerySet:
organization_id = self.kwargs["organization_id"]
@@ -913,7 +931,9 @@ def get_item_serializer_class(self) -> Any:
return serializers.LearnerResponse
-@method_decorator(accessible_for(roles={"admin", "editor", "viewer"}), name="get")
+@method_decorator(
+ accessible_for(roles={"admin", "editor", "viewer", "instructor"}), name="get"
+)
class SingleLearnerView(View):
def get(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def]
try:
@@ -954,7 +974,7 @@ def get(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unty
return JsonResponse({"error": "An internal error occurred."}, status=500)
-@method_decorator(accessible_for(roles={"admin"}), name="post")
+@method_decorator(accessible_for(roles={"admin", "instructor"}), name="post")
class EnrollmentsView(PaginatedApiMixin, View):
def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def]
payload = json.loads(request.body)
@@ -998,7 +1018,9 @@ def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unt
return JsonResponse({"error": e.json()}, status=400)
-@method_decorator(accessible_for(roles={"admin", "editor", "viewer"}), name="get")
+@method_decorator(
+ accessible_for(roles={"admin", "editor", "instructor", "viewer"}), name="get"
+)
class EnrollmentView(View):
def get(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def]
try:
@@ -1015,7 +1037,9 @@ def get(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unty
return JsonResponse({"error": e.json()}, status=400)
-@method_decorator(accessible_for(roles={"admin", "editor", "viewer"}), name="get")
+@method_decorator(
+ accessible_for(roles={"admin", "editor", "instructor", "viewer"}), name="get"
+)
class EnrollmentsStatisticsView(View):
def get(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def]
course_id = kwargs["course_id"]
diff --git a/django_email_learning/platform/views.py b/django_email_learning/platform/views.py
index 64cc691..3f7dcc6 100644
--- a/django_email_learning/platform/views.py
+++ b/django_email_learning/platform/views.py
@@ -68,6 +68,14 @@ def get_shared_context(self) -> Dict[str, Any]:
and getattr(self.request.user, "has_platform_admin_role", False)
)
),
+ "isInstructor": (
+ self.request.user.is_superuser
+ or (
+ OrganizationUser.objects.filter(
+ user=self.request.user, role="instructor"
+ ).exists() # type: ignore[misc]
+ )
+ ),
"aiTextEditingModel": AI_CONFIGURATIONS.get("TEXT_EDITING_MODEL"),
"customLogo": {
"horizontalLight": DJANGO_EMAIL_LEARNING_SETTINGS.get("LOGO", {})
@@ -478,7 +486,16 @@ def get_locale_messages(self) -> Dict[str, str]:
"role": _("Role"),
"admin": _("Admin"),
"editor": _("Editor"),
+ "instructor": _("Instructor"),
"viewer": _("Viewer"),
+ "viewer_role_description": _("Read-only access to course content."),
+ "editor_role_description": _("Can create and edit course content."),
+ "instructor_role_description": _(
+ "All editor permissions, plus access to learners and assignment review and approval."
+ ),
+ "admin_role_description": _(
+ "Full access, including creating users and organizations."
+ ),
"email": _("Email"),
"delete_user_with_email": _("Deleting USER_EMAIL"),
"user_delete_confirmation": _(
@@ -494,7 +511,7 @@ def get_locale_messages(self) -> Dict[str, str]:
@method_decorator(login_required, name="dispatch")
-@method_decorator(is_an_organization_member(only_admin=True), name="dispatch")
+@method_decorator(is_an_organization_member(), name="dispatch")
class Learners(BasePlatformView):
template_name = "platform/learners.html"
diff --git a/frontend/platform/learners/Learners.jsx b/frontend/platform/learners/Learners.jsx
index 2c3cfed..575269f 100644
--- a/frontend/platform/learners/Learners.jsx
+++ b/frontend/platform/learners/Learners.jsx
@@ -202,7 +202,7 @@ function Learners(initialQs="") {
}>
-
+ { enrollments && }
diff --git a/frontend/platform/organization/components/UserForm.jsx b/frontend/platform/organization/components/UserForm.jsx
index 8a650a6..fe661d6 100644
--- a/frontend/platform/organization/components/UserForm.jsx
+++ b/frontend/platform/organization/components/UserForm.jsx
@@ -17,6 +17,15 @@ const UserForm = ({ onClose, organizationId, refreshUsers, user = null }) => {
const [role, setRole] = useState(user ? user.role : 'viewer');
const [error, setError] = useState('');
+ const roleDescriptionByRole = {
+ viewer: localeMessages["viewer_role_description"],
+ editor: localeMessages["editor_role_description"],
+ instructor: localeMessages["instructor_role_description"],
+ admin: localeMessages["admin_role_description"],
+ };
+
+ const selectedRoleDescription = roleDescriptionByRole[role] || '';
+
const createUser = (id) => {
fetch(`${apiBaseUrl}/organizations/${organizationId}/users/`, {
method: 'POST',
@@ -123,9 +132,15 @@ const UserForm = ({ onClose, organizationId, refreshUsers, user = null }) => {
>
+
+ {selectedRoleDescription && (
+
+ {selectedRoleDescription}
+
+ )}
{error && {error}}