diff --git a/frontend/components.d.ts b/frontend/components.d.ts index 6dc3781d..95951283 100644 --- a/frontend/components.d.ts +++ b/frontend/components.d.ts @@ -42,6 +42,8 @@ declare module 'vue' { CodeEditor: typeof import('./src/components/Controls/CodeEditor.vue')['default'] CollapseSidebar: typeof import('./src/components/Icons/CollapseSidebar.vue')['default'] ColorSwatches: typeof import('./src/components/Controls/ColorSwatches.vue')['default'] + CommandPalette: typeof import('./src/components/CommandPalette/CommandPalette.vue')['default'] + CommandPaletteGroup: typeof import('./src/components/CommandPalette/CommandPaletteGroup.vue')['default'] Configuration: typeof import('./src/components/Sidebar/Configuration.vue')['default'] ContactUsEmail: typeof import('./src/components/ContactUsEmail.vue')['default'] CouponDetails: typeof import('./src/components/Settings/Coupons/CouponDetails.vue')['default'] @@ -85,6 +87,11 @@ declare module 'vue' { LiveClassAttendance: typeof import('./src/components/Modals/LiveClassAttendance.vue')['default'] LiveClassModal: typeof import('./src/components/Modals/LiveClassModal.vue')['default'] LMSLogo: typeof import('./src/components/Icons/LMSLogo.vue')['default'] + LucideArrowDown: typeof import('~icons/lucide/arrow-down')['default'] + LucideArrowUp: typeof import('~icons/lucide/arrow-up')['default'] + LucideCornerDownLeft: typeof import('~icons/lucide/corner-down-left')['default'] + LucideSearch: typeof import('~icons/lucide/search')['default'] + LucideX: typeof import('~icons/lucide/x')['default'] Members: typeof import('./src/components/Settings/Members.vue')['default'] MobileLayout: typeof import('./src/components/MobileLayout.vue')['default'] MultiSelect: typeof import('./src/components/Controls/MultiSelect.vue')['default'] diff --git a/frontend/src/components/CommandPalette/CommandPalette.vue b/frontend/src/components/CommandPalette/CommandPalette.vue new file mode 100644 index 00000000..a32cbb00 --- /dev/null +++ b/frontend/src/components/CommandPalette/CommandPalette.vue @@ -0,0 +1,272 @@ + + + diff --git a/frontend/src/components/CommandPalette/CommandPaletteGroup.vue b/frontend/src/components/CommandPalette/CommandPaletteGroup.vue new file mode 100644 index 00000000..715b53ea --- /dev/null +++ b/frontend/src/components/CommandPalette/CommandPaletteGroup.vue @@ -0,0 +1,45 @@ + + diff --git a/frontend/src/components/CourseCard.vue b/frontend/src/components/CourseCard.vue index 34f2094e..1d49ffb3 100644 --- a/frontend/src/components/CourseCard.vue +++ b/frontend/src/components/CourseCard.vue @@ -156,7 +156,7 @@ const getGradientColor = () => { localStorage.getItem('theme') == 'light' ? 'lightMode' : 'darkMode' let color = props.course.card_gradient?.toLowerCase() || 'blue' let colorMap = colors[theme][color] - return `linear-gradient(to top right, black, ${colorMap[400]})` + return `linear-gradient(to top right, black, ${colorMap[200]})` /* return `bg-gradient-to-br from-${color}-100 via-${color}-200 to-${color}-400` */ /* return `linear-gradient(to bottom right, ${colorMap[100]}, ${colorMap[400]})` */ /* return `radial-gradient(ellipse at 80% 20%, black 20%, ${colorMap[500]} 100%)` */ diff --git a/frontend/src/components/Sidebar/AppSidebar.vue b/frontend/src/components/Sidebar/AppSidebar.vue index 7e9387e4..fbb95344 100644 --- a/frontend/src/components/Sidebar/AppSidebar.vue +++ b/frontend/src/components/Sidebar/AppSidebar.vue @@ -9,11 +9,21 @@ >
-
- +
+
+ {{ __(link.label) }} +
+
+ diff --git a/frontend/src/router.js b/frontend/src/router.js index 08f86e74..fe6b311d 100644 --- a/frontend/src/router.js +++ b/frontend/src/router.js @@ -243,6 +243,11 @@ const routes = [ ), props: true, }, + { + path: '/search', + name: 'Search', + component: () => import('@/pages/Search/Search.vue'), + }, { path: '/data-import', name: 'DataImportList', diff --git a/frontend/src/stores/settings.js b/frontend/src/stores/settings.js index 605ed1b1..b0cf32f0 100644 --- a/frontend/src/stores/settings.js +++ b/frontend/src/stores/settings.js @@ -1,10 +1,10 @@ import { defineStore } from 'pinia' import { ref } from 'vue' import { createResource } from 'frappe-ui' -import { sessionStore } from './session' export const useSettings = defineStore('settings', () => { const isSettingsOpen = ref(false) + const isCommandPaletteOpen = ref(false) const activeTab = ref(null) const settings = createResource({ @@ -19,9 +19,16 @@ export const useSettings = defineStore('settings', () => { auto: false, }) + const programs = createResource({ + url: 'lms.lms.utils.get_programs', + auto: false, + }) + return { - isSettingsOpen, activeTab, + isSettingsOpen, + isCommandPaletteOpen, + programs, settings, sidebarSettings, } diff --git a/frontend/src/utils/index.js b/frontend/src/utils/index.js index 9a567af7..b7b1d817 100644 --- a/frontend/src/utils/index.js +++ b/frontend/src/utils/index.js @@ -403,46 +403,176 @@ export function getUserTimezone() { } export function getSidebarLinks() { + let links = getSidebarItems() + + links.forEach((link) => { + link.items = link.items.filter((item) => { + return item.condition ? item.condition() : true + }) + }) + + links = links.filter((link) => { + return link.items.length > 0 + }) + + return links +} + +const getSidebarItems = () => { + const { userResource } = usersStore() + const { settings } = useSettings() + return [ { - label: 'Courses', - icon: 'BookOpen', - to: 'Courses', - activeFor: [ - 'Courses', - 'CourseDetail', - 'Lesson', - 'CourseForm', - 'LessonForm', + label: 'General', + hideLabel: true, + items: [ + { + label: 'Home', + icon: 'Home', + to: 'Home', + condition: () => { + return userResource?.data + }, + }, + { + label: 'Search', + icon: 'Search', + to: 'Search', + condition: () => { + return userResource?.data + }, + }, + { + label: 'Notifications', + icon: 'Bell', + to: 'Notifications', + condition: () => { + return userResource?.data + }, + }, ], }, { - label: 'Batches', - icon: 'Users', - to: 'Batches', - activeFor: ['Batches', 'BatchDetail', 'Batch', 'BatchForm'], + label: 'Learning', + hideLabel: true, + items: [ + { + label: 'Courses', + icon: 'BookOpen', + to: 'Courses', + activeFor: [ + 'Courses', + 'CourseDetail', + 'Lesson', + 'CourseForm', + 'LessonForm', + ], + }, + { + label: 'Programs', + icon: 'Route', + to: 'Programs', + activeFor: ['Programs', 'ProgramDetail'], + await: true, + condition: () => { + return checkIfCanAddProgram() + }, + }, + { + label: 'Batches', + icon: 'Users', + to: 'Batches', + activeFor: ['Batches', 'BatchDetail', 'Batch', 'BatchForm'], + }, + { + label: 'Certifications', + icon: 'GraduationCap', + to: 'CertifiedParticipants', + activeFor: ['CertifiedParticipants'], + }, + { + label: 'Jobs', + icon: 'Briefcase', + to: 'Jobs', + activeFor: ['Jobs', 'JobDetail'], + }, + { + label: 'Statistics', + icon: 'TrendingUp', + to: 'Statistics', + activeFor: ['Statistics'], + }, + { + label: 'Contact Us', + icon: settings.data?.contact_us_url ? 'Headset' : 'Mail', + to: settings.data?.contact_us_url + ? settings.data?.contact_us_url + : settings.data?.contact_us_email, + condition: () => { + return ( + settings?.data?.contact_us_email || + settings?.data?.contact_us_url + ) + }, + }, + ], }, { - label: 'Certifications', - icon: 'GraduationCap', - to: 'CertifiedParticipants', - activeFor: ['CertifiedParticipants'], - }, - { - label: 'Jobs', - icon: 'Briefcase', - to: 'Jobs', - activeFor: ['Jobs', 'JobDetail'], - }, - { - label: 'Statistics', - icon: 'TrendingUp', - to: 'Statistics', - activeFor: ['Statistics'], + label: 'Assessments', + hideLabel: true, + items: [ + { + label: 'Quizzes', + icon: 'CircleHelp', + to: 'Quizzes', + condition: () => { + return isAdmin() + }, + }, + { + label: 'Assignments', + icon: 'Pencil', + to: 'Assignments', + condition: () => { + return isAdmin() + }, + }, + { + label: 'Programming Exercises', + icon: 'Code', + to: 'ProgrammingExercises', + condition: () => { + return isAdmin() + }, + }, + ], }, ] } +const isAdmin = () => { + const { userResource } = usersStore() + return ( + userResource?.data?.is_instructor || + userResource?.data?.is_moderator || + userResource.data?.is_evaluator + ) +} + +const checkIfCanAddProgram = () => { + const { userResource } = usersStore() + const { programs } = useSettings() + if (!userResource.data) return false + if (userResource?.data?.is_moderator || userResource?.data?.is_instructor) { + return true + } + return ( + programs.data?.enrolled.length > 0 || + programs.data?.published.length > 0 + ) +} + export function getFormattedDateRange( startDate, endDate, diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 68cbe5ec..1284a567 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -65,6 +65,6 @@ export default defineConfig(({ mode }) => ({ 'highlight.js', 'plyr', ], - exclude: mode === 'production' ? [] : ['frappe-ui'], + //exclude: mode === 'production' ? [] : ['frappe-ui'], }, })) diff --git a/lms/command_palette.py b/lms/command_palette.py new file mode 100644 index 00000000..0a182cee --- /dev/null +++ b/lms/command_palette.py @@ -0,0 +1,86 @@ +import frappe +from frappe.utils import nowdate + + +@frappe.whitelist() +def search_sqlite(query: str): + from lms.sqlite import LearningSearch, LearningSearchIndexMissingError + + search = LearningSearch() + + try: + result = search.search(query) + except LearningSearchIndexMissingError: + return [] + + return prepare_search_results(result) + + +def prepare_search_results(result): + roles = frappe.get_roles() + groups = {} + + for r in result["results"]: + doctype = r["doctype"] + if doctype == "LMS Course" and can_access_course(r, roles): + r["author_info"] = get_instructor_info(doctype, r) + groups.setdefault("Courses", []).append(r) + elif doctype == "LMS Batch" and can_access_batch(r, roles): + r["author_info"] = get_instructor_info(doctype, r) + groups.setdefault("Batches", []).append(r) + elif doctype == "Job Opportunity" and can_access_job(r, roles): + r["author_info"] = get_instructor_info(doctype, r) + groups.setdefault("Job Opportunities", []).append(r) + + out = [] + for key in groups: + out.append({"title": key, "items": groups[key]}) + + return out + + +def can_access_course(course, roles): + if can_create_course(roles): + return True + elif course.get("published"): + return True + return False + + +def can_access_batch(batch, roles): + if can_create_batch(roles): + return True + elif batch.get("published") and batch.get("start_date") >= nowdate(): + return True + return False + + +def can_access_job(job, roles): + if "Moderator" in roles: + return True + return job.get("status") == "Open" + + +def can_create_course(roles): + return "Course Creator" in roles or "Moderator" in roles + + +def can_create_batch(roles): + return "Batch Evaluator" in roles or "Moderator" in roles + + +def get_instructor_info(doctype, record): + instructors = frappe.get_all( + "Course Instructor", filters={"parenttype": doctype, "parent": record.get("name")}, pluck="instructor" + ) + + instructor = record.get("author") + if len(instructors): + instructor = instructors[0] + + return frappe.db.get_value( + "User", + instructor, + ["full_name", "email", "user_image", "username"], + as_dict=True, + ) diff --git a/lms/hooks.py b/lms/hooks.py index 4e61b7fe..adc4160a 100644 --- a/lms/hooks.py +++ b/lms/hooks.py @@ -64,6 +64,9 @@ after_install = "lms.install.after_install" after_sync = "lms.install.after_sync" before_uninstall = "lms.install.before_uninstall" setup_wizard_requires = "assets/lms/js/setup_wizard.js" +after_migrate = [ + "lms.sqlite.build_index_in_background", +] # Desk Notifications # ------------------ @@ -115,6 +118,9 @@ doc_events = { # Scheduled Tasks # --------------- scheduler_events = { + "all": [ + "lms.sqlite.build_index_in_background", + ], "hourly": [ "lms.lms.doctype.lms_certificate_request.lms_certificate_request.schedule_evals", "lms.lms.api.update_course_statistics", @@ -254,3 +260,5 @@ add_to_apps_screen = [ "has_permission": "lms.lms.api.check_app_permission", } ] + +sqlite_search = ["lms.sqlite.LearningSearch"] diff --git a/lms/lms/doctype/lms_course/lms_course.json b/lms/lms/doctype/lms_course/lms_course.json index 20fc0482..f4b03ffa 100644 --- a/lms/lms/doctype/lms_course/lms_course.json +++ b/lms/lms/doctype/lms_course/lms_course.json @@ -76,6 +76,8 @@ "default": "0", "fieldname": "published", "fieldtype": "Check", + "in_list_view": 1, + "in_standard_filter": 1, "label": "Published" }, { @@ -152,8 +154,6 @@ "fieldname": "status", "fieldtype": "Select", "hidden": 1, - "in_list_view": 1, - "in_standard_filter": 1, "label": "Status", "options": "In Progress\nUnder Review\nApproved", "read_only": 1 @@ -313,7 +313,7 @@ } ], "make_attachments_public": 1, - "modified": "2025-10-13 15:08:11.734204", + "modified": "2025-12-11 17:21:05.231761", "modified_by": "sayali@frappe.io", "module": "LMS", "name": "LMS Course", @@ -336,6 +336,7 @@ "delete": 1, "email": 1, "export": 1, + "import": 1, "print": 1, "read": 1, "report": 1, @@ -348,6 +349,7 @@ "delete": 1, "email": 1, "export": 1, + "import": 1, "print": 1, "read": 1, "report": 1, diff --git a/lms/sqlite.py b/lms/sqlite.py new file mode 100644 index 00000000..36ad7a4a --- /dev/null +++ b/lms/sqlite.py @@ -0,0 +1,137 @@ +from contextlib import suppress + +import frappe +from frappe.search.sqlite_search import SQLiteSearch, SQLiteSearchIndexMissingError +from frappe.utils import nowdate + + +class LearningSearch(SQLiteSearch): + INDEX_NAME = "learning.db" + + INDEX_SCHEMA = { + "metadata_fields": [ + "owner", + "published", + "published_on", + "start_date", + "status", + "company_name", + "creation", + ], + "tokenizer": "unicode61 remove_diacritics 2 tokenchars '-_'", + } + + INDEXABLE_DOCTYPES = { + "LMS Course": { + "fields": [ + "name", + "title", + {"content": "description"}, + "short_introduction", + "published", + "category", + "owner", + {"modified": "published_on"}, + ], + }, + "LMS Batch": { + "fields": [ + "name", + "title", + "description", + {"content": "batch_details"}, + "published", + "category", + "owner", + {"modified": "start_date"}, + ], + }, + "Job Opportunity": { + "fields": [ + "name", + {"title": "job_title"}, + {"content": "description"}, + "owner", + "location", + "country", + "company_name", + "status", + "creation", + {"modified": "creation"}, + ], + }, + } + + DOCTYPE_FIELDS = { + "LMS Course": [ + "name", + "title", + "description", + "short_introduction", + "category", + "creation", + "modified", + "owner", + ], + "LMS Batch": [ + "name", + "title", + "description", + "batch_details", + "category", + "creation", + "modified", + "owner", + ], + "Job Opportunity": [ + "name", + "job_title", + "company_name", + "description", + "creation", + "modified", + "owner", + ], + } + + def build_index(self): + try: + super().build_index() + except Exception as e: + frappe.throw(e) + + def get_search_filters(self): + return {} + + @SQLiteSearch.scoring_function + def get_doctype_boost(self, row, query, query_words): + doctype = row["doctype"] + if doctype == "LMS Course": + if row["published"]: + return 1.3 + elif doctype == "LMS Batch": + if row["published"] and row["start_date"] >= nowdate(): + return 1.3 + elif row["published"]: + return 1.2 + return 1.0 + + +class LearningSearchIndexMissingError(SQLiteSearchIndexMissingError): + pass + + +def build_index(): + search = LearningSearch() + search.build_index() + + +def build_index_in_background(): + if not frappe.cache().get_value("learning_search_indexing_in_progress"): + frappe.enqueue(build_index, queue="long") + + +def build_index_if_not_exists(): + search = LearningSearch() + if not search.index_exists(): + build_index()