Skip to content

Commit 9b2a354

Browse files
authored
Merge pull request #333 from AvaCodeSolutions/feat/305/org-links
feat: #305 add optional links for organizations
2 parents 892fb76 + f560b77 commit 9b2a354

File tree

11 files changed

+289
-19
lines changed

11 files changed

+289
-19
lines changed
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# Generated by Django 6.0.3 on 2026-04-06 08:11
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
dependencies = [
8+
("django_email_learning", "0005_course_target_audience_externalreference"),
9+
]
10+
11+
operations = [
12+
migrations.AddField(
13+
model_name="organization",
14+
name="linkedin_page",
15+
field=models.URLField(blank=True, max_length=500, null=True),
16+
),
17+
migrations.AddField(
18+
model_name="organization",
19+
name="website",
20+
field=models.URLField(blank=True, max_length=500, null=True),
21+
),
22+
migrations.AddField(
23+
model_name="organization",
24+
name="youtube_channel",
25+
field=models.URLField(blank=True, max_length=500, null=True),
26+
),
27+
]

django_email_learning/models.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,9 @@ class Organization(models.Model):
8484
name = models.CharField(max_length=200, unique=True)
8585
logo = models.ImageField(upload_to="organization_logos/", null=True, blank=True)
8686
description = models.TextField(null=True, blank=True)
87+
website = models.URLField(max_length=500, null=True, blank=True)
88+
youtube_channel = models.URLField(max_length=500, null=True, blank=True)
89+
linkedin_page = models.URLField(max_length=500, null=True, blank=True)
8790

8891
def __str__(self) -> str:
8992
return self.name

django_email_learning/platform/api/serializers.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,9 @@ class OrganizationResponse(BaseModel):
336336
logo_path: Optional[str] = None
337337
description: Optional[str] = None
338338
public_url: str
339+
website: Optional[str] = None
340+
youtube_channel: Optional[str] = None
341+
linkedin_page: Optional[str] = None
339342

340343
model_config = ConfigDict(from_attributes=True)
341344

@@ -357,6 +360,9 @@ def from_django_model(
357360
"logo_path": organization.logo.name if organization.logo else None,
358361
"description": organization.description,
359362
"public_url": abs_url_builder(url),
363+
"website": organization.website,
364+
"youtube_channel": organization.youtube_channel,
365+
"linkedin_page": organization.linkedin_page,
360366
}
361367
)
362368

@@ -367,9 +373,22 @@ class CreateOrganizationRequest(BaseModel):
367373
None, examples=["A description of the organization."]
368374
)
369375
logo: Optional[str] = Field(None, examples=["/path/to/logo.png"])
376+
website: Optional[str] = Field(None, examples=["https://example.com"])
377+
youtube_channel: Optional[str] = Field(
378+
None, examples=["https://youtube.com/channel/xyz"]
379+
)
380+
linkedin_page: Optional[str] = Field(
381+
None, examples=["https://linkedin.com/company/xyz"]
382+
)
370383

371384
def to_django_model(self) -> Organization:
372-
organization = Organization(name=self.name, description=self.description)
385+
organization = Organization(
386+
name=self.name,
387+
description=self.description,
388+
website=self.website,
389+
youtube_channel=self.youtube_channel,
390+
linkedin_page=self.linkedin_page,
391+
)
373392
organization.save()
374393
organization.refresh_from_db()
375394
if self.logo:
@@ -384,6 +403,13 @@ class UpdateOrganizationRequest(BaseModel):
384403
description: Optional[str] = Field(
385404
None, examples=["A description of the organization."]
386405
)
406+
website: Optional[str] = Field(None, examples=["https://example.com"])
407+
youtube_channel: Optional[str] = Field(
408+
None, examples=["https://youtube.com/channel/xyz"]
409+
)
410+
linkedin_page: Optional[str] = Field(
411+
None, examples=["https://linkedin.com/company/xyz"]
412+
)
387413
logo: Optional[str] = Field(None, examples=["/path/to/logo.png"])
388414
remove_logo: Optional[bool] = Field(None, examples=[True])
389415

django_email_learning/platform/api/views.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -564,6 +564,12 @@ def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unt
564564
organization.logo = serializer.logo
565565
if serializer.remove_logo:
566566
organization.logo = None
567+
if serializer.website is not None:
568+
organization.website = serializer.website
569+
if serializer.youtube_channel is not None:
570+
organization.youtube_channel = serializer.youtube_channel
571+
if serializer.linkedin_page is not None:
572+
organization.linkedin_page = serializer.linkedin_page
567573
organization.save()
568574
return JsonResponse(
569575
serializers.OrganizationResponse.from_django_model(

django_email_learning/public/serializers.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ class OrganizationSerializer(BaseModel):
3535
description: str | None = None
3636
courses: list[PublicCourseSerializer] = []
3737
public_url: str
38+
website: str | None = None
39+
youtube_channel: str | None = None
40+
linkedin_page: str | None = None
3841

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

django_email_learning/public/views.py

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,21 +17,39 @@
1717
)
1818

1919

20+
def get_organization_json_ld_links(organization: Organization) -> dict[str, object]:
21+
json_ld_links: dict[str, object] = {"url": organization.public_url}
22+
same_as: list[str] = []
23+
24+
if organization.website:
25+
same_as.append(organization.website)
26+
27+
if organization.linkedin_page:
28+
same_as.append(organization.linkedin_page)
29+
30+
if organization.youtube_channel:
31+
same_as.append(organization.youtube_channel)
32+
33+
if same_as:
34+
json_ld_links["sameAs"] = same_as
35+
36+
return json_ld_links
37+
38+
2039
def build_organization_courses_json_ld(
2140
courses: list[PublicCourseSerializer], organization: Organization
2241
) -> str:
2342
course_list = []
2443
for course in courses:
25-
course_data = {
44+
course_data: dict[str, object] = {
2645
"@type": "Course",
2746
"name": course.title,
2847
"description": course.description,
2948
"inLanguage": course.language,
30-
"provider": {
31-
"@type": "Organization",
32-
"name": organization.name,
33-
},
3449
}
50+
course_data["provider"] = {"@type": "Organization", "name": organization.name}
51+
52+
course_data["provider"].update(get_organization_json_ld_links(organization)) # type: ignore[attr-defined]
3553
if course.image:
3654
course_data["image"] = course.image
3755
course_list.append(course_data)
@@ -62,12 +80,13 @@ def build_single_course_json_ld( # type: ignore[no-untyped-def]
6280
},
6381
}
6482

83+
course_json_ld["provider"].update( # type: ignore[attr-defined]
84+
get_organization_json_ld_links(course.organization)
85+
)
86+
6587
if course_data.image:
6688
course_json_ld["image"] = course_data.image
6789

68-
if course.organization.public_url:
69-
course_json_ld["provider"]["url"] = course.organization.public_url # type: ignore[index]
70-
7190
if course.organization.logo:
7291
course_json_ld["provider"]["logo"] = request.build_absolute_uri( # type: ignore[index]
7392
course.organization.logo.url
@@ -135,6 +154,9 @@ def get_context_data(self, **kwargs) -> dict: # type: ignore[no-untyped-def]
135154
description=organization.description,
136155
courses=courses,
137156
public_url=organization.public_url,
157+
website=organization.website,
158+
youtube_channel=organization.youtube_channel,
159+
linkedin_page=organization.linkedin_page,
138160
)
139161
enroll_api_path = reverse("django_email_learning:api_public:enroll")
140162
current_lang_code = get_language()
@@ -163,6 +185,9 @@ def get_context_data(self, **kwargs) -> dict: # type: ignore[no-untyped-def]
163185
"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."
164186
),
165187
"continue": _("Continue"),
188+
"linkedin_page": _("LinkedIn Page"),
189+
"youtube_channel": _("YouTube Channel"),
190+
"website": _("Website"),
166191
},
167192
}
168193
context["organization_name"] = organization.name
@@ -233,6 +258,9 @@ def get_context_data(self, **kwargs) -> dict: # type: ignore[no-untyped-def]
233258
else None,
234259
description=course.organization.description,
235260
public_url=course.organization.public_url,
261+
website=course.organization.website,
262+
youtube_channel=course.organization.youtube_channel,
263+
linkedin_page=course.organization.linkedin_page,
236264
)
237265
enroll_api_path = reverse("django_email_learning:api_public:enroll")
238266
current_lang_code = get_language()

frontend/platform/organizations/Organizations.jsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,9 @@ function Organizations() {
141141
initialName={org.name}
142142
initialDescription={org.description}
143143
initialLogoUrl={org.logo}
144+
initialWebsite={org.website}
145+
initialLinkedinPage={org.linkedin_page}
146+
initialYoutubeChannel={org.youtube_channel}
144147
organizationId={org.id}
145148
/></Suspense>);
146149
setDialogOpen(true);

frontend/platform/organizations/components/OrganizationForm.jsx

Lines changed: 63 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,45 @@
1-
import { Alert, Box, Button, DialogActions } from "@mui/material";
1+
import { Alert, Box, Button, DialogActions, TextField } from "@mui/material";
22
import RequiredTextField from "../../../src/components/RequiredTextField.jsx";
33
import ImageUpload from '../../../src/components/ImageUpload.jsx';
44
import { useState } from "react";
55
import { getCookie } from '../../../src/utils.js';
66
import { useAppContext } from '../../../src/render.jsx';
77

8-
function OrganizationForm({ successCallback, failureCallback, cancelCallback, createMode, initialName, initialDescription, initialLogoUrl, organizationId }) {
8+
function OrganizationForm({ successCallback, failureCallback, cancelCallback, createMode, initialName, initialDescription, initialLogoUrl, initialWebsite, initialLinkedinPage, initialYoutubeChannel, organizationId }) {
99
const [name, setName] = useState(initialName || "");
1010
const [description, setDescription] = useState(initialDescription || "");
11+
const [website, setWebsite] = useState(initialWebsite || "");
12+
const [linkedinPage, setLinkedinPage] = useState(initialLinkedinPage || "");
13+
const [youtubeChannel, setYoutubeChannel] = useState(initialYoutubeChannel || "");
1114
const [nameHelperText, setNameHelperText] = useState("");
1215
const [descriptionHelperText, setDescriptionHelperText] = useState("");
16+
const [websiteHelperText, setWebsiteHelperText] = useState("");
17+
const [linkedinPageHelperText, setLinkedinPageHelperText] = useState("");
18+
const [youtubeChannelHelperText, setYoutubeChannelHelperText] = useState("");
1319
const [logoServerPath, setLogoServerPath] = useState(null);
1420
const [errorMessage, setErrorMessage] = useState();
1521
const { localeMessages, apiBaseUrl } = useAppContext();
1622

23+
const validateOptionalUrl = (value) => {
24+
const trimmedValue = value.trim();
25+
26+
if (!trimmedValue) {
27+
return true;
28+
}
29+
30+
try {
31+
const parsedUrl = new URL(trimmedValue);
32+
33+
return ["http:", "https:"].includes(parsedUrl.protocol) && Boolean(parsedUrl.hostname);
34+
} catch {
35+
return false;
36+
}
37+
};
38+
1739
const validateForm = () => {
1840
let valid = true;
41+
setErrorMessage(undefined);
42+
1943
if (!name.trim()) {
2044
setNameHelperText(localeMessages["name_required"]);
2145
valid = false;
@@ -30,6 +54,27 @@ function OrganizationForm({ successCallback, failureCallback, cancelCallback, cr
3054
setDescriptionHelperText("");
3155
}
3256

57+
if (!validateOptionalUrl(website)) {
58+
setWebsiteHelperText("Enter a valid URL starting with http:// or https://");
59+
valid = false;
60+
} else {
61+
setWebsiteHelperText("");
62+
}
63+
64+
if (!validateOptionalUrl(linkedinPage)) {
65+
setLinkedinPageHelperText("Enter a valid URL starting with http:// or https://");
66+
valid = false;
67+
} else {
68+
setLinkedinPageHelperText("");
69+
}
70+
71+
if (!validateOptionalUrl(youtubeChannel)) {
72+
setYoutubeChannelHelperText("Enter a valid URL starting with http:// or https://");
73+
valid = false;
74+
} else {
75+
setYoutubeChannelHelperText("");
76+
}
77+
3378
return valid;
3479
}
3580

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

4287
let payload = {
43-
name: name,
44-
description: description,
88+
name: name.trim(),
89+
description: description.trim(),
90+
website: website.trim(),
91+
linkedin_page: linkedinPage.trim(),
92+
youtube_channel: youtubeChannel.trim(),
4593
};
4694

4795
if (logoServerPath) {
@@ -90,9 +138,12 @@ function OrganizationForm({ successCallback, failureCallback, cancelCallback, cr
90138
'X-CSRFToken': getCookie('csrftoken'),
91139
},
92140
body: JSON.stringify({
93-
name: name,
94-
description: description,
141+
name: name.trim(),
142+
description: description.trim(),
95143
logo: logoServerPath,
144+
website: website.trim(),
145+
linkedin_page: linkedinPage.trim(),
146+
youtube_channel: youtubeChannel.trim(),
96147
}),
97148
})
98149
.then(response => {
@@ -107,7 +158,8 @@ function OrganizationForm({ successCallback, failureCallback, cancelCallback, cr
107158
successCallback(data);
108159
})
109160
.catch(error => {
110-
setErrorMessages(localeMessages["error_try_again"]);
161+
setErrorMessage(localeMessages["error_try_again"]);
162+
failureCallback(error);
111163
});
112164
}
113165

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

0 commit comments

Comments
 (0)