Skip to content

Commit a59915c

Browse files
authored
Merge pull request #401 from AvaCodeSolutions/feat/394/instructors-for-courses
feat: 394 add optional instructors for courses
2 parents ea9195a + 1a0d0d5 commit a59915c

10 files changed

Lines changed: 984 additions & 3 deletions

File tree

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# Generated by Django 6.0.4 on 2026-04-30 09:10
2+
3+
import django.db.models.deletion
4+
from django.db import migrations, models
5+
6+
7+
class Migration(migrations.Migration):
8+
dependencies = [
9+
("django_email_learning", "0020_assignment_reminder_interval_days"),
10+
]
11+
12+
operations = [
13+
migrations.CreateModel(
14+
name="CourseInstructor",
15+
fields=[
16+
(
17+
"id",
18+
models.BigAutoField(
19+
auto_created=True,
20+
primary_key=True,
21+
serialize=False,
22+
verbose_name="ID",
23+
),
24+
),
25+
(
26+
"course",
27+
models.ForeignKey(
28+
on_delete=django.db.models.deletion.CASCADE,
29+
related_name="instructors",
30+
to="django_email_learning.course",
31+
),
32+
),
33+
(
34+
"org_user",
35+
models.ForeignKey(
36+
on_delete=django.db.models.deletion.CASCADE,
37+
to="django_email_learning.organizationuser",
38+
),
39+
),
40+
],
41+
options={
42+
"unique_together": {("course", "org_user")},
43+
},
44+
),
45+
]

django_email_learning/models.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,13 @@ class OrganizationUser(models.Model):
133133
def __str__(self) -> str:
134134
return f"{self.user.username} - {self.organization.name}"
135135

136+
def can_act_as_instructor(self) -> bool:
137+
# TODO: When we add display name for org user we can also accept admin role as instructor
138+
# if they have display name set, for now only users with instructor role can be course instructors
139+
if self.role == "instructor":
140+
return True
141+
return False
142+
136143
class Meta:
137144
unique_together = [["user", "organization"]]
138145

@@ -305,6 +312,28 @@ def replace_image(self, file_path: str) -> str:
305312
raise ValueError("Image file does not exist.")
306313

307314

315+
class CourseInstructor(models.Model):
316+
course = models.ForeignKey(
317+
Course, on_delete=models.CASCADE, related_name="instructors"
318+
)
319+
org_user = models.ForeignKey(OrganizationUser, on_delete=models.CASCADE)
320+
321+
def __str__(self) -> str:
322+
return f"{self.course.title} - {self.org_user.user.email}"
323+
324+
def save(self, *args, **kwargs) -> None: # type: ignore[no-untyped-def]
325+
if self.org_user.organization != self.course.organization:
326+
raise ValidationError(
327+
"Instructor must belong to the same organization as the course."
328+
)
329+
if not self.org_user.can_act_as_instructor():
330+
raise ValidationError("Organization user doesn't have instructor role.")
331+
super().save(*args, **kwargs)
332+
333+
class Meta:
334+
unique_together = [["course", "org_user"]]
335+
336+
308337
class ExternalReference(models.Model):
309338
course = models.ForeignKey(
310339
Course, on_delete=models.CASCADE, related_name="external_references"

django_email_learning/platform/api/serializers.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from django_email_learning.models import (
1414
ApiKey,
1515
ContentDelivery,
16+
CourseInstructor,
1617
DeliveryStatus,
1718
Organization,
1819
ImapConnection,
@@ -118,6 +119,11 @@ class CreateCourseRequest(BaseModel):
118119
],
119120
)
120121
is_public: bool = Field(default=True, examples=[True])
122+
instructors: Optional[list[int]] = Field(
123+
None,
124+
examples=[[1, 2, 3]],
125+
description="List of organization user IDs to be assigned as instructors for this course.",
126+
)
121127

122128
def to_django_model(self, organization_id: int) -> Course:
123129
organization = Organization.objects.get(id=organization_id)
@@ -146,6 +152,22 @@ def to_django_model(self, organization_id: int) -> Course:
146152
)
147153
if imap_connection:
148154
course.imap_connection = imap_connection
155+
if self.instructors:
156+
course.save() # Save course before adding instructors
157+
for instructor_id in self.instructors:
158+
try:
159+
org_user = OrganizationUser.objects.get(
160+
id=instructor_id, organization=organization
161+
)
162+
except OrganizationUser.DoesNotExist:
163+
raise ValueError(
164+
f"OrganizationUser with id {instructor_id} does not exist in organization {organization.name}."
165+
)
166+
if not org_user.can_act_as_instructor():
167+
raise ValueError(
168+
f"OrganizationUser with id {instructor_id} does not have instructor role."
169+
)
170+
CourseInstructor.objects.create(course=course, org_user=org_user)
149171
if self.image:
150172
course.replace_image(self.image)
151173
if self.target_audience:
@@ -189,6 +211,7 @@ class UpdateCourseRequest(BaseModel):
189211
],
190212
)
191213
is_public: Optional[bool] = Field(None, examples=[True])
214+
instructors: Optional[list[int]] = Field(None, examples=[1, 2, 3])
192215

193216
def to_django_model(self, course_id: int) -> Course:
194217
try:
@@ -227,9 +250,39 @@ def to_django_model(self, course_id: int) -> Course:
227250
course.external_references.create(name=ref["name"], url=ref["url"])
228251
if self.is_public is not None:
229252
course.is_public = self.is_public
253+
if self.instructors is not None:
254+
instructors_to_remove = course.instructors.exclude(
255+
org_user_id__in=self.instructors
256+
)
257+
for instructor in instructors_to_remove:
258+
instructor.delete()
259+
instructors_to_add = set(self.instructors) - set(
260+
course.instructors.values_list("org_user_id", flat=True)
261+
)
262+
for instructor_id in instructors_to_add:
263+
try:
264+
org_user = OrganizationUser.objects.get(
265+
id=instructor_id, organization=course.organization
266+
)
267+
except OrganizationUser.DoesNotExist:
268+
raise ValueError(
269+
f"OrganizationUser with id {instructor_id} does not exist in organization {course.organization.name}."
270+
)
271+
if not org_user.can_act_as_instructor():
272+
raise ValueError(
273+
f"OrganizationUser with id {instructor_id} does not have instructor role."
274+
)
275+
CourseInstructor.objects.create(course=course, org_user=org_user)
230276
return course
231277

232278

279+
class InstructorResponse(BaseModel):
280+
id: int
281+
email: str
282+
283+
model_config = ConfigDict(from_attributes=True)
284+
285+
233286
class CourseResponse(BaseModel):
234287
id: int
235288
title: str
@@ -246,6 +299,7 @@ class CourseResponse(BaseModel):
246299
target_audience: Optional[str] = None
247300
external_references: Optional[list[dict[str, str]]] = None
248301
is_public: bool
302+
instructors: Optional[list[InstructorResponse]] = None
249303

250304
model_config = ConfigDict(from_attributes=True)
251305

@@ -278,6 +332,12 @@ def from_django_model(
278332
if course.external_references.exists()
279333
else None,
280334
"is_public": course.is_public,
335+
"instructors": [
336+
InstructorResponse(
337+
id=instructor.org_user.id, email=instructor.org_user.user.email
338+
)
339+
for instructor in course.instructors.all()
340+
],
281341
}
282342
)
283343

@@ -450,6 +510,7 @@ class OrganizationUserResponse(BaseModel):
450510
organization_id: int
451511
email: str
452512
role: UserRole
513+
can_act_as_instructor: bool
453514

454515
@staticmethod
455516
def from_django_model(org_user: OrganizationUser) -> "OrganizationUserResponse":
@@ -459,6 +520,7 @@ def from_django_model(org_user: OrganizationUser) -> "OrganizationUserResponse":
459520
organization_id=org_user.organization.id,
460521
email=org_user.user.email,
461522
role=UserRole(org_user.role),
523+
can_act_as_instructor=org_user.can_act_as_instructor(),
462524
)
463525

464526

django_email_learning/platform/views.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,15 @@ def get_locale_messages(self) -> Dict[str, str]:
241241
"add_folder_helper_text": _(
242242
"Add folders to fetch emails from. The 'inbox' folder is required and will always be included."
243243
), # noqa: E501
244+
"add_instructors": _("Add Instructors"),
245+
"instructors_tooltip": _(
246+
"Assign instructors from your organization to this course. Instructors can review and approve learner assignment submissions."
247+
),
248+
"select_instructors": _("Select Instructors"),
249+
"new_instructor": _("New Instructor"),
250+
"instructor_email": _("Instructor Email"),
251+
"add_instructor": _("Add Instructor"),
252+
"instructor_add_failed": _("Failed to add instructor. Please try again."),
244253
}
245254

246255

frontend/platform/courses/Courses.jsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,7 @@ function Courses() {
191191
</Box>
192192
</Grid>
193193

194-
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} fullWidth maxWidth="sm">
194+
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} fullWidth maxWidth="md">
195195
{dialogContent}
196196
</Dialog>
197197

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import { useState, useEffect, useMemo } from 'react';
2+
import {
3+
Accordion,
4+
AccordionDetails,
5+
AccordionSummary,
6+
Box,
7+
Chip,
8+
FormControl,
9+
InputLabel,
10+
MenuItem,
11+
OutlinedInput,
12+
Select,
13+
Typography,
14+
} from '@mui/material';
15+
import { useAppContext } from '../../../src/render.jsx';
16+
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
17+
import PlusIcon from '@mui/icons-material/Add';
18+
import CreateInstructorForm from './CreateInstructorForm';
19+
20+
21+
function AddInstructorsSection({ onChangeCallback, activeOrganizationId, initialInstructorIds = [] }) {
22+
const [orgInstructors, setOrgInstructors] = useState([]);
23+
const [selectedIds, setSelectedIds] = useState(initialInstructorIds);
24+
const [expanded, setExpanded] = useState(false);
25+
const { localeMessages, apiBaseUrl } = useAppContext();
26+
27+
const hasInstructors = useMemo(() => orgInstructors.length > 0, [orgInstructors]);
28+
29+
const switchExpanded = () => {
30+
if (hasInstructors) {
31+
setExpanded(!expanded);
32+
}
33+
};
34+
35+
useEffect(() => {
36+
fetch(`${apiBaseUrl}/organizations/${activeOrganizationId}/users/`, {
37+
method: 'GET',
38+
credentials: 'include',
39+
headers: { 'Content-Type': 'application/json' },
40+
})
41+
.then((response) => response.json())
42+
.then((data) => {
43+
const instructors = (data.organization_users || []).filter(
44+
(u) => u.can_act_as_instructor
45+
);
46+
setOrgInstructors(instructors);
47+
if (instructors.length === 0) {
48+
setExpanded(true);
49+
}
50+
})
51+
.catch((error) => {
52+
console.error('Error fetching organization users:', error);
53+
});
54+
}, []);
55+
56+
const handleSelectionChange = (event) => {
57+
const value = event.target.value;
58+
setSelectedIds(value);
59+
if (onChangeCallback) {
60+
onChangeCallback(value);
61+
}
62+
};
63+
64+
return (
65+
<div>
66+
{hasInstructors && (
67+
<FormControl sx={{ mb: 2, minWidth: '100%' }}>
68+
<InputLabel id="instructor-select-label">
69+
{localeMessages['select_instructors']}
70+
</InputLabel>
71+
<Select
72+
labelId="instructor-select-label"
73+
multiple
74+
value={selectedIds}
75+
onChange={handleSelectionChange}
76+
input={<OutlinedInput label={localeMessages['select_instructors']} />}
77+
renderValue={(selected) => (
78+
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
79+
{selected.map((id) => {
80+
const instructor = orgInstructors.find((i) => i.id === id);
81+
return instructor ? (
82+
<Chip
83+
key={id}
84+
label={instructor.email}
85+
size="small"
86+
onDelete={(e) => {
87+
e.stopPropagation();
88+
const updatedIds = selectedIds.filter((i) => i !== id);
89+
setSelectedIds(updatedIds);
90+
if (onChangeCallback) onChangeCallback(updatedIds);
91+
}}
92+
onMouseDown={(e) => e.stopPropagation()}
93+
/>
94+
) : null;
95+
})}
96+
</Box>
97+
)}
98+
>
99+
{orgInstructors.map((instructor) => (
100+
<MenuItem key={instructor.id} value={instructor.id}>
101+
{instructor.email}
102+
</MenuItem>
103+
))}
104+
</Select>
105+
</FormControl>
106+
)}
107+
<Accordion expanded={expanded} onChange={switchExpanded}>
108+
<AccordionSummary
109+
expandIcon={hasInstructors ? <ExpandMoreIcon /> : null}
110+
aria-controls="new-instructor-content"
111+
id="new-instructor-header"
112+
>
113+
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
114+
<PlusIcon />
115+
<Typography component="span">{localeMessages['new_instructor']}</Typography>
116+
</Box>
117+
</AccordionSummary>
118+
<AccordionDetails>
119+
<CreateInstructorForm
120+
activeOrganizationId={activeOrganizationId}
121+
onSuccess={(newOrgUser) => {
122+
const updatedInstructors = [...orgInstructors, newOrgUser];
123+
setOrgInstructors(updatedInstructors);
124+
const updatedIds = [...selectedIds, newOrgUser.id];
125+
setSelectedIds(updatedIds);
126+
if (onChangeCallback) {
127+
onChangeCallback(updatedIds);
128+
}
129+
setExpanded(false);
130+
}}
131+
/>
132+
</AccordionDetails>
133+
</Accordion>
134+
</div>
135+
);
136+
}
137+
138+
export default AddInstructorsSection;

0 commit comments

Comments
 (0)