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
45 changes: 45 additions & 0 deletions django_email_learning/migrations/0021_courseinstructor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Generated by Django 6.0.4 on 2026-04-30 09:10

import django.db.models.deletion
from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("django_email_learning", "0020_assignment_reminder_interval_days"),
]

operations = [
migrations.CreateModel(
name="CourseInstructor",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"course",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="instructors",
to="django_email_learning.course",
),
),
(
"org_user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="django_email_learning.organizationuser",
),
),
],
options={
"unique_together": {("course", "org_user")},
},
),
]
29 changes: 29 additions & 0 deletions django_email_learning/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,13 @@ class OrganizationUser(models.Model):
def __str__(self) -> str:
return f"{self.user.username} - {self.organization.name}"

def can_act_as_instructor(self) -> bool:
# TODO: When we add display name for org user we can also accept admin role as instructor
# if they have display name set, for now only users with instructor role can be course instructors
if self.role == "instructor":
return True
return False

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

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


class CourseInstructor(models.Model):
course = models.ForeignKey(
Course, on_delete=models.CASCADE, related_name="instructors"
)
org_user = models.ForeignKey(OrganizationUser, on_delete=models.CASCADE)

def __str__(self) -> str:
return f"{self.course.title} - {self.org_user.user.email}"

def save(self, *args, **kwargs) -> None: # type: ignore[no-untyped-def]
if self.org_user.organization != self.course.organization:
raise ValidationError(
"Instructor must belong to the same organization as the course."
)
if not self.org_user.can_act_as_instructor():
raise ValidationError("Organization user doesn't have instructor role.")
super().save(*args, **kwargs)

class Meta:
unique_together = [["course", "org_user"]]


class ExternalReference(models.Model):
course = models.ForeignKey(
Course, on_delete=models.CASCADE, related_name="external_references"
Expand Down
62 changes: 62 additions & 0 deletions django_email_learning/platform/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from django_email_learning.models import (
ApiKey,
ContentDelivery,
CourseInstructor,
DeliveryStatus,
Organization,
ImapConnection,
Expand Down Expand Up @@ -118,6 +119,11 @@ class CreateCourseRequest(BaseModel):
],
)
is_public: bool = Field(default=True, examples=[True])
instructors: Optional[list[int]] = Field(
None,
examples=[[1, 2, 3]],
description="List of organization user IDs to be assigned as instructors for this course.",
)

def to_django_model(self, organization_id: int) -> Course:
organization = Organization.objects.get(id=organization_id)
Expand Down Expand Up @@ -146,6 +152,22 @@ def to_django_model(self, organization_id: int) -> Course:
)
if imap_connection:
course.imap_connection = imap_connection
if self.instructors:
course.save() # Save course before adding instructors
for instructor_id in self.instructors:
try:
org_user = OrganizationUser.objects.get(
id=instructor_id, organization=organization
)
except OrganizationUser.DoesNotExist:
raise ValueError(
f"OrganizationUser with id {instructor_id} does not exist in organization {organization.name}."
)
if not org_user.can_act_as_instructor():
raise ValueError(
f"OrganizationUser with id {instructor_id} does not have instructor role."
)
CourseInstructor.objects.create(course=course, org_user=org_user)
if self.image:
course.replace_image(self.image)
if self.target_audience:
Expand Down Expand Up @@ -189,6 +211,7 @@ class UpdateCourseRequest(BaseModel):
],
)
is_public: Optional[bool] = Field(None, examples=[True])
instructors: Optional[list[int]] = Field(None, examples=[1, 2, 3])

def to_django_model(self, course_id: int) -> Course:
try:
Expand Down Expand Up @@ -227,9 +250,39 @@ def to_django_model(self, course_id: int) -> Course:
course.external_references.create(name=ref["name"], url=ref["url"])
if self.is_public is not None:
course.is_public = self.is_public
if self.instructors is not None:
instructors_to_remove = course.instructors.exclude(
org_user_id__in=self.instructors
)
for instructor in instructors_to_remove:
instructor.delete()
instructors_to_add = set(self.instructors) - set(
course.instructors.values_list("org_user_id", flat=True)
)
for instructor_id in instructors_to_add:
try:
org_user = OrganizationUser.objects.get(
id=instructor_id, organization=course.organization
)
except OrganizationUser.DoesNotExist:
raise ValueError(
f"OrganizationUser with id {instructor_id} does not exist in organization {course.organization.name}."
)
if not org_user.can_act_as_instructor():
raise ValueError(
f"OrganizationUser with id {instructor_id} does not have instructor role."
)
CourseInstructor.objects.create(course=course, org_user=org_user)
return course


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

model_config = ConfigDict(from_attributes=True)


class CourseResponse(BaseModel):
id: int
title: str
Expand All @@ -246,6 +299,7 @@ class CourseResponse(BaseModel):
target_audience: Optional[str] = None
external_references: Optional[list[dict[str, str]]] = None
is_public: bool
instructors: Optional[list[InstructorResponse]] = None

model_config = ConfigDict(from_attributes=True)

Expand Down Expand Up @@ -278,6 +332,12 @@ def from_django_model(
if course.external_references.exists()
else None,
"is_public": course.is_public,
"instructors": [
InstructorResponse(
id=instructor.org_user.id, email=instructor.org_user.user.email
)
for instructor in course.instructors.all()
],
}
)

Expand Down Expand Up @@ -450,6 +510,7 @@ class OrganizationUserResponse(BaseModel):
organization_id: int
email: str
role: UserRole
can_act_as_instructor: bool

@staticmethod
def from_django_model(org_user: OrganizationUser) -> "OrganizationUserResponse":
Expand All @@ -459,6 +520,7 @@ def from_django_model(org_user: OrganizationUser) -> "OrganizationUserResponse":
organization_id=org_user.organization.id,
email=org_user.user.email,
role=UserRole(org_user.role),
can_act_as_instructor=org_user.can_act_as_instructor(),
)


Expand Down
9 changes: 9 additions & 0 deletions django_email_learning/platform/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,15 @@ def get_locale_messages(self) -> Dict[str, str]:
"add_folder_helper_text": _(
"Add folders to fetch emails from. The 'inbox' folder is required and will always be included."
), # noqa: E501
"add_instructors": _("Add Instructors"),
"instructors_tooltip": _(
"Assign instructors from your organization to this course. Instructors can review and approve learner assignment submissions."
),
"select_instructors": _("Select Instructors"),
"new_instructor": _("New Instructor"),
"instructor_email": _("Instructor Email"),
"add_instructor": _("Add Instructor"),
"instructor_add_failed": _("Failed to add instructor. Please try again."),
}


Expand Down
2 changes: 1 addition & 1 deletion frontend/platform/courses/Courses.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ function Courses() {
</Box>
</Grid>

<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} fullWidth maxWidth="sm">
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} fullWidth maxWidth="md">
{dialogContent}
</Dialog>

Expand Down
138 changes: 138 additions & 0 deletions frontend/platform/courses/components/AddInstructorsSection.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { useState, useEffect, useMemo } from 'react';
import {
Accordion,
AccordionDetails,
AccordionSummary,
Box,
Chip,
FormControl,
InputLabel,
MenuItem,
OutlinedInput,
Select,
Typography,
} from '@mui/material';
import { useAppContext } from '../../../src/render.jsx';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import PlusIcon from '@mui/icons-material/Add';
import CreateInstructorForm from './CreateInstructorForm';


function AddInstructorsSection({ onChangeCallback, activeOrganizationId, initialInstructorIds = [] }) {
const [orgInstructors, setOrgInstructors] = useState([]);
const [selectedIds, setSelectedIds] = useState(initialInstructorIds);
const [expanded, setExpanded] = useState(false);
const { localeMessages, apiBaseUrl } = useAppContext();

const hasInstructors = useMemo(() => orgInstructors.length > 0, [orgInstructors]);

const switchExpanded = () => {
if (hasInstructors) {
setExpanded(!expanded);
}
};

useEffect(() => {
fetch(`${apiBaseUrl}/organizations/${activeOrganizationId}/users/`, {
method: 'GET',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
})
.then((response) => response.json())
.then((data) => {
const instructors = (data.organization_users || []).filter(
(u) => u.can_act_as_instructor
);
setOrgInstructors(instructors);
if (instructors.length === 0) {
setExpanded(true);
}
})
.catch((error) => {
console.error('Error fetching organization users:', error);
});
}, []);

const handleSelectionChange = (event) => {
const value = event.target.value;
setSelectedIds(value);
if (onChangeCallback) {
onChangeCallback(value);
}
};

return (
<div>
{hasInstructors && (
<FormControl sx={{ mb: 2, minWidth: '100%' }}>
<InputLabel id="instructor-select-label">
{localeMessages['select_instructors']}
</InputLabel>
<Select
labelId="instructor-select-label"
multiple
value={selectedIds}
onChange={handleSelectionChange}
input={<OutlinedInput label={localeMessages['select_instructors']} />}
renderValue={(selected) => (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{selected.map((id) => {
const instructor = orgInstructors.find((i) => i.id === id);
return instructor ? (
<Chip
key={id}
label={instructor.email}
size="small"
onDelete={(e) => {
e.stopPropagation();
const updatedIds = selectedIds.filter((i) => i !== id);
setSelectedIds(updatedIds);
if (onChangeCallback) onChangeCallback(updatedIds);
}}
onMouseDown={(e) => e.stopPropagation()}
/>
) : null;
})}
</Box>
)}
>
{orgInstructors.map((instructor) => (
<MenuItem key={instructor.id} value={instructor.id}>
{instructor.email}
</MenuItem>
))}
</Select>
</FormControl>
)}
<Accordion expanded={expanded} onChange={switchExpanded}>
<AccordionSummary
expandIcon={hasInstructors ? <ExpandMoreIcon /> : null}
aria-controls="new-instructor-content"
id="new-instructor-header"
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<PlusIcon />
<Typography component="span">{localeMessages['new_instructor']}</Typography>
</Box>
</AccordionSummary>
<AccordionDetails>
<CreateInstructorForm
activeOrganizationId={activeOrganizationId}
onSuccess={(newOrgUser) => {
const updatedInstructors = [...orgInstructors, newOrgUser];
setOrgInstructors(updatedInstructors);
const updatedIds = [...selectedIds, newOrgUser.id];
setSelectedIds(updatedIds);
if (onChangeCallback) {
onChangeCallback(updatedIds);
}
setExpanded(false);
}}
/>
</AccordionDetails>
</Accordion>
</div>
);
}

export default AddInstructorsSection;
Loading