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 }) => { > {localeMessages["viewer"]} {localeMessages["editor"]} + {localeMessages["instructor"]} {localeMessages["admin"]} + {selectedRoleDescription && ( + + {selectedRoleDescription} + + )} {error && {error}}