diff --git a/django_email_learning/migrations/0005_course_image_alter_apikey_salt_and_more.py b/django_email_learning/migrations/0005_course_image_alter_apikey_salt_and_more.py
new file mode 100644
index 00000000..e02cb45d
--- /dev/null
+++ b/django_email_learning/migrations/0005_course_image_alter_apikey_salt_and_more.py
@@ -0,0 +1,35 @@
+# Generated by Django 6.0.1 on 2026-01-30 10:57
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("django_email_learning", "0004_jobexecution_alter_apikey_salt_and_more"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="course",
+ name="image",
+ field=models.ImageField(blank=True, null=True, upload_to="course_images/"),
+ ),
+ migrations.AlterField(
+ model_name="apikey",
+ name="salt",
+ field=models.CharField(
+ default="847bdf99eb494499a2dce4f279456bcd",
+ editable=False,
+ max_length=32,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="imapconnection",
+ name="salt",
+ field=models.CharField(
+ default="847bdf99eb494499a2dce4f279456bcd",
+ editable=False,
+ max_length=32,
+ ),
+ ),
+ ]
diff --git a/django_email_learning/models.py b/django_email_learning/models.py
index de2d4125..950cf7b1 100644
--- a/django_email_learning/models.py
+++ b/django_email_learning/models.py
@@ -24,6 +24,7 @@
from django.utils import timezone
from datetime import timedelta
from django_email_learning.services import jwt_service
+from PIL import Image
from typing import Optional
@@ -178,6 +179,7 @@ class Course(models.Model):
ImapConnection, on_delete=models.SET_NULL, null=True, blank=True
)
organization = models.ForeignKey(Organization, on_delete=models.CASCADE)
+ image = models.ImageField(upload_to="course_images/", null=True, blank=True)
def __str__(self) -> str:
return self.title
@@ -228,6 +230,26 @@ def generate_unsubscribe_link(self, email: str) -> str:
link = f"{settings.DJANGO_EMAIL_LEARNING['SITE_BASE_URL']}{unsubscribe_path}?token={token}"
return link
+ def replace_image(self, file_path: str) -> str:
+ if default_storage.exists(file_path):
+ with default_storage.open(file_path) as f:
+ img = Image.open(f)
+ width, height = img.size
+ if width < 580 or height < 360:
+ raise ValueError(
+ "Image dimensions must be at least 580x360 pixels."
+ )
+ allowed_extensions = [".jpg", ".jpeg", ".png", ".svg"]
+ if not any(file_path.lower().endswith(ext) for ext in allowed_extensions):
+ raise ValueError("Image must be an image file with a valid extension.")
+ final_path = f"organization/{self.organization.id}/course_images/{self.id}_{file_path.split('/')[-1]}"
+ default_storage.save(final_path, default_storage.open(file_path))
+ self.image = final_path
+ self.save()
+ return final_path
+ else:
+ raise ValueError("Image file does not exist.")
+
class Lesson(models.Model):
title = models.CharField(max_length=200)
diff --git a/django_email_learning/platform/api/serializers.py b/django_email_learning/platform/api/serializers.py
index 34f9841a..d677b724 100644
--- a/django_email_learning/platform/api/serializers.py
+++ b/django_email_learning/platform/api/serializers.py
@@ -64,6 +64,7 @@ class CreateCourseRequest(BaseModel):
None, examples=["A beginner's course on Python programming."]
)
imap_connection_id: Optional[int] = Field(None, examples=[1])
+ image: Optional[str] = Field(None, examples=["/path/to/course_image.png"])
def to_django_model(self, organization_id: int) -> Course:
organization = Organization.objects.get(id=organization_id)
@@ -90,6 +91,8 @@ def to_django_model(self, organization_id: int) -> Course:
)
if imap_connection:
course.imap_connection = imap_connection
+ if self.image:
+ course.replace_image(self.image)
return course
@@ -104,6 +107,7 @@ class UpdateCourseRequest(BaseModel):
imap_connection_id: Optional[int] = Field(None, examples=[1])
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"])
def to_django_model(self, course_id: int) -> Course:
try:
@@ -126,6 +130,10 @@ def to_django_model(self, course_id: int) -> Course:
course.enabled = self.enabled
if self.reset_imap_connection:
course.imap_connection = None
+ if self.image is not None:
+ course.replace_image(self.image)
+ if not self.image:
+ course.image = None
return course
@@ -139,9 +147,32 @@ class CourseResponse(BaseModel):
imap_connection_id: Optional[int]
enabled: bool
enrollments_count: dict[str, int]
+ image: Optional[str] = None
+ image_path: Optional[str] = None
model_config = ConfigDict(from_attributes=True)
+ @staticmethod
+ def from_django_model(
+ course: Course, abs_url_builder: Callable
+ ) -> "CourseResponse":
+ return CourseResponse.model_validate(
+ {
+ "id": course.id,
+ "title": course.title,
+ "slug": course.slug,
+ "description": course.description,
+ "organization_id": course.organization.id,
+ "imap_connection_id": course.imap_connection.id
+ if course.imap_connection
+ else None,
+ "enabled": course.enabled,
+ "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,
+ }
+ )
+
class CourseSummaryResponse(BaseModel):
id: int
diff --git a/django_email_learning/platform/api/views.py b/django_email_learning/platform/api/views.py
index 617aaa12..01330876 100644
--- a/django_email_learning/platform/api/views.py
+++ b/django_email_learning/platform/api/views.py
@@ -54,7 +54,9 @@ def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unt
)
course.save()
return JsonResponse(
- serializers.CourseResponse.model_validate(course).model_dump(),
+ serializers.CourseResponse.from_django_model(
+ course, abs_url_builder=request.build_absolute_uri
+ ).model_dump(),
status=201,
)
except ValidationError as e:
@@ -74,7 +76,9 @@ def get(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unty
response_list = []
for course in courses:
response_list.append(
- serializers.CourseResponse.model_validate(course).model_dump()
+ serializers.CourseResponse.from_django_model(
+ course, abs_url_builder=request.build_absolute_uri
+ ).model_dump()
)
return JsonResponse({"courses": response_list}, status=200)
@@ -289,7 +293,9 @@ def get(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unty
try:
course = Course.objects.get(id=kwargs["course_id"])
return JsonResponse(
- serializers.CourseResponse.model_validate(course).model_dump(),
+ serializers.CourseResponse.from_django_model(
+ course, abs_url_builder=request.build_absolute_uri
+ ).model_dump(),
status=200,
)
except Course.DoesNotExist:
@@ -306,7 +312,9 @@ def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unt
course = serializer.to_django_model(course_id=kwargs["course_id"])
course.save()
return JsonResponse(
- serializers.CourseResponse.model_validate(course).model_dump(),
+ serializers.CourseResponse.from_django_model(
+ course, abs_url_builder=request.build_absolute_uri
+ ).model_dump(),
status=200,
)
except ValidationError as e:
diff --git a/django_email_learning/public/serializers.py b/django_email_learning/public/serializers.py
index 3e989749..3bc19c59 100644
--- a/django_email_learning/public/serializers.py
+++ b/django_email_learning/public/serializers.py
@@ -7,6 +7,7 @@ class PublicCourseSerializer(BaseModel):
slug: str
description: str | None = None
imap_email: str | None = None
+ image: str | None = None
@field_serializer("description")
def serialize_description_with_br(self, description: str | None) -> str | None:
diff --git a/django_email_learning/public/views.py b/django_email_learning/public/views.py
index 868688c6..b090ce5c 100644
--- a/django_email_learning/public/views.py
+++ b/django_email_learning/public/views.py
@@ -38,6 +38,9 @@ def get_context_data(self, **kwargs) -> dict: # type: ignore[no-untyped-def]
title=course.title,
slug=course.slug,
description=course.description,
+ image=self.request.build_absolute_uri(course.image.url)
+ if course.image
+ else None,
imap_email=course.imap_connection.email
if course.imap_connection
else None,
diff --git a/django_email_learning/templates/platform/courses.html b/django_email_learning/templates/platform/courses.html
index 248c0b3c..bff13b8d 100644
--- a/django_email_learning/templates/platform/courses.html
+++ b/django_email_learning/templates/platform/courses.html
@@ -47,6 +47,9 @@
"invalid_port_helper_text": "{% translate 'The port must be a valid number.' %}",
"invalid_email_helper_text": "{% translate 'The email must be a valid email address.' %}",
"total_enrollments": "{% translate 'Total Enrollments' %}",
+ "upload_button_label": "{% translate 'Upload Image' %}",
+ "remove_image": "{% translate 'Remove Image' %}",
+ "uploaded_image_alt": "{% translate 'Course Image' %}",
}
{% vite_asset 'platform/courses/Courses.jsx' %}
diff --git a/django_email_learning/templates/platform/organizations.html b/django_email_learning/templates/platform/organizations.html
index fcc4eebb..da23a8b5 100644
--- a/django_email_learning/templates/platform/organizations.html
+++ b/django_email_learning/templates/platform/organizations.html
@@ -12,15 +12,15 @@
"name_required": "{% translate 'Name is required.' %}",
"description_required": "{% translate 'Description is required.' %}",
"error_try_again": "{% translate 'An error occurred. Please try again.' %}",
- "logo": "{% translate 'Logo' %}",
+ "upload_button_label": "{% translate 'Upload Logo' %}",
"create_organization": "{% translate 'Create Organization' %}",
"cancel": "{% translate 'Cancel' %}",
"delete": "{% translate 'Delete' %}",
"confirm_deletion": "{% translate 'Confirm Deletion' %}",
- "remove_logo": "{% translate 'Remove Logo' %}",
+ "remove_image": "{% translate 'Remove Logo' %}",
"create": "{% translate 'Create' %}",
"update": "{% translate 'Update' %}",
- "organization_logo": "{% translate 'Organization Logo' %}",
+ "uploaded_image_alt": "{% translate 'Organization Logo' %}",
"are_you_sure_delete_org": "{% translate 'Are you sure you want to delete the organization \"ORGANIZATION_NAME\"? All the courses contents and users under this organization will also be deleted.' %}",
}
diff --git a/frontend/platform/courses/components/CourseForm.jsx b/frontend/platform/courses/components/CourseForm.jsx
index 89db978c..669f514d 100644
--- a/frontend/platform/courses/components/CourseForm.jsx
+++ b/frontend/platform/courses/components/CourseForm.jsx
@@ -3,6 +3,7 @@ import RequiredTextField from '../../../src/components/RequiredTextField.jsx';
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
import IconButton from '@mui/material/IconButton';
import AddImapConnectionForm from './AddImapConnectionForm.jsx';
+import ImageUpload from '../../../src/components/ImageUpload.jsx';
import { useEffect, useState } from 'react';
import { getCookie } from '../../../src/utils.js';
@@ -17,6 +18,8 @@ function CourseForm({successCallback, failureCallback, cancelCallback, activeOrg
const [slugHelperText, setSlugHelperText] = useState("")
const [descriptionHelperText, setDescriptionHelperText] = useState("")
const [errorMessage, setErrorMessage] = useState("")
+ const [imageUrl, setImageUrl] = useState(null)
+ const [imageServerPath, setImageServerPath] = useState(null)
const apiBaseUrl = localStorage.getItem('apiBaseUrl');
@@ -48,6 +51,8 @@ function CourseForm({successCallback, failureCallback, cancelCallback, activeOrg
setCourseTitle(data.title);
setCourseSlug(data.slug);
setCourseDescription(data.description);
+ setImageUrl(data.image);
+ setImageServerPath(data.image_path);
if (data.imap_connection_id) {
setImapConnectionId(data.imap_connection_id);
setAddImapConnection(true);
@@ -102,11 +107,12 @@ function CourseForm({successCallback, failureCallback, cancelCallback, activeOrg
// slug is not updatable
description: courseDescription,
imap_connection_id: imapConnectionId && addImapConnection? parseInt(imapConnectionId) : null,
- reset_imap_connection: !addImapConnection || imapConnectionId == null
+ reset_imap_connection: !addImapConnection || imapConnectionId == null,
+ image: imageServerPath ? imageServerPath : null
}),
})
.then(response => {
- if (!response.ok) {
+ if (!response.ok && response.status != 409) {
if (response.status >= 500) {
setErrorMessage("Server error occurred. Please try again later.");
}
@@ -115,12 +121,16 @@ function CourseForm({successCallback, failureCallback, cancelCallback, activeOrg
return response.json();
})
.then(data => {
- console.log('Success:', data);
- successCallback(data);
+ if (data.error) {
+ setErrorMessage(data.error);
+ failureCallback(data);
+ } else {
+ console.log('Success:', data);
+ successCallback(data);
+ }
})
.catch((error) => {
console.error('Error:', error);
- if (error)
failureCallback(error);
});
};
@@ -141,15 +151,13 @@ function CourseForm({successCallback, failureCallback, cancelCallback, activeOrg
title: courseTitle,
slug: courseSlug,
description: courseDescription,
- imap_connection_id: imapConnectionId ? parseInt(imapConnectionId) : null
+ imap_connection_id: imapConnectionId ? parseInt(imapConnectionId) : null,
+ image: imageServerPath ? imageServerPath : null
}),
})
.then(response => {
- if (!response.ok) {
- if (response.status === 409) {
- setErrorMessage("A course with this title or slug already exists.");
- }
- else if (response.status >= 500) {
+ if (!response.ok && response.status != 409) {
+ if (response.status >= 500) {
setErrorMessage("Server error occurred. Please try again later.");
}
throw new Error('Network response was not ok');
@@ -157,16 +165,20 @@ function CourseForm({successCallback, failureCallback, cancelCallback, activeOrg
return response.json();
})
.then(data => {
- console.log('Success:', data);
- // Optionally reset form fields here
- setCourseTitle("");
- setCourseSlug("");
- setCourseDescription("");
- successCallback(data);
+ if (data.error) {
+ setErrorMessage(data.error);
+ failureCallback(data);
+ } else {
+ console.log('Success:', data);
+ // Optionally reset form fields here
+ setCourseTitle("");
+ setCourseSlug("");
+ setCourseDescription("");
+ successCallback(data);
+ }
})
.catch((error) => {
console.error('Error:', error);
- if (error)
failureCallback(error);
});
};
@@ -193,7 +205,13 @@ function CourseForm({successCallback, failureCallback, cancelCallback, activeOrg
activeOrganizationId={activeOrganizationId}
initialImapConnectionId={imapConnectionId}
/>
- }
+ }
+
+ {
+ setImageUrl(data.file_url);
+ setImageServerPath(data.file_path);
+ }} />
+
{ createMode && }
diff --git a/frontend/platform/organizations/components/OrganizationForm.jsx b/frontend/platform/organizations/components/OrganizationForm.jsx
index 64bc7e5c..ae2af0d9 100644
--- a/frontend/platform/organizations/components/OrganizationForm.jsx
+++ b/frontend/platform/organizations/components/OrganizationForm.jsx
@@ -1,8 +1,7 @@
-import { styled } from '@mui/material/styles';
-import { Box, Button, DialogActions } from "@mui/material";
+import { Alert, Box, Button, DialogActions } from "@mui/material";
import RequiredTextField from "../../../src/components/RequiredTextField.jsx";
-import CloudUploadIcon from '@mui/icons-material/CloudUpload';
-import { useState, useEffect, use } from "react";
+import ImageUpload from '../../../src/components/ImageUpload.jsx';
+import { useState } from "react";
import { getCookie } from '../../../src/utils.js';
function OrganizationForm({ successCallback, failureCallback, cancelCallback, createMode, initialName, initialDescription, initialLogoUrl, organizationId }) {
@@ -10,19 +9,10 @@ function OrganizationForm({ successCallback, failureCallback, cancelCallback, cr
const [description, setDescription] = useState(initialDescription || "");
const [nameHelperText, setNameHelperText] = useState("");
const [descriptionHelperText, setDescriptionHelperText] = useState("");
- const [logoFile, setLogoFile] = useState(null);
- const [logoUrl, setLogoUrl] = useState(initialLogoUrl || null);
const [logoServerPath, setLogoServerPath] = useState(null);
- const [errorMessages, setErrorMessages] = useState({});
-
+ const [errorMessage, setErrorMessage] = useState();
const apiBaseUrl = localStorage.getItem('apiBaseUrl');
- const removeLogo = () => {
- setLogoUrl(null);
- setLogoServerPath(null);
- setLogoFile(null);
- }
-
const validateForm = () => {
let valid = true;
if (!name.trim()) {
@@ -57,7 +47,7 @@ function OrganizationForm({ successCallback, failureCallback, cancelCallback, cr
payload.logo = logoServerPath;
}
- if (!logoServerPath && !logoUrl) {
+ if (!logoServerPath && !initialLogoUrl) {
payload.remove_logo = true;
}
@@ -81,7 +71,8 @@ function OrganizationForm({ successCallback, failureCallback, cancelCallback, cr
successCallback(data);
})
.catch(error => {
- setErrorMessages(localeMessages["error_try_again"]);
+ setErrorMessage(localeMessages["error_try_again"]);
+ failureCallback(error);
});
}
@@ -119,71 +110,16 @@ function OrganizationForm({ successCallback, failureCallback, cancelCallback, cr
});
}
- useEffect(() => {
- if (logoFile) {
- fetch(`${apiBaseUrl}/organizations/1/file/`, {
- method: 'POST',
- headers: {
- 'X-CSRFToken': getCookie('csrftoken'),
- },
- body: (() => {
- const formData = new FormData();
- formData.append('file', logoFile);
- return formData;
- })(),
- })
- .then(response => {
- if (!response.ok) {
- throw new Error('File upload failed');
- }
- return response.json();
- })
- .then(data => {
- console.log('File uploaded successfully:', data);
- setLogoUrl(data.file_url);
- setLogoServerPath(data.file_path);
- console.log('File path set to:', logoServerPath);
- })
- .catch(error => {
- console.error('Error uploading file:', error);
- });
- }
- }, [logoFile]);
-
- const VisuallyHiddenInput = styled('input')({
- clip: 'rect(0 0 0 0)',
- clipPath: 'inset(50%)',
- height: 1,
- overflow: 'hidden',
- position: 'absolute',
- bottom: 0,
- left: 0,
- whiteSpace: 'nowrap',
- width: 1,
- });
-
return (
+ { errorMessage && {errorMessage} }
setName(e.target.value)} />
setDescription(e.target.value)} />
- { !logoUrl ? }
- sx={{ textAlign: direction === 'rtl' ? 'right' : 'left', mt: 2, mb: 2}}
- dir={direction}
- >
- {localeMessages["logo"]}
- setLogoFile(event.target.files[0])}
- />
-
- : (<>![{localeMessages["organization_logo"]}]({logoUrl})
- >
- )}
+ {
+ setLogoServerPath(data.file_path);
+ }} onUploadError={(error) => {
+ setErrorMessages(localeMessages["logo_upload_failed"]);
+ }} />
+ : (<>![{localeMessages["uploaded_image_alt"]}]({imageUrl})
+ {localeMessages["remove_image"]}>
+ )}
+ >)
+}
+
+export default ImageUpload;