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,27 @@
# Generated by Django 6.0.3 on 2026-04-06 08:11

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("django_email_learning", "0005_course_target_audience_externalreference"),
]

operations = [
migrations.AddField(
model_name="organization",
name="linkedin_page",
field=models.URLField(blank=True, max_length=500, null=True),
),
migrations.AddField(
model_name="organization",
name="website",
field=models.URLField(blank=True, max_length=500, null=True),
),
migrations.AddField(
model_name="organization",
name="youtube_channel",
field=models.URLField(blank=True, max_length=500, null=True),
),
]
3 changes: 3 additions & 0 deletions django_email_learning/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@ class Organization(models.Model):
name = models.CharField(max_length=200, unique=True)
logo = models.ImageField(upload_to="organization_logos/", null=True, blank=True)
description = models.TextField(null=True, blank=True)
website = models.URLField(max_length=500, null=True, blank=True)
youtube_channel = models.URLField(max_length=500, null=True, blank=True)
linkedin_page = models.URLField(max_length=500, null=True, blank=True)

def __str__(self) -> str:
return self.name
Expand Down
28 changes: 27 additions & 1 deletion django_email_learning/platform/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,9 @@ class OrganizationResponse(BaseModel):
logo_path: Optional[str] = None
description: Optional[str] = None
public_url: str
website: Optional[str] = None
youtube_channel: Optional[str] = None
linkedin_page: Optional[str] = None

model_config = ConfigDict(from_attributes=True)

Expand All @@ -357,6 +360,9 @@ def from_django_model(
"logo_path": organization.logo.name if organization.logo else None,
"description": organization.description,
"public_url": abs_url_builder(url),
"website": organization.website,
"youtube_channel": organization.youtube_channel,
"linkedin_page": organization.linkedin_page,
}
)

Expand All @@ -367,9 +373,22 @@ class CreateOrganizationRequest(BaseModel):
None, examples=["A description of the organization."]
)
logo: Optional[str] = Field(None, examples=["/path/to/logo.png"])
website: Optional[str] = Field(None, examples=["https://example.com"])
youtube_channel: Optional[str] = Field(
None, examples=["https://youtube.com/channel/xyz"]
)
linkedin_page: Optional[str] = Field(
None, examples=["https://linkedin.com/company/xyz"]
)

def to_django_model(self) -> Organization:
organization = Organization(name=self.name, description=self.description)
organization = Organization(
name=self.name,
description=self.description,
website=self.website,
youtube_channel=self.youtube_channel,
linkedin_page=self.linkedin_page,
)
organization.save()
organization.refresh_from_db()
if self.logo:
Expand All @@ -384,6 +403,13 @@ class UpdateOrganizationRequest(BaseModel):
description: Optional[str] = Field(
None, examples=["A description of the organization."]
)
website: Optional[str] = Field(None, examples=["https://example.com"])
youtube_channel: Optional[str] = Field(
None, examples=["https://youtube.com/channel/xyz"]
)
linkedin_page: Optional[str] = Field(
None, examples=["https://linkedin.com/company/xyz"]
)
logo: Optional[str] = Field(None, examples=["/path/to/logo.png"])
remove_logo: Optional[bool] = Field(None, examples=[True])

Expand Down
6 changes: 6 additions & 0 deletions django_email_learning/platform/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -564,6 +564,12 @@ def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unt
organization.logo = serializer.logo
if serializer.remove_logo:
organization.logo = None
if serializer.website is not None:
organization.website = serializer.website
if serializer.youtube_channel is not None:
organization.youtube_channel = serializer.youtube_channel
if serializer.linkedin_page is not None:
organization.linkedin_page = serializer.linkedin_page
organization.save()
return JsonResponse(
serializers.OrganizationResponse.from_django_model(
Expand Down
3 changes: 3 additions & 0 deletions django_email_learning/public/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ class OrganizationSerializer(BaseModel):
description: str | None = None
courses: list[PublicCourseSerializer] = []
public_url: str
website: str | None = None
youtube_channel: str | None = None
linkedin_page: str | None = None

@field_serializer("description")
def serialize_description_with_br(self, description: str | None) -> str | None:
Expand Down
44 changes: 36 additions & 8 deletions django_email_learning/public/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,21 +17,39 @@
)


def get_organization_json_ld_links(organization: Organization) -> dict[str, object]:
json_ld_links: dict[str, object] = {"url": organization.public_url}
same_as: list[str] = []

if organization.website:
same_as.append(organization.website)

if organization.linkedin_page:
same_as.append(organization.linkedin_page)

if organization.youtube_channel:
same_as.append(organization.youtube_channel)

if same_as:
json_ld_links["sameAs"] = same_as

return json_ld_links


def build_organization_courses_json_ld(
courses: list[PublicCourseSerializer], organization: Organization
) -> str:
course_list = []
for course in courses:
course_data = {
course_data: dict[str, object] = {
"@type": "Course",
"name": course.title,
"description": course.description,
"inLanguage": course.language,
"provider": {
"@type": "Organization",
"name": organization.name,
},
}
course_data["provider"] = {"@type": "Organization", "name": organization.name}

course_data["provider"].update(get_organization_json_ld_links(organization)) # type: ignore[attr-defined]
if course.image:
course_data["image"] = course.image
course_list.append(course_data)
Expand Down Expand Up @@ -62,12 +80,13 @@ def build_single_course_json_ld( # type: ignore[no-untyped-def]
},
}

course_json_ld["provider"].update( # type: ignore[attr-defined]
get_organization_json_ld_links(course.organization)
)

if course_data.image:
course_json_ld["image"] = course_data.image

if course.organization.public_url:
course_json_ld["provider"]["url"] = course.organization.public_url # type: ignore[index]

if course.organization.logo:
course_json_ld["provider"]["logo"] = request.build_absolute_uri( # type: ignore[index]
course.organization.logo.url
Expand Down Expand Up @@ -135,6 +154,9 @@ def get_context_data(self, **kwargs) -> dict: # type: ignore[no-untyped-def]
description=organization.description,
courses=courses,
public_url=organization.public_url,
website=organization.website,
youtube_channel=organization.youtube_channel,
linkedin_page=organization.linkedin_page,
)
enroll_api_path = reverse("django_email_learning:api_public:enroll")
current_lang_code = get_language()
Expand Down Expand Up @@ -163,6 +185,9 @@ def get_context_data(self, **kwargs) -> dict: # type: ignore[no-untyped-def]
"It seems you are using an in-app browser or have disabled cookies. Please open this link in a regular browser and ensure cookies are enabled to enroll in courses."
),
"continue": _("Continue"),
"linkedin_page": _("LinkedIn Page"),
"youtube_channel": _("YouTube Channel"),
"website": _("Website"),
},
}
context["organization_name"] = organization.name
Expand Down Expand Up @@ -233,6 +258,9 @@ def get_context_data(self, **kwargs) -> dict: # type: ignore[no-untyped-def]
else None,
description=course.organization.description,
public_url=course.organization.public_url,
website=course.organization.website,
youtube_channel=course.organization.youtube_channel,
linkedin_page=course.organization.linkedin_page,
)
enroll_api_path = reverse("django_email_learning:api_public:enroll")
current_lang_code = get_language()
Expand Down
3 changes: 3 additions & 0 deletions frontend/platform/organizations/Organizations.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,9 @@ function Organizations() {
initialName={org.name}
initialDescription={org.description}
initialLogoUrl={org.logo}
initialWebsite={org.website}
initialLinkedinPage={org.linkedin_page}
initialYoutubeChannel={org.youtube_channel}
organizationId={org.id}
/></Suspense>);
setDialogOpen(true);
Expand Down
71 changes: 63 additions & 8 deletions frontend/platform/organizations/components/OrganizationForm.jsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,45 @@
import { Alert, Box, Button, DialogActions } from "@mui/material";
import { Alert, Box, Button, DialogActions, TextField } from "@mui/material";
import RequiredTextField from "../../../src/components/RequiredTextField.jsx";
import ImageUpload from '../../../src/components/ImageUpload.jsx';
import { useState } from "react";
import { getCookie } from '../../../src/utils.js';
import { useAppContext } from '../../../src/render.jsx';

function OrganizationForm({ successCallback, failureCallback, cancelCallback, createMode, initialName, initialDescription, initialLogoUrl, organizationId }) {
function OrganizationForm({ successCallback, failureCallback, cancelCallback, createMode, initialName, initialDescription, initialLogoUrl, initialWebsite, initialLinkedinPage, initialYoutubeChannel, organizationId }) {
const [name, setName] = useState(initialName || "");
const [description, setDescription] = useState(initialDescription || "");
const [website, setWebsite] = useState(initialWebsite || "");
const [linkedinPage, setLinkedinPage] = useState(initialLinkedinPage || "");
const [youtubeChannel, setYoutubeChannel] = useState(initialYoutubeChannel || "");
const [nameHelperText, setNameHelperText] = useState("");
const [descriptionHelperText, setDescriptionHelperText] = useState("");
const [websiteHelperText, setWebsiteHelperText] = useState("");
const [linkedinPageHelperText, setLinkedinPageHelperText] = useState("");
const [youtubeChannelHelperText, setYoutubeChannelHelperText] = useState("");
const [logoServerPath, setLogoServerPath] = useState(null);
const [errorMessage, setErrorMessage] = useState();
const { localeMessages, apiBaseUrl } = useAppContext();

const validateOptionalUrl = (value) => {
const trimmedValue = value.trim();

if (!trimmedValue) {
return true;
}

try {
const parsedUrl = new URL(trimmedValue);

return ["http:", "https:"].includes(parsedUrl.protocol) && Boolean(parsedUrl.hostname);
} catch {
return false;
}
};

const validateForm = () => {
let valid = true;
setErrorMessage(undefined);

if (!name.trim()) {
setNameHelperText(localeMessages["name_required"]);
valid = false;
Expand All @@ -30,6 +54,27 @@ function OrganizationForm({ successCallback, failureCallback, cancelCallback, cr
setDescriptionHelperText("");
}

if (!validateOptionalUrl(website)) {
setWebsiteHelperText("Enter a valid URL starting with http:// or https://");
valid = false;
} else {
setWebsiteHelperText("");
}

if (!validateOptionalUrl(linkedinPage)) {
setLinkedinPageHelperText("Enter a valid URL starting with http:// or https://");
valid = false;
} else {
setLinkedinPageHelperText("");
}

if (!validateOptionalUrl(youtubeChannel)) {
setYoutubeChannelHelperText("Enter a valid URL starting with http:// or https://");
valid = false;
} else {
setYoutubeChannelHelperText("");
}

return valid;
}

Expand All @@ -40,8 +85,11 @@ function OrganizationForm({ successCallback, failureCallback, cancelCallback, cr
}

let payload = {
name: name,
description: description,
name: name.trim(),
description: description.trim(),
website: website.trim(),
linkedin_page: linkedinPage.trim(),
youtube_channel: youtubeChannel.trim(),
};

if (logoServerPath) {
Expand Down Expand Up @@ -90,9 +138,12 @@ function OrganizationForm({ successCallback, failureCallback, cancelCallback, cr
'X-CSRFToken': getCookie('csrftoken'),
},
body: JSON.stringify({
name: name,
description: description,
name: name.trim(),
description: description.trim(),
logo: logoServerPath,
website: website.trim(),
linkedin_page: linkedinPage.trim(),
youtube_channel: youtubeChannel.trim(),
}),
})
.then(response => {
Expand All @@ -107,7 +158,8 @@ function OrganizationForm({ successCallback, failureCallback, cancelCallback, cr
successCallback(data);
})
.catch(error => {
setErrorMessages(localeMessages["error_try_again"]);
setErrorMessage(localeMessages["error_try_again"]);
failureCallback(error);
});
}

Expand All @@ -116,10 +168,13 @@ function OrganizationForm({ successCallback, failureCallback, cancelCallback, cr
{ errorMessage && <Alert severity="error" sx={{ mb: 2 }}>{errorMessage}</Alert> }
<RequiredTextField label={localeMessages["name"]} helperText={nameHelperText} fullWidth margin="normal" value={name} onChange={(e) => setName(e.target.value)} />
<RequiredTextField label={localeMessages["description"]} helperText={descriptionHelperText} fullWidth margin="normal" multiline rows={4} value={description} onChange={(e) => setDescription(e.target.value)} />
<TextField label="Website" type="url" fullWidth margin="normal" value={website} error={Boolean(websiteHelperText)} helperText={websiteHelperText} onChange={(e) => setWebsite(e.target.value)} />
<TextField label="LinkedIn page" type="url" fullWidth margin="normal" value={linkedinPage} error={Boolean(linkedinPageHelperText)} helperText={linkedinPageHelperText} onChange={(e) => setLinkedinPage(e.target.value)} />
<TextField label="YouTube channel" type="url" fullWidth margin="normal" value={youtubeChannel} error={Boolean(youtubeChannelHelperText)} helperText={youtubeChannelHelperText} onChange={(e) => setYoutubeChannel(e.target.value)} />
<ImageUpload initialUrl={initialLogoUrl} onUploadSuccess={(data) => {
setLogoServerPath(data.file_path);
}} onUploadError={(error) => {
setErrorMessages(localeMessages["logo_upload_failed"]);
setErrorMessage(localeMessages["logo_upload_failed"]);
}} />
<DialogActions>
<Button onClick={cancelCallback}>{localeMessages["cancel"]}</Button>
Expand Down
Loading