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
122 changes: 122 additions & 0 deletions django_email_learning/migrations/0002_course_language.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
# Generated by Django 6.0.2 on 2026-03-06 07:29

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("django_email_learning", "0001_initial"),
]

operations = [
migrations.AddField(
model_name="course",
name="language",
field=models.CharField(
choices=[
("af", "Afrikaans"),
("ar", "Arabic"),
("ar-dz", "Algerian Arabic"),
("ast", "Asturian"),
("az", "Azerbaijani"),
("bg", "Bulgarian"),
("be", "Belarusian"),
("bn", "Bengali"),
("br", "Breton"),
("bs", "Bosnian"),
("ca", "Catalan"),
("ckb", "Central Kurdish (Sorani)"),
("cs", "Czech"),
("cy", "Welsh"),
("da", "Danish"),
("de", "German"),
("dsb", "Lower Sorbian"),
("el", "Greek"),
("en", "English"),
("en-au", "Australian English"),
("en-gb", "British English"),
("eo", "Esperanto"),
("es", "Spanish"),
("es-ar", "Argentinian Spanish"),
("es-co", "Colombian Spanish"),
("es-mx", "Mexican Spanish"),
("es-ni", "Nicaraguan Spanish"),
("es-ve", "Venezuelan Spanish"),
("et", "Estonian"),
("eu", "Basque"),
("fa", "Persian"),
("fi", "Finnish"),
("fr", "French"),
("fy", "Frisian"),
("ga", "Irish"),
("gd", "Scottish Gaelic"),
("gl", "Galician"),
("he", "Hebrew"),
("hi", "Hindi"),
("hr", "Croatian"),
("hsb", "Upper Sorbian"),
("ht", "Haitian Creole"),
("hu", "Hungarian"),
("hy", "Armenian"),
("ia", "Interlingua"),
("id", "Indonesian"),
("ig", "Igbo"),
("io", "Ido"),
("is", "Icelandic"),
("it", "Italian"),
("ja", "Japanese"),
("ka", "Georgian"),
("kab", "Kabyle"),
("kk", "Kazakh"),
("km", "Khmer"),
("kn", "Kannada"),
("ko", "Korean"),
("ky", "Kyrgyz"),
("lb", "Luxembourgish"),
("lt", "Lithuanian"),
("lv", "Latvian"),
("mk", "Macedonian"),
("ml", "Malayalam"),
("mn", "Mongolian"),
("mr", "Marathi"),
("ms", "Malay"),
("my", "Burmese"),
("nb", "Norwegian Bokmål"),
("ne", "Nepali"),
("nl", "Dutch"),
("nn", "Norwegian Nynorsk"),
("os", "Ossetic"),
("pa", "Punjabi"),
("pl", "Polish"),
("pt", "Portuguese"),
("pt-br", "Brazilian Portuguese"),
("ro", "Romanian"),
("ru", "Russian"),
("sk", "Slovak"),
("sl", "Slovenian"),
("sq", "Albanian"),
("sr", "Serbian"),
("sr-latn", "Serbian Latin"),
("sv", "Swedish"),
("sw", "Swahili"),
("ta", "Tamil"),
("te", "Telugu"),
("tg", "Tajik"),
("th", "Thai"),
("tk", "Turkmen"),
("tr", "Turkish"),
("tt", "Tatar"),
("udm", "Udmurt"),
("ug", "Uyghur"),
("uk", "Ukrainian"),
("ur", "Urdu"),
("uz", "Uzbek"),
("vi", "Vietnamese"),
("zh-hans", "Simplified Chinese"),
("zh-hant", "Traditional Chinese"),
],
default="en",
max_length=10,
),
),
]
6 changes: 6 additions & 0 deletions django_email_learning/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from enum import StrEnum
from typing import Any
from django.conf import settings
from django.conf.global_settings import LANGUAGES
from django.core.files.storage import default_storage
from django.urls import reverse
from django.db import models, transaction
Expand Down Expand Up @@ -196,6 +197,11 @@ class Course(models.Model):
)
organization = models.ForeignKey(Organization, on_delete=models.CASCADE)
image = models.ImageField(upload_to="course_images/", null=True, blank=True)
language = models.CharField(
max_length=10,
choices=LANGUAGES,
default="en",
)

def __str__(self) -> str:
return self.title
Expand Down
12 changes: 11 additions & 1 deletion django_email_learning/platform/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
OrganizationUser,
)
from django_email_learning.services.jwt_service import generate_jwt
from django.utils.translation import get_language_info
import enum


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

def to_django_model(self, organization_id: int) -> Course:
organization = Organization.objects.get(id=organization_id)
Expand All @@ -113,6 +115,7 @@ def to_django_model(self, organization_id: int) -> Course:
slug=self.slug,
description=self.description,
organization=organization,
language=self.language,
)
if imap_connection:
course.imap_connection = imap_connection
Expand All @@ -133,6 +136,7 @@ class UpdateCourseRequest(BaseModel):
enabled: Optional[bool] = Field(None, examples=[True])
reset_imap_connection: Optional[bool] = Field(None, examples=[False])
image: Optional[str] = Field(None, examples=["/path/to/course_image.png"])
language: Optional[str] = Field(None, min_length=2, max_length=10, examples=["en"])

def to_django_model(self, course_id: int) -> Course:
try:
Expand Down Expand Up @@ -160,7 +164,8 @@ def to_django_model(self, course_id: int) -> Course:
course.replace_image(self.image)
if not self.image:
course.image = None

if self.language is not None:
course.language = self.language
return course


Expand All @@ -175,13 +180,16 @@ class CourseResponse(BaseModel):
enrollments_count: dict[str, int]
image: Optional[str] = None
image_path: Optional[str] = None
language: str
is_rtl: bool = False

model_config = ConfigDict(from_attributes=True)

@staticmethod
def from_django_model(
course: Course, abs_url_builder: Callable
) -> "CourseResponse":
language_info = get_language_info(course.language)
return CourseResponse.model_validate(
{
"id": course.id,
Expand All @@ -196,6 +204,8 @@ def from_django_model(
"enrollments_count": course.enrollments_count,
"image": abs_url_builder(course.image.url) if course.image else None,
"image_path": course.image.name if course.image else None,
"language": course.language,
"is_rtl": language_info["bidi"],
}
)

Expand Down
10 changes: 10 additions & 0 deletions django_email_learning/platform/views.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import logging
from django.conf.global_settings import LANGUAGES
from django.views.generic import TemplateView
from django.utils.translation import get_language_info, get_language
from django.contrib.auth.decorators import login_required
Expand Down Expand Up @@ -103,6 +104,9 @@ def get_shared_context(self) -> Dict[str, Any]:
),
}
| self.get_locale_messages(),
"languageOptions": [
{"value": code, "label": name} for code, name in LANGUAGES
],
},
"activeOrganizationId": active_organization_id,
"favicon": DJANGO_EMAIL_LEARNING_SETTINGS.get("FAVICON"),
Expand Down Expand Up @@ -162,6 +166,7 @@ def get_locale_messages(self) -> Dict[str, str]:
"course_title": _("Course Title"),
"course_description": _("Course Description"),
"course_slug": _("Course Slug"),
"course_language": _("Course Language"),
"slug_tooltip": _(
"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."
),
Expand Down Expand Up @@ -191,6 +196,7 @@ def get_locale_messages(self) -> Dict[str, str]:
"The course description is required."
),
"slug_required_helper_text": _("The course slug is required."),
"language_required_helper_text": _("The course language is required."),
"email_required_helper_text": _("The email is required."),
"password_required_helper_text": _("The password is required."),
"server_required_helper_text": _("The server is required."),
Expand All @@ -214,6 +220,10 @@ def get_context_data(self, **kwargs) -> dict: # type: ignore[no-untyped-def]
course = Course.objects.get(pk=self.kwargs["course_id"])
context["appContext"]["courseId"] = course.id
context["appContext"]["courseTitle"] = course.title
context["appContext"]["courseLanguage"] = course.language
context["appContext"]["direction"] = (
"rtl" if get_language_info(course.language)["bidi"] else "ltr"
)
context["page_title"] = _("Course: %(title)s") % {"title": course.title}
return context

Expand Down
9 changes: 9 additions & 0 deletions django_email_learning/public/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,22 @@ class PublicCourseSerializer(BaseModel):
description: str | None = None
imap_email: str | None = None
image: str | None = None
language: str
is_rtl: bool = False

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

@field_serializer("language")
def serialize_language(self, language_code: str) -> str:
from django.conf.global_settings import LANGUAGES

language_dict = dict(LANGUAGES)
return language_dict.get(language_code, language_code)


class OrganizationSerializer(BaseModel):
id: int
Expand Down
4 changes: 4 additions & 0 deletions django_email_learning/public/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ def get_context_data(self, **kwargs) -> dict: # type: ignore[no-untyped-def]
raise Http404(_("Organization does not exist"))
courses = []
for course in organization.courses:
course_lang_info = get_language_info(course.language)
course_data = PublicCourseSerializer(
id=course.id,
title=course.title,
Expand All @@ -48,6 +49,8 @@ def get_context_data(self, **kwargs) -> dict: # type: ignore[no-untyped-def]
imap_email=course.imap_connection.email
if course.imap_connection
else None,
language=course.language,
is_rtl=course_lang_info["bidi"],
)
courses.append(course_data)
organization_data = OrganizationSerializer(
Expand Down Expand Up @@ -76,6 +79,7 @@ def get_context_data(self, **kwargs) -> dict: # type: ignore[no-untyped-def]
"no_courses_available": _("No courses available."),
"email_required": _("Email is required"),
"email_invalid": _("Please enter a valid email address"),
"course_language": _("Course language"),
},
}
context["page_title"] = organization.name
Expand Down
4 changes: 2 additions & 2 deletions frontend/platform/course/components/ContentTable.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,8 @@ const ContentTable = ({ courseId, eventHandler, loaded = false }) => {
}

return (
<TableContainer component={Paper}>
<Table sx={{ width: "100%" }} aria-label="Contents">
<TableContainer component={Paper} dir={direction}>
<Table sx={{ width: "100%", direction: direction }} aria-label="Contents">
<TableHead>
<TableRow>
{ userRole !== 'viewer' && <TableCell sx={{ width: '40px', boxSizing: 'border-box' }}></TableCell>}
Expand Down
4 changes: 2 additions & 2 deletions frontend/platform/course/components/LessonForm.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ function LessonForm({ header, initialTitle, initialContent, cancelCallback, succ
const [isDeletingImage, setIsDeletingImage] = useState(false);


const { localeMessages, apiBaseUrl, userRole } = useAppContext();
const { localeMessages, apiBaseUrl, userRole, direction } = useAppContext();
const orgId = localStorage.getItem('activeOrganizationId');

const VisuallyHiddenInput = styled('input')({
Expand Down Expand Up @@ -294,7 +294,7 @@ function LessonForm({ header, initialTitle, initialContent, cancelCallback, succ
)}
<RequiredTextField value={title} label={localeMessages["lesson_title"]} name="lesson_title" sx={{ width: '100%' }} onChange={(e) => setTitle(e.target.value)} helperText={titleHelperText} disabled={userRole === 'viewer'} />
<Box sx={{ my: 2 }}>
<ContentEditor initialContent={content} contentUpdateCallback={handleContentChange} disabled={userRole === 'viewer'} extraMinLines={3} editorInstanceCallback={setEditorInstance} />
<ContentEditor initialContent={content} contentUpdateCallback={handleContentChange} disabled={userRole === 'viewer'} extraMinLines={3} editorInstanceCallback={setEditorInstance} defaultDirection={direction} />
<Typography color="errorText.main" sx={{ marginTop: 1, fontSize: '0.75rem' }}>
{contentHelperText}
</Typography>
Expand Down
10 changes: 8 additions & 2 deletions frontend/platform/courses/Courses.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,11 @@ function Courses() {
const [courses, setCourses] = useState([])
const [organizationId, setOrganizationId] = useState(null);
const [queryParameters, setQueryParameters] = useState("");
const { direction, localeMessages, apiBaseUrl, platformBaseUrl, userRole } = useAppContext();
const { direction, localeMessages, apiBaseUrl, platformBaseUrl, userRole, languageOptions = [] } = useAppContext();

const getLanguageLabel = (languageCode) => {
return languageOptions.find((languageOption) => languageOption.value === languageCode)?.label || languageCode;
}

const renderCourses = () => {
if (!organizationId) {
Expand Down Expand Up @@ -132,6 +136,7 @@ function Courses() {
<TableHead>
<TableRow>
<TableCell sx={{ textAlign: direction === 'rtl' ? 'right' : 'left' }}>{localeMessages["title"]}</TableCell>
<TableCell sx={{ display: { xs: 'none', sm: 'table-cell' }, textAlign: direction === 'rtl' ? 'right' : 'left' }}>{localeMessages["course_language"]}</TableCell>
<TableCell sx={{ textAlign: direction === 'rtl' ? 'right' : 'left' }}>{localeMessages["total_enrollments"]}</TableCell>
<TableCell sx={{ textAlign: direction === 'rtl' ? 'right' : 'left' }}>{localeMessages["enabled"]}</TableCell>
{userRole !== 'viewer' && <TableCell align={direction === 'rtl' ? 'left' : 'right'}>{localeMessages["actions"]}</TableCell>}
Expand All @@ -143,6 +148,7 @@ function Courses() {
<TableCell component="th" scope="row" sx={{ textAlign: direction === 'rtl' ? 'right' : 'left' }}>
<Link href={`${platformBaseUrl}/courses/${course.id}`} color='secondary.dark'>{course.title}</Link>
</TableCell>
<TableCell sx={{ display: { xs: 'none', sm: 'table-cell' }, textAlign: direction === 'rtl' ? 'right' : 'left' }}>{getLanguageLabel(course.language)}</TableCell>
<TableCell sx={{ textAlign: direction === 'rtl' ? 'right' : 'left' }}>{course.enrollments_count.total}</TableCell>
<TableCell sx={{ textAlign: direction === 'rtl' ? 'right' : 'left' }}>
<Switch
Expand All @@ -168,7 +174,7 @@ function Courses() {
{courses.length === 0 && (
<TableRow>
<TableCell
colSpan={userRole !== 'viewer' ? 4 : 3}
colSpan={userRole !== 'viewer' ? 5 : 4}
sx={{ textAlign: 'center', py: 4, color: 'text.secondary' }}
>
{localeMessages["no_courses_found"] || "No courses found."}
Expand Down
Loading