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 ? - : (<>{localeMessages["organization_logo"]}
- - )} + { + setLogoServerPath(data.file_path); + }} onUploadError={(error) => { + setErrorMessages(localeMessages["logo_upload_failed"]); + }} /> diff --git a/frontend/src/components/ImageUpload.jsx b/frontend/src/components/ImageUpload.jsx new file mode 100644 index 00000000..702eb22f --- /dev/null +++ b/frontend/src/components/ImageUpload.jsx @@ -0,0 +1,91 @@ +import { useState, useEffect } from 'react'; +import { styled } from '@mui/material/styles'; +import Button from '@mui/material/Button'; +import CloudUploadIcon from '@mui/icons-material/CloudUpload'; +import { getCookie } from '../utils.js'; + + +const ImageUpload = ({ onUploadSuccess, onUploadError, initialUrl }) => { + console.log("Rendering ImageUpload component with initialUrl:", initialUrl); + const [imageFile, setImageFile] = useState(null); + const [imageUrl, setImageUrl] = useState(initialUrl); + + + const apiBaseUrl = localStorage.getItem('apiBaseUrl'); + + useEffect(() => { + setImageUrl(initialUrl); + }, [initialUrl]); + + const removeImage = () => { + setImageUrl(null); + setImageFile(null); + onUploadSuccess({ file_url: null, file_path: null }); + } + + useEffect(() => { + if (imageFile) { + fetch(`${apiBaseUrl}/organizations/1/file/`, { + method: 'POST', + headers: { + 'X-CSRFToken': getCookie('csrftoken'), + }, + body: (() => { + const formData = new FormData(); + formData.append('file', imageFile); + return formData; + })(), + }) + .then(response => { + if (!response.ok) { + throw new Error('File upload failed'); + } + return response.json(); + }) + .then(data => { + console.log('File uploaded successfully:', data); + onUploadSuccess(data); + setImageUrl(data.file_url); + }) + .catch(error => { + console.error('Error uploading file:', error); + onUploadError(error); + }); + } + }, [imageFile]); + + 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 (<> + { !imageUrl ? + : (<>{localeMessages["uploaded_image_alt"]}
+ + )} + ) +} + +export default ImageUpload;