Skip to content

Commit b55c0ac

Browse files
committed
feat: #398 Add display name and photo for organization users
1 parent a59915c commit b55c0ac

12 files changed

Lines changed: 332 additions & 25 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Generated by Django 6.0.4 on 2026-04-30 10:47
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
dependencies = [
8+
("django_email_learning", "0021_courseinstructor"),
9+
]
10+
11+
operations = [
12+
migrations.AddField(
13+
model_name="organizationuser",
14+
name="display_name",
15+
field=models.CharField(blank=True, max_length=200, null=True),
16+
),
17+
migrations.AddField(
18+
model_name="organizationuser",
19+
name="photo",
20+
field=models.ImageField(
21+
blank=True, null=True, upload_to="org_user_photos/"
22+
),
23+
),
24+
]

django_email_learning/models.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,17 +129,24 @@ class OrganizationUser(models.Model):
129129
],
130130
db_index=True,
131131
)
132+
display_name = models.CharField(max_length=200, null=True, blank=True)
133+
photo = models.ImageField(upload_to="org_user_photos/", null=True, blank=True)
132134

133135
def __str__(self) -> str:
134136
return f"{self.user.username} - {self.organization.name}"
135137

136138
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
139139
if self.role == "instructor":
140140
return True
141+
if self.role == "admin" and self.display_name:
142+
return True
141143
return False
142144

145+
def save(self, *args, **kwargs) -> None: # type: ignore[no-untyped-def]
146+
if self.role == "instructor" and not self.display_name:
147+
raise ValidationError("Instructor role requires a display name.")
148+
super().save(*args, **kwargs)
149+
143150
class Meta:
144151
unique_together = [["user", "organization"]]
145152

django_email_learning/platform/api/serializers.py

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -498,10 +498,30 @@ class UserRole(enum.StrEnum):
498498
class AddOrganizationUserRequest(BaseModel):
499499
user_id: int = Field(gt=0, examples=[1])
500500
role: UserRole = Field(min_length=1, examples=[UserRole.ADMIN])
501+
display_name: Optional[str] = Field(None, examples=["John Doe"])
502+
photo: Optional[str] = Field(None, examples=["/path/to/photo.png"])
501503

504+
@model_validator(mode="before")
505+
def validate_instructor_display_name(cls, values: dict) -> dict:
506+
role = values.get("role")
507+
display_name = values.get("display_name")
508+
if role == UserRole.INSTRUCTOR and not display_name:
509+
raise ValueError("Instructor role requires a display name.")
510+
return values
502511

503-
class UpdateOrganizationUserRoleRequest(BaseModel):
512+
513+
class UpdateOrganizationUserRequest(BaseModel):
504514
role: UserRole = Field(min_length=1, examples=[UserRole.ADMIN])
515+
display_name: Optional[str] = Field(None, examples=["John Doe"])
516+
photo: Optional[str] = Field(None, examples=["/path/to/photo.png"])
517+
518+
@model_validator(mode="before")
519+
def validate_instructor_display_name(cls, values: dict) -> dict:
520+
role = values.get("role")
521+
display_name = values.get("display_name")
522+
if role == UserRole.INSTRUCTOR and not display_name:
523+
raise ValueError("Instructor role requires a display name.")
524+
return values
505525

506526

507527
class OrganizationUserResponse(BaseModel):
@@ -511,16 +531,26 @@ class OrganizationUserResponse(BaseModel):
511531
email: str
512532
role: UserRole
513533
can_act_as_instructor: bool
534+
display_name: Optional[str] = None
535+
photo: Optional[str] = None
536+
photo_url: Optional[str] = None
514537

515538
@staticmethod
516-
def from_django_model(org_user: OrganizationUser) -> "OrganizationUserResponse":
539+
def from_django_model(
540+
org_user: OrganizationUser, request: Any
541+
) -> "OrganizationUserResponse":
517542
return OrganizationUserResponse(
518543
id=org_user.id,
519544
user_id=org_user.user.id,
520545
organization_id=org_user.organization.id,
521546
email=org_user.user.email,
522547
role=UserRole(org_user.role),
523548
can_act_as_instructor=org_user.can_act_as_instructor(),
549+
display_name=org_user.display_name,
550+
photo=org_user.photo.name if org_user.photo else None,
551+
photo_url=request.build_absolute_uri(org_user.photo.url)
552+
if org_user.photo
553+
else None,
524554
)
525555

526556

django_email_learning/platform/api/views.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -626,11 +626,13 @@ def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unt
626626
user_id=serializer.user_id,
627627
organization=organization,
628628
role=serializer.role,
629+
display_name=serializer.display_name,
630+
photo=serializer.photo,
629631
)
630632
org_user.save()
631633
return JsonResponse(
632634
serializers.OrganizationUserResponse.from_django_model(
633-
org_user
635+
org_user, request
634636
).model_dump(),
635637
status=201,
636638
)
@@ -649,7 +651,7 @@ def get(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unty
649651
for org_user in organization_users:
650652
response_list.append(
651653
serializers.OrganizationUserResponse.from_django_model(
652-
org_user
654+
org_user, request
653655
).model_dump()
654656
)
655657
return JsonResponse({"organization_users": response_list}, status=200)
@@ -674,17 +676,19 @@ def delete(self, request, *args, **kwargs): # type: ignore[no-untyped-def]
674676
def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def]
675677
try:
676678
payload = json.loads(request.body)
677-
serializer = serializers.UpdateOrganizationUserRoleRequest.model_validate(
679+
serializer = serializers.UpdateOrganizationUserRequest.model_validate(
678680
payload
679681
)
680682
org_user = OrganizationUser.objects.get(
681683
organization_id=kwargs["organization_id"], user_id=kwargs["user_id"]
682684
)
683685
org_user.role = serializer.role
686+
org_user.display_name = serializer.display_name
687+
org_user.photo = serializer.photo
684688
org_user.save()
685689
return JsonResponse(
686690
serializers.OrganizationUserResponse.from_django_model(
687-
org_user
691+
org_user, request
688692
).model_dump(),
689693
status=200,
690694
)

django_email_learning/platform/views.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,11 @@ def get_locale_messages(self) -> Dict[str, str]:
248248
"select_instructors": _("Select Instructors"),
249249
"new_instructor": _("New Instructor"),
250250
"instructor_email": _("Instructor Email"),
251+
"instructor_display_name": _("Display Name"),
252+
"instructor_display_name_required": _(
253+
"Display name is required for instructors."
254+
),
255+
"instructor_photo": _("Instructor Photo"),
251256
"add_instructor": _("Add Instructor"),
252257
"instructor_add_failed": _("Failed to add instructor. Please try again."),
253258
}
@@ -522,6 +527,9 @@ def get_locale_messages(self) -> Dict[str, str]:
522527
"change_user_role": _("Change User Role"),
523528
"user": _("User"),
524529
"role": _("Role"),
530+
"display_name": _("Display Name"),
531+
"display_name_required": _("Display name is required for instructors."),
532+
"photo": _("Photo"),
525533
"admin": _("Admin"),
526534
"editor": _("Editor"),
527535
"instructor": _("Instructor"),
@@ -545,6 +553,9 @@ def get_locale_messages(self) -> Dict[str, str]:
545553
),
546554
"cancel": _("Cancel"),
547555
"delete": _("Delete"),
556+
"upload_button_label": _("Upload Image"),
557+
"remove_image": _("Remove Image"),
558+
"uploaded_image_alt": _("User Photo"),
548559
}
549560

550561

frontend/platform/courses/components/AddInstructorsSection.jsx

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
Accordion,
44
AccordionDetails,
55
AccordionSummary,
6+
Avatar,
67
Box,
78
Chip,
89
FormControl,
@@ -81,8 +82,13 @@ function AddInstructorsSection({ onChangeCallback, activeOrganizationId, initial
8182
return instructor ? (
8283
<Chip
8384
key={id}
84-
label={instructor.email}
85+
label={instructor.display_name || instructor.email}
8586
size="small"
87+
avatar={
88+
instructor.photo
89+
? <Avatar src={instructor.photo_url} />
90+
: <Avatar>{(instructor.display_name || instructor.email)[0].toUpperCase()}</Avatar>
91+
}
8692
onDelete={(e) => {
8793
e.stopPropagation();
8894
const updatedIds = selectedIds.filter((i) => i !== id);
@@ -98,7 +104,22 @@ function AddInstructorsSection({ onChangeCallback, activeOrganizationId, initial
98104
>
99105
{orgInstructors.map((instructor) => (
100106
<MenuItem key={instructor.id} value={instructor.id}>
101-
{instructor.email}
107+
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
108+
{instructor.photo
109+
? <Avatar src={instructor.photo_url} sx={{ width: 28, height: 28 }} />
110+
: <Avatar sx={{ width: 28, height: 28, fontSize: 13 }}>{(instructor.display_name || instructor.email)[0].toUpperCase()}</Avatar>
111+
}
112+
<Box>
113+
<Typography variant="body2" sx={{ fontWeight: 500, lineHeight: 1.2 }}>
114+
{instructor.display_name || instructor.email}
115+
</Typography>
116+
{instructor.display_name && (
117+
<Typography variant="caption" color="text.secondary" sx={{ lineHeight: 1 }}>
118+
{instructor.email}
119+
</Typography>
120+
)}
121+
</Box>
122+
</Box>
102123
</MenuItem>
103124
))}
104125
</Select>

frontend/platform/courses/components/CreateInstructorForm.jsx

Lines changed: 57 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,46 @@
11
import { useState } from 'react';
2-
import { Alert, Box, Button } from '@mui/material';
2+
import { Alert, Box, Button, Typography } from '@mui/material';
33
import RequiredTextField from '../../../src/components/RequiredTextField';
4+
import ImageUpload from '../../../src/components/ImageUpload.jsx';
45
import { useAppContext } from '../../../src/render.jsx';
56
import { getCookie } from '../../../src/utils';
67

78

89
const CreateInstructorForm = ({ onSuccess, activeOrganizationId }) => {
910
const [email, setEmail] = useState('');
1011
const [emailHelperText, setEmailHelperText] = useState('');
12+
const [displayName, setDisplayName] = useState('');
13+
const [displayNameHelperText, setDisplayNameHelperText] = useState('');
14+
const [photoPath, setPhotoPath] = useState(null);
15+
const [photoUrl, setPhotoUrl] = useState(null);
1116
const [errorMessage, setErrorMessage] = useState('');
1217
const { localeMessages, apiBaseUrl } = useAppContext();
1318

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

1621
const handleSubmit = () => {
1722
const trimmedEmail = email.trim();
23+
const trimmedDisplayName = displayName.trim();
24+
let valid = true;
25+
1826
if (!trimmedEmail) {
1927
setEmailHelperText(localeMessages['email_required_helper_text']);
20-
return;
21-
}
22-
if (!isValidEmail(trimmedEmail)) {
28+
valid = false;
29+
} else if (!isValidEmail(trimmedEmail)) {
2330
setEmailHelperText(localeMessages['invalid_email_helper_text']);
24-
return;
31+
valid = false;
32+
} else {
33+
setEmailHelperText('');
34+
}
35+
36+
if (!trimmedDisplayName) {
37+
setDisplayNameHelperText(localeMessages['instructor_display_name_required']);
38+
valid = false;
39+
} else {
40+
setDisplayNameHelperText('');
2541
}
26-
setEmailHelperText('');
42+
43+
if (!valid) return;
2744
setErrorMessage('');
2845

2946
fetch(`${apiBaseUrl}/users/get-or-create-by-email/`, {
@@ -47,7 +64,12 @@ const CreateInstructorForm = ({ onSuccess, activeOrganizationId }) => {
4764
'Content-Type': 'application/json',
4865
'X-CSRFToken': getCookie('csrftoken'),
4966
},
50-
body: JSON.stringify({ user_id: userData.id, role: 'instructor' }),
67+
body: JSON.stringify({
68+
user_id: userData.id,
69+
role: 'instructor',
70+
display_name: trimmedDisplayName,
71+
photo: photoPath,
72+
}),
5173
})
5274
)
5375
.then((response) => {
@@ -57,6 +79,9 @@ const CreateInstructorForm = ({ onSuccess, activeOrganizationId }) => {
5779
.then((orgUserData) => {
5880
if (onSuccess) onSuccess(orgUserData);
5981
setEmail('');
82+
setDisplayName('');
83+
setPhotoPath(null);
84+
setPhotoUrl(null);
6085
})
6186
.catch((error) => {
6287
console.error('Error adding instructor:', error);
@@ -76,9 +101,33 @@ const CreateInstructorForm = ({ onSuccess, activeOrganizationId }) => {
76101
onChange={(e) => setEmail(e.target.value)}
77102
type="email"
78103
/>
79-
<Button variant="contained" onClick={handleSubmit} sx={{ mt: 1, boxShadow: 'none' }}>
104+
<RequiredTextField
105+
label={localeMessages['instructor_display_name']}
106+
helperText={displayNameHelperText}
107+
fullWidth
108+
margin="normal"
109+
value={displayName}
110+
onChange={(e) => {
111+
setDisplayName(e.target.value);
112+
if (displayNameHelperText) setDisplayNameHelperText('');
113+
}}
114+
/>
115+
<Typography variant="body2" sx={{ mt: 1, mb: 0.5 }}>
116+
{localeMessages['instructor_photo']}
117+
</Typography>
118+
<ImageUpload
119+
initialUrl={photoUrl}
120+
onUploadSuccess={(data) => {
121+
setPhotoUrl(data.file_url);
122+
setPhotoPath(data.file_path);
123+
}}
124+
onUploadError={() => setErrorMessage(localeMessages['instructor_add_failed'])}
125+
/>
126+
127+
<Button variant="contained" onClick={handleSubmit} sx={{ mt: 1, boxShadow: 'none', display: 'block', ml: 'auto' }}>
80128
{localeMessages['add_instructor']}
81129
</Button>
130+
82131
</Box>
83132
);
84133
};

0 commit comments

Comments
 (0)