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
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Generated by Django 6.0.4 on 2026-04-30 10:47

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("django_email_learning", "0021_courseinstructor"),
]

operations = [
migrations.AddField(
model_name="organizationuser",
name="display_name",
field=models.CharField(blank=True, max_length=200, null=True),
),
migrations.AddField(
model_name="organizationuser",
name="photo",
field=models.ImageField(
blank=True, null=True, upload_to="org_user_photos/"
),
),
]
11 changes: 9 additions & 2 deletions django_email_learning/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,17 +129,24 @@ class OrganizationUser(models.Model):
],
db_index=True,
)
display_name = models.CharField(max_length=200, null=True, blank=True)
photo = models.ImageField(upload_to="org_user_photos/", null=True, blank=True)

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
if self.role == "admin" and self.display_name:
return True
return False

def save(self, *args, **kwargs) -> None: # type: ignore[no-untyped-def]
if self.role == "instructor" and not self.display_name:
raise ValidationError("Instructor role requires a display name.")
super().save(*args, **kwargs)

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

Expand Down
34 changes: 32 additions & 2 deletions django_email_learning/platform/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -498,10 +498,30 @@ class UserRole(enum.StrEnum):
class AddOrganizationUserRequest(BaseModel):
user_id: int = Field(gt=0, examples=[1])
role: UserRole = Field(min_length=1, examples=[UserRole.ADMIN])
display_name: Optional[str] = Field(None, examples=["John Doe"])
photo: Optional[str] = Field(None, examples=["/path/to/photo.png"])

@model_validator(mode="before")
def validate_instructor_display_name(cls, values: dict) -> dict:
role = values.get("role")
display_name = values.get("display_name")
if role == UserRole.INSTRUCTOR and not display_name:
raise ValueError("Instructor role requires a display name.")
return values

class UpdateOrganizationUserRoleRequest(BaseModel):

class UpdateOrganizationUserRequest(BaseModel):
role: UserRole = Field(min_length=1, examples=[UserRole.ADMIN])
display_name: Optional[str] = Field(None, examples=["John Doe"])
photo: Optional[str] = Field(None, examples=["/path/to/photo.png"])

@model_validator(mode="before")
def validate_instructor_display_name(cls, values: dict) -> dict:
role = values.get("role")
display_name = values.get("display_name")
if role == UserRole.INSTRUCTOR and not display_name:
raise ValueError("Instructor role requires a display name.")
return values


class OrganizationUserResponse(BaseModel):
Expand All @@ -511,16 +531,26 @@ class OrganizationUserResponse(BaseModel):
email: str
role: UserRole
can_act_as_instructor: bool
display_name: Optional[str] = None
photo: Optional[str] = None
photo_url: Optional[str] = None

@staticmethod
def from_django_model(org_user: OrganizationUser) -> "OrganizationUserResponse":
def from_django_model(
org_user: OrganizationUser, request: Any
) -> "OrganizationUserResponse":
return OrganizationUserResponse(
id=org_user.id,
user_id=org_user.user.id,
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(),
display_name=org_user.display_name,
photo=org_user.photo.name if org_user.photo else None,
photo_url=request.build_absolute_uri(org_user.photo.url)
if org_user.photo
else None,
)


Expand Down
12 changes: 8 additions & 4 deletions django_email_learning/platform/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -626,11 +626,13 @@ def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unt
user_id=serializer.user_id,
organization=organization,
role=serializer.role,
display_name=serializer.display_name,
photo=serializer.photo,
)
org_user.save()
return JsonResponse(
serializers.OrganizationUserResponse.from_django_model(
org_user
org_user, request
).model_dump(),
status=201,
)
Expand All @@ -649,7 +651,7 @@ def get(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unty
for org_user in organization_users:
response_list.append(
serializers.OrganizationUserResponse.from_django_model(
org_user
org_user, request
).model_dump()
)
return JsonResponse({"organization_users": response_list}, status=200)
Expand All @@ -674,17 +676,19 @@ def delete(self, request, *args, **kwargs): # type: ignore[no-untyped-def]
def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def]
try:
payload = json.loads(request.body)
serializer = serializers.UpdateOrganizationUserRoleRequest.model_validate(
serializer = serializers.UpdateOrganizationUserRequest.model_validate(
payload
)
org_user = OrganizationUser.objects.get(
organization_id=kwargs["organization_id"], user_id=kwargs["user_id"]
)
org_user.role = serializer.role
org_user.display_name = serializer.display_name
org_user.photo = serializer.photo
org_user.save()
return JsonResponse(
serializers.OrganizationUserResponse.from_django_model(
org_user
org_user, request
).model_dump(),
status=200,
)
Expand Down
11 changes: 11 additions & 0 deletions django_email_learning/platform/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,11 @@ def get_locale_messages(self) -> Dict[str, str]:
"select_instructors": _("Select Instructors"),
"new_instructor": _("New Instructor"),
"instructor_email": _("Instructor Email"),
"instructor_display_name": _("Display Name"),
"instructor_display_name_required": _(
"Display name is required for instructors."
),
"instructor_photo": _("Instructor Photo"),
"add_instructor": _("Add Instructor"),
"instructor_add_failed": _("Failed to add instructor. Please try again."),
}
Expand Down Expand Up @@ -522,6 +527,9 @@ def get_locale_messages(self) -> Dict[str, str]:
"change_user_role": _("Change User Role"),
"user": _("User"),
"role": _("Role"),
"display_name": _("Display Name"),
"display_name_required": _("Display name is required for instructors."),
"photo": _("Photo"),
"admin": _("Admin"),
"editor": _("Editor"),
"instructor": _("Instructor"),
Expand All @@ -545,6 +553,9 @@ def get_locale_messages(self) -> Dict[str, str]:
),
"cancel": _("Cancel"),
"delete": _("Delete"),
"upload_button_label": _("Upload Image"),
"remove_image": _("Remove Image"),
"uploaded_image_alt": _("User Photo"),
}


Expand Down
25 changes: 23 additions & 2 deletions frontend/platform/courses/components/AddInstructorsSection.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
Accordion,
AccordionDetails,
AccordionSummary,
Avatar,
Box,
Chip,
FormControl,
Expand Down Expand Up @@ -81,8 +82,13 @@ function AddInstructorsSection({ onChangeCallback, activeOrganizationId, initial
return instructor ? (
<Chip
key={id}
label={instructor.email}
label={instructor.display_name || instructor.email}
size="small"
avatar={
instructor.photo
? <Avatar src={instructor.photo_url} />
: <Avatar>{(instructor.display_name || instructor.email)[0].toUpperCase()}</Avatar>
}
onDelete={(e) => {
e.stopPropagation();
const updatedIds = selectedIds.filter((i) => i !== id);
Expand All @@ -98,7 +104,22 @@ function AddInstructorsSection({ onChangeCallback, activeOrganizationId, initial
>
{orgInstructors.map((instructor) => (
<MenuItem key={instructor.id} value={instructor.id}>
{instructor.email}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
{instructor.photo
? <Avatar src={instructor.photo_url} sx={{ width: 28, height: 28 }} />
: <Avatar sx={{ width: 28, height: 28, fontSize: 13 }}>{(instructor.display_name || instructor.email)[0].toUpperCase()}</Avatar>
}
<Box>
<Typography variant="body2" sx={{ fontWeight: 500, lineHeight: 1.2 }}>
{instructor.display_name || instructor.email}
</Typography>
{instructor.display_name && (
<Typography variant="caption" color="text.secondary" sx={{ lineHeight: 1 }}>
{instructor.email}
</Typography>
)}
</Box>
</Box>
</MenuItem>
))}
</Select>
Expand Down
65 changes: 57 additions & 8 deletions frontend/platform/courses/components/CreateInstructorForm.jsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,46 @@
import { useState } from 'react';
import { Alert, Box, Button } from '@mui/material';
import { Alert, Box, Button, Typography } from '@mui/material';
import RequiredTextField from '../../../src/components/RequiredTextField';
import ImageUpload from '../../../src/components/ImageUpload.jsx';
import { useAppContext } from '../../../src/render.jsx';
import { getCookie } from '../../../src/utils';


const CreateInstructorForm = ({ onSuccess, activeOrganizationId }) => {
const [email, setEmail] = useState('');
const [emailHelperText, setEmailHelperText] = useState('');
const [displayName, setDisplayName] = useState('');
const [displayNameHelperText, setDisplayNameHelperText] = useState('');
const [photoPath, setPhotoPath] = useState(null);
const [photoUrl, setPhotoUrl] = useState(null);
const [errorMessage, setErrorMessage] = useState('');
const { localeMessages, apiBaseUrl } = useAppContext();

const isValidEmail = (value) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);

const handleSubmit = () => {
const trimmedEmail = email.trim();
const trimmedDisplayName = displayName.trim();
let valid = true;

if (!trimmedEmail) {
setEmailHelperText(localeMessages['email_required_helper_text']);
return;
}
if (!isValidEmail(trimmedEmail)) {
valid = false;
} else if (!isValidEmail(trimmedEmail)) {
setEmailHelperText(localeMessages['invalid_email_helper_text']);
return;
valid = false;
} else {
setEmailHelperText('');
}

if (!trimmedDisplayName) {
setDisplayNameHelperText(localeMessages['instructor_display_name_required']);
valid = false;
} else {
setDisplayNameHelperText('');
}
setEmailHelperText('');

if (!valid) return;
setErrorMessage('');

fetch(`${apiBaseUrl}/users/get-or-create-by-email/`, {
Expand All @@ -47,7 +64,12 @@ const CreateInstructorForm = ({ onSuccess, activeOrganizationId }) => {
'Content-Type': 'application/json',
'X-CSRFToken': getCookie('csrftoken'),
},
body: JSON.stringify({ user_id: userData.id, role: 'instructor' }),
body: JSON.stringify({
user_id: userData.id,
role: 'instructor',
display_name: trimmedDisplayName,
photo: photoPath,
}),
})
)
.then((response) => {
Expand All @@ -57,6 +79,9 @@ const CreateInstructorForm = ({ onSuccess, activeOrganizationId }) => {
.then((orgUserData) => {
if (onSuccess) onSuccess(orgUserData);
setEmail('');
setDisplayName('');
setPhotoPath(null);
setPhotoUrl(null);
})
.catch((error) => {
console.error('Error adding instructor:', error);
Expand All @@ -76,9 +101,33 @@ const CreateInstructorForm = ({ onSuccess, activeOrganizationId }) => {
onChange={(e) => setEmail(e.target.value)}
type="email"
/>
<Button variant="contained" onClick={handleSubmit} sx={{ mt: 1, boxShadow: 'none' }}>
<RequiredTextField
label={localeMessages['instructor_display_name']}
helperText={displayNameHelperText}
fullWidth
margin="normal"
value={displayName}
onChange={(e) => {
setDisplayName(e.target.value);
if (displayNameHelperText) setDisplayNameHelperText('');
}}
/>
<Typography variant="body2" sx={{ mt: 1, mb: 0.5 }}>
{localeMessages['instructor_photo']}
</Typography>
<ImageUpload
initialUrl={photoUrl}
onUploadSuccess={(data) => {
setPhotoUrl(data.file_url);
setPhotoPath(data.file_path);
}}
onUploadError={() => setErrorMessage(localeMessages['instructor_add_failed'])}
/>

<Button variant="contained" onClick={handleSubmit} sx={{ mt: 1, boxShadow: 'none', display: 'block', ml: 'auto' }}>
{localeMessages['add_instructor']}
</Button>

</Box>
);
};
Expand Down
Loading