feat: batch timetable

This commit is contained in:
Jannat Patel
2023-09-15 21:55:06 +05:30
parent 9c00a5561a
commit 24e9f46e2f
15 changed files with 448 additions and 128 deletions

View File

@@ -10,10 +10,23 @@ frappe.ui.form.on("LMS Batch", {
}, },
}; };
}); });
frm.set_query("reference_doctype", "timetable", function () {
let doctypes = [
"Course Lesson",
"LMS Quiz",
"LMS Assignment",
"LMS Live Class",
];
return {
filters: {
name: ["in", doctypes],
},
};
});
}, },
fetch_lessons: (frm) => { fetch_lessons: (frm) => {
frm.clear_table("scheduled_flow");
frappe.call({ frappe.call({
method: "lms.lms.doctype.lms_batch.lms_batch.fetch_lessons", method: "lms.lms.doctype.lms_batch.lms_batch.fetch_lessons",
args: { args: {
@@ -22,7 +35,7 @@ frappe.ui.form.on("LMS Batch", {
callback: (r) => { callback: (r) => {
if (r.message) { if (r.message) {
r.message.forEach((lesson) => { r.message.forEach((lesson) => {
let row = frm.add_child("scheduled_flow"); let row = frm.add_child("timetable");
row.lesson = lesson.name; row.lesson = lesson.name;
row.lesson_title = lesson.title; row.lesson_title = lesson.title;
}); });

View File

@@ -36,7 +36,7 @@
"assessment", "assessment",
"schedule_tab", "schedule_tab",
"fetch_lessons", "fetch_lessons",
"scheduled_flow" "timetable"
], ],
"fields": [ "fields": [
{ {
@@ -146,12 +146,6 @@
"fieldtype": "Autocomplete", "fieldtype": "Autocomplete",
"label": "Category" "label": "Category"
}, },
{
"fieldname": "scheduled_flow",
"fieldtype": "Table",
"label": "Scheduled Flow",
"options": "Scheduled Flow"
},
{ {
"fieldname": "section_break_ubxi", "fieldname": "section_break_ubxi",
"fieldtype": "Section Break" "fieldtype": "Section Break"
@@ -164,7 +158,7 @@
{ {
"fieldname": "schedule_tab", "fieldname": "schedule_tab",
"fieldtype": "Tab Break", "fieldtype": "Tab Break",
"label": "Schedule" "label": "Timetable"
}, },
{ {
"fieldname": "section_break_gsac", "fieldname": "section_break_gsac",
@@ -199,11 +193,17 @@
"fieldname": "published", "fieldname": "published",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Published" "label": "Published"
},
{
"fieldname": "timetable",
"fieldtype": "Table",
"label": "Timetable",
"options": "LMS Batch Timetable"
} }
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [],
"modified": "2023-09-12 12:30:06.565104", "modified": "2023-09-14 12:51:11.847853",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "LMS", "module": "LMS",
"name": "LMS Batch", "name": "LMS Batch",

View File

@@ -8,7 +8,8 @@ import json
from frappe import _ from frappe import _
from frappe.model.document import Document from frappe.model.document import Document
from frappe.utils import cint, format_date, format_datetime from frappe.utils import cint, format_date, format_datetime
from lms.lms.utils import get_lessons from lms.lms.utils import get_lessons, get_lesson_index, get_lesson_url
from lms.www.utils import get_quiz_details, get_assignment_details
class LMSBatch(Document): class LMSBatch(Document):
@@ -19,7 +20,7 @@ class LMSBatch(Document):
self.validate_duplicate_students() self.validate_duplicate_students()
self.validate_duplicate_assessments() self.validate_duplicate_assessments()
self.validate_membership() self.validate_membership()
self.validate_schedule() self.validate_timetable()
def validate_duplicate_students(self): def validate_duplicate_students(self):
students = [row.student for row in self.students] students = [row.student for row in self.students]
@@ -68,8 +69,8 @@ class LMSBatch(Document):
if cint(self.seat_count) < len(self.students): if cint(self.seat_count) < len(self.students):
frappe.throw(_("There are no seats available in this batch.")) frappe.throw(_("There are no seats available in this batch."))
def validate_schedule(self): def validate_timetable(self):
for schedule in self.scheduled_flow: for schedule in self.timetable:
if schedule.start_time and schedule.end_time: if schedule.start_time and schedule.end_time:
if ( if (
schedule.start_time > schedule.end_time or schedule.start_time == schedule.end_time schedule.start_time > schedule.end_time or schedule.start_time == schedule.end_time
@@ -262,3 +263,45 @@ def add_course(course, parent, name=None, evaluator=None):
doc.save() doc.save()
return doc.name return doc.name
@frappe.whitelist()
def get_batch_timetable(batch):
timetable = frappe.get_all(
"LMS Batch Timetable",
filters={"parent": batch},
fields=["reference_doctype", "reference_docname", "date", "start_time", "end_time"],
order_by="date",
)
for entry in timetable:
entry.title = frappe.db.get_value(
entry.reference_doctype, entry.reference_docname, "title"
)
assessment = frappe._dict({"assessment_name": entry.reference_docname})
if entry.reference_doctype == "Course Lesson":
entry.icon = "icon-list"
course = frappe.db.get_value(
entry.reference_doctype, entry.reference_docname, "course"
)
entry.url = get_lesson_url(course, get_lesson_index(entry.reference_docname))
elif entry.reference_doctype == "LMS Quiz":
entry.icon = "icon-quiz"
entry.url = "/quizzes"
details = get_quiz_details(assessment, frappe.session.user)
entry.update(details)
elif entry.reference_doctype == "LMS Assignment":
entry.icon = "icon-quiz"
details = get_assignment_details(assessment, frappe.session.user)
entry.update(details)
elif entry.reference_doctype == "LMS Live Class":
entry.icon = "icon-call"
entry.url = frappe.db.get_value(
entry.reference_doctype, entry.reference_docname, "join_url"
)
return timetable

View File

@@ -0,0 +1,8 @@
// Copyright (c) 2023, Frappe and contributors
// For license information, please see license.txt
// frappe.ui.form.on("LMS Batch Timetable", {
// refresh(frm) {
// },
// });

View File

@@ -0,0 +1,81 @@
{
"actions": [],
"allow_rename": 1,
"autoname": "hash",
"creation": "2023-09-14 12:44:51.098956",
"default_view": "List",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"column_break_htdc",
"reference_doctype",
"reference_docname",
"date",
"column_break_merq",
"start_time",
"end_time",
"duration"
],
"fields": [
{
"fieldname": "reference_doctype",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Reference DocType",
"options": "DocType"
},
{
"fieldname": "reference_docname",
"fieldtype": "Dynamic Link",
"in_list_view": 1,
"label": "Reference DocName",
"options": "reference_doctype"
},
{
"fieldname": "column_break_merq",
"fieldtype": "Column Break"
},
{
"fieldname": "date",
"fieldtype": "Date",
"in_list_view": 1,
"label": "Date"
},
{
"fieldname": "start_time",
"fieldtype": "Time",
"in_list_view": 1,
"label": "Start Time"
},
{
"fieldname": "duration",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Duration"
},
{
"fieldname": "column_break_htdc",
"fieldtype": "Column Break"
},
{
"fieldname": "end_time",
"fieldtype": "Time",
"in_list_view": 1,
"label": "End Time"
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2023-09-15 10:35:40.642660",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Batch Timetable",
"naming_rule": "Random",
"owner": "Administrator",
"permissions": [],
"sort_field": "modified",
"sort_order": "DESC",
"states": []
}

View File

@@ -0,0 +1,9 @@
# Copyright (c) 2023, Frappe and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class LMSBatchTimetable(Document):
pass

View File

@@ -0,0 +1,9 @@
# Copyright (c) 2023, Frappe and Contributors
# See license.txt
# import frappe
from frappe.tests.utils import FrappeTestCase
class TestLMSBatchTimetable(FrappeTestCase):
pass

View File

@@ -126,7 +126,7 @@
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [],
"modified": "2023-03-14 18:44:48.813103", "modified": "2023-09-14 15:02:44.081474",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "LMS", "module": "LMS",
"name": "LMS Live Class", "name": "LMS Live Class",
@@ -157,8 +157,10 @@
"write": 1 "write": 1
} }
], ],
"show_title_field_in_link": 1,
"sort_field": "modified", "sort_field": "modified",
"sort_order": "DESC", "sort_order": "DESC",
"states": [], "states": [],
"title_field": "title",
"track_changes": 1 "track_changes": 1
} }

View File

@@ -2353,3 +2353,68 @@ select {
z-index: 10; z-index: 10;
width: fit-content; width: fit-content;
} }
.toastui-calendar-milestone {
display: none;
}
.toastui-calendar-task {
display: none;
}
.toastui-calendar-panel-resizer {
display: none;
}
.toastui-calendar-day-name__date {
font-size: var(--text-base) !important;
}
.toastui-calendar-day-name__name {
font-size: var(--text-base) !important;
}
.toastui-calendar-day-view-day-names, .toastui-calendar-week-view-day-names {
border-bottom: none !important;
}
.toastui-calendar-layout {
border: 1px solid var(--gray-200) !important;
border-radius: var(--border-radius-md) !important;
background-color: var(--gray-100) !important;
}
.toastui-calendar-panel .toastui-calendar-day-names.toastui-calendar-week {
border-top: none !important;
}
.toastui-calendar-panel.toastui-calendar-time {
height: 80% !important;
}
.toastui-calendar-panel.toastui-calendar-week-view-day-names {
background-color: var(--gray-50) !important;
}
.toastui-calendar-allday {
border-bottom: 1px solid var(--gray-200) !important;
}
.calendar-navigation {
display: flex;
justify-content: center;
align-items: center;
margin-bottom: 1rem;
}
.calendar-range {
margin: 0 2rem;
font-weight: 500;
color: var(--text-color);
}
.calendar-event-title {
font-size: var(--text-base);
font-weight: 500;
margin-top: 0.25rem;
}

View File

@@ -88,7 +88,7 @@
<ul class="nav lms-nav" id="batches-tab"> <ul class="nav lms-nav" id="batches-tab">
{% if is_student %} {% if is_student or is_moderator %}
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {% if is_student %} active {% endif %}" data-toggle="tab" href="#dashboard"> <a class="nav-link {% if is_student %} active {% endif %}" data-toggle="tab" href="#dashboard">
{{ _("Dashboard") }} {{ _("Dashboard") }}
@@ -105,13 +105,10 @@
</a> </a>
</li> </li>
{% if flow | length %} {% if show_timetable %}
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" data-toggle="tab" href="#schedule"> <a class="nav-link" data-toggle="tab" href="#timetable">
{{ _("Schedule") }} {{ _("Timetable") }}
<span class="course-list-count">
{{ flow | length }}
</span>
</a> </a>
</li> </li>
{% endif %} {% endif %}
@@ -169,9 +166,9 @@
{{ CoursesSection(batch_info, batch_courses) }} {{ CoursesSection(batch_info, batch_courses) }}
</div> </div>
{% if flow | length %} {% if show_timetable %}
<div class="tab-pane" id="schedule" role="tabpanel" aria-labelledby="schedule"> <div class="tab-pane" id="timetable" role="tabpanel" aria-labelledby="timetable">
{{ ScheduleSection(flow) }} {{ Timetable() }}
</div> </div>
{% endif %} {% endif %}
@@ -513,79 +510,49 @@
{% endmacro %} {% endmacro %}
{% macro ScheduleSection(flow) %} {% macro Timetable() %}
<article> <article>
<header class="edit-header mb-5"> <header class="edit-header mb-5">
<div class="bold-heading"> <div class="bold-heading">
{{ _("Schedule") }} {{ _("Timetable") }}
</div> </div>
</header> </header>
<div class="calendar-navigation">
<div> <button class="btn icon-btn btn-default" id="prev-week">
{% for chapter in flow %} <svg class="icon icon-md">
<div class="chapter-parent"> <use href="#icon-left"></use>
<div class="chapter-title" data-toggle="collapse" data-target="#{{ get_slugified_chapter_title(chapter.chapter_title) }}"> </svg>
<img class="chapter-icon" src="/assets/lms/icons/chevron-right.svg"> </button>
<div class="chapter-title-main"> <span class="calendar-range"></span>
{{ chapter.chapter_title }} <button class="btn icon-btn btn-default" id="next-week">
</div> <svg class="icon icon-md">
</div> <use href="#icon-right"></use>
<div class="chapter-content lessons collapse navbar-collapse" id="{{ get_slugified_chapter_title(chapter.chapter_title) }}"> </svg>
</button>
<div class="schedule-header">
<div class="w-50">
{{ _("Lesson") }}
</div>
<div class="w-25">
{{ _("Date") }}
</div>
<div class="w-25 text-center">
{{ _("Start Time") }}
</div>
<div class="w-25 text-center">
{{ _("End Time") }}
</div>
</div>
{% for lesson in chapter.lessons %}
<div class="lesson-info flex align-center">
<a class="lesson-links w-50" href="{{ lesson.url }}">
<svg class="icon icon-sm mr-2">
<use class="" href="#{{ lesson.icon }}">
</svg>
{{ lesson.title }}
{% if current_student.name and get_membership(lesson.course, current_student.name) %}
{% set lesson_progress = get_progress(lesson.course, lesson.name, current_student.name) %}
<svg class="icon icon-md lesson-progress-tick ml-3 {% if lesson_progress != 'Complete' %} hide {% endif %}">
<use class="" href="#icon-success">
</svg>
{% endif %}
</a>
<div class="w-25">
{{ frappe.utils.format_date(lesson.date, "medium") }}
</div>
<div class="w-25 text-center">
{% if lesson.start_time %}
{{ frappe.utils.format_time(lesson.start_time, "HH:mm a") }}
{% else %}
-
{% endif %}
</div>
<div class="w-25 text-center">
{% if lesson.end_time %}
{{ frappe.utils.format_time(lesson.end_time, "HH:mm a") }}
{% else %}
-
{% endif %}
</div>
</div>
{% endfor %}
</div>
</div>
{% endfor %}
</div> </div>
<div id="calendar" class="timetable-calendar" style="height: 700px"
data-start="{{ batch_info.start_time }}" data-end="{{ batch_info.end_time }}">
</div>
<!-- <div>
{% for week in timetable %}
<div>
<div class="bold-heading">
{{ _("Week ") }} {{ loop.index }}
</div>
<div>
{% for entry in timetable[week] %}
<a {% if is_student %} href="{{ entry.url }}" {% endif %}>
<svg class="icon icon-md">
<use href="#{{ entry.icon }}"></use>
</svg>
{{ entry.title }}
</a>
{% endfor %}
</div>
</div>
{% endfor %}
</div> -->
</article> </article>
{% endmacro %} {% endmacro %}
@@ -595,4 +562,6 @@
frappe.boot.single_types = [] frappe.boot.single_types = []
let courses = {{ course_list | json }}; let courses = {{ course_list | json }};
</script> </script>
<link rel="stylesheet" href="https://uicdn.toast.com/calendar/latest/toastui-calendar.min.css" />
<script src="https://uicdn.toast.com/calendar/latest/toastui-calendar.min.js"></script>
{% endblock %} {% endblock %}

View File

@@ -2,6 +2,22 @@ frappe.ready(() => {
let self = this; let self = this;
frappe.require("controls.bundle.js"); frappe.require("controls.bundle.js");
if ($("#calendar").length) {
setup_timetable();
}
if ($("#calendar").length) {
$(document).on("click", "#prev-week", (e) => {
this.calendar_ && this.calendar_.prev();
});
}
if ($("#calendar").length) {
$(document).on("click", "#next-week", (e) => {
this.calendar_ && this.calendar_.next();
});
}
if ($("#live-class-form").length) { if ($("#live-class-form").length) {
setTimeout(() => { setTimeout(() => {
make_live_class_form(); make_live_class_form();
@@ -606,3 +622,124 @@ const submit_evaluation_form = (values) => {
}, },
}); });
}; };
const setup_timetable = () => {
frappe.call({
method: "lms.lms.doctype.lms_batch.lms_batch.get_batch_timetable",
args: {
batch: $(".class-details").data("batch"),
},
callback: (r) => {
if (r.message.length) {
setup_calendar(r.message);
}
},
});
};
const setup_calendar = (events) => {
const element = $("#calendar");
const Calendar = tui.Calendar;
let calendar_events = [];
let calendar_id = "calendar1";
const container = element[0];
const start_time = $(elemet).data("start");
const end_time = $(elemet).data("end");
const options = {
defaultView: "week",
usageStatistics: false,
week: {
narrowWeekend: true,
hourStart: 7,
hourEnd: 18,
},
month: {
narrowWeekend: true,
},
taskView: false,
isReadOnly: true,
calendars: [
{
id: calendar_id,
name: "Timetable",
backgroundColor: "#ffffff",
},
],
template: {
time: function (event) {
return `<div class="calendar-event-time">
<div> ${frappe.datetime.get_time(event.start.d.d)} -
${frappe.datetime.get_time(event.end.d.d)} </div>
<div class="calendar-event-title"> ${event.title} </div>
</div>`;
},
},
};
const calendar = new Calendar(container, options);
this.calendar_ = calendar;
events.forEach((event, idx) => {
let colors = get_background_color(event.reference_doctype);
calendar_events.push({
id: `event${idx}`,
calendarId: calendar_id,
title: event.title,
start: `${event.date}T${event.start_time}`,
end: `${event.date}T${event.end_time}`,
isAllday: event.start_time ? false : true,
borderColor: colors.dark,
customStyle: {
borderRadius: "var(--border-radius-md)",
boxShadow: "var(--shadow-base)",
borderWidth: "8px",
padding: "1rem",
},
raw: {
url: event.url,
},
});
});
calendar.createEvents(calendar_events);
calendar.on("clickEvent", ({ event }) => {
const el = document.getElementById("clicked-event");
window.open(event.raw.url, "_blank");
});
if (new Date().getMonth() < new Date(events[0].date).getMonth()) {
calendar.setDate(new Date(events[0].date));
}
let week_start = frappe.datetime.global_date_format(
calendar.getDateRangeStart().d.d
);
let week_end = frappe.datetime.global_date_format(
calendar.getDateRangeEnd().d.d
);
$(".calendar-range").text(`${week_start} - ${week_end}`);
};
const get_background_color = (doctype) => {
if (doctype == "Course Lesson")
return {
light: "var(--blue-50)",
dark: "var(--blue-400)",
};
if (doctype == "LMS Quiz")
return {
light: "var(--green-50)",
dark: "var(--green-400)",
};
if (doctype == "LMS Assignment")
return {
light: "var(--orange-50)",
dark: "var(--orange-400)",
};
if (doctype == "LMS Live Class")
return {
light: "var(--red-50)",
dark: "var(--red-400)",
};
};

View File

@@ -1,6 +1,6 @@
from frappe import _ from frappe import _
import frappe import frappe
from frappe.utils import getdate, cint from frappe.utils import getdate, get_datetime
from lms.www.utils import get_assessments, is_student from lms.www.utils import get_assessments, is_student
from lms.lms.utils import ( from lms.lms.utils import (
has_course_moderator_role, has_course_moderator_role,
@@ -89,7 +89,20 @@ def get_context(context):
) )
context.all_assignments = get_all_assignments(batch_name) context.all_assignments = get_all_assignments(batch_name)
context.all_quizzes = get_all_quizzes(batch_name) context.all_quizzes = get_all_quizzes(batch_name)
context.flow = get_scheduled_flow(batch_name) context.show_timetable = frappe.db.count(
"LMS Batch Timetable",
{
"parent": batch_name,
},
)
print(
frappe.db.count(
"LMS Batch Timetable",
{
"parent": batch_name,
},
)
)
def get_all_quizzes(batch_name): def get_all_quizzes(batch_name):
@@ -210,38 +223,6 @@ def sort_students(batch_students):
return batch_students return batch_students
def get_scheduled_flow(batch_name):
chapters = []
lessons = frappe.get_all(
"Scheduled Flow",
{"parent": batch_name},
["name", "lesson", "date", "start_time", "end_time"],
order_by="idx",
)
for lesson in lessons:
lesson = get_lesson_details(lesson, batch_name)
chapter_exists = [
chapter for chapter in chapters if chapter.chapter == lesson.chapter
]
if len(chapter_exists) == 0:
chapters.append(
frappe._dict(
{
"chapter": lesson.chapter,
"chapter_title": frappe.db.get_value("Course Chapter", lesson.chapter, "title"),
"lessons": [lesson],
}
)
)
else:
chapter_exists[0]["lessons"].append(lesson)
return chapters
def get_lesson_details(lesson, batch_name): def get_lesson_details(lesson, batch_name):
lesson.update( lesson.update(
frappe.db.get_value( frappe.db.get_value(

View File

@@ -31,7 +31,6 @@ def get_context(context):
batch.seats_left = ( batch.seats_left = (
batch.seat_count - batch.student_count if batch.seat_count else None batch.seat_count - batch.student_count if batch.seat_count else None
) )
print(batch.name, batch.published)
if not batch.published: if not batch.published:
private_batches.append(batch) private_batches.append(batch)
elif getdate(batch.start_date) < getdate(): elif getdate(batch.start_date) < getdate():

View File

@@ -108,6 +108,8 @@ def get_assignment_details(assessment, member):
f"/assignment-submission/{assessment.assessment_name}/{submission_name}" f"/assignment-submission/{assessment.assessment_name}/{submission_name}"
) )
return assessment
def get_quiz_details(assessment, member): def get_quiz_details(assessment, member):
assessment.title = frappe.db.get_value("LMS Quiz", assessment.assessment_name, "title") assessment.title = frappe.db.get_value("LMS Quiz", assessment.assessment_name, "title")
@@ -131,6 +133,8 @@ def get_quiz_details(assessment, member):
) )
assessment.url = f"/quiz-submission/{assessment.assessment_name}/{submission_name}" assessment.url = f"/quiz-submission/{assessment.assessment_name}/{submission_name}"
return assessment
def is_student(batch, member=None): def is_student(batch, member=None):
if not member: if not member: