Skip to content

Commit 3b4dbff

Browse files
authored
Merge pull request #392 from AvaCodeSolutions/feat/390/instructor-role
feat: 390 Add instructor role
2 parents 84d31e8 + 8d10adb commit 3b4dbff

14 files changed

Lines changed: 161 additions & 56 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# Generated by Django 6.0.4 on 2026-04-29 07:18
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
dependencies = [
8+
("django_email_learning", "0016_alter_jobexecution_job_name_and_more"),
9+
]
10+
11+
operations = [
12+
migrations.AlterField(
13+
model_name="organizationuser",
14+
name="role",
15+
field=models.CharField(
16+
choices=[
17+
("admin", "Admin"),
18+
("editor", "Editor"),
19+
("instructor", "Instructor"),
20+
("viewer", "Viewer"),
21+
],
22+
db_index=True,
23+
max_length=50,
24+
),
25+
),
26+
]

django_email_learning/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ class OrganizationUser(models.Model):
124124
choices=[
125125
("admin", "Admin"),
126126
("editor", "Editor"),
127+
("instructor", "Instructor"),
127128
("viewer", "Viewer"),
128129
],
129130
db_index=True,

django_email_learning/platform/api/serializers.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,7 @@ class UpdateOrganizationRequest(BaseModel):
430430
class UserRole(enum.StrEnum):
431431
ADMIN = "admin"
432432
EDITOR = "editor"
433+
INSTRUCTOR = "instructor"
433434
VIEWER = "viewer"
434435

435436

django_email_learning/platform/api/views.py

Lines changed: 46 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -67,8 +67,10 @@
6767

6868

6969
@method_decorator(ensure_csrf_cookie, name="get")
70-
@method_decorator(accessible_for(roles={"admin", "editor"}), name="post")
71-
@method_decorator(accessible_for(roles={"admin", "editor", "viewer"}), name="get")
70+
@method_decorator(accessible_for(roles={"admin", "editor", "instructor"}), name="post")
71+
@method_decorator(
72+
accessible_for(roles={"admin", "editor", "instructor", "viewer"}), name="get"
73+
)
7274
class CourseView(View):
7375
def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def]
7476
payload = json.loads(request.body)
@@ -114,8 +116,10 @@ def get(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unty
114116
return JsonResponse({"courses": response_list}, status=200)
115117

116118

117-
@method_decorator(accessible_for(roles={"admin", "editor"}), name="post")
118-
@method_decorator(accessible_for(roles={"admin", "editor", "viewer"}), name="get")
119+
@method_decorator(accessible_for(roles={"admin", "editor", "instructor"}), name="post")
120+
@method_decorator(
121+
accessible_for(roles={"admin", "editor", "viewer", "instructor"}), name="get"
122+
)
119123
class CourseContentView(View):
120124
def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def]
121125
payload = json.loads(request.body)
@@ -161,7 +165,7 @@ def get(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unty
161165
return JsonResponse({"error": "Course not found"}, status=404)
162166

163167

164-
@method_decorator(accessible_for(roles={"admin", "editor"}), name="post")
168+
@method_decorator(accessible_for(roles={"admin", "editor", "instructor"}), name="post")
165169
class ReorderCourseContentView(View):
166170
def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def]
167171
payload = json.loads(request.body)
@@ -207,9 +211,13 @@ def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unt
207211
return JsonResponse({"error": str(e)}, status=409)
208212

209213

210-
@method_decorator(accessible_for(roles={"admin", "editor", "viewer"}), name="get")
211-
@method_decorator(accessible_for(roles={"admin", "editor"}), name="delete")
212-
@method_decorator(accessible_for(roles={"admin", "editor"}), name="post")
214+
@method_decorator(
215+
accessible_for(roles={"admin", "editor", "instructor", "viewer"}), name="get"
216+
)
217+
@method_decorator(
218+
accessible_for(roles={"admin", "editor", "instructor"}), name="delete"
219+
)
220+
@method_decorator(accessible_for(roles={"admin", "editor", "instructor"}), name="post")
213221
class SingleCourseContentView(View):
214222
def get(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def]
215223
try:
@@ -344,9 +352,13 @@ def _update_course_content_atomic(
344352
)
345353

346354

347-
@method_decorator(accessible_for(roles={"admin", "editor"}), name="post")
348-
@method_decorator(accessible_for(roles={"admin", "editor"}), name="delete")
349-
@method_decorator(accessible_for(roles={"admin", "editor", "viewer"}), name="get")
355+
@method_decorator(accessible_for(roles={"admin", "editor", "instructor"}), name="post")
356+
@method_decorator(
357+
accessible_for(roles={"admin", "editor", "instructor"}), name="delete"
358+
)
359+
@method_decorator(
360+
accessible_for(roles={"admin", "editor", "instructor", "viewer"}), name="get"
361+
)
350362
class SingleCourseView(View):
351363
def get(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def]
352364
try:
@@ -496,8 +508,10 @@ def post(self, request, *args, **kwargs): # type: ignore[no-untyped-def]
496508
return JsonResponse({"error": "Failed to enroll users"}, status=500)
497509

498510

499-
@method_decorator(accessible_for(roles={"admin", "editor"}), name="post")
500-
@method_decorator(accessible_for(roles={"admin", "editor", "viewer"}), name="get")
511+
@method_decorator(accessible_for(roles={"admin", "editor", "instructor"}), name="post")
512+
@method_decorator(
513+
accessible_for(roles={"admin", "editor", "instructor", "viewer"}), name="get"
514+
)
501515
class ImapConnectionView(View):
502516
def get(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def]
503517
response_list = []
@@ -661,7 +675,9 @@ def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unt
661675

662676
@method_decorator(is_platform_admin(), name="post")
663677
@method_decorator(is_platform_admin(), name="delete")
664-
@method_decorator(accessible_for(roles={"admin", "editor", "viewer"}), name="get")
678+
@method_decorator(
679+
accessible_for(roles={"admin", "editor", "instructor", "viewer"}), name="get"
680+
)
665681
class SingleOrganizationView(View):
666682
def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def]
667683
try:
@@ -771,7 +787,7 @@ def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unt
771787
return JsonResponse({"error": str(e)}, status=409)
772788

773789

774-
@method_decorator(accessible_for(roles={"admin", "editor"}), name="post")
790+
@method_decorator(accessible_for(roles={"admin", "editor", "instructor"}), name="post")
775791
class SendLessonToPlatformUser(View):
776792
def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def]
777793
if not request.user.email:
@@ -802,8 +818,10 @@ def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unt
802818
)
803819

804820

805-
@method_decorator(accessible_for(roles={"admin", "editor"}), name="post")
806-
@method_decorator(accessible_for(roles={"admin", "editor"}), name="delete")
821+
@method_decorator(accessible_for(roles={"admin", "editor", "instructor"}), name="post")
822+
@method_decorator(
823+
accessible_for(roles={"admin", "editor", "instructor"}), name="delete"
824+
)
807825
class FileView(View):
808826
def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def]
809827
uploaded_file = request.FILES.get("file")
@@ -891,7 +909,7 @@ def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unt
891909
return JsonResponse(response_serializer.model_dump(), status=200)
892910

893911

894-
@method_decorator(accessible_for(roles={"admin", "editor", "viewer"}), name="get")
912+
@method_decorator(accessible_for(roles={"admin", "instructor"}), name="get")
895913
class LearnersView(PaginatedApiMixin, View):
896914
def get_query_set(self, request: Any) -> models.QuerySet:
897915
organization_id = self.kwargs["organization_id"]
@@ -913,7 +931,9 @@ def get_item_serializer_class(self) -> Any:
913931
return serializers.LearnerResponse
914932

915933

916-
@method_decorator(accessible_for(roles={"admin", "editor", "viewer"}), name="get")
934+
@method_decorator(
935+
accessible_for(roles={"admin", "editor", "viewer", "instructor"}), name="get"
936+
)
917937
class SingleLearnerView(View):
918938
def get(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def]
919939
try:
@@ -954,7 +974,7 @@ def get(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unty
954974
return JsonResponse({"error": "An internal error occurred."}, status=500)
955975

956976

957-
@method_decorator(accessible_for(roles={"admin"}), name="post")
977+
@method_decorator(accessible_for(roles={"admin", "instructor"}), name="post")
958978
class EnrollmentsView(PaginatedApiMixin, View):
959979
def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def]
960980
payload = json.loads(request.body)
@@ -998,7 +1018,9 @@ def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unt
9981018
return JsonResponse({"error": e.json()}, status=400)
9991019

10001020

1001-
@method_decorator(accessible_for(roles={"admin", "editor", "viewer"}), name="get")
1021+
@method_decorator(
1022+
accessible_for(roles={"admin", "editor", "instructor", "viewer"}), name="get"
1023+
)
10021024
class EnrollmentView(View):
10031025
def get(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def]
10041026
try:
@@ -1015,7 +1037,9 @@ def get(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unty
10151037
return JsonResponse({"error": e.json()}, status=400)
10161038

10171039

1018-
@method_decorator(accessible_for(roles={"admin", "editor", "viewer"}), name="get")
1040+
@method_decorator(
1041+
accessible_for(roles={"admin", "editor", "instructor", "viewer"}), name="get"
1042+
)
10191043
class EnrollmentsStatisticsView(View):
10201044
def get(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def]
10211045
course_id = kwargs["course_id"]

django_email_learning/platform/views.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,14 @@ def get_shared_context(self) -> Dict[str, Any]:
6868
and getattr(self.request.user, "has_platform_admin_role", False)
6969
)
7070
),
71+
"isInstructor": (
72+
self.request.user.is_superuser
73+
or (
74+
OrganizationUser.objects.filter(
75+
user=self.request.user, role="instructor"
76+
).exists() # type: ignore[misc]
77+
)
78+
),
7179
"aiTextEditingModel": AI_CONFIGURATIONS.get("TEXT_EDITING_MODEL"),
7280
"customLogo": {
7381
"horizontalLight": DJANGO_EMAIL_LEARNING_SETTINGS.get("LOGO", {})
@@ -478,7 +486,16 @@ def get_locale_messages(self) -> Dict[str, str]:
478486
"role": _("Role"),
479487
"admin": _("Admin"),
480488
"editor": _("Editor"),
489+
"instructor": _("Instructor"),
481490
"viewer": _("Viewer"),
491+
"viewer_role_description": _("Read-only access to course content."),
492+
"editor_role_description": _("Can create and edit course content."),
493+
"instructor_role_description": _(
494+
"All editor permissions, plus access to learners and assignment review and approval."
495+
),
496+
"admin_role_description": _(
497+
"Full access, including creating users and organizations."
498+
),
482499
"email": _("Email"),
483500
"delete_user_with_email": _("Deleting USER_EMAIL"),
484501
"user_delete_confirmation": _(
@@ -494,7 +511,7 @@ def get_locale_messages(self) -> Dict[str, str]:
494511

495512

496513
@method_decorator(login_required, name="dispatch")
497-
@method_decorator(is_an_organization_member(only_admin=True), name="dispatch")
514+
@method_decorator(is_an_organization_member(), name="dispatch")
498515
class Learners(BasePlatformView):
499516
template_name = "platform/learners.html"
500517

frontend/platform/learners/Learners.jsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,7 @@ function Learners(initialQs="") {
202202
</Box>
203203
<Box sx={{ p: 2 }}>
204204
<Suspense fallback={<Box sx={{ p: 2 }}><LinearProgress /></Box>}>
205-
<EnrollentList enrollments={enrollments} selectHandler={showEnrollmentStatus} />
205+
{ enrollments && <EnrollentList enrollments={enrollments} selectHandler={showEnrollmentStatus} /> }
206206
</Suspense>
207207
</Box>
208208
</Box>

frontend/platform/organization/components/UserForm.jsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,15 @@ const UserForm = ({ onClose, organizationId, refreshUsers, user = null }) => {
1717
const [role, setRole] = useState(user ? user.role : 'viewer');
1818
const [error, setError] = useState('');
1919

20+
const roleDescriptionByRole = {
21+
viewer: localeMessages["viewer_role_description"],
22+
editor: localeMessages["editor_role_description"],
23+
instructor: localeMessages["instructor_role_description"],
24+
admin: localeMessages["admin_role_description"],
25+
};
26+
27+
const selectedRoleDescription = roleDescriptionByRole[role] || '';
28+
2029
const createUser = (id) => {
2130
fetch(`${apiBaseUrl}/organizations/${organizationId}/users/`, {
2231
method: 'POST',
@@ -123,9 +132,15 @@ const UserForm = ({ onClose, organizationId, refreshUsers, user = null }) => {
123132
>
124133
<MenuItem value="viewer">{localeMessages["viewer"]}</MenuItem>
125134
<MenuItem value="editor">{localeMessages["editor"]}</MenuItem>
135+
<MenuItem value="instructor">{localeMessages["instructor"]}</MenuItem>
126136
<MenuItem value="admin">{localeMessages["admin"]}</MenuItem>
127137
</Select>
128138
</FormControl>
139+
{selectedRoleDescription && (
140+
<Typography variant="body2" color="text.secondary">
141+
{selectedRoleDescription}
142+
</Typography>
143+
)}
129144
{error && <Typography color="error">{error}</Typography>}
130145
<Button type="submit" variant="contained" color="secondary">
131146
{ user ? localeMessages["edit_user"] : localeMessages["add_user"]}

frontend/src/components/MenuBar.jsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ function MenuBar({activeOrganizationId, changeOrganizationCallback, showOrganiza
5656
const [menuOpen, setMenuOpen] = useState(false)
5757
const [organizations, setOrganizations] = useState([])
5858
const [deliverContentsJobStatus, setDeliverContentsJobStatus] = useState(null)
59-
const { localeMessages, isPlatformAdmin, isOrganizationAdmin, direction, apiBaseUrl, platformBaseUrl, sidebarCustomComponent, customLogo } = useAppContext();
59+
const { localeMessages, isPlatformAdmin, isOrganizationAdmin, isInstructor, direction, apiBaseUrl, platformBaseUrl, sidebarCustomComponent, customLogo } = useAppContext();
6060

6161
const theme = useTheme();
6262
const isMdUpScreen = useMediaQuery(theme.breakpoints.up('md'));
@@ -137,7 +137,7 @@ function MenuBar({activeOrganizationId, changeOrganizationCallback, showOrganiza
137137
}
138138

139139
pages.push({ name: localeMessages["course_management"], icon: <SchoolOutlinedIcon fontSize="small" />, href: platformBaseUrl + '/courses/' });
140-
if (isOrganizationAdmin || isPlatformAdmin) {
140+
if (isOrganizationAdmin || isPlatformAdmin || isInstructor) {
141141
pages.push({ name: localeMessages["learners"], icon: <PeopleOutlinedIcon fontSize="small" />, href: platformBaseUrl + '/learners/' });
142142
}
143143
// pages.push({ name: 'Analytics', icon: <BarChartIcon fontSize="small" />, href: platformBaseUrl + '/analytics/' });

tests/conftest.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,18 +51,28 @@ def users(db):
5151
email="orgadmin@example.com",
5252
password="orgadminpass",
5353
)
54+
instructor_user = User(
55+
id=6,
56+
username="instructor",
57+
email="instructor@example.com",
58+
password="instructorpass",
59+
)
5460
User.objects.bulk_create(
5561
[
5662
superadmin,
5763
editor_user,
5864
platform_admin_user,
5965
viewer_user,
6066
organization_admin_user,
67+
instructor_user,
6168
]
6269
)
6370
group = Group.objects.get(name=PLATFORM_ADMIN_GROUP_NAME)
6471
platform_admin_user.groups.add(group)
6572
editor = OrganizationUser(user=editor_user, organization_id=1, role="editor")
73+
instructor = OrganizationUser(
74+
user=instructor_user, organization_id=1, role="instructor"
75+
)
6676
platform_admin = OrganizationUser(
6777
user=platform_admin_user, organization_id=1, role="admin"
6878
)
@@ -71,11 +81,12 @@ def users(db):
7181
)
7282
viewer = OrganizationUser(user=viewer_user, organization_id=1, role="viewer")
7383
OrganizationUser.objects.bulk_create(
74-
[editor, platform_admin, organization_admin, viewer]
84+
[editor, instructor, platform_admin, organization_admin, viewer]
7585
)
7686
return {
7787
"superadmin": superadmin,
7888
"editor_user": editor_user,
89+
"instructor_user": instructor_user,
7990
"platform_admin": platform_admin_user,
8091
"organization_admin": organization_admin_user,
8192
"viewer_user": viewer_user,
@@ -101,6 +112,13 @@ def editor_client(users):
101112
return client
102113

103114

115+
@pytest.fixture()
116+
def instructor_client(users):
117+
client = Client()
118+
client.force_login(users["instructor_user"])
119+
return client
120+
121+
104122
@pytest.fixture()
105123
def platform_admin_client(users):
106124
client = Client()
@@ -127,6 +145,7 @@ def client(
127145
request,
128146
superadmin_client,
129147
editor_client,
148+
instructor_client,
130149
platform_admin_client,
131150
viewer_client,
132151
anonymous_client,
@@ -140,6 +159,7 @@ def _get_client(role_name):
140159
"platform_admin": platform_admin_client,
141160
"viewer": viewer_client,
142161
"org_admin": org_admin_client,
162+
"instructor": instructor_client,
143163
}
144164
return role_map.get(role_name)
145165

0 commit comments

Comments
 (0)