Skip to content

Commit aa789e8

Browse files
authored
Merge pull request #250 from AvaCodeSolutions/feat/230/course-language
feat: #230 Add language to course model
2 parents 19e698d + a6b3aa6 commit aa789e8

18 files changed

Lines changed: 271 additions & 20 deletions

File tree

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
# Generated by Django 6.0.2 on 2026-03-06 07:29
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
dependencies = [
8+
("django_email_learning", "0001_initial"),
9+
]
10+
11+
operations = [
12+
migrations.AddField(
13+
model_name="course",
14+
name="language",
15+
field=models.CharField(
16+
choices=[
17+
("af", "Afrikaans"),
18+
("ar", "Arabic"),
19+
("ar-dz", "Algerian Arabic"),
20+
("ast", "Asturian"),
21+
("az", "Azerbaijani"),
22+
("bg", "Bulgarian"),
23+
("be", "Belarusian"),
24+
("bn", "Bengali"),
25+
("br", "Breton"),
26+
("bs", "Bosnian"),
27+
("ca", "Catalan"),
28+
("ckb", "Central Kurdish (Sorani)"),
29+
("cs", "Czech"),
30+
("cy", "Welsh"),
31+
("da", "Danish"),
32+
("de", "German"),
33+
("dsb", "Lower Sorbian"),
34+
("el", "Greek"),
35+
("en", "English"),
36+
("en-au", "Australian English"),
37+
("en-gb", "British English"),
38+
("eo", "Esperanto"),
39+
("es", "Spanish"),
40+
("es-ar", "Argentinian Spanish"),
41+
("es-co", "Colombian Spanish"),
42+
("es-mx", "Mexican Spanish"),
43+
("es-ni", "Nicaraguan Spanish"),
44+
("es-ve", "Venezuelan Spanish"),
45+
("et", "Estonian"),
46+
("eu", "Basque"),
47+
("fa", "Persian"),
48+
("fi", "Finnish"),
49+
("fr", "French"),
50+
("fy", "Frisian"),
51+
("ga", "Irish"),
52+
("gd", "Scottish Gaelic"),
53+
("gl", "Galician"),
54+
("he", "Hebrew"),
55+
("hi", "Hindi"),
56+
("hr", "Croatian"),
57+
("hsb", "Upper Sorbian"),
58+
("ht", "Haitian Creole"),
59+
("hu", "Hungarian"),
60+
("hy", "Armenian"),
61+
("ia", "Interlingua"),
62+
("id", "Indonesian"),
63+
("ig", "Igbo"),
64+
("io", "Ido"),
65+
("is", "Icelandic"),
66+
("it", "Italian"),
67+
("ja", "Japanese"),
68+
("ka", "Georgian"),
69+
("kab", "Kabyle"),
70+
("kk", "Kazakh"),
71+
("km", "Khmer"),
72+
("kn", "Kannada"),
73+
("ko", "Korean"),
74+
("ky", "Kyrgyz"),
75+
("lb", "Luxembourgish"),
76+
("lt", "Lithuanian"),
77+
("lv", "Latvian"),
78+
("mk", "Macedonian"),
79+
("ml", "Malayalam"),
80+
("mn", "Mongolian"),
81+
("mr", "Marathi"),
82+
("ms", "Malay"),
83+
("my", "Burmese"),
84+
("nb", "Norwegian Bokmål"),
85+
("ne", "Nepali"),
86+
("nl", "Dutch"),
87+
("nn", "Norwegian Nynorsk"),
88+
("os", "Ossetic"),
89+
("pa", "Punjabi"),
90+
("pl", "Polish"),
91+
("pt", "Portuguese"),
92+
("pt-br", "Brazilian Portuguese"),
93+
("ro", "Romanian"),
94+
("ru", "Russian"),
95+
("sk", "Slovak"),
96+
("sl", "Slovenian"),
97+
("sq", "Albanian"),
98+
("sr", "Serbian"),
99+
("sr-latn", "Serbian Latin"),
100+
("sv", "Swedish"),
101+
("sw", "Swahili"),
102+
("ta", "Tamil"),
103+
("te", "Telugu"),
104+
("tg", "Tajik"),
105+
("th", "Thai"),
106+
("tk", "Turkmen"),
107+
("tr", "Turkish"),
108+
("tt", "Tatar"),
109+
("udm", "Udmurt"),
110+
("ug", "Uyghur"),
111+
("uk", "Ukrainian"),
112+
("ur", "Urdu"),
113+
("uz", "Uzbek"),
114+
("vi", "Vietnamese"),
115+
("zh-hans", "Simplified Chinese"),
116+
("zh-hant", "Traditional Chinese"),
117+
],
118+
default="en",
119+
max_length=10,
120+
),
121+
),
122+
]

django_email_learning/models.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from enum import StrEnum
88
from typing import Any
99
from django.conf import settings
10+
from django.conf.global_settings import LANGUAGES
1011
from django.core.files.storage import default_storage
1112
from django.urls import reverse
1213
from django.db import models, transaction
@@ -196,6 +197,11 @@ class Course(models.Model):
196197
)
197198
organization = models.ForeignKey(Organization, on_delete=models.CASCADE)
198199
image = models.ImageField(upload_to="course_images/", null=True, blank=True)
200+
language = models.CharField(
201+
max_length=10,
202+
choices=LANGUAGES,
203+
default="en",
204+
)
199205

200206
def __str__(self) -> str:
201207
return self.title

django_email_learning/platform/api/serializers.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
OrganizationUser,
2828
)
2929
from django_email_learning.services.jwt_service import generate_jwt
30+
from django.utils.translation import get_language_info
3031
import enum
3132

3233

@@ -90,6 +91,7 @@ class CreateCourseRequest(BaseModel):
9091
)
9192
imap_connection_id: Optional[int] = Field(None, examples=[1])
9293
image: Optional[str] = Field(None, examples=["/path/to/course_image.png"])
94+
language: str = Field(min_length=2, max_length=10, examples=["en"])
9395

9496
def to_django_model(self, organization_id: int) -> Course:
9597
organization = Organization.objects.get(id=organization_id)
@@ -113,6 +115,7 @@ def to_django_model(self, organization_id: int) -> Course:
113115
slug=self.slug,
114116
description=self.description,
115117
organization=organization,
118+
language=self.language,
116119
)
117120
if imap_connection:
118121
course.imap_connection = imap_connection
@@ -133,6 +136,7 @@ class UpdateCourseRequest(BaseModel):
133136
enabled: Optional[bool] = Field(None, examples=[True])
134137
reset_imap_connection: Optional[bool] = Field(None, examples=[False])
135138
image: Optional[str] = Field(None, examples=["/path/to/course_image.png"])
139+
language: Optional[str] = Field(None, min_length=2, max_length=10, examples=["en"])
136140

137141
def to_django_model(self, course_id: int) -> Course:
138142
try:
@@ -160,7 +164,8 @@ def to_django_model(self, course_id: int) -> Course:
160164
course.replace_image(self.image)
161165
if not self.image:
162166
course.image = None
163-
167+
if self.language is not None:
168+
course.language = self.language
164169
return course
165170

166171

@@ -175,13 +180,16 @@ class CourseResponse(BaseModel):
175180
enrollments_count: dict[str, int]
176181
image: Optional[str] = None
177182
image_path: Optional[str] = None
183+
language: str
184+
is_rtl: bool = False
178185

179186
model_config = ConfigDict(from_attributes=True)
180187

181188
@staticmethod
182189
def from_django_model(
183190
course: Course, abs_url_builder: Callable
184191
) -> "CourseResponse":
192+
language_info = get_language_info(course.language)
185193
return CourseResponse.model_validate(
186194
{
187195
"id": course.id,
@@ -196,6 +204,8 @@ def from_django_model(
196204
"enrollments_count": course.enrollments_count,
197205
"image": abs_url_builder(course.image.url) if course.image else None,
198206
"image_path": course.image.name if course.image else None,
207+
"language": course.language,
208+
"is_rtl": language_info["bidi"],
199209
}
200210
)
201211

django_email_learning/platform/views.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import logging
2+
from django.conf.global_settings import LANGUAGES
23
from django.views.generic import TemplateView
34
from django.utils.translation import get_language_info, get_language
45
from django.contrib.auth.decorators import login_required
@@ -103,6 +104,9 @@ def get_shared_context(self) -> Dict[str, Any]:
103104
),
104105
}
105106
| self.get_locale_messages(),
107+
"languageOptions": [
108+
{"value": code, "label": name} for code, name in LANGUAGES
109+
],
106110
},
107111
"activeOrganizationId": active_organization_id,
108112
"favicon": DJANGO_EMAIL_LEARNING_SETTINGS.get("FAVICON"),
@@ -162,6 +166,7 @@ def get_locale_messages(self) -> Dict[str, str]:
162166
"course_title": _("Course Title"),
163167
"course_description": _("Course Description"),
164168
"course_slug": _("Course Slug"),
169+
"course_language": _("Course Language"),
165170
"slug_tooltip": _(
166171
"The slug is a unique identifier for the course used in URLs and API endpoints. It should be lowercase, contain no spaces (use hyphens instead), and be unique across all courses for your organization. Once set, the slug cannot be changed."
167172
),
@@ -191,6 +196,7 @@ def get_locale_messages(self) -> Dict[str, str]:
191196
"The course description is required."
192197
),
193198
"slug_required_helper_text": _("The course slug is required."),
199+
"language_required_helper_text": _("The course language is required."),
194200
"email_required_helper_text": _("The email is required."),
195201
"password_required_helper_text": _("The password is required."),
196202
"server_required_helper_text": _("The server is required."),
@@ -214,6 +220,10 @@ def get_context_data(self, **kwargs) -> dict: # type: ignore[no-untyped-def]
214220
course = Course.objects.get(pk=self.kwargs["course_id"])
215221
context["appContext"]["courseId"] = course.id
216222
context["appContext"]["courseTitle"] = course.title
223+
context["appContext"]["courseLanguage"] = course.language
224+
context["appContext"]["direction"] = (
225+
"rtl" if get_language_info(course.language)["bidi"] else "ltr"
226+
)
217227
context["page_title"] = _("Course: %(title)s") % {"title": course.title}
218228
return context
219229

django_email_learning/public/serializers.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,22 @@ class PublicCourseSerializer(BaseModel):
88
description: str | None = None
99
imap_email: str | None = None
1010
image: str | None = None
11+
language: str
12+
is_rtl: bool = False
1113

1214
@field_serializer("description")
1315
def serialize_description_with_br(self, description: str | None) -> str | None:
1416
if description is not None:
1517
return description.replace("\n", "<br />")
1618
return description
1719

20+
@field_serializer("language")
21+
def serialize_language(self, language_code: str) -> str:
22+
from django.conf.global_settings import LANGUAGES
23+
24+
language_dict = dict(LANGUAGES)
25+
return language_dict.get(language_code, language_code)
26+
1827

1928
class OrganizationSerializer(BaseModel):
2029
id: int

django_email_learning/public/views.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ def get_context_data(self, **kwargs) -> dict: # type: ignore[no-untyped-def]
3737
raise Http404(_("Organization does not exist"))
3838
courses = []
3939
for course in organization.courses:
40+
course_lang_info = get_language_info(course.language)
4041
course_data = PublicCourseSerializer(
4142
id=course.id,
4243
title=course.title,
@@ -48,6 +49,8 @@ def get_context_data(self, **kwargs) -> dict: # type: ignore[no-untyped-def]
4849
imap_email=course.imap_connection.email
4950
if course.imap_connection
5051
else None,
52+
language=course.language,
53+
is_rtl=course_lang_info["bidi"],
5154
)
5255
courses.append(course_data)
5356
organization_data = OrganizationSerializer(
@@ -76,6 +79,7 @@ def get_context_data(self, **kwargs) -> dict: # type: ignore[no-untyped-def]
7679
"no_courses_available": _("No courses available."),
7780
"email_required": _("Email is required"),
7881
"email_invalid": _("Please enter a valid email address"),
82+
"course_language": _("Course language"),
7983
},
8084
}
8185
context["page_title"] = organization.name

frontend/platform/course/components/ContentTable.jsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,8 +113,8 @@ const ContentTable = ({ courseId, eventHandler, loaded = false }) => {
113113
}
114114

115115
return (
116-
<TableContainer component={Paper}>
117-
<Table sx={{ width: "100%" }} aria-label="Contents">
116+
<TableContainer component={Paper} dir={direction}>
117+
<Table sx={{ width: "100%", direction: direction }} aria-label="Contents">
118118
<TableHead>
119119
<TableRow>
120120
{ userRole !== 'viewer' && <TableCell sx={{ width: '40px', boxSizing: 'border-box' }}></TableCell>}

frontend/platform/course/components/LessonForm.jsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ function LessonForm({ header, initialTitle, initialContent, cancelCallback, succ
2424
const [isDeletingImage, setIsDeletingImage] = useState(false);
2525

2626

27-
const { localeMessages, apiBaseUrl, userRole } = useAppContext();
27+
const { localeMessages, apiBaseUrl, userRole, direction } = useAppContext();
2828
const orgId = localStorage.getItem('activeOrganizationId');
2929

3030
const VisuallyHiddenInput = styled('input')({
@@ -294,7 +294,7 @@ function LessonForm({ header, initialTitle, initialContent, cancelCallback, succ
294294
)}
295295
<RequiredTextField value={title} label={localeMessages["lesson_title"]} name="lesson_title" sx={{ width: '100%' }} onChange={(e) => setTitle(e.target.value)} helperText={titleHelperText} disabled={userRole === 'viewer'} />
296296
<Box sx={{ my: 2 }}>
297-
<ContentEditor initialContent={content} contentUpdateCallback={handleContentChange} disabled={userRole === 'viewer'} extraMinLines={3} editorInstanceCallback={setEditorInstance} />
297+
<ContentEditor initialContent={content} contentUpdateCallback={handleContentChange} disabled={userRole === 'viewer'} extraMinLines={3} editorInstanceCallback={setEditorInstance} defaultDirection={direction} />
298298
<Typography color="errorText.main" sx={{ marginTop: 1, fontSize: '0.75rem' }}>
299299
{contentHelperText}
300300
</Typography>

frontend/platform/courses/Courses.jsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,11 @@ function Courses() {
3737
const [courses, setCourses] = useState([])
3838
const [organizationId, setOrganizationId] = useState(null);
3939
const [queryParameters, setQueryParameters] = useState("");
40-
const { direction, localeMessages, apiBaseUrl, platformBaseUrl, userRole } = useAppContext();
40+
const { direction, localeMessages, apiBaseUrl, platformBaseUrl, userRole, languageOptions = [] } = useAppContext();
41+
42+
const getLanguageLabel = (languageCode) => {
43+
return languageOptions.find((languageOption) => languageOption.value === languageCode)?.label || languageCode;
44+
}
4145

4246
const renderCourses = () => {
4347
if (!organizationId) {
@@ -132,6 +136,7 @@ function Courses() {
132136
<TableHead>
133137
<TableRow>
134138
<TableCell sx={{ textAlign: direction === 'rtl' ? 'right' : 'left' }}>{localeMessages["title"]}</TableCell>
139+
<TableCell sx={{ display: { xs: 'none', sm: 'table-cell' }, textAlign: direction === 'rtl' ? 'right' : 'left' }}>{localeMessages["course_language"]}</TableCell>
135140
<TableCell sx={{ textAlign: direction === 'rtl' ? 'right' : 'left' }}>{localeMessages["total_enrollments"]}</TableCell>
136141
<TableCell sx={{ textAlign: direction === 'rtl' ? 'right' : 'left' }}>{localeMessages["enabled"]}</TableCell>
137142
{userRole !== 'viewer' && <TableCell align={direction === 'rtl' ? 'left' : 'right'}>{localeMessages["actions"]}</TableCell>}
@@ -143,6 +148,7 @@ function Courses() {
143148
<TableCell component="th" scope="row" sx={{ textAlign: direction === 'rtl' ? 'right' : 'left' }}>
144149
<Link href={`${platformBaseUrl}/courses/${course.id}`} color='secondary.dark'>{course.title}</Link>
145150
</TableCell>
151+
<TableCell sx={{ display: { xs: 'none', sm: 'table-cell' }, textAlign: direction === 'rtl' ? 'right' : 'left' }}>{getLanguageLabel(course.language)}</TableCell>
146152
<TableCell sx={{ textAlign: direction === 'rtl' ? 'right' : 'left' }}>{course.enrollments_count.total}</TableCell>
147153
<TableCell sx={{ textAlign: direction === 'rtl' ? 'right' : 'left' }}>
148154
<Switch
@@ -168,7 +174,7 @@ function Courses() {
168174
{courses.length === 0 && (
169175
<TableRow>
170176
<TableCell
171-
colSpan={userRole !== 'viewer' ? 4 : 3}
177+
colSpan={userRole !== 'viewer' ? 5 : 4}
172178
sx={{ textAlign: 'center', py: 4, color: 'text.secondary' }}
173179
>
174180
{localeMessages["no_courses_found"] || "No courses found."}

0 commit comments

Comments
 (0)