diff --git a/cypress/e2e/course_creation.cy.js b/cypress/e2e/course_creation.cy.js index a48a868b..f50acd03 100644 --- a/cypress/e2e/course_creation.cy.js +++ b/cypress/e2e/course_creation.cy.js @@ -98,7 +98,7 @@ describe("Course Creation", () => { // View Course cy.wait(1000); - cy.visit("/lms"); + cy.visit("/lms/courses"); cy.closeOnboardingModal(); cy.url().should("include", "/lms/courses"); diff --git a/frontend/src/components/AppSidebar.vue b/frontend/src/components/AppSidebar.vue index 2f99bbed..b39a24ff 100644 --- a/frontend/src/components/AppSidebar.vue +++ b/frontend/src/components/AppSidebar.vue @@ -365,7 +365,7 @@ const addPrograms = async () => { let canAddProgram = await checkIfCanAddProgram() if (!canAddProgram) return let activeFor = ['Programs', 'ProgramDetail'] - let index = 1 + let index = 2 sidebarLinks.value.splice(index, 0, { label: 'Programs', @@ -383,6 +383,15 @@ const checkIfCanAddProgram = async () => { return programs.enrolled.length > 0 || programs.published.length > 0 } +const addHome = () => { + sidebarLinks.value.unshift({ + label: 'Home', + icon: 'Home', + to: 'Home', + activeFor: ['Home'], + }) +} + const openPageModal = (link) => { showPageModal.value = true pageToEdit.value = link @@ -634,6 +643,7 @@ watch(userResource, () => { if (userResource.data) { isModerator.value = userResource.data.is_moderator isInstructor.value = userResource.data.is_instructor + addHome() addPrograms() addProgrammingExercises() addQuizzes() diff --git a/frontend/src/components/CourseOutline.vue b/frontend/src/components/CourseOutline.vue index 094cc9dc..a68b20a6 100644 --- a/frontend/src/components/CourseOutline.vue +++ b/frontend/src/components/CourseOutline.vue @@ -208,6 +208,10 @@ const props = defineProps({ type: Boolean, default: false, }, + lessonProgress: { + type: Number, + default: 0, + }, }) const outline = createResource({ @@ -229,6 +233,13 @@ watch( } ) +watch( + () => props.lessonProgress, + () => { + outline.reload() + } +) + const deleteLesson = createResource({ url: 'lms.lms.api.delete_lesson', makeParams(values) { diff --git a/frontend/src/components/SidebarLink.vue b/frontend/src/components/SidebarLink.vue index fedf807b..f1d3f3a3 100644 --- a/frontend/src/components/SidebarLink.vue +++ b/frontend/src/components/SidebarLink.vue @@ -11,7 +11,7 @@ class="flex items-center w-full duration-300 ease-in-out group" :class="isCollapsed ? 'p-1 relative' : 'px-2 py-1'" > - + -
+
{{ __('Upcoming Evaluations') }}
-
+
- + {{ evl.course_title }}
-
- {{ __('Please schedule an evaluation to get certified.') }} +
+ {{ __('Schedule an evaluation to get certified.') }}
course.course), batch: props.batch, }, diff --git a/frontend/src/pages/BatchForm.vue b/frontend/src/pages/BatchForm.vue index 3a3a0b29..b1b0c019 100644 --- a/frontend/src/pages/BatchForm.vue +++ b/frontend/src/pages/BatchForm.vue @@ -79,14 +79,14 @@
+
+
+
+ + {{ __('Courses Created') }} + + + + + {{ __('See all') }} + + + + +
+
+ + + +
+
+ +
+
+ + {{ __('Upcoming Batches') }} + + + + + {{ __('See all') }} + + + + +
+
+ + + +
+
+ +
+ +
+ {{ __('No courses created') }} +
+
+ {{ + __( + 'There are no courses currently. Create your first course to get started!' + ) + }} +
+ + + +
+ +
+
+
+ {{ __('Upcoming Evaluations') }} +
+
+
+
+ {{ evaluation.course_title }} +
+
+
+ + + {{ dayjs(evaluation.date).format('DD MMMM YYYY') }} + +
+
+ + + {{ formatTime(evaluation.start_time) }} + +
+
+ + + {{ evaluation.member_name }} + +
+
+
+
+
+
+
+ {{ __('Upcoming Live Classes') }} +
+
+
+
+ {{ cls.title }} +
+
+ {{ cls.description }} +
+
+
+ + + {{ dayjs(cls.date).format('DD MMMM YYYY') }} + +
+
+ + + {{ formatTime(cls.time) }} - + {{ dayjs(getClassEnd(cls)).format('HH:mm A') }} + +
+ + +
+ + + {{ __('Ended') }} + +
+
+
+
+
+
+
+
+ + diff --git a/frontend/src/pages/Home/Home.vue b/frontend/src/pages/Home/Home.vue new file mode 100644 index 00000000..c5a8216a --- /dev/null +++ b/frontend/src/pages/Home/Home.vue @@ -0,0 +1,138 @@ + + diff --git a/frontend/src/pages/Home/Streak.vue b/frontend/src/pages/Home/Streak.vue new file mode 100644 index 00000000..49ba31a9 --- /dev/null +++ b/frontend/src/pages/Home/Streak.vue @@ -0,0 +1,78 @@ + + diff --git a/frontend/src/pages/Home/StudentHome.vue b/frontend/src/pages/Home/StudentHome.vue new file mode 100644 index 00000000..457d99f3 --- /dev/null +++ b/frontend/src/pages/Home/StudentHome.vue @@ -0,0 +1,195 @@ + + diff --git a/frontend/src/pages/Lesson.vue b/frontend/src/pages/Lesson.vue index cb0bf7a0..bbf5a96f 100644 --- a/frontend/src/pages/Lesson.vue +++ b/frontend/src/pages/Lesson.vue @@ -268,6 +268,7 @@ :courseName="courseName" :key="chapterNumber" :getProgress="lesson.data.membership ? true : false" + :lessonProgress="lessonProgress" />
diff --git a/frontend/src/router.js b/frontend/src/router.js index a25d12f6..473f1776 100644 --- a/frontend/src/router.js +++ b/frontend/src/router.js @@ -3,13 +3,11 @@ import { usersStore } from './stores/user' import { sessionStore } from './stores/session' import { useSettings } from './stores/settings' -let defaultRoute = '/courses' const routes = [ { path: '/', - redirect: { - name: 'Courses', - }, + name: 'Home', + component: () => import('@/pages/Home/Home.vue'), }, { path: '/courses', @@ -260,6 +258,8 @@ router.beforeEach(async (to, from, next) => { } if (!isLoggedIn) { + if (to.name == 'Home') router.push({ name: 'Courses' }) + await allowGuestAccess.promise if (!allowGuestAccess.data) { window.location.href = '/login' diff --git a/frontend/src/stores/session.js b/frontend/src/stores/session.js index 17697079..04faec5d 100644 --- a/frontend/src/stores/session.js +++ b/frontend/src/stores/session.js @@ -61,7 +61,7 @@ export const sessionStore = defineStore('lms-session', () => { field: 'livecode_url', }, cache: 'livecodeURL', - auto: true, + auto: user.value ? true : false, }) return { diff --git a/frontend/src/stores/settings.js b/frontend/src/stores/settings.js index 68c3cd06..a29d9d52 100644 --- a/frontend/src/stores/settings.js +++ b/frontend/src/stores/settings.js @@ -4,7 +4,6 @@ import { createResource } from 'frappe-ui' import { sessionStore } from './session' export const useSettings = defineStore('settings', () => { - const { isLoggedIn } = sessionStore() const isSettingsOpen = ref(false) const activeTab = ref(null) diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 94d5ac8f..f6b494e6 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -61,7 +61,7 @@ export default defineConfig({ ], server: { host: '0.0.0.0', // Accept connections from any network interface - allowedHosts: ['ps', 'fs'], // Explicitly allow this host + allowedHosts: ['ps', 'fs', 'home'], // Explicitly allow this host }, resolve: { alias: { diff --git a/lms/lms/utils.py b/lms/lms/utils.py index 3e1f489b..9f6ef546 100644 --- a/lms/lms/utils.py +++ b/lms/lms/utils.py @@ -2,6 +2,7 @@ import hashlib import json import re import string +from datetime import datetime, timedelta import frappe import razorpay @@ -39,12 +40,12 @@ def slugify(title, used_slugs=None): If a list of used slugs is specified, it will make sure the generated slug is not one of them. - >>> slugify("Hello World!") - 'hello-world' - >>> slugify("Hello World!", ["hello-world"]) - 'hello-world-2' - >>> slugify("Hello World!", ["hello-world", "hello-world-2"]) - 'hello-world-3' + >>> slugify("Hello World!") + 'hello-world' + >>> slugify("Hello World!", ["hello-world"]) + 'hello-world-2' + >>> slugify("Hello World!", ["hello-world", "hello-world-2"]) + 'hello-world-3' """ if not used_slugs: used_slugs = [] @@ -844,14 +845,22 @@ def get_evaluator(course, batch=None): @frappe.whitelist() -def get_upcoming_evals(student, courses, batch=None): +def get_upcoming_evals(courses=None, batch=None): + if frappe.session.user == "Guest": + return [] + + if not courses: + courses = [] + filters = { - "member": student, - "course": ["in", courses], + "member": frappe.session.user, "date": [">=", frappe.utils.nowdate()], "status": "Upcoming", } + if len(courses) > 0: + filters["course"] = ["in", courses] + if batch: filters["batch_name"] = batch @@ -1127,7 +1136,7 @@ def get_course_details(course): # course_details.is_instructor = is_instructor(course_details.name) if course_details.paid_course or course_details.paid_certificate: """course_details.course_price, course_details.currency = check_multicurrency( - course_details.course_price, course_details.currency, None, course_details.amount_usd + course_details.course_price, course_details.currency, None, course_details.amount_usd )""" course_details.price = fmt_money(course_details.course_price, 0, course_details.currency) @@ -2133,3 +2142,340 @@ def get_related_courses(course): def persona_captured(): frappe.db.set_single_value("LMS Settings", "persona_captured", 1) + + +@frappe.whitelist() +def get_my_courses(): + my_courses = [] + if frappe.session.user == "Guest": + return my_courses + + courses = get_my_latest_courses() + + if not len(courses): + courses = get_featured_home_courses() + + if not len(courses): + courses = get_popular_courses() + + for course in courses: + my_courses.append(get_course_details(course)) + + return my_courses + + +def get_my_latest_courses(): + return frappe.get_all( + "LMS Enrollment", + { + "member": frappe.session.user, + }, + order_by="creation desc", + limit=3, + pluck="course", + ) + + +def get_featured_home_courses(): + return frappe.get_all( + "LMS Course", + {"published": 1, "featured": 1}, + order_by="published_on desc", + limit=3, + pluck="name", + ) + + +def get_popular_courses(): + return frappe.get_all( + "LMS Course", + { + "published": 1, + }, + order_by="enrollments desc", + limit=3, + pluck="name", + ) + + +@frappe.whitelist() +def get_my_batches(): + my_batches = [] + if frappe.session.user == "Guest": + return my_batches + + batches = get_my_latest_batches() + + if not len(batches): + batches = get_upcoming_batches() + + for batch in batches: + batch_details = get_batch_details(batch) + if batch_details: + my_batches.append(batch_details) + + return my_batches + + +def get_my_latest_batches(): + return frappe.get_all( + "LMS Batch Enrollment", + { + "member": frappe.session.user, + }, + order_by="creation desc", + limit=4, + pluck="batch", + ) + + +def get_upcoming_batches(): + return frappe.get_all( + "LMS Batch", + { + "published": 1, + "start_date": [">=", getdate()], + }, + order_by="start_date asc", + limit=4, + pluck="name", + ) + + +@frappe.whitelist() +def get_my_live_classes(): + my_live_classes = [] + if frappe.session.user == "Guest": + return my_live_classes + + batches = frappe.get_all( + "LMS Batch Enrollment", + { + "member": frappe.session.user, + }, + order_by="creation desc", + pluck="batch", + ) + + live_class_details = frappe.get_all( + "LMS Live Class", + filters={ + "date": [">=", getdate()], + "batch_name": ["in", batches], + }, + fields=[ + "name", + "title", + "description", + "time", + "date", + "duration", + "attendees", + "start_url", + "join_url", + "owner", + ], + limit=2, + order_by="date", + ) + + if len(live_class_details): + for live_class in live_class_details: + live_class.course_title = frappe.db.get_value("LMS Course", live_class.course, "title") + + my_live_classes.append(live_class) + + return my_live_classes + + +@frappe.whitelist() +def get_created_courses(): + created_courses = [] + if frappe.session.user == "Guest": + return created_courses + + CourseInstructor = frappe.qb.DocType("Course Instructor") + Course = frappe.qb.DocType("LMS Course") + + query = ( + frappe.qb.from_(CourseInstructor) + .join(Course) + .on(CourseInstructor.parent == Course.name) + .select(Course.name) + .where(CourseInstructor.instructor == frappe.session.user) + .orderby(Course.published_on, order=frappe.qb.desc) + .limit(3) + ) + + results = query.run(as_dict=True) + courses = [row["name"] for row in results] + + for course in courses: + course_details = get_course_details(course) + created_courses.append(course_details) + + return created_courses + + +@frappe.whitelist() +def get_created_batches(): + created_batches = [] + if frappe.session.user == "Guest": + return created_batches + + CourseInstructor = frappe.qb.DocType("Course Instructor") + Batch = frappe.qb.DocType("LMS Batch") + + query = ( + frappe.qb.from_(CourseInstructor) + .join(Batch) + .on(CourseInstructor.parent == Batch.name) + .select(Batch.name) + .where(CourseInstructor.instructor == frappe.session.user) + .where(Batch.start_date >= getdate()) + .orderby(Batch.start_date, order=frappe.qb.asc) + .limit(4) + ) + + results = query.run(as_dict=True) + batches = [row["name"] for row in results] + + for batch in batches: + batch_details = get_batch_details(batch) + created_batches.append(batch_details) + + return created_batches + + +@frappe.whitelist() +def get_admin_live_classes(): + if frappe.session.user == "Guest": + return [] + + CourseInstructor = frappe.qb.DocType("Course Instructor") + LMSLiveClass = frappe.qb.DocType("LMS Live Class") + + query = ( + frappe.qb.from_(CourseInstructor) + .join(LMSLiveClass) + .on(CourseInstructor.parent == LMSLiveClass.batch_name) + .select( + LMSLiveClass.name, + LMSLiveClass.title, + LMSLiveClass.description, + LMSLiveClass.time, + LMSLiveClass.date, + LMSLiveClass.duration, + LMSLiveClass.attendees, + LMSLiveClass.start_url, + LMSLiveClass.join_url, + LMSLiveClass.owner, + ) + .where(CourseInstructor.instructor == frappe.session.user) + .where(LMSLiveClass.date >= getdate()) + .orderby(LMSLiveClass.date, order=frappe.qb.asc) + .limit(4) + ) + results = query.run(as_dict=True) + return results + + +@frappe.whitelist() +def get_admin_evals(): + if frappe.session.user == "Guest": + return [] + + evals = frappe.get_all( + "LMS Certificate Request", + { + "evaluator": frappe.session.user, + "date": [">=", getdate()], + }, + [ + "name", + "date", + "start_time", + "course", + "evaluator", + "google_meet_link", + "member", + "member_name", + ], + limit=4, + order_by="date asc", + ) + + for evaluation in evals: + evaluation.course_title = frappe.db.get_value("LMS Course", evaluation.course, "title") + + return evals + + +def fetch_activity_dates(user): + doctypes = [ + "LMS Course Progress", + "LMS Quiz Submission", + "LMS Assignment Submission", + "LMS Programming Exercise Submission", + ] + + all_dates = [] + for dt in doctypes: + all_dates.extend(frappe.get_all(dt, {"member": user}, pluck="creation")) + + return sorted({d.date() if hasattr(d, "date") else d for d in all_dates}) + + +def calculate_streaks(all_dates): + streak = 0 + longest_streak = 0 + prev_day = None + + for d in all_dates: + if d.weekday() in (5, 6): + continue + + if prev_day: + expected = prev_day + timedelta(days=1) + while expected.weekday() in (5, 6): + expected += timedelta(days=1) + + streak = streak + 1 if d == expected else 1 + else: + streak = 1 + + longest_streak = max(longest_streak, streak) + prev_day = d + + return streak, longest_streak + + +def calculate_current_streak(all_dates, streak): + if not all_dates: + return 0 + + last_date = all_dates[-1] + today = getdate() + + ref_day = today + while ref_day.weekday() in (5, 6): + ref_day -= timedelta(days=1) + + if last_date == ref_day or last_date == ref_day - timedelta(days=1): + return streak + return 0 + + +@frappe.whitelist() +def get_streak_info(): + if frappe.session.user == "Guest": + return {} + + all_dates = fetch_activity_dates(frappe.session.user) + streak, longest_streak = calculate_streaks(all_dates) + current_streak = calculate_current_streak(all_dates, streak) + + return { + "current_streak": current_streak, + "longest_streak": longest_streak, + } diff --git a/lms/public/js/livecode-canvas.js b/lms/public/js/livecode-canvas.js deleted file mode 100644 index b916a9bd..00000000 --- a/lms/public/js/livecode-canvas.js +++ /dev/null @@ -1,105 +0,0 @@ -function getLiveCodeOptions() { - var START = ` -import sketch -code = open("main.py").read() -env = dict(sketch.__dict__) -exec(code, env) -`; - - var SKETCH = ` -import json - -def sendmsg(msgtype, function, args): - """Sends a message to the frontend. - - The frontend will receive the specified message whenever - this function is called. The frontend can decide to some - action on each of these messages. - """ - msg = dict(msgtype=msgtype, function=function, args=args) - print("--MSG--", json.dumps(msg)) - -def _draw(func, **kwargs): - sendmsg(msgtype="draw", function=func, args=kwargs) - -def circle(x, y, d): - """Draws a circle of diameter d with center (x, y). - """ - _draw("circle", x=x, y=y, d=d) - -def line(x1, y1, x2, y2): - """Draws a line from point (x1, y1) to point (x2, y2). - """ - _draw("line", x1=x1, y1=y1, x2=x2, y2=y2) - -def rect(x, y, w, h): - """Draws a rectangle on the canvas. - - Parameters - ---------- - x: x coordinate of the top-left corner of the rectangle - y: y coordinate of the top-left corner of the rectangle - w: width of the rectangle - h: height of the rectangle - """ - _draw("rect", x=x, y=y, w=w, h=h) - -def clear(): - _draw("clear") - -# clear the canvas on start -clear() -`; - const CANVAS_FUNCTIONS = { - circle: function (ctx, args) { - ctx.beginPath(); - ctx.arc(args.x, args.y, args.d / 2, 0, 2 * Math.PI); - ctx.stroke(); - }, - line: function (ctx, args) { - ctx.beginPath(); - ctx.moveTo(args.x1, args.y1); - ctx.lineTo(args.x2, args.y2); - ctx.stroke(); - }, - rect: function (ctx, args) { - ctx.beginPath(); - ctx.rect(args.x, args.y, args.w, args.h); - ctx.stroke(); - }, - clear: function (ctx, args) { - var width = 300; - var height = 300; - ctx.clearRect(0, 0, width, height); - }, - }; - - function drawOnCanvas(canvasElement, funcName, args) { - var ctx = canvasElement.getContext("2d"); - var func = CANVAS_FUNCTIONS[funcName]; - - var scalex = canvasElement.width / 300; - var scaley = canvasElement.height / 300; - - ctx.save(); - ctx.scale(scalex, scaley); - func(ctx, args); - ctx.restore(); - } - - return { - runtime: "python", - files: [ - { filename: "start.py", contents: START }, - { filename: "sketch.py", contents: SKETCH }, - ], - command: ["python", "start.py"], - codemirror: true, - onMessage: { - draw: function (editor, msg) { - const canvasElement = editor.parent.querySelector("canvas"); - drawOnCanvas(canvasElement, msg.function, msg.args); - }, - }, - }; -} diff --git a/lms/public/js/setup_wizard.js b/lms/public/js/setup_wizard.js deleted file mode 100644 index 81c6ba58..00000000 --- a/lms/public/js/setup_wizard.js +++ /dev/null @@ -1,5 +0,0 @@ -frappe.provide("lms.setup"); - -// redirect to desk page 'lms' after setup wizard is complete -// 'lms' desk page redirects to '/courses' -//frappe.setup.welcome_page = "/app/lms-home";