Skip to content

Commit 9398ff1

Browse files
authored
Merge pull request #191 from AvaCodeSolutions/feat/161/image-for-courses
feat: #161 Add image for courses
2 parents 8568aec + 2fcafa8 commit 9398ff1

12 files changed

Lines changed: 269 additions & 108 deletions

File tree

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# Generated by Django 6.0.1 on 2026-01-30 10:57
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
dependencies = [
8+
("django_email_learning", "0004_jobexecution_alter_apikey_salt_and_more"),
9+
]
10+
11+
operations = [
12+
migrations.AddField(
13+
model_name="course",
14+
name="image",
15+
field=models.ImageField(blank=True, null=True, upload_to="course_images/"),
16+
),
17+
migrations.AlterField(
18+
model_name="apikey",
19+
name="salt",
20+
field=models.CharField(
21+
default="847bdf99eb494499a2dce4f279456bcd",
22+
editable=False,
23+
max_length=32,
24+
),
25+
),
26+
migrations.AlterField(
27+
model_name="imapconnection",
28+
name="salt",
29+
field=models.CharField(
30+
default="847bdf99eb494499a2dce4f279456bcd",
31+
editable=False,
32+
max_length=32,
33+
),
34+
),
35+
]

django_email_learning/models.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from django.utils import timezone
2525
from datetime import timedelta
2626
from django_email_learning.services import jwt_service
27+
from PIL import Image
2728
from typing import Optional
2829

2930

@@ -178,6 +179,7 @@ class Course(models.Model):
178179
ImapConnection, on_delete=models.SET_NULL, null=True, blank=True
179180
)
180181
organization = models.ForeignKey(Organization, on_delete=models.CASCADE)
182+
image = models.ImageField(upload_to="course_images/", null=True, blank=True)
181183

182184
def __str__(self) -> str:
183185
return self.title
@@ -228,6 +230,26 @@ def generate_unsubscribe_link(self, email: str) -> str:
228230
link = f"{settings.DJANGO_EMAIL_LEARNING['SITE_BASE_URL']}{unsubscribe_path}?token={token}"
229231
return link
230232

233+
def replace_image(self, file_path: str) -> str:
234+
if default_storage.exists(file_path):
235+
with default_storage.open(file_path) as f:
236+
img = Image.open(f)
237+
width, height = img.size
238+
if width < 580 or height < 360:
239+
raise ValueError(
240+
"Image dimensions must be at least 580x360 pixels."
241+
)
242+
allowed_extensions = [".jpg", ".jpeg", ".png", ".svg"]
243+
if not any(file_path.lower().endswith(ext) for ext in allowed_extensions):
244+
raise ValueError("Image must be an image file with a valid extension.")
245+
final_path = f"organization/{self.organization.id}/course_images/{self.id}_{file_path.split('/')[-1]}"
246+
default_storage.save(final_path, default_storage.open(file_path))
247+
self.image = final_path
248+
self.save()
249+
return final_path
250+
else:
251+
raise ValueError("Image file does not exist.")
252+
231253

232254
class Lesson(models.Model):
233255
title = models.CharField(max_length=200)

django_email_learning/platform/api/serializers.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ class CreateCourseRequest(BaseModel):
6464
None, examples=["A beginner's course on Python programming."]
6565
)
6666
imap_connection_id: Optional[int] = Field(None, examples=[1])
67+
image: Optional[str] = Field(None, examples=["/path/to/course_image.png"])
6768

6869
def to_django_model(self, organization_id: int) -> Course:
6970
organization = Organization.objects.get(id=organization_id)
@@ -90,6 +91,8 @@ def to_django_model(self, organization_id: int) -> Course:
9091
)
9192
if imap_connection:
9293
course.imap_connection = imap_connection
94+
if self.image:
95+
course.replace_image(self.image)
9396
return course
9497

9598

@@ -104,6 +107,7 @@ class UpdateCourseRequest(BaseModel):
104107
imap_connection_id: Optional[int] = Field(None, examples=[1])
105108
enabled: Optional[bool] = Field(None, examples=[True])
106109
reset_imap_connection: Optional[bool] = Field(None, examples=[False])
110+
image: Optional[str] = Field(None, examples=["/path/to/course_image.png"])
107111

108112
def to_django_model(self, course_id: int) -> Course:
109113
try:
@@ -126,6 +130,10 @@ def to_django_model(self, course_id: int) -> Course:
126130
course.enabled = self.enabled
127131
if self.reset_imap_connection:
128132
course.imap_connection = None
133+
if self.image is not None:
134+
course.replace_image(self.image)
135+
if not self.image:
136+
course.image = None
129137

130138
return course
131139

@@ -139,9 +147,32 @@ class CourseResponse(BaseModel):
139147
imap_connection_id: Optional[int]
140148
enabled: bool
141149
enrollments_count: dict[str, int]
150+
image: Optional[str] = None
151+
image_path: Optional[str] = None
142152

143153
model_config = ConfigDict(from_attributes=True)
144154

155+
@staticmethod
156+
def from_django_model(
157+
course: Course, abs_url_builder: Callable
158+
) -> "CourseResponse":
159+
return CourseResponse.model_validate(
160+
{
161+
"id": course.id,
162+
"title": course.title,
163+
"slug": course.slug,
164+
"description": course.description,
165+
"organization_id": course.organization.id,
166+
"imap_connection_id": course.imap_connection.id
167+
if course.imap_connection
168+
else None,
169+
"enabled": course.enabled,
170+
"enrollments_count": course.enrollments_count,
171+
"image": abs_url_builder(course.image.url) if course.image else None,
172+
"image_path": course.image.name if course.image else None,
173+
}
174+
)
175+
145176

146177
class CourseSummaryResponse(BaseModel):
147178
id: int

django_email_learning/platform/api/views.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,9 @@ def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unt
5454
)
5555
course.save()
5656
return JsonResponse(
57-
serializers.CourseResponse.model_validate(course).model_dump(),
57+
serializers.CourseResponse.from_django_model(
58+
course, abs_url_builder=request.build_absolute_uri
59+
).model_dump(),
5860
status=201,
5961
)
6062
except ValidationError as e:
@@ -74,7 +76,9 @@ def get(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unty
7476
response_list = []
7577
for course in courses:
7678
response_list.append(
77-
serializers.CourseResponse.model_validate(course).model_dump()
79+
serializers.CourseResponse.from_django_model(
80+
course, abs_url_builder=request.build_absolute_uri
81+
).model_dump()
7882
)
7983
return JsonResponse({"courses": response_list}, status=200)
8084

@@ -289,7 +293,9 @@ def get(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unty
289293
try:
290294
course = Course.objects.get(id=kwargs["course_id"])
291295
return JsonResponse(
292-
serializers.CourseResponse.model_validate(course).model_dump(),
296+
serializers.CourseResponse.from_django_model(
297+
course, abs_url_builder=request.build_absolute_uri
298+
).model_dump(),
293299
status=200,
294300
)
295301
except Course.DoesNotExist:
@@ -306,7 +312,9 @@ def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unt
306312
course = serializer.to_django_model(course_id=kwargs["course_id"])
307313
course.save()
308314
return JsonResponse(
309-
serializers.CourseResponse.model_validate(course).model_dump(),
315+
serializers.CourseResponse.from_django_model(
316+
course, abs_url_builder=request.build_absolute_uri
317+
).model_dump(),
310318
status=200,
311319
)
312320
except ValidationError as e:

django_email_learning/public/serializers.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ class PublicCourseSerializer(BaseModel):
77
slug: str
88
description: str | None = None
99
imap_email: str | None = None
10+
image: str | None = None
1011

1112
@field_serializer("description")
1213
def serialize_description_with_br(self, description: str | None) -> str | None:

django_email_learning/public/views.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ def get_context_data(self, **kwargs) -> dict: # type: ignore[no-untyped-def]
3838
title=course.title,
3939
slug=course.slug,
4040
description=course.description,
41+
image=self.request.build_absolute_uri(course.image.url)
42+
if course.image
43+
else None,
4144
imap_email=course.imap_connection.email
4245
if course.imap_connection
4346
else None,

django_email_learning/templates/platform/courses.html

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@
4747
"invalid_port_helper_text": "{% translate 'The port must be a valid number.' %}",
4848
"invalid_email_helper_text": "{% translate 'The email must be a valid email address.' %}",
4949
"total_enrollments": "{% translate 'Total Enrollments' %}",
50+
"upload_button_label": "{% translate 'Upload Image' %}",
51+
"remove_image": "{% translate 'Remove Image' %}",
52+
"uploaded_image_alt": "{% translate 'Course Image' %}",
5053
}
5154
</script>
5255
{% vite_asset 'platform/courses/Courses.jsx' %}

django_email_learning/templates/platform/organizations.html

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,15 @@
1212
"name_required": "{% translate 'Name is required.' %}",
1313
"description_required": "{% translate 'Description is required.' %}",
1414
"error_try_again": "{% translate 'An error occurred. Please try again.' %}",
15-
"logo": "{% translate 'Logo' %}",
15+
"upload_button_label": "{% translate 'Upload Logo' %}",
1616
"create_organization": "{% translate 'Create Organization' %}",
1717
"cancel": "{% translate 'Cancel' %}",
1818
"delete": "{% translate 'Delete' %}",
1919
"confirm_deletion": "{% translate 'Confirm Deletion' %}",
20-
"remove_logo": "{% translate 'Remove Logo' %}",
20+
"remove_image": "{% translate 'Remove Logo' %}",
2121
"create": "{% translate 'Create' %}",
2222
"update": "{% translate 'Update' %}",
23-
"organization_logo": "{% translate 'Organization Logo' %}",
23+
"uploaded_image_alt": "{% translate 'Organization Logo' %}",
2424
"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.' %}",
2525
}
2626
</script>

frontend/platform/courses/components/CourseForm.jsx

Lines changed: 37 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import RequiredTextField from '../../../src/components/RequiredTextField.jsx';
33
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
44
import IconButton from '@mui/material/IconButton';
55
import AddImapConnectionForm from './AddImapConnectionForm.jsx';
6+
import ImageUpload from '../../../src/components/ImageUpload.jsx';
67
import { useEffect, useState } from 'react';
78
import { getCookie } from '../../../src/utils.js';
89

@@ -17,6 +18,8 @@ function CourseForm({successCallback, failureCallback, cancelCallback, activeOrg
1718
const [slugHelperText, setSlugHelperText] = useState("")
1819
const [descriptionHelperText, setDescriptionHelperText] = useState("")
1920
const [errorMessage, setErrorMessage] = useState("")
21+
const [imageUrl, setImageUrl] = useState(null)
22+
const [imageServerPath, setImageServerPath] = useState(null)
2023

2124
const apiBaseUrl = localStorage.getItem('apiBaseUrl');
2225

@@ -48,6 +51,8 @@ function CourseForm({successCallback, failureCallback, cancelCallback, activeOrg
4851
setCourseTitle(data.title);
4952
setCourseSlug(data.slug);
5053
setCourseDescription(data.description);
54+
setImageUrl(data.image);
55+
setImageServerPath(data.image_path);
5156
if (data.imap_connection_id) {
5257
setImapConnectionId(data.imap_connection_id);
5358
setAddImapConnection(true);
@@ -102,11 +107,12 @@ function CourseForm({successCallback, failureCallback, cancelCallback, activeOrg
102107
// slug is not updatable
103108
description: courseDescription,
104109
imap_connection_id: imapConnectionId && addImapConnection? parseInt(imapConnectionId) : null,
105-
reset_imap_connection: !addImapConnection || imapConnectionId == null
110+
reset_imap_connection: !addImapConnection || imapConnectionId == null,
111+
image: imageServerPath ? imageServerPath : null
106112
}),
107113
})
108114
.then(response => {
109-
if (!response.ok) {
115+
if (!response.ok && response.status != 409) {
110116
if (response.status >= 500) {
111117
setErrorMessage("Server error occurred. Please try again later.");
112118
}
@@ -115,12 +121,16 @@ function CourseForm({successCallback, failureCallback, cancelCallback, activeOrg
115121
return response.json();
116122
})
117123
.then(data => {
118-
console.log('Success:', data);
119-
successCallback(data);
124+
if (data.error) {
125+
setErrorMessage(data.error);
126+
failureCallback(data);
127+
} else {
128+
console.log('Success:', data);
129+
successCallback(data);
130+
}
120131
})
121132
.catch((error) => {
122133
console.error('Error:', error);
123-
if (error)
124134
failureCallback(error);
125135
});
126136
};
@@ -141,32 +151,34 @@ function CourseForm({successCallback, failureCallback, cancelCallback, activeOrg
141151
title: courseTitle,
142152
slug: courseSlug,
143153
description: courseDescription,
144-
imap_connection_id: imapConnectionId ? parseInt(imapConnectionId) : null
154+
imap_connection_id: imapConnectionId ? parseInt(imapConnectionId) : null,
155+
image: imageServerPath ? imageServerPath : null
145156
}),
146157
})
147158
.then(response => {
148-
if (!response.ok) {
149-
if (response.status === 409) {
150-
setErrorMessage("A course with this title or slug already exists.");
151-
}
152-
else if (response.status >= 500) {
159+
if (!response.ok && response.status != 409) {
160+
if (response.status >= 500) {
153161
setErrorMessage("Server error occurred. Please try again later.");
154162
}
155163
throw new Error('Network response was not ok');
156164
}
157165
return response.json();
158166
})
159167
.then(data => {
160-
console.log('Success:', data);
161-
// Optionally reset form fields here
162-
setCourseTitle("");
163-
setCourseSlug("");
164-
setCourseDescription("");
165-
successCallback(data);
168+
if (data.error) {
169+
setErrorMessage(data.error);
170+
failureCallback(data);
171+
} else {
172+
console.log('Success:', data);
173+
// Optionally reset form fields here
174+
setCourseTitle("");
175+
setCourseSlug("");
176+
setCourseDescription("");
177+
successCallback(data);
178+
}
166179
})
167180
.catch((error) => {
168181
console.error('Error:', error);
169-
if (error)
170182
failureCallback(error);
171183
});
172184
};
@@ -193,7 +205,13 @@ function CourseForm({successCallback, failureCallback, cancelCallback, activeOrg
193205
activeOrganizationId={activeOrganizationId}
194206
initialImapConnectionId={imapConnectionId}
195207
/>
196-
</Box>}
208+
</Box>}
209+
<Box>
210+
<ImageUpload initialUrl={imageUrl} onUploadSuccess={(data) => {
211+
setImageUrl(data.file_url);
212+
setImageServerPath(data.file_path);
213+
}} />
214+
</Box>
197215
<Box mt={2} textAlign="right">
198216
<Button onClick={cancelCallback} sx={{ mr: 1 }}>Cancel</Button>
199217
{ createMode && <Button variant="contained" onClick={() => handleCreateCourse()} sx={{ boxShadow: 'none' }}>{localeMessages["create"]}</Button> }

0 commit comments

Comments
 (0)