Merge pull request #1440 from frappe/develop

chore: merge 'develop' into 'main'
This commit is contained in:
Jannat Patel
2025-04-16 18:55:21 +05:30
committed by GitHub
35 changed files with 10521 additions and 3810 deletions

View File

@@ -68,7 +68,6 @@ declare module 'vue' {
NoPermission: typeof import('./src/components/NoPermission.vue')['default']
NoSidebarLayout: typeof import('./src/components/NoSidebarLayout.vue')['default']
NotPermitted: typeof import('./src/components/NotPermitted.vue')['default']
OnboardingBanner: typeof import('./src/components/OnboardingBanner.vue')['default']
PageModal: typeof import('./src/components/Modals/PageModal.vue')['default']
PaymentSettings: typeof import('./src/components/PaymentSettings.vue')['default']
ProgressBar: typeof import('./src/components/ProgressBar.vue')['default']

View File

@@ -38,7 +38,7 @@
<div class="mb-4">
<Button @click="openFileSelector" :loading="uploading">
{{
uploading ? `Uploading ${progress}%` : 'Upload an zip file'
uploading ? `Uploading ${progress}%` : 'Upload an ZIP file'
}}
</Button>
</div>

View File

@@ -350,6 +350,20 @@ const tabsStructure = computed(() => {
},
],
},
{
label: 'SEO',
icon: 'Search',
fields: [
{
label: 'Meta Description',
name: 'meta_description',
type: 'textarea',
rows: 5,
description:
"This description will be shown on lists and pages that don't have meta description",
},
],
},
],
},
]

View File

@@ -1,159 +0,0 @@
<template>
<div v-if="showOnboardingBanner && onboardingDetails.data">
<Tooltip :text="__('Skip Onboarding')" placement="left">
<X
class="w-4 h-4 stroke-1 absolute top-2 right-2 cursor-pointer mr-1"
@click="skipOnboarding.reload()"
/>
</Tooltip>
<div class="flex items-center justify-evenly bg-surface-gray-2 p-10">
<div
@click="redirectToCourseForm()"
class="flex items-center space-x-2"
:class="{
'cursor-pointer': !onboardingDetails.data.course_created?.length,
}"
>
<span
v-if="onboardingDetails.data.course_created?.length"
class="py-1 px-1 bg-surface-white rounded-full"
>
<Check class="h-4 w-4 stroke-2 text-ink-green-3" />
</span>
<span
v-else
class="font-semibold bg-surface-white px-2 py-1 rounded-full"
>
1
</span>
<span class="text-lg font-semibold">
{{ __('Create a course') }}
</span>
</div>
<div
@click="redirectToChapterForm()"
class="flex items-center space-x-2"
:class="{
'cursor-pointer':
onboardingDetails.data.course_created?.length &&
!onboardingDetails.data.chapter_created?.length,
'text-ink-gray-3': !onboardingDetails.data.course_created?.length,
}"
>
<span
v-if="onboardingDetails.data.chapter_created?.length"
class="py-1 px-1 bg-surface-white rounded-full"
>
<Check class="h-4 w-4 stroke-2 text-ink-green-3" />
</span>
<span
v-else
class="font-semibold bg-surface-white px-2 py-1 rounded-full"
>
2
</span>
<span class="text-lg font-semibold">
{{ __('Add a chapter') }}
</span>
</div>
<div
@click="redirectToLessonForm()"
class="flex items-center space-x-2"
:class="{
'cursor-pointer':
onboardingDetails.data.course_created?.length &&
onboardingDetails.data.chapter_created?.length,
'text-ink-gray-3':
!onboardingDetails.data.course_created?.length ||
!onboardingDetails.data.chapter_created?.length,
}"
>
<span
v-if="onboardingDetails.data.lesson_created?.length"
class="py-1 px-1 bg-surface-white rounded-full"
>
<Check class="h-4 w-4 stroke-2 text-ink-green-3" />
</span>
<span class="font-semibold bg-surface-white px-2 py-1 rounded-full">
3
</span>
<span class="text-lg font-semibold">
{{ __('Add a lesson') }}
</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { Check, X } from 'lucide-vue-next'
import { useRouter } from 'vue-router'
import { useSettings } from '@/stores/settings'
import { createResource, Tooltip } from 'frappe-ui'
const showOnboardingBanner = ref(false)
const settings = useSettings()
const onboardingDetails = settings.onboardingDetails
const router = useRouter()
watch(onboardingDetails, () => {
if (!onboardingDetails.data?.is_onboarded) {
showOnboardingBanner.value = true
} else {
showOnboardingBanner.value = false
}
})
const redirectToCourseForm = () => {
if (onboardingDetails.data?.course_created.length) {
return
} else {
router.push({ name: 'CourseForm', params: { courseName: 'new' } })
}
}
const redirectToChapterForm = () => {
if (!onboardingDetails.data?.course_created.length) {
return
} else {
router.push({
name: 'CourseForm',
params: {
courseName: onboardingDetails.data?.first_course,
},
})
}
}
const redirectToLessonForm = () => {
if (!onboardingDetails.data?.course_created.length) {
return
} else if (!onboardingDetails.data?.chapter_created.length) {
return
} else {
router.push({
name: 'LessonForm',
params: {
courseName: onboardingDetails.data?.first_course,
chapterNumber: 1,
lessonNumber: 1,
},
})
}
}
const skipOnboarding = createResource({
url: 'frappe.client.set_value',
makeParams() {
return {
doctype: 'LMS Settings',
name: 'LMS Settings',
fieldname: 'is_onboarding_complete',
value: 1,
}
},
onSuccess(data) {
onboardingDetails.reload()
},
})
</script>

View File

@@ -8,98 +8,109 @@
{{ __('Save') }}
</Button>
</header>
<div class="w-1/2 mx-auto py-5">
<div class="w-3/4 mx-auto py-5">
<div class="">
<div class="text-lg font-semibold mb-4">
{{ __('Details') }}
</div>
<div class="space-y-4 mb-4">
<FormControl
v-model="batch.title"
:label="__('Title')"
:required="true"
class="w-full"
/>
<div class="flex items-center space-x-5">
<div class="space-y-10 mb-4">
<div class="grid grid-cols-2 gap-10">
<FormControl
v-model="batch.published"
type="checkbox"
:label="__('Published')"
v-model="batch.title"
:label="__('Title')"
:required="true"
class="w-full"
/>
<FormControl
v-model="batch.allow_self_enrollment"
type="checkbox"
:label="__('Allow self enrollment')"
/>
<FormControl
v-model="batch.certification"
type="checkbox"
:label="__('Certification')"
<MultiSelect
v-model="instructors"
doctype="User"
:label="__('Instructors')"
:required="true"
:filters="{ ignore_user_type: 1 }"
/>
</div>
</div>
</div>
<div class="mb-4">
<div class="text-xs text-ink-gray-5 mb-2">
{{ __('Meta Image') }}
</div>
<FileUploader
v-if="!batch.image"
:fileTypes="['image/*']"
:validateFile="validateFile"
@success="(file) => saveImage(file)"
>
<template v-slot="{ file, progress, uploading, openFileSelector }">
<div class="flex items-center">
<div class="border rounded-md w-fit py-5 px-20">
<Image class="size-5 stroke-1 text-ink-gray-7" />
<div class="grid grid-cols-2 gap-10">
<div class="flex flex-col space-y-5">
<FormControl
v-model="batch.published"
type="checkbox"
:label="__('Published')"
/>
<FormControl
v-model="batch.allow_self_enrollment"
type="checkbox"
:label="__('Allow self enrollment')"
/>
<FormControl
v-model="batch.certification"
type="checkbox"
:label="__('Certification')"
/>
</div>
<div>
<div class="text-xs text-ink-gray-5 mb-2">
{{ __('Meta Image') }}
</div>
<div class="ml-4">
<Button @click="openFileSelector">
{{ __('Upload') }}
</Button>
<div class="mt-2 text-ink-gray-5 text-sm">
{{
__(
'Appears when the batch URL is shared on any online platform'
)
}}
<FileUploader
v-if="!batch.image"
:fileTypes="['image/*']"
:validateFile="validateFile"
@success="(file) => saveImage(file)"
>
<template
v-slot="{ file, progress, uploading, openFileSelector }"
>
<div class="flex items-center">
<div class="border rounded-md w-fit py-5 px-20">
<Image class="size-5 stroke-1 text-ink-gray-7" />
</div>
<div class="ml-4">
<Button @click="openFileSelector">
{{ __('Upload') }}
</Button>
<div class="mt-2 text-ink-gray-5 text-sm">
{{
__(
'Appears when the batch URL is shared on any online platform'
)
}}
</div>
</div>
</div>
</template>
</FileUploader>
<div v-else class="mb-4">
<div class="flex items-center">
<img
:src="batch.image.file_url"
class="border rounded-md w-40"
/>
<div class="ml-4">
<Button @click="removeImage()">
{{ __('Remove') }}
</Button>
<div class="mt-2 text-ink-gray-5 text-sm">
{{
__(
'Appears when the batch URL is shared on any online platform'
)
}}
</div>
</div>
</div>
</div>
</div>
</template>
</FileUploader>
<div v-else class="mb-4">
<div class="flex items-center">
<img :src="batch.image.file_url" class="border rounded-md w-40" />
<div class="ml-4">
<Button @click="removeImage()">
{{ __('Remove') }}
</Button>
<div class="mt-2 text-ink-gray-5 text-sm">
{{
__(
'Appears when the batch URL is shared on any online platform'
)
}}
</div>
</div>
</div>
</div>
</div>
<MultiSelect
v-model="instructors"
doctype="User"
:label="__('Instructors')"
:required="true"
:filters="{ ignore_user_type: 1 }"
/>
<div class="my-10">
<div class="text-lg font-semibold mb-4">
{{ __('Date and Time') }}
</div>
<div class="grid grid-cols-2 gap-10">
<div class="grid grid-cols-3 gap-10">
<div>
<FormControl
v-model="batch.start_date"
@@ -115,14 +126,6 @@
class="mb-4"
:required="true"
/>
<FormControl
v-model="batch.timezone"
:label="__('Timezone')"
type="text"
:placeholder="__('Example: IST (+5:30)')"
class="mb-4"
:required="true"
/>
</div>
<div>
<FormControl
@@ -140,6 +143,16 @@
:required="true"
/>
</div>
<div>
<FormControl
v-model="batch.timezone"
:label="__('Timezone')"
type="text"
:placeholder="__('Example: IST (+5:30)')"
class="mb-4"
:required="true"
/>
</div>
</div>
</div>
@@ -147,7 +160,7 @@
<div class="text-lg font-semibold mb-4">
{{ __('Settings') }}
</div>
<div class="grid grid-cols-2 gap-10">
<div class="grid grid-cols-3 gap-10">
<div>
<FormControl
v-model="batch.seat_count"
@@ -162,11 +175,6 @@
type="date"
class="mb-4"
/>
<Link
doctype="Email Template"
:label="__('Email Template')"
v-model="batch.confirmation_email_template"
/>
</div>
<div>
<FormControl
@@ -191,6 +199,13 @@
v-model="batch.category"
/>
</div>
<div>
<Link
doctype="Email Template"
:label="__('Email Template')"
v-model="batch.confirmation_email_template"
/>
</div>
</div>
</div>
@@ -198,17 +213,16 @@
<div class="text-lg font-semibold mb-4">
{{ __('Payment') }}
</div>
<div>
<FormControl
v-model="batch.paid_batch"
type="checkbox"
:label="__('Paid Batch')"
/>
<FormControl
v-model="batch.paid_batch"
type="checkbox"
:label="__('Paid Batch')"
/>
<div class="grid grid-cols-3 gap-10 mt-4">
<FormControl
v-model="batch.amount"
:label="__('Amount')"
type="number"
class="my-4"
/>
<Link
doctype="Currency"
@@ -445,7 +459,7 @@ const createNewBatch = () => {
})
},
onError(err) {
showToast('Error', err.messages?.[0] || err, 'x')
showToast('Message', err.messages?.[0] || err, 'alert-circle')
},
}
)
@@ -464,7 +478,7 @@ const editBatchDetails = () => {
})
},
onError(err) {
showToast('Error', err.messages?.[0] || err, 'x')
showToast('Message', err.messages?.[0] || err, 'alert-circle')
},
}
)

View File

@@ -310,11 +310,7 @@ const course = reactive({
})
onMounted(() => {
if (
props.courseName == 'new' &&
!user.data?.is_moderator &&
!user.data?.is_instructor
) {
if (!user.data?.is_moderator && !user.data?.is_instructor) {
router.push({ name: 'Courses' })
}

View File

@@ -50,8 +50,7 @@ export const sessionStore = defineStore('lms-session', () => {
brand.name = data.app_name
brand.logo = data.app_logo
brand.favicon =
data.favicon?.file_url ||
'/assets/lms/frontend/public/learning.svg'
data.favicon?.file_url || '/assets/lms/frontend/learning.svg'
},
})

View File

@@ -109,7 +109,7 @@ export function showToast(title, text, icon, iconClasses = null) {
icon: icon,
iconClasses: iconClasses,
position: icon == 'check' ? 'bottom-right' : 'top-center',
timeout: 5,
timeout: icon != 'check' ? 10 : 5,
})
}

View File

@@ -1 +1 @@
__version__ = "2.26.0"
__version__ = "2.27.0"

View File

@@ -246,7 +246,7 @@ on_login = "lms.lms.user.on_login"
add_to_apps_screen = [
{
"name": "lms",
"logo": "/assets/lms/images/lms-logo.png",
"logo": "/assets/lms/frontend/learning.svg",
"title": "Learning",
"route": "/lms",
"has_permission": "lms.lms.api.check_app_permission",

View File

@@ -53,7 +53,12 @@ class LMSBatch(Document):
if self.paid_batch:
installed_apps = frappe.get_installed_apps()
if "payments" not in installed_apps:
frappe.throw(_("Please install the Payments app to create a paid batches."))
documentation_link = "https://docs.frappe.io/learning/setting-up-payment-gateway"
frappe.throw(
_(
"Please install the Payments App to create a paid batch. Refer to the documentation for more details. {0}"
).format(documentation_link)
)
def validate_amount_and_currency(self):
if self.paid_batch and (not self.amount or not self.currency):

View File

@@ -50,7 +50,12 @@ class LMSCourse(Document):
if self.paid_course:
installed_apps = frappe.get_installed_apps()
if "payments" not in installed_apps:
frappe.throw(_("Please install the Payments app to create a paid courses."))
documentation_link = "https://docs.frappe.io/learning/setting-up-payment-gateway"
frappe.throw(
_(
"Please install the Payments App to create a paid course. Refer to the documentation for more details. {0}"
).format(documentation_link)
)
def validate_certification(self):
if self.enable_certification and self.paid_certificate:

View File

@@ -20,10 +20,11 @@ frappe.ui.form.on("LMS Settings", {
frm.get_field("payments_app_is_not_installed").html(`
<div class="alert alert-warning">
Please install the
<a target="_blank" style="color: var(--alert-text-warning); background: var(--alert-bg-warning);" href="https://frappecloud.com/marketplace/apps/payments">
Payments app
</a>
to enable payment gateway.
<a target="_blank" style="text-decoration: underline; color: var(--alert-text-warning); background: var(--alert-bg-warning);" href="https://frappecloud.com/marketplace/apps/payments">Payments app</a>
to enable payment gateway. Refer to the
<a target="_blank" style="text-decoration: underline; color: var(--alert-text-warning); background: var(--alert-bg-warning);" href="https://docs.frappe.io/learning/setting-up-payment-gateway">Documentation</a>
for more information.
</div>
`);
},
});

View File

@@ -8,7 +8,6 @@
"general_tab",
"default_home",
"send_calendar_invite_for_evaluations",
"is_onboarding_complete",
"column_break_zdel",
"allow_guest_access",
"enable_learning_paths",
@@ -60,7 +59,9 @@
"batch_confirmation_template",
"column_break_uwsp",
"assignment_submission_template",
"payment_reminder_template"
"payment_reminder_template",
"seo_tab",
"meta_description"
],
"fields": [
{
@@ -107,13 +108,6 @@
"fieldtype": "Check",
"label": "Identify User Persona"
},
{
"default": "0",
"fieldname": "is_onboarding_complete",
"fieldtype": "Check",
"label": "Is Onboarding Complete",
"read_only": 1
},
{
"default": "0",
"fieldname": "default_home",
@@ -372,14 +366,25 @@
"fieldname": "disable_signup",
"fieldtype": "Check",
"label": "Disable Signup"
},
{
"fieldname": "seo_tab",
"fieldtype": "Tab Break",
"label": "SEO"
},
{
"description": "This description will be shown on lists and pages without meta description",
"fieldname": "meta_description",
"fieldtype": "Small Text",
"label": "Meta Description"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2025-04-07 18:05:52.000651",
"modified_by": "Administrator",
"modified": "2025-04-10 16:17:00.658698",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "LMS Settings",
"owner": "Administrator",

View File

@@ -8,7 +8,6 @@ import requests
from frappe import _
from frappe.desk.doctype.dashboard_chart.dashboard_chart import get_result
from frappe.desk.doctype.notification_log.notification_log import make_notification_logs
from frappe.desk.search import get_user_groups
from frappe.desk.notifications import extract_mentions
from frappe.utils import (
add_months,
@@ -20,7 +19,6 @@ from frappe.utils import (
format_date,
get_datetime,
getdate,
validate_phone_number,
get_fullname,
pretty_date,
get_time_str,
@@ -1390,6 +1388,13 @@ def get_batch_details(batch):
batch_details.instructors = get_instructors(batch)
batch_details.accept_enrollments = batch_details.start_date > getdate()
if (
not batch_details.accept_enrollments
and batch_details.start_date == getdate()
and get_time_str(batch_details.start_time) > nowtime()
):
batch_details.accept_enrollments = True
batch_details.courses = frappe.get_all(
"Batch Course", filters={"parent": batch}, fields=["course", "title", "evaluator"]
)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

6261
lms/locale/sr_CS.po Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,6 @@ import re
from bs4 import BeautifulSoup
from frappe import _
from frappe.utils.telemetry import capture
from frappe.utils import cint
no_cache = 1
@@ -15,22 +14,24 @@ def get_context():
or "/assets/lms/frontend/favicon.png"
)
title = frappe.db.get_single_value("Website Settings", "app_name") or "Frappe Learning"
description = frappe.db.get_single_value("LMS Settings", "meta_description")
csrf_token = frappe.sessions.get_csrf_token()
frappe.db.commit()
context = frappe._dict()
context.csrf_token = csrf_token
context.meta = get_meta(app_path, title, favicon)
context.meta = get_meta(app_path, title, favicon, description)
capture("active_site", "lms")
context.title = title
context.favicon = favicon
return context
def get_meta(app_path, title, favicon):
meta = {}
def get_meta(app_path, title, favicon, description):
meta = frappe._dict()
if app_path:
meta = get_meta_from_document(app_path, favicon)
meta = get_meta_from_document(app_path)
route_meta = frappe.get_all("Website Meta Tag", {"parent": app_path}, ["key", "value"])
@@ -47,22 +48,32 @@ def get_meta(app_path, title, favicon):
elif row.key == "link":
meta["link"] = row.value
if not meta.get("title"):
meta["title"] = title
if not meta.get("description"):
meta["description"] = description
if not meta.get("image"):
meta["image"] = favicon
if not meta.get("keywords"):
meta["keywords"] = ""
if not meta:
meta = {
"title": title,
"image": favicon,
"description": "Easy to use Learning Management System",
"description": description,
}
return meta
def get_meta_from_document(app_path, favicon):
def get_meta_from_document(app_path):
if app_path == "courses":
return {
"title": _("Course List"),
"image": favicon,
"description": "This page lists all the courses published on our website",
"keywords": "All Courses, Courses, Learn",
"link": "/courses",
}
@@ -72,7 +83,6 @@ def get_meta_from_document(app_path, favicon):
return {
"title": _("New Course"),
"image": frappe.db.get_single_value("Website Settings", "banner_image"),
"description": "Create a new course",
"keywords": "New Course, Create Course",
"link": "/lms/courses/new/edit",
}
@@ -99,8 +109,6 @@ def get_meta_from_document(app_path, favicon):
if app_path == "batches":
return {
"title": _("Batches"),
"image": favicon,
"description": "This page lists all the batches published on our website",
"keywords": "All Batches, Batches, Learn",
"link": "/batches",
}
@@ -130,8 +138,6 @@ def get_meta_from_document(app_path, favicon):
if "new/edit" in app_path:
return {
"title": _("New Batch"),
"image": favicon,
"description": "Create a new batch",
"keywords": "New Batch, Create Batch",
"link": "/lms/batches/new/edit",
}
@@ -157,8 +163,6 @@ def get_meta_from_document(app_path, favicon):
if app_path == "job-openings":
return {
"title": _("Job Openings"),
"image": favicon,
"description": "This page lists all the job openings published on our website",
"keywords": "Job Openings, Jobs, Vacancies",
"link": "/job-openings",
}
@@ -171,6 +175,11 @@ def get_meta_from_document(app_path, favicon):
["job_title", "company_logo", "description"],
as_dict=True,
)
if job_opening.description:
soup = BeautifulSoup(job_opening.description, "html.parser")
job_opening.description = soup.get_text()
return {
"title": job_opening.job_title,
"image": job_opening.company_logo,
@@ -182,8 +191,6 @@ def get_meta_from_document(app_path, favicon):
if app_path == "statistics":
return {
"title": _("Statistics"),
"image": favicon,
"description": "This page lists all the statistics of this platform",
"keywords": "Enrollment Count, Completion, Signups",
"link": "/statistics",
}
@@ -231,8 +238,6 @@ def get_meta_from_document(app_path, favicon):
if app_path == "quizzes":
return {
"title": _("Quizzes"),
"image": favicon,
"description": _("Test your knowledge with interactive quizzes and more."),
"keywords": "Quizzes, interactive quizzes, online quizzes",
"link": "/quizzes",
}
@@ -248,8 +253,6 @@ def get_meta_from_document(app_path, favicon):
if quiz:
return {
"title": quiz.title,
"image": favicon,
"description": "Test your knowledge with interactive quizzes.",
"keywords": quiz.title,
"link": f"/quizzes/{quiz_name}",
}
@@ -257,8 +260,6 @@ def get_meta_from_document(app_path, favicon):
if app_path == "assignments":
return {
"title": _("Assignments"),
"image": favicon,
"description": _("Test your knowledge with interactive assignments and more."),
"keywords": "Assignments, interactive assignments, online assignments",
"link": "/assignments",
}
@@ -274,8 +275,6 @@ def get_meta_from_document(app_path, favicon):
if assignment:
return {
"title": assignment.title,
"image": favicon,
"description": "Test your knowledge with interactive assignments.",
"keywords": assignment.title,
"link": f"/assignments/{assignment_name}",
}
@@ -283,8 +282,8 @@ def get_meta_from_document(app_path, favicon):
if app_path == "programs":
return {
"title": _("Programs"),
"image": favicon,
"description": "This page lists all the programs published on our website",
"keywords": "All Programs, Programs, Learn",
"link": "/programs",
}
return {}