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,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,
),
),
]
22 changes: 22 additions & 0 deletions django_email_learning/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
31 changes: 31 additions & 0 deletions django_email_learning/platform/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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


Expand All @@ -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:
Expand All @@ -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

Expand All @@ -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
Expand Down
16 changes: 12 additions & 4 deletions django_email_learning/platform/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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)

Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down
1 change: 1 addition & 0 deletions django_email_learning/public/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions django_email_learning/public/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions django_email_learning/templates/platform/courses.html
Original file line number Diff line number Diff line change
Expand Up @@ -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' %}",
}
</script>
{% vite_asset 'platform/courses/Courses.jsx' %}
Expand Down
6 changes: 3 additions & 3 deletions django_email_learning/templates/platform/organizations.html
Original file line number Diff line number Diff line change
Expand Up @@ -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.' %}",
}
</script>
Expand Down
56 changes: 37 additions & 19 deletions frontend/platform/courses/components/CourseForm.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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');

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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.");
}
Expand All @@ -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);
});
};
Expand All @@ -141,32 +151,34 @@ 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');
}
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);
});
};
Expand All @@ -193,7 +205,13 @@ function CourseForm({successCallback, failureCallback, cancelCallback, activeOrg
activeOrganizationId={activeOrganizationId}
initialImapConnectionId={imapConnectionId}
/>
</Box>}
</Box>}
<Box>
<ImageUpload initialUrl={imageUrl} onUploadSuccess={(data) => {
setImageUrl(data.file_url);
setImageServerPath(data.file_path);
}} />
</Box>
<Box mt={2} textAlign="right">
<Button onClick={cancelCallback} sx={{ mr: 1 }}>Cancel</Button>
{ createMode && <Button variant="contained" onClick={() => handleCreateCourse()} sx={{ boxShadow: 'none' }}>{localeMessages["create"]}</Button> }
Expand Down
Loading