Compare commits
155 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb6e97992b | ||
|
|
64fac451f3 | ||
|
|
eb6b72515e | ||
|
|
0550d3aea3 | ||
|
|
f6577acbff | ||
|
|
09c494f38a | ||
|
|
6c600d747e | ||
|
|
9dcfc347d9 | ||
|
|
fb40b627fc | ||
|
|
c597f96375 | ||
|
|
f1961ab614 | ||
|
|
c2c7b7b250 | ||
|
|
c20c272f8e | ||
|
|
85e4115306 | ||
|
|
10c2bc589a | ||
|
|
a30244cb4a | ||
|
|
5691fcdca4 | ||
|
|
f5848207e2 | ||
|
|
ad224161d8 | ||
|
|
5837a1ffab | ||
|
|
1cfd7cdb98 | ||
|
|
56a4aa2a3f | ||
|
|
d91d2ded77 | ||
|
|
6a48d44b14 | ||
|
|
31c5d423d0 | ||
|
|
79177b5f5b | ||
|
|
74658b2054 | ||
|
|
052fffccef | ||
|
|
bd2b558154 | ||
|
|
65ee6b62ea | ||
|
|
26266a22e8 | ||
|
|
e52ca63075 | ||
|
|
4d8b2eb5b4 | ||
|
|
2d81a1ce31 | ||
|
|
052a85fbc0 | ||
|
|
fa0e84c671 | ||
|
|
4759736571 | ||
|
|
f77686feaa | ||
|
|
34548b93f4 | ||
|
|
f438d33f75 | ||
|
|
be1c0de4c6 | ||
|
|
ae5ea9a8aa | ||
|
|
eeb7fb1f78 | ||
|
|
3f32d5bb3b | ||
|
|
12019ca37d | ||
|
|
4d133b2f99 | ||
|
|
e733226b0c | ||
|
|
2ed583a0c3 | ||
|
|
048cee654e | ||
|
|
1293294593 | ||
|
|
a1947a3106 | ||
|
|
eff6cd6bbe | ||
|
|
d784ac5699 | ||
|
|
9acad5157b | ||
|
|
94459efa3f | ||
|
|
e88bc6a5ce | ||
|
|
55a7ab54e9 | ||
|
|
0c324c87cc | ||
|
|
31e8befa11 | ||
|
|
86ab7a6d97 | ||
|
|
14bdfb2d98 | ||
|
|
0036e585da | ||
|
|
cba2343fc0 | ||
|
|
864eebce2f | ||
|
|
156d36fb5e | ||
|
|
068718aa8a | ||
|
|
10219abfd6 | ||
|
|
2ec231a3d0 | ||
|
|
78f29b3aff | ||
|
|
7f768e81f4 | ||
|
|
aa1460eda1 | ||
|
|
85f85063ac | ||
|
|
0a7ce3c5d8 | ||
|
|
8468d0e3db | ||
|
|
059ac27f0b | ||
|
|
a96f8836b1 | ||
|
|
4018116136 | ||
|
|
aa083c8a40 | ||
|
|
8752243e9c | ||
|
|
1d028e81c4 | ||
|
|
2752d3e42c | ||
|
|
aa074ef762 | ||
|
|
bae75cd2f6 | ||
|
|
81a714b5a2 | ||
|
|
10cd44c22f | ||
|
|
a44f59c362 | ||
|
|
8d372fcab4 | ||
|
|
97d6c518b5 | ||
|
|
f331c48e1d | ||
|
|
9d0b10058d | ||
|
|
4ccd3ba71e | ||
|
|
7a6f5a868c | ||
|
|
0fae11d031 | ||
|
|
8a9725c990 | ||
|
|
d0189b0e3a | ||
|
|
c6853cc95e | ||
|
|
f28f37fb2c | ||
|
|
7dbbe9dba4 | ||
|
|
b625d9b099 | ||
|
|
a85c81a4b4 | ||
|
|
1677a4a32b | ||
|
|
776d46f5a2 | ||
|
|
6384eeaa13 | ||
|
|
fdc0befcee | ||
|
|
f2c28eb695 | ||
|
|
4095916991 | ||
|
|
551703364a | ||
|
|
4a2fae023c | ||
|
|
fca206120e | ||
|
|
65b2199065 | ||
|
|
9d03a52bf9 | ||
|
|
c8aa44dfcb | ||
|
|
7fcbe85ab9 | ||
|
|
de0dea7df8 | ||
|
|
43cf7d04b8 | ||
|
|
4d18580482 | ||
|
|
b48e007ea8 | ||
|
|
d5e8973866 | ||
|
|
a8c530f98c | ||
|
|
47769ccd62 | ||
|
|
bfc1d9a0a8 | ||
|
|
824484e608 | ||
|
|
d3f7baae4c | ||
|
|
8d961e9b71 | ||
|
|
f22855920c | ||
|
|
18728e3519 | ||
|
|
65dc2838d3 | ||
|
|
be930ce076 | ||
|
|
1ea47a008c | ||
|
|
e0169cff79 | ||
|
|
7c53ac10e2 | ||
|
|
212e0de6e9 | ||
|
|
8e74384b5a | ||
|
|
86e7e68ce1 | ||
|
|
a77999dbb6 | ||
|
|
3288fb0f06 | ||
|
|
a81b384f90 | ||
|
|
75c11d3fcc | ||
|
|
51a6cc035c | ||
|
|
ae8008d05c | ||
|
|
7f44177986 | ||
|
|
d88aaedf3f | ||
|
|
802d4ccb0b | ||
|
|
76a84c7f5d | ||
|
|
40aefba203 | ||
|
|
6cdfb822b4 | ||
|
|
fdacab66f7 | ||
|
|
5cc12e71df | ||
|
|
f5e5fa2f36 | ||
|
|
6022b83b8c | ||
|
|
a01b1657cc | ||
|
|
6b785bd0e6 | ||
|
|
0beffc3083 | ||
|
|
d345d09b13 | ||
|
|
38e1eb8fc7 |
BIN
.github/batch.png
vendored
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
.github/batches.png
vendored
|
Before Width: | Height: | Size: 572 KiB |
BIN
.github/certificate.png
vendored
|
Before Width: | Height: | Size: 307 KiB After Width: | Height: | Size: 912 KiB |
2
.github/helper/install_dependencies.sh
vendored
@@ -5,7 +5,7 @@ echo "Setting Up System Dependencies..."
|
|||||||
|
|
||||||
sudo apt update
|
sudo apt update
|
||||||
sudo apt remove mysql-server mysql-client
|
sudo apt remove mysql-server mysql-client
|
||||||
sudo apt install libcups2-dev redis-server mariadb-client-10.6
|
sudo apt-get install libcups2-dev redis-server mariadb-client
|
||||||
|
|
||||||
install_wkhtmltopdf() {
|
install_wkhtmltopdf() {
|
||||||
wget -q https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6-1/wkhtmltox_0.12.6-1.focal_amd64.deb
|
wget -q https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6-1/wkhtmltox_0.12.6-1.focal_amd64.deb
|
||||||
|
|||||||
BIN
.github/hero.png
vendored
|
Before Width: | Height: | Size: 842 KiB After Width: | Height: | Size: 2.0 MiB |
BIN
.github/quiz.png
vendored
|
Before Width: | Height: | Size: 578 KiB After Width: | Height: | Size: 1.0 MiB |
64
.github/workflows/build.yml
vendored
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
name: Build Container Image
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
tags:
|
||||||
|
- "*"
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
arch: [amd64, arm64]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout Entire Repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
with:
|
||||||
|
platforms: linux/${{ matrix.arch }}
|
||||||
|
- name: Login to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v2
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Set Branch
|
||||||
|
run: |
|
||||||
|
export APPS_JSON='[{"url": "https://github.com/frappe/lms","branch": "main"}]'
|
||||||
|
echo "APPS_JSON_BASE64=$(echo $APPS_JSON | base64 -w 0)" >> $GITHUB_ENV
|
||||||
|
echo "FRAPPE_BRANCH=version-15" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Set Image Tag
|
||||||
|
run: |
|
||||||
|
echo "IMAGE_TAG=stable" >> $GITHUB_ENV
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
repository: frappe/frappe_docker
|
||||||
|
path: builds
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
push: true
|
||||||
|
context: builds
|
||||||
|
file: builds/images/layered/Containerfile
|
||||||
|
tags: >
|
||||||
|
ghcr.io/${{ github.repository }}:${{ github.ref_name }},
|
||||||
|
ghcr.io/${{ github.repository }}:${{ env.IMAGE_TAG }}
|
||||||
|
build-args: |
|
||||||
|
"FRAPPE_BRANCH=${{ env.FRAPPE_BRANCH }}"
|
||||||
|
"APPS_JSON_BASE64=${{ env.APPS_JSON_BASE64 }}"
|
||||||
2
.github/workflows/ui-tests.yml
vendored
@@ -24,7 +24,7 @@ jobs:
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
mariadb:
|
mariadb:
|
||||||
image: mariadb:10.6
|
image: mariadb:10.8
|
||||||
env:
|
env:
|
||||||
MARIADB_ROOT_PASSWORD: 123
|
MARIADB_ROOT_PASSWORD: 123
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
56
README.md
@@ -1,11 +1,10 @@
|
|||||||
<div align="center" markdown="1">
|
<div align="center" markdown="1">
|
||||||
|
|
||||||
<img src=".github/lms-logo.png" alt="Frappe Learning logo" width="100"/>
|
<img src=".github/lms-logo.png" alt="Frappe Learning logo" width="80" height="80"/>
|
||||||
<h1>Frappe Learning</h1>
|
<h1>Frappe Learning</h1>
|
||||||
|
|
||||||
**Easy to use, open source, Learning Management System**
|
**Easy to use, open source, Learning Management System**
|
||||||
|
|
||||||

|
|
||||||

|

|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -24,10 +23,10 @@
|
|||||||
## Frappe Learning
|
## Frappe Learning
|
||||||
Frappe Learning is an easy-to-use learning system that helps you bring structure to your content.
|
Frappe Learning is an easy-to-use learning system that helps you bring structure to your content.
|
||||||
|
|
||||||
## Motivation
|
### Motivation
|
||||||
In 2021, we were looking for a Learning Management System to launch [Mon.School](https://mon.school) for FOSS United. We checked out Moodle, but it didn’t feel right. The forms were unnecessarily lengthy and the UI was confusing. It shouldn't be this hard to create a course right? So I started making a learning system for Mon.School which soon became a product in itself. The aim is to have a simple platform that anyone can use to launch a course of their own and make knowledge sharing easier.
|
In 2021, we were looking for a Learning Management System to launch [Mon.School](https://mon.school) for FOSS United. We checked out Moodle, but it didn’t feel right. The forms were unnecessarily lengthy and the UI was confusing. It shouldn't be this hard to create a course right? So I started making a learning system for Mon.School which soon became a product in itself. The aim is to have a simple platform that anyone can use to launch a course of their own and make knowledge sharing easier.
|
||||||
|
|
||||||
## Key Features
|
### Key Features
|
||||||
|
|
||||||
- **Structured Learning**: Design a course with a 3-level hierarchy, where your courses have chapters and you can group your lessons within these chapters. This ensures that the context of the lesson is set by the chapter.
|
- **Structured Learning**: Design a course with a 3-level hierarchy, where your courses have chapters and you can group your lessons within these chapters. This ensures that the context of the lesson is set by the chapter.
|
||||||
|
|
||||||
@@ -37,24 +36,42 @@ In 2021, we were looking for a Learning Management System to launch [Mon.School]
|
|||||||
|
|
||||||
- **Getting Certified**: Once a learner has completed the course or batch, you can grant them a certificate. The app provides an inbuilt certificate template. You can use this or else create a template of your own and use that instead.
|
- **Getting Certified**: Once a learner has completed the course or batch, you can grant them a certificate. The app provides an inbuilt certificate template. You can use this or else create a template of your own and use that instead.
|
||||||
|
|
||||||
### Batches to group learners
|
<details>
|
||||||
|
<summary>View Screenshots</summary>
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
### Quiz to evaluate them
|

|
||||||
|
<div align="center">
|
||||||
|
<sub>
|
||||||
|
Create batches to group your learners
|
||||||
|
</sub>
|
||||||
|
</div>
|
||||||
|
<br>
|
||||||
|
|
||||||
|
|
||||||

|

|
||||||
|
<div align="center">
|
||||||
|
<sub>
|
||||||
|
Evaluate their knowledge by quizzes
|
||||||
|
</sub>
|
||||||
|
</div>
|
||||||
|
<br>
|
||||||
|
|
||||||
### Certificate to authenticate their knowledge
|
|
||||||
|
|
||||||

|

|
||||||
|
<div align="center">
|
||||||
|
<sub>
|
||||||
|
Autenticate their work with certification
|
||||||
|
</sub>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
## Under the Hood
|
|
||||||
|
|
||||||
- [**Frappe Framework**](https://github.com/frappe/frappe): A full-stack web application framework written in Python and Javascript. The framework provides a robust foundation for building web applications, including a database abstraction layer, user authentication, and a REST API.
|
### Under the Hood
|
||||||
|
|
||||||
- [**Frappe UI**](https://github.com/frappe/frappe-ui): A Vue-based UI library, to provide a modern user interface. The Frappe UI library provides a variety of components that can be used to build single-page applications on top of the Frappe Framework.
|
- [**Frappe Framework**](https://github.com/frappe/frappe): A full-stack web application framework.
|
||||||
|
|
||||||
|
- [**Frappe UI**](https://github.com/frappe/frappe-ui): A Vue-based UI library, to provide a modern user interface.
|
||||||
|
|
||||||
## Production Setup
|
## Production Setup
|
||||||
|
|
||||||
@@ -89,15 +106,15 @@ wget https://frappe.io/easy-install.py
|
|||||||
python3 ./easy-install.py deploy \
|
python3 ./easy-install.py deploy \
|
||||||
--project=learning_prod_setup \
|
--project=learning_prod_setup \
|
||||||
--email=your_email.example.com \
|
--email=your_email.example.com \
|
||||||
--image=ghcr.io/frappe/learning \
|
--image=ghcr.io/frappe/lms \
|
||||||
--version=stable \
|
--version=stable \
|
||||||
--app=learning \
|
--app=lms \
|
||||||
--sitename subdomain.domain.tld
|
--sitename subdomain.domain.tld
|
||||||
```
|
```
|
||||||
|
|
||||||
Replace the following parameters with your values:
|
Replace the following parameters with your values:
|
||||||
- `your_email.example.com`: Your email address
|
- `your_email.example.com`: Your email address
|
||||||
- `subdomain.domain.tld`: Your domain name where Insights will be hosted
|
- `subdomain.domain.tld`: Your domain name where Learning will be hosted
|
||||||
|
|
||||||
The script will set up a production-ready instance of Frappe Learning with all the necessary configurations in about 5 minutes.
|
The script will set up a production-ready instance of Frappe Learning with all the necessary configurations in about 5 minutes.
|
||||||
|
|
||||||
@@ -113,16 +130,16 @@ You need Docker, docker-compose and git setup on your machine. Refer [Docker doc
|
|||||||
cd frappe-learning
|
cd frappe-learning
|
||||||
|
|
||||||
# Download the docker-compose file
|
# Download the docker-compose file
|
||||||
wget -O docker-compose.yml https://raw.githubusercontent.com/frappe/insights/develop/docker/docker-compose.yml
|
wget -O docker-compose.yml https://raw.githubusercontent.com/frappe/lms/develop/docker/docker-compose.yml
|
||||||
|
|
||||||
# Download the setup script
|
# Download the setup script
|
||||||
wget -O init.sh https://raw.githubusercontent.com/frappe/insights/develop/docker/init.sh
|
wget -O init.sh https://raw.githubusercontent.com/frappe/lms/develop/docker/init.sh
|
||||||
|
|
||||||
**Step 2**: Run the container and daemonize it
|
**Step 2**: Run the container and daemonize it
|
||||||
|
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
|
|
||||||
**Step 3**: The site [http://lms.localhost:8000/insights](http://lms.localhost:8000/lms) should now be available. The default credentials are:
|
**Step 3**: The site [http://lms.localhost:8000/lms](http://lms.localhost:8000/lms) should now be available. The default credentials are:
|
||||||
- Username: Administrator
|
- Username: Administrator
|
||||||
- Password: admin
|
- Password: admin
|
||||||
|
|
||||||
@@ -134,7 +151,7 @@ To setup the repository locally follow the steps mentioned below:
|
|||||||
1. Start the server by running `bench start`
|
1. Start the server by running `bench start`
|
||||||
1. In a separate terminal window, create a new site by running `bench new-site learning.test`
|
1. In a separate terminal window, create a new site by running `bench new-site learning.test`
|
||||||
1. Map your site to localhost with the command `bench --site learning.test add-to-hosts`
|
1. Map your site to localhost with the command `bench --site learning.test add-to-hosts`
|
||||||
1. Get the Insights app. Run `bench get-app https://github.com/frappe/lms`
|
1. Get the Learning app. Run `bench get-app https://github.com/frappe/lms`
|
||||||
1. Run `bench --site learning.test install-app lms`.
|
1. Run `bench --site learning.test install-app lms`.
|
||||||
1. Now open the URL `http://learning.test:8000/lms` in your browser, you should see the app running
|
1. Now open the URL `http://learning.test:8000/lms` in your browser, you should see the app running
|
||||||
|
|
||||||
@@ -145,7 +162,8 @@ To setup the repository locally follow the steps mentioned below:
|
|||||||
- [Documentation](https://docs.frappe.io/learning)
|
- [Documentation](https://docs.frappe.io/learning)
|
||||||
- [YouTube](https://www.youtube.com/channel/UCn3bV5kx77HsVwtnlCeEi_A)
|
- [YouTube](https://www.youtube.com/channel/UCn3bV5kx77HsVwtnlCeEi_A)
|
||||||
|
|
||||||
<h2></h2>
|
<br>
|
||||||
|
<br>
|
||||||
<div align="center" style="padding-top: 0.75rem;">
|
<div align="center" style="padding-top: 0.75rem;">
|
||||||
<a href="https://frappe.io" target="_blank">
|
<a href="https://frappe.io" target="_blank">
|
||||||
<picture>
|
<picture>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ version: "3.7"
|
|||||||
name: lms
|
name: lms
|
||||||
services:
|
services:
|
||||||
mariadb:
|
mariadb:
|
||||||
image: mariadb:10.6
|
image: mariadb:10.8
|
||||||
command:
|
command:
|
||||||
- --character-set-server=utf8mb4
|
- --character-set-server=utf8mb4
|
||||||
- --collation-server=utf8mb4_unicode_ci
|
- --collation-server=utf8mb4_unicode_ci
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
"@editorjs/simple-image": "^1.6.0",
|
"@editorjs/simple-image": "^1.6.0",
|
||||||
"@editorjs/table": "^2.4.2",
|
"@editorjs/table": "^2.4.2",
|
||||||
"ace-builds": "^1.36.2",
|
"ace-builds": "^1.36.2",
|
||||||
|
"apexcharts": "^4.3.0",
|
||||||
"chart.js": "^4.4.1",
|
"chart.js": "^4.4.1",
|
||||||
"codemirror-editor-vue3": "^2.8.0",
|
"codemirror-editor-vue3": "^2.8.0",
|
||||||
"dayjs": "^1.11.6",
|
"dayjs": "^1.11.6",
|
||||||
@@ -35,6 +36,7 @@
|
|||||||
"vue-chartjs": "^5.3.0",
|
"vue-chartjs": "^5.3.0",
|
||||||
"vue-draggable-next": "^2.2.1",
|
"vue-draggable-next": "^2.2.1",
|
||||||
"vue-router": "^4.0.12",
|
"vue-router": "^4.0.12",
|
||||||
|
"vue3-apexcharts": "^1.8.0",
|
||||||
"vuedraggable": "4.1.0"
|
"vuedraggable": "4.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -185,6 +185,17 @@ const addQuizzes = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const addAssignments = () => {
|
||||||
|
if (isInstructor.value || isModerator.value) {
|
||||||
|
sidebarLinks.value.push({
|
||||||
|
label: 'Assignments',
|
||||||
|
icon: 'Pencil',
|
||||||
|
to: 'Assignments',
|
||||||
|
activeFor: ['Assignments', 'AssignmentForm'],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const addPrograms = () => {
|
const addPrograms = () => {
|
||||||
let activeFor = ['Programs', 'ProgramForm']
|
let activeFor = ['Programs', 'ProgramForm']
|
||||||
let index = 1
|
let index = 1
|
||||||
@@ -247,8 +258,9 @@ watch(userResource, () => {
|
|||||||
if (userResource.data) {
|
if (userResource.data) {
|
||||||
isModerator.value = userResource.data.is_moderator
|
isModerator.value = userResource.data.is_moderator
|
||||||
isInstructor.value = userResource.data.is_instructor
|
isInstructor.value = userResource.data.is_instructor
|
||||||
addQuizzes()
|
|
||||||
addPrograms()
|
addPrograms()
|
||||||
|
addQuizzes()
|
||||||
|
addAssignments()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
75
frontend/src/components/AssessmentPlugin.vue
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
v-model="show"
|
||||||
|
:options="{
|
||||||
|
size: 'xl',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #body>
|
||||||
|
<div class="p-5 space-y-4">
|
||||||
|
<div v-if="type == 'quiz'" class="text-lg font-semibold">
|
||||||
|
{{ __('Add a quiz to your lesson') }}
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-lg font-semibold">
|
||||||
|
{{ __('Add an assignment to your lesson') }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Link
|
||||||
|
v-if="type == 'quiz'"
|
||||||
|
v-model="quiz"
|
||||||
|
doctype="LMS Quiz"
|
||||||
|
:label="__('Select a quiz')"
|
||||||
|
:onCreate="(value, close) => redirectToForm()"
|
||||||
|
/>
|
||||||
|
<Link
|
||||||
|
v-else
|
||||||
|
v-model="assignment"
|
||||||
|
doctype="LMS Assignment"
|
||||||
|
:label="__('Select an assignment')"
|
||||||
|
:onCreate="(value, close) => redirectToForm()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end space-x-2">
|
||||||
|
<Button variant="solid" @click="addAssessment()">
|
||||||
|
{{ __('Save') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { Dialog, Button } from 'frappe-ui'
|
||||||
|
import { onMounted, ref, nextTick } from 'vue'
|
||||||
|
import Link from '@/components/Controls/Link.vue'
|
||||||
|
|
||||||
|
const show = ref(false)
|
||||||
|
const quiz = ref(null)
|
||||||
|
const assignment = ref(null)
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
type: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
onAddition: {
|
||||||
|
type: Function,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await nextTick()
|
||||||
|
show.value = true
|
||||||
|
})
|
||||||
|
|
||||||
|
const addAssessment = () => {
|
||||||
|
props.onAddition(props.type == 'quiz' ? quiz.value : assignment.value)
|
||||||
|
show.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const redirectToForm = () => {
|
||||||
|
if (props.type == 'quiz') window.open('/lms/quizzes/new', '_blank')
|
||||||
|
else window.open('/lms/assignments/new', '_blank')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<div class="text-lg font-semibold mb-4">
|
<div class="text-lg font-semibold">
|
||||||
{{ __('Assessments') }}
|
{{ __('Assessments') }}
|
||||||
</div>
|
</div>
|
||||||
<Button v-if="canSeeAddButton()" @click="showModal = true">
|
<Button v-if="canSeeAddButton()" @click="showModal = true">
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
{{ __('Add') }}
|
{{ __('Add') }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="assessments.data?.length">
|
<div v-if="assessments.data?.length" class="text-sm">
|
||||||
<ListView
|
<ListView
|
||||||
:columns="getAssessmentColumns()"
|
:columns="getAssessmentColumns()"
|
||||||
:rows="assessments.data"
|
:rows="assessments.data"
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
:options="{
|
:options="{
|
||||||
showTooltip: false,
|
showTooltip: false,
|
||||||
getRowRoute: (row) => getRowRoute(row),
|
getRowRoute: (row) => getRowRoute(row),
|
||||||
|
selectable: user.data?.is_student ? false : true,
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<ListHeader
|
<ListHeader
|
||||||
@@ -38,7 +39,18 @@
|
|||||||
<ListRow :row="row" v-for="row in assessments.data">
|
<ListRow :row="row" v-for="row in assessments.data">
|
||||||
<template #default="{ column, item }">
|
<template #default="{ column, item }">
|
||||||
<ListRowItem :item="row[column.key]" :align="column.align">
|
<ListRowItem :item="row[column.key]" :align="column.align">
|
||||||
<div>
|
<div v-if="column.key == 'assessment_type'">
|
||||||
|
{{ row[column.key] == 'LMS Quiz' ? 'Quiz' : 'Assignment' }}
|
||||||
|
</div>
|
||||||
|
<div v-else-if="column.key == 'title'">
|
||||||
|
{{ row[column.key] }}
|
||||||
|
</div>
|
||||||
|
<div v-else-if="isNaN(row[column.key])">
|
||||||
|
<Badge :theme="getStatusTheme(row[column.key])">
|
||||||
|
{{ row[column.key] }}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
{{ row[column.key] }}
|
{{ row[column.key] }}
|
||||||
</div>
|
</div>
|
||||||
</ListRowItem>
|
</ListRowItem>
|
||||||
@@ -80,6 +92,7 @@ import {
|
|||||||
ListSelectBanner,
|
ListSelectBanner,
|
||||||
createResource,
|
createResource,
|
||||||
Button,
|
Button,
|
||||||
|
Badge,
|
||||||
} from 'frappe-ui'
|
} from 'frappe-ui'
|
||||||
import { inject, ref } from 'vue'
|
import { inject, ref } from 'vue'
|
||||||
import AssessmentModal from '@/components/Modals/AssessmentModal.vue'
|
import AssessmentModal from '@/components/Modals/AssessmentModal.vue'
|
||||||
@@ -145,7 +158,7 @@ const getRowRoute = (row) => {
|
|||||||
return {
|
return {
|
||||||
name: 'AssignmentSubmission',
|
name: 'AssignmentSubmission',
|
||||||
params: {
|
params: {
|
||||||
assignmentName: row.assessment_name,
|
assignmentID: row.assessment_name,
|
||||||
submissionName: row.submission.name,
|
submissionName: row.submission.name,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -153,7 +166,7 @@ const getRowRoute = (row) => {
|
|||||||
return {
|
return {
|
||||||
name: 'AssignmentSubmission',
|
name: 'AssignmentSubmission',
|
||||||
params: {
|
params: {
|
||||||
assignmentName: row.assessment_name,
|
assignmentID: row.assessment_name,
|
||||||
submissionName: 'new',
|
submissionName: 'new',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -177,20 +190,33 @@ const getAssessmentColumns = () => {
|
|||||||
{
|
{
|
||||||
label: 'Assessment',
|
label: 'Assessment',
|
||||||
key: 'title',
|
key: 'title',
|
||||||
|
width: '25rem',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Type',
|
label: 'Type',
|
||||||
key: 'assessment_type',
|
key: 'assessment_type',
|
||||||
|
width: '15rem',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
if (!user.data?.is_moderator) {
|
if (!user.data?.is_moderator) {
|
||||||
columns.push({
|
columns.push({
|
||||||
label: 'Status/Score',
|
label: 'Status/Percentage',
|
||||||
key: 'status',
|
key: 'status',
|
||||||
align: 'center',
|
align: 'left',
|
||||||
|
width: '10rem',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return columns
|
return columns
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getStatusTheme = (status) => {
|
||||||
|
if (status === 'Pass') {
|
||||||
|
return 'green'
|
||||||
|
} else if (status === 'Not Graded') {
|
||||||
|
return 'orange'
|
||||||
|
} else {
|
||||||
|
return 'red'
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
448
frontend/src/components/Assignment.vue
Normal file
@@ -0,0 +1,448 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="assignment.data"
|
||||||
|
class="grid grid-cols-[68%,32%] h-full"
|
||||||
|
:class="{ 'border rounded-lg': !showTitle }"
|
||||||
|
>
|
||||||
|
<div class="border-r p-5 overflow-y-auto h-[calc(100vh-3.2rem)]">
|
||||||
|
<div v-if="showTitle" class="text-lg font-semibold mb-5">
|
||||||
|
<div v-if="submissionName === 'new'">
|
||||||
|
{{ __('Submission by') }} {{ user.data?.full_name }}
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
{{ __('Submission by') }} {{ submissionResource.doc?.member_name }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-600 font-medium mb-2">
|
||||||
|
{{ __('Question') }}:
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-html="assignment.data.question"
|
||||||
|
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-gray-300 prose-th:border-gray-300 prose-td:relative prose-th:relative prose-th:bg-gray-100 prose-sm max-w-none !whitespace-normal"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<div class="p-5">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<div class="font-semibold">
|
||||||
|
{{ __('Submission') }}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<Badge v-if="isDirty" theme="orange">
|
||||||
|
{{ __('Not Saved') }}
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
v-else-if="submissionResource.doc?.status"
|
||||||
|
:theme="statusTheme"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
{{ submissionResource.doc?.status }}
|
||||||
|
</Badge>
|
||||||
|
<Button variant="solid" @click="submitAssignment()">
|
||||||
|
{{ __('Save') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="
|
||||||
|
submissionName != 'new' &&
|
||||||
|
!['Pass', 'Fail'].includes(submissionResource.doc?.status) &&
|
||||||
|
submissionResource.doc?.owner == user.data?.name
|
||||||
|
"
|
||||||
|
class="bg-blue-100 p-3 rounded-md leading-5 text-sm mb-4"
|
||||||
|
>
|
||||||
|
{{ __("You've successfully submitted the assignment.") }}
|
||||||
|
{{
|
||||||
|
__(
|
||||||
|
"Once the moderator grades your submission, you'll find the details here."
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
{{ __('Feel free to make edits to your submission if needed.') }}
|
||||||
|
</div>
|
||||||
|
<div v-if="showUploader()">
|
||||||
|
<div class="text-xs text-gray-600 mt-1 mb-2">
|
||||||
|
{{ __('Add your assignment as {0}').format(assignment.data.type) }}
|
||||||
|
</div>
|
||||||
|
<FileUploader
|
||||||
|
v-if="!submissionFile"
|
||||||
|
:fileTypes="getType()"
|
||||||
|
:validateFile="validateFile"
|
||||||
|
@success="(file) => saveSubmission(file)"
|
||||||
|
>
|
||||||
|
<template #default="{ uploading, progress, openFileSelector }">
|
||||||
|
<Button @click="openFileSelector" :loading="uploading">
|
||||||
|
{{
|
||||||
|
uploading
|
||||||
|
? __('Uploading {0}%').format(progress)
|
||||||
|
: __('Upload File')
|
||||||
|
}}
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
</FileUploader>
|
||||||
|
<div v-else>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="border rounded-md p-2 mr-2">
|
||||||
|
<FileText class="h-5 w-5 stroke-1.5 text-gray-700" />
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
:href="submissionFile.file_url"
|
||||||
|
target="_blank"
|
||||||
|
class="flex flex-col cursor-pointer !no-underline"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{{ submissionFile.file_name }}
|
||||||
|
</span>
|
||||||
|
<span class="text-sm text-gray-500 mt-1">
|
||||||
|
{{ getFileSize(submissionFile.file_size) }}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<X
|
||||||
|
v-if="canModifyAssignment"
|
||||||
|
@click="removeSubmission()"
|
||||||
|
class="bg-gray-200 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="assignment.data.type == 'URL'">
|
||||||
|
<div class="text-xs text-gray-600 mb-1">
|
||||||
|
{{ __('Enter a URL') }}
|
||||||
|
</div>
|
||||||
|
<FormControl
|
||||||
|
v-model="answer"
|
||||||
|
type="text"
|
||||||
|
:readonly="!canModifyAssignment"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<div class="text-sm mb-4">
|
||||||
|
{{ __('Write your answer here') }}
|
||||||
|
</div>
|
||||||
|
<TextEditor
|
||||||
|
:content="answer"
|
||||||
|
@change="(val) => (answer = val)"
|
||||||
|
:editable="true"
|
||||||
|
:fixedMenu="true"
|
||||||
|
editorClass="prose-sm max-w-none border-b border-x bg-gray-100 rounded-b-md py-1 px-2 min-h-[7rem]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="
|
||||||
|
user.data?.name == submissionResource.doc?.owner &&
|
||||||
|
submissionResource.doc?.comments
|
||||||
|
"
|
||||||
|
class="mt-8 p-3 bg-blue-100 rounded-md"
|
||||||
|
>
|
||||||
|
<div class="text-sm text-gray-600 font-medium mb-2">
|
||||||
|
{{ __('Comments by Evaluator') }}:
|
||||||
|
</div>
|
||||||
|
<div class="leading-5">
|
||||||
|
{{ submissionResource.doc.comments }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Grading -->
|
||||||
|
<div v-if="canGradeSubmission" class="mt-8 space-y-4">
|
||||||
|
<div class="font-semibold mb-2">
|
||||||
|
{{ __('Grading') }}
|
||||||
|
</div>
|
||||||
|
<FormControl
|
||||||
|
v-if="submissionResource.doc"
|
||||||
|
v-model="submissionResource.doc.status"
|
||||||
|
:label="__('Grade')"
|
||||||
|
type="select"
|
||||||
|
:options="submissionStatusOptions"
|
||||||
|
/>
|
||||||
|
<FormControl
|
||||||
|
v-if="submissionResource.doc"
|
||||||
|
v-model="submissionResource.doc.comments"
|
||||||
|
:label="__('Comments')"
|
||||||
|
type="textarea"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
call,
|
||||||
|
createResource,
|
||||||
|
createDocumentResource,
|
||||||
|
FileUploader,
|
||||||
|
FormControl,
|
||||||
|
TextEditor,
|
||||||
|
} from 'frappe-ui'
|
||||||
|
import { computed, inject, onMounted, onBeforeUnmount, ref, watch } from 'vue'
|
||||||
|
import { FileText, X } from 'lucide-vue-next'
|
||||||
|
import { showToast, getFileSize } from '@/utils'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
const submissionFile = ref(null)
|
||||||
|
const answer = ref(null)
|
||||||
|
const router = useRouter()
|
||||||
|
const user = inject('$user')
|
||||||
|
const showTitle = router.currentRoute.value.name == 'AssignmentSubmission'
|
||||||
|
const isDirty = ref(false)
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
assignmentID: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
submissionName: {
|
||||||
|
type: String,
|
||||||
|
default: 'new',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('keydown', keyboardShortcut)
|
||||||
|
})
|
||||||
|
|
||||||
|
const keyboardShortcut = (e) => {
|
||||||
|
if (e.key === 's' && (e.ctrlKey || e.metaKey)) {
|
||||||
|
submitAssignment()
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('keydown', keyboardShortcut)
|
||||||
|
})
|
||||||
|
|
||||||
|
const assignment = createResource({
|
||||||
|
url: 'frappe.client.get',
|
||||||
|
params: {
|
||||||
|
doctype: 'LMS Assignment',
|
||||||
|
name: props.assignmentID,
|
||||||
|
},
|
||||||
|
auto: true,
|
||||||
|
onSuccess(data) {
|
||||||
|
if (props.submissionName != 'new') {
|
||||||
|
submissionResource.reload()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const newSubmission = createResource({
|
||||||
|
url: 'frappe.client.insert',
|
||||||
|
makeParams(values) {
|
||||||
|
let doc = {
|
||||||
|
doctype: 'LMS Assignment Submission',
|
||||||
|
assignment: props.assignmentID,
|
||||||
|
member: user.data?.name,
|
||||||
|
}
|
||||||
|
if (showUploader()) {
|
||||||
|
doc.assignment_attachment = submissionFile.value.file_url
|
||||||
|
} else {
|
||||||
|
doc.answer = answer.value
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
doc: doc,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const imageResource = createResource({
|
||||||
|
url: 'lms.lms.api.get_file_info',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
file_url: values.image,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
auto: false,
|
||||||
|
onSuccess(data) {
|
||||||
|
submissionFile.value = data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const submissionResource = createDocumentResource({
|
||||||
|
doctype: 'LMS Assignment Submission',
|
||||||
|
name: props.submissionName,
|
||||||
|
onError(err) {
|
||||||
|
showToast(__('Error'), __(err.messages?.[0] || err), 'x')
|
||||||
|
},
|
||||||
|
auto: false,
|
||||||
|
cache: [user.data?.name, props.assignmentID],
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(submissionResource, () => {
|
||||||
|
if (submissionResource.doc) {
|
||||||
|
if (submissionResource.doc.assignment_attachment) {
|
||||||
|
imageResource.reload({
|
||||||
|
image: submissionResource.doc.assignment_attachment,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (submissionResource.doc.answer) {
|
||||||
|
answer.value = submissionResource.doc.answer
|
||||||
|
}
|
||||||
|
|
||||||
|
if (submissionResource.isDirty) {
|
||||||
|
isDirty.value = true
|
||||||
|
} else if (showUploader() && !submissionFile.value) {
|
||||||
|
isDirty.value = true
|
||||||
|
} else if (!showUploader() && !answer.value) {
|
||||||
|
isDirty.value = true
|
||||||
|
} else {
|
||||||
|
isDirty.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(submissionFile, () => {
|
||||||
|
if (props.submissionName == 'new' && submissionFile.value) {
|
||||||
|
isDirty.value = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const submitAssignment = () => {
|
||||||
|
if (props.submissionName != 'new') {
|
||||||
|
let evaluator =
|
||||||
|
submissionResource.doc && submissionResource.doc.owner != user.data?.name
|
||||||
|
? user.data?.name
|
||||||
|
: null
|
||||||
|
submissionResource.setValue.submit(
|
||||||
|
{
|
||||||
|
...submissionResource.doc,
|
||||||
|
evaluator: evaluator,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess(data) {
|
||||||
|
showToast(__('Success'), __('Changes saved successfully'), 'check')
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
addNewSubmission()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const addNewSubmission = () => {
|
||||||
|
newSubmission.submit(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
onSuccess(data) {
|
||||||
|
showToast('Success', 'Assignment submitted successfully.', 'check')
|
||||||
|
if (router.currentRoute.value.name == 'AssignmentSubmission') {
|
||||||
|
router.push({
|
||||||
|
name: 'AssignmentSubmission',
|
||||||
|
params: {
|
||||||
|
assignmentID: props.assignmentID,
|
||||||
|
submissionName: data.name,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
markLessonProgress()
|
||||||
|
router.go()
|
||||||
|
}
|
||||||
|
submissionResource.name = data.name
|
||||||
|
submissionResource.reload()
|
||||||
|
},
|
||||||
|
onError(err) {
|
||||||
|
showToast('Error', err.messages?.[0] || err, 'x')
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveSubmission = (file) => {
|
||||||
|
submissionFile.value = file
|
||||||
|
}
|
||||||
|
|
||||||
|
const markLessonProgress = () => {
|
||||||
|
if (router.currentRoute.value.name == 'Lesson') {
|
||||||
|
let courseName = router.currentRoute.value.params.courseName
|
||||||
|
let chapterNumber = router.currentRoute.value.params.chapterNumber
|
||||||
|
let lessonNumber = router.currentRoute.value.params.lessonNumber
|
||||||
|
|
||||||
|
call('lms.lms.api.mark_lesson_progress', {
|
||||||
|
course: courseName,
|
||||||
|
chapter_number: chapterNumber,
|
||||||
|
lesson_number: lessonNumber,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getType = () => {
|
||||||
|
const type = assignment.data?.type
|
||||||
|
if (type == 'Image') {
|
||||||
|
return ['image/*']
|
||||||
|
} else if (type == 'Document') {
|
||||||
|
return [
|
||||||
|
'.doc',
|
||||||
|
'.docx',
|
||||||
|
'.xml',
|
||||||
|
'application/msword',
|
||||||
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
]
|
||||||
|
} else if (type == 'PDF') {
|
||||||
|
return ['.pdf']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const validateFile = (file) => {
|
||||||
|
let type = assignment.data?.type
|
||||||
|
let extension = file.name.split('.').pop().toLowerCase()
|
||||||
|
if (type == 'Image' && !['jpg', 'jpeg', 'png'].includes(extension)) {
|
||||||
|
return 'Only image file is allowed.'
|
||||||
|
} else if (
|
||||||
|
type == 'Document' &&
|
||||||
|
!['doc', 'docx', 'xml'].includes(extension)
|
||||||
|
) {
|
||||||
|
return 'Only document file is allowed.'
|
||||||
|
} else if (type == 'PDF' && !['pdf'].includes(extension)) {
|
||||||
|
return 'Only PDF file is allowed.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeSubmission = () => {
|
||||||
|
submissionFile.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const canGradeSubmission = computed(() => {
|
||||||
|
return (
|
||||||
|
(user.data?.is_moderator ||
|
||||||
|
user.data?.is_evaluator ||
|
||||||
|
user.data?.is_instructor) &&
|
||||||
|
props.submissionName != 'new' &&
|
||||||
|
router.currentRoute.value.name == 'AssignmentSubmission'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const canModifyAssignment = computed(() => {
|
||||||
|
return (
|
||||||
|
!submissionResource.doc ||
|
||||||
|
(submissionResource.doc?.owner == user.data?.name &&
|
||||||
|
submissionResource.doc?.status == 'Not Graded')
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const submissionStatusOptions = computed(() => {
|
||||||
|
return [
|
||||||
|
{ label: 'Not Graded', value: 'Not Graded' },
|
||||||
|
{ label: 'Pass', value: 'Pass' },
|
||||||
|
{ label: 'Fail', value: 'Fail' },
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const statusTheme = computed(() => {
|
||||||
|
if (!submissionResource.doc) {
|
||||||
|
return 'orange'
|
||||||
|
} else if (submissionResource.doc.status == 'Pass') {
|
||||||
|
return 'green'
|
||||||
|
} else if (submissionResource.doc.status == 'Not Graded') {
|
||||||
|
return 'blue'
|
||||||
|
} else {
|
||||||
|
return 'red'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const showUploader = () => {
|
||||||
|
return ['PDF', 'Image', 'Document'].includes(assignment.data?.type)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
46
frontend/src/components/AssignmentBlock.vue
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<template>
|
||||||
|
<Assignment
|
||||||
|
v-if="user.data && submission.data"
|
||||||
|
:assignmentID="assignmentID"
|
||||||
|
:submissionName="submission.data?.name || 'new'"
|
||||||
|
/>
|
||||||
|
<div v-else class="border rounded-md text-center py-20">
|
||||||
|
<div>
|
||||||
|
{{ __('Please login to access the assignment.') }}
|
||||||
|
</div>
|
||||||
|
<Button @click="redirectToLogin()" class="mt-2">
|
||||||
|
<span>
|
||||||
|
{{ __('Login') }}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { inject, watch } from 'vue'
|
||||||
|
import { Button, createResource } from 'frappe-ui'
|
||||||
|
import Assignment from '@/components/Assignment.vue'
|
||||||
|
|
||||||
|
const user = inject('$user')
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
assignmentID: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const submission = createResource({
|
||||||
|
url: 'frappe.client.get_value',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
doctype: 'LMS Assignment Submission',
|
||||||
|
fieldname: 'name',
|
||||||
|
filters: {
|
||||||
|
assignment: props.assignmentID,
|
||||||
|
member: user.data?.name,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
auto: true,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<div class="text-xl font-semibold">
|
<div class="text-lg font-semibold">
|
||||||
{{ __('Courses') }}
|
{{ __('Courses') }}
|
||||||
</div>
|
</div>
|
||||||
<Button v-if="canSeeAddButton()" @click="openCourseModal()">
|
<Button v-if="canSeeAddButton()" @click="openCourseModal()">
|
||||||
@@ -118,13 +118,13 @@ const getCoursesColumns = () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Lessons',
|
label: 'Lessons',
|
||||||
key: 'lesson_count',
|
key: 'lessons',
|
||||||
align: 'right',
|
align: 'right',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Enrollments',
|
label: 'Enrollments',
|
||||||
align: 'right',
|
align: 'right',
|
||||||
key: 'enrollment_count',
|
key: 'enrollments',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="space-y-10">
|
||||||
<UpcomingEvaluations
|
<UpcomingEvaluations
|
||||||
:batch="batch.data.name"
|
:batch="batch.data.name"
|
||||||
:endDate="batch.data.evaluation_end_date"
|
:endDate="batch.data.evaluation_end_date"
|
||||||
:courses="batch.data.courses"
|
:courses="batch.data.courses"
|
||||||
:isStudent="isStudent"
|
|
||||||
/>
|
/>
|
||||||
<Assessments :batch="batch.data.name" />
|
<Assessments :batch="batch.data.name" />
|
||||||
|
<StudentHeatmap />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import UpcomingEvaluations from '@/components/UpcomingEvaluations.vue'
|
import UpcomingEvaluations from '@/components/UpcomingEvaluations.vue'
|
||||||
import Assessments from '@/components/Assessments.vue'
|
import Assessments from '@/components/Assessments.vue'
|
||||||
|
import StudentHeatmap from '@/components/StudentHeatmap.vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
batch: {
|
batch: {
|
||||||
|
|||||||
@@ -1,80 +1,199 @@
|
|||||||
<template>
|
<template>
|
||||||
<Button class="float-right mb-3" @click="openStudentModal()">
|
<div class="">
|
||||||
<template #prefix>
|
<div class="w-full flex items-center justify-between pb-4">
|
||||||
<Plus class="h-4 w-4" />
|
<div class="font-medium text-gray-600">
|
||||||
</template>
|
{{ __('Statistics') }}
|
||||||
{{ __('Add') }}
|
</div>
|
||||||
</Button>
|
</div>
|
||||||
<div class="text-lg font-semibold mb-4">
|
<div class="grid grid-cols-3 gap-5 mb-8">
|
||||||
{{ __('Students') }}
|
<div class="flex items-center shadow py-2 px-3 rounded-md">
|
||||||
</div>
|
<div class="p-2 rounded-md bg-gray-100 mr-3">
|
||||||
<div v-if="students.data?.length">
|
<User class="w-5 h-5 stroke-1.5 text-gray-700" />
|
||||||
<ListView
|
</div>
|
||||||
:columns="getStudentColumns()"
|
<div class="flex items-center space-x-2">
|
||||||
:rows="students.data"
|
<span class="font-semibold">
|
||||||
row-key="name"
|
{{ students.data?.length }}
|
||||||
:options="{ showTooltip: false }"
|
</span>
|
||||||
>
|
<span class="text-gray-700">
|
||||||
<ListHeader
|
{{ __('Students') }}
|
||||||
class="mb-2 grid items-center space-x-4 rounded bg-gray-100 p-2"
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center shadow py-2 px-3 rounded-md">
|
||||||
|
<div class="p-2 rounded-md bg-gray-100 mr-3">
|
||||||
|
<BookOpen class="w-5 h-5 stroke-1.5 text-gray-700" />
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<span class="font-semibold">
|
||||||
|
{{ batch.courses?.length }}
|
||||||
|
</span>
|
||||||
|
<span class="text-gray-700">
|
||||||
|
{{ __('Courses') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center shadow py-2 px-3 rounded-md">
|
||||||
|
<div class="p-2 rounded-md bg-gray-100 mr-3">
|
||||||
|
<ShieldCheck class="w-5 h-5 stroke-1.5 text-gray-700" />
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<span class="font-semibold">
|
||||||
|
{{ assessmentCount }}
|
||||||
|
</span>
|
||||||
|
<span class="text-gray-700">
|
||||||
|
{{ __('Assessments') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="showProgressChart" class="mb-8">
|
||||||
|
<div class="text-gray-600 font-medium">
|
||||||
|
{{ __('Progress') }}
|
||||||
|
</div>
|
||||||
|
<ApexChart
|
||||||
|
:options="chartOptions"
|
||||||
|
:series="chartData"
|
||||||
|
type="bar"
|
||||||
|
height="200"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-center text-sm text-gray-700 space-x-4"
|
||||||
>
|
>
|
||||||
<ListHeaderItem :item="item" v-for="item in getStudentColumns()">
|
<div class="flex items-center space-x-2">
|
||||||
<template #prefix="{ item }">
|
<div
|
||||||
<component
|
class="w-3 h-3 rounded-sm"
|
||||||
v-if="item.icon"
|
:style="{ 'background-color': theme.colors.green[600] }"
|
||||||
:is="item.icon"
|
></div>
|
||||||
class="h-4 w-4 stroke-1.5 ml-4"
|
<div>
|
||||||
/>
|
{{ __('Courses') }}
|
||||||
</template>
|
|
||||||
</ListHeaderItem>
|
|
||||||
</ListHeader>
|
|
||||||
<ListRows>
|
|
||||||
<ListRow :row="row" v-for="row in students.data">
|
|
||||||
<template #default="{ column, item }">
|
|
||||||
<ListRowItem :item="row[column.key]" :align="column.align">
|
|
||||||
<template #prefix>
|
|
||||||
<div v-if="column.key == 'full_name'">
|
|
||||||
<Avatar
|
|
||||||
class="flex items-center"
|
|
||||||
:image="row['user_image']"
|
|
||||||
:label="item"
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<div>
|
|
||||||
{{ row[column.key] }}
|
|
||||||
</div>
|
|
||||||
</ListRowItem>
|
|
||||||
</template>
|
|
||||||
</ListRow>
|
|
||||||
</ListRows>
|
|
||||||
<ListSelectBanner>
|
|
||||||
<template #actions="{ unselectAll, selections }">
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
@click="removeStudents(selections, unselectAll)"
|
|
||||||
>
|
|
||||||
<Trash2 class="h-4 w-4 stroke-1.5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<div
|
||||||
|
class="w-3 h-3 rounded-sm"
|
||||||
|
:style="{ 'background-color': theme.colors.blue[600] }"
|
||||||
|
></div>
|
||||||
|
<div>
|
||||||
|
{{ __('Assessments') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<div class="text-gray-600 font-medium">
|
||||||
|
{{ __('Students') }}
|
||||||
|
</div>
|
||||||
|
<Button @click="openStudentModal()">
|
||||||
|
<template #prefix>
|
||||||
|
<Plus class="h-4 w-4" />
|
||||||
</template>
|
</template>
|
||||||
</ListSelectBanner>
|
{{ __('Add') }}
|
||||||
</ListView>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="text-sm italic text-gray-600">
|
|
||||||
{{ __('There are no students in this batch.') }}
|
<div v-if="students.data?.length">
|
||||||
|
<ListView
|
||||||
|
:columns="getStudentColumns()"
|
||||||
|
:rows="students.data"
|
||||||
|
row-key="name"
|
||||||
|
:options="{
|
||||||
|
showTooltip: false,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<ListHeader
|
||||||
|
class="mb-2 grid items-center space-x-4 rounded bg-gray-100 p-2"
|
||||||
|
>
|
||||||
|
<ListHeaderItem
|
||||||
|
:item="item"
|
||||||
|
v-for="item in getStudentColumns()"
|
||||||
|
:title="item.label"
|
||||||
|
>
|
||||||
|
<template #prefix="{ item }">
|
||||||
|
<FeatherIcon
|
||||||
|
v-if="item.icon"
|
||||||
|
:name="item.icon"
|
||||||
|
class="h-4 w-4 stroke-1.5"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</ListHeaderItem>
|
||||||
|
</ListHeader>
|
||||||
|
<ListRows>
|
||||||
|
<ListRow
|
||||||
|
:row="row"
|
||||||
|
v-for="row in students.data"
|
||||||
|
class="group cursor-pointer"
|
||||||
|
@click="openStudentProgressModal(row)"
|
||||||
|
>
|
||||||
|
<template #default="{ column, item }">
|
||||||
|
<ListRowItem
|
||||||
|
:item="row[column.key]"
|
||||||
|
:align="column.align"
|
||||||
|
class="text-sm"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<div v-if="column.key == 'full_name'">
|
||||||
|
<Avatar
|
||||||
|
class="flex items-center"
|
||||||
|
:image="row['user_image']"
|
||||||
|
:label="item"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div
|
||||||
|
v-if="column.key == 'progress'"
|
||||||
|
class="flex items-center space-x-4 w-full"
|
||||||
|
>
|
||||||
|
<ProgressBar :progress="row[column.key]" size="sm" />
|
||||||
|
<div class="text-xs">{{ row[column.key] }}%</div>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
{{ row[column.key] }}
|
||||||
|
</div>
|
||||||
|
</ListRowItem>
|
||||||
|
</template>
|
||||||
|
</ListRow>
|
||||||
|
</ListRows>
|
||||||
|
<ListSelectBanner>
|
||||||
|
<template #actions="{ unselectAll, selections }">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
@click="removeStudents(selections, unselectAll)"
|
||||||
|
>
|
||||||
|
<Trash2 class="h-4 w-4 stroke-1.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ListSelectBanner>
|
||||||
|
</ListView>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-sm italic text-gray-600">
|
||||||
|
{{ __('There are no students in this batch.') }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<StudentModal
|
<StudentModal
|
||||||
:batch="props.batch"
|
:batch="props.batch.name"
|
||||||
v-model="showStudentModal"
|
v-model="showStudentModal"
|
||||||
v-model:reloadStudents="students"
|
v-model:reloadStudents="students"
|
||||||
/>
|
/>
|
||||||
|
<BatchStudentProgress
|
||||||
|
:student="selectedStudent"
|
||||||
|
v-model="showStudentProgressModal"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import {
|
import {
|
||||||
|
Avatar,
|
||||||
|
Button,
|
||||||
createResource,
|
createResource,
|
||||||
|
FeatherIcon,
|
||||||
ListHeader,
|
ListHeader,
|
||||||
ListHeaderItem,
|
ListHeaderItem,
|
||||||
ListSelectBanner,
|
ListSelectBanner,
|
||||||
@@ -82,60 +201,86 @@ import {
|
|||||||
ListRows,
|
ListRows,
|
||||||
ListView,
|
ListView,
|
||||||
ListRowItem,
|
ListRowItem,
|
||||||
Avatar,
|
|
||||||
Button,
|
|
||||||
} from 'frappe-ui'
|
} from 'frappe-ui'
|
||||||
import { Trash2, Plus } from 'lucide-vue-next'
|
import {
|
||||||
import { ref } from 'vue'
|
BookOpen,
|
||||||
|
Clipboard,
|
||||||
|
Plus,
|
||||||
|
ShieldCheck,
|
||||||
|
Trash2,
|
||||||
|
User,
|
||||||
|
} from 'lucide-vue-next'
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
import StudentModal from '@/components/Modals/StudentModal.vue'
|
import StudentModal from '@/components/Modals/StudentModal.vue'
|
||||||
import { showToast } from '@/utils'
|
import { showToast } from '@/utils'
|
||||||
|
import ProgressBar from '@/components/ProgressBar.vue'
|
||||||
|
import BatchStudentProgress from '@/components/Modals/BatchStudentProgress.vue'
|
||||||
|
import ApexChart from 'vue3-apexcharts'
|
||||||
|
import { theme } from '@/utils/theme'
|
||||||
|
|
||||||
const showStudentModal = ref(false)
|
const showStudentModal = ref(false)
|
||||||
|
const showStudentProgressModal = ref(false)
|
||||||
|
const selectedStudent = ref(null)
|
||||||
|
const chartData = ref(null)
|
||||||
|
const chartOptions = ref(null)
|
||||||
|
const showProgressChart = ref(false)
|
||||||
|
const assessmentCount = ref(0)
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
batch: {
|
batch: {
|
||||||
type: String,
|
type: Object,
|
||||||
default: null,
|
default: null,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const students = createResource({
|
const students = createResource({
|
||||||
url: 'lms.lms.utils.get_batch_students',
|
url: 'lms.lms.utils.get_batch_students',
|
||||||
cache: ['students', props.batch],
|
cache: ['students', props.batch.name],
|
||||||
params: {
|
params: {
|
||||||
batch: props.batch,
|
batch: props.batch?.name,
|
||||||
},
|
},
|
||||||
auto: true,
|
auto: true,
|
||||||
|
onSuccess(data) {
|
||||||
|
chartData.value = getChartData()
|
||||||
|
showProgressChart.value = true
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const getStudentColumns = () => {
|
const getStudentColumns = () => {
|
||||||
return [
|
let columns = [
|
||||||
{
|
{
|
||||||
label: 'Full Name',
|
label: 'Full Name',
|
||||||
key: 'full_name',
|
key: 'full_name',
|
||||||
width: 2,
|
width: '20rem',
|
||||||
|
icon: 'user',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Courses Done',
|
label: 'Progress',
|
||||||
key: 'courses_completed',
|
key: 'progress',
|
||||||
align: 'center',
|
width: '15rem',
|
||||||
},
|
icon: 'activity',
|
||||||
{
|
|
||||||
label: 'Assessments Done',
|
|
||||||
key: 'assessments_completed',
|
|
||||||
align: 'center',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Last Active',
|
label: 'Last Active',
|
||||||
key: 'last_active',
|
key: 'last_active',
|
||||||
|
width: '10rem',
|
||||||
|
align: 'center',
|
||||||
|
icon: 'clock',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
return columns
|
||||||
}
|
}
|
||||||
|
|
||||||
const openStudentModal = () => {
|
const openStudentModal = () => {
|
||||||
showStudentModal.value = true
|
showStudentModal.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openStudentProgressModal = (row) => {
|
||||||
|
showStudentProgressModal.value = true
|
||||||
|
selectedStudent.value = row
|
||||||
|
}
|
||||||
|
|
||||||
const deleteStudents = createResource({
|
const deleteStudents = createResource({
|
||||||
url: 'lms.lms.api.delete_documents',
|
url: 'lms.lms.api.delete_documents',
|
||||||
makeParams(values) {
|
makeParams(values) {
|
||||||
@@ -160,4 +305,106 @@ const removeStudents = (selections, unselectAll) => {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getChartData = () => {
|
||||||
|
let categories = {}
|
||||||
|
|
||||||
|
Object.keys(students.data?.[0].courses).forEach((course) => {
|
||||||
|
categories[course] = {
|
||||||
|
value: 0,
|
||||||
|
type: 'course',
|
||||||
|
label: course,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
Object.keys(students.data?.[0].assessments).forEach((assessment) => {
|
||||||
|
categories[assessment] = {
|
||||||
|
value: 0,
|
||||||
|
type: 'assessment',
|
||||||
|
label: assessment,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
students.data.forEach((student) => {
|
||||||
|
Object.keys(student.courses).forEach((course) => {
|
||||||
|
if (student.courses[course] === 100) {
|
||||||
|
categories[course].value += 1
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
Object.keys(student.assessments).forEach((assessment) => {
|
||||||
|
if (student.assessments[assessment] === 100) {
|
||||||
|
categories[assessment].value += 1
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
chartOptions.value = getChartOptions(categories)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: __('Completed by Students'),
|
||||||
|
data: Object.values(categories).map((item) => item.value),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const getChartOptions = (categories) => {
|
||||||
|
const courseColor = theme.colors.green[700]
|
||||||
|
const assessmentColor = theme.colors.blue[700]
|
||||||
|
const maxY =
|
||||||
|
students.data?.length % 5
|
||||||
|
? students.data?.length + (5 - (students.data?.length % 5))
|
||||||
|
: students.data?.length
|
||||||
|
|
||||||
|
return {
|
||||||
|
chart: {
|
||||||
|
type: 'bar',
|
||||||
|
toolbar: {
|
||||||
|
show: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plotOptions: {
|
||||||
|
bar: {
|
||||||
|
distributed: true,
|
||||||
|
borderRadius: 3,
|
||||||
|
borderRadiusApplication: 'end',
|
||||||
|
horizontal: true,
|
||||||
|
barHeight: '40%',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
colors: Object.values(categories).map((item) =>
|
||||||
|
item.type === 'course' ? courseColor : assessmentColor
|
||||||
|
),
|
||||||
|
xaxis: {
|
||||||
|
categories: Object.values(categories).map((item) => item.label),
|
||||||
|
labels: {
|
||||||
|
style: {
|
||||||
|
fontSize: '10px',
|
||||||
|
},
|
||||||
|
rotate: 0,
|
||||||
|
formatter: function (value) {
|
||||||
|
return value.length > 30 ? `${value.substring(0, 30)}...` : value // Trim long labels
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
yaxis: {
|
||||||
|
max: maxY,
|
||||||
|
min: 0,
|
||||||
|
stepSize: 10,
|
||||||
|
tickAmount: maxY / 5,
|
||||||
|
/* reversed: true */
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(students, () => {
|
||||||
|
if (students.data?.length) {
|
||||||
|
assessmentCount.value = Object.keys(students.data?.[0].assessments).length
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
<style>
|
||||||
|
.apexcharts-legend {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -29,8 +29,8 @@
|
|||||||
<slot name="item-label" v-bind="{ active, selected, option }" />
|
<slot name="item-label" v-bind="{ active, selected, option }" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-if="attrs.onCreate" #footer="{ value, close }">
|
<template #footer="{ value, close }">
|
||||||
<div>
|
<div v-if="attrs.onCreate">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="w-full !justify-start"
|
class="w-full !justify-start"
|
||||||
@@ -42,6 +42,18 @@
|
|||||||
</template>
|
</template>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
class="w-full !justify-start"
|
||||||
|
:label="__('Clear')"
|
||||||
|
@click="() => clearValue(close)"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<X class="h-4 w-4 stroke-1.5" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Autocomplete>
|
</Autocomplete>
|
||||||
<p v-if="description" class="text-sm text-gray-600">{{ description }}</p>
|
<p v-if="description" class="text-sm text-gray-600">{{ description }}</p>
|
||||||
@@ -52,7 +64,7 @@
|
|||||||
import Autocomplete from '@/components/Controls/Autocomplete.vue'
|
import Autocomplete from '@/components/Controls/Autocomplete.vue'
|
||||||
import { watchDebounced } from '@vueuse/core'
|
import { watchDebounced } from '@vueuse/core'
|
||||||
import { createResource, Button } from 'frappe-ui'
|
import { createResource, Button } from 'frappe-ui'
|
||||||
import { Plus } from 'lucide-vue-next'
|
import { Plus, X } from 'lucide-vue-next'
|
||||||
import { useAttrs, computed, ref } from 'vue'
|
import { useAttrs, computed, ref } from 'vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -75,9 +87,7 @@ const props = defineProps({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue', 'change'])
|
const emit = defineEmits(['update:modelValue', 'change'])
|
||||||
|
|
||||||
const attrs = useAttrs()
|
const attrs = useAttrs()
|
||||||
|
|
||||||
const valuePropPassed = computed(() => 'value' in attrs)
|
const valuePropPassed = computed(() => 'value' in attrs)
|
||||||
|
|
||||||
const value = computed({
|
const value = computed({
|
||||||
@@ -131,7 +141,7 @@ const options = createResource({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
function reload(val) {
|
const reload = (val) => {
|
||||||
options.update({
|
options.update({
|
||||||
params: {
|
params: {
|
||||||
txt: val,
|
txt: val,
|
||||||
@@ -142,6 +152,11 @@ function reload(val) {
|
|||||||
options.reload()
|
options.reload()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const clearValue = (close) => {
|
||||||
|
emit(valuePropPassed.value ? 'change' : 'update:modelValue', '')
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
|
||||||
const labelClasses = computed(() => {
|
const labelClasses = computed(() => {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -59,7 +59,7 @@
|
|||||||
|
|
||||||
<div v-if="course.status != 'Approved'">
|
<div v-if="course.status != 'Approved'">
|
||||||
<Badge
|
<Badge
|
||||||
variant="solid"
|
variant="subtle"
|
||||||
:theme="course.status === 'Under Review' ? 'orange' : 'blue'"
|
:theme="course.status === 'Under Review' ? 'orange' : 'blue'"
|
||||||
size="sm"
|
size="sm"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -87,25 +87,29 @@
|
|||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</router-link>
|
</router-link>
|
||||||
<div class="mt-8 mb-4 font-medium">
|
<div class="space-y-4">
|
||||||
{{ __('This course has:') }}
|
<div class="mt-8 font-medium">
|
||||||
</div>
|
{{ __('This course has:') }}
|
||||||
<div class="flex items-center mb-3">
|
</div>
|
||||||
<BookOpen class="h-5 w-5 stroke-1.5 text-gray-600" />
|
<div class="flex items-center">
|
||||||
<span class="ml-2">
|
<BookOpen class="h-4 w-4 stroke-1.5 text-gray-600" />
|
||||||
{{ course.data.lessons }} {{ __('Lessons') }}
|
<span class="ml-2">
|
||||||
</span>
|
{{ course.data.lessons }} {{ __('Lessons') }}
|
||||||
</div>
|
</span>
|
||||||
<div class="flex items-center mb-3">
|
</div>
|
||||||
<Users class="h-5 w-5 stroke-1.5 text-gray-600" />
|
<div class="flex items-center">
|
||||||
<span class="ml-2">
|
<Users class="h-4 w-4 stroke-1.5 text-gray-600" />
|
||||||
{{ formatAmount(course.data.enrollments) }}
|
<span class="ml-2">
|
||||||
{{ __('Enrolled Students') }}
|
{{ formatAmount(course.data.enrollments) }}
|
||||||
</span>
|
{{ __('Enrolled Students') }}
|
||||||
</div>
|
</span>
|
||||||
<div class="flex items-center">
|
</div>
|
||||||
<Star class="h-5 w-5 stroke-1.5 fill-orange-500 text-gray-50" />
|
<div v-if="parseInt(course.data.rating) > 0" class="flex items-center">
|
||||||
<span class="ml-2"> {{ course.data.rating }} {{ __('Rating') }} </span>
|
<Star class="h-4 w-4 stroke-1.5 fill-orange-500 text-gray-50" />
|
||||||
|
<span class="ml-2">
|
||||||
|
{{ course.data.rating }} {{ __('Rating') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,20 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="space-y-5">
|
<div class="space-y-5">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex items-center text-sm font-medium space-x-2">
|
||||||
|
<span>
|
||||||
|
{{ __('What does include in preview mean?') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-600 mb-1 leading-5">
|
||||||
|
{{
|
||||||
|
__(
|
||||||
|
'If Include in Preview is enabled for a lesson then the lesson will also be accessible to non logged in users.'
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<div
|
<div
|
||||||
class="flex items-center text-sm font-medium space-x-2 cursor-pointer"
|
class="flex items-center text-sm font-medium space-x-2 cursor-pointer"
|
||||||
@@ -56,21 +71,6 @@
|
|||||||
}}
|
}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-2">
|
|
||||||
<div class="flex items-center text-sm font-medium space-x-2">
|
|
||||||
<span>
|
|
||||||
{{ __('What does include in preview mean?') }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="text-xs text-gray-600 mb-1 leading-5">
|
|
||||||
{{
|
|
||||||
__(
|
|
||||||
'If Include in Preview is enabled for a lesson then the lesson will also be accessible to non logged in users.'
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<ExplanationVideos v-model="showExplanation" :title="title" :type="type" />
|
<ExplanationVideos v-model="showExplanation" :title="title" :type="type" />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -15,45 +15,55 @@
|
|||||||
<div v-if="liveClasses.data?.length" class="grid grid-cols-2 gap-5">
|
<div v-if="liveClasses.data?.length" class="grid grid-cols-2 gap-5">
|
||||||
<div
|
<div
|
||||||
v-for="cls in liveClasses.data"
|
v-for="cls in liveClasses.data"
|
||||||
class="flex flex-col border rounded-md h-full text-sm text-gray-700 p-3"
|
class="flex flex-col border rounded-md h-full text-gray-700 p-3"
|
||||||
>
|
>
|
||||||
<div class="font-semibold text-gray-900 text-lg mb-4">
|
<div class="font-semibold text-gray-900 text-lg mb-1">
|
||||||
{{ cls.title }}
|
{{ cls.title }}
|
||||||
</div>
|
</div>
|
||||||
<div class="leading-5 text-gray-700 text-sm mb-4">
|
<div class="short-introduction">
|
||||||
{{ cls.description }}
|
{{ cls.description }}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center mb-2">
|
<div class="space-y-3">
|
||||||
<Calendar class="w-4 h-4 stroke-1.5 text-gray-700" />
|
<div class="flex items-center space-x-2">
|
||||||
<span class="ml-2">
|
<Calendar class="w-4 h-4 stroke-1.5" />
|
||||||
{{ dayjs(cls.date).format('DD MMMM YYYY') }}
|
<span>
|
||||||
</span>
|
{{ dayjs(cls.date).format('DD MMMM YYYY') }}
|
||||||
</div>
|
</span>
|
||||||
<div class="flex items-center mb-5">
|
</div>
|
||||||
<Clock class="w-4 h-4 stroke-1.5" />
|
<div class="flex items-center space-x-2">
|
||||||
<span class="ml-2">
|
<Clock class="w-4 h-4 stroke-1.5" />
|
||||||
{{ formatTime(cls.time) }}
|
<span>
|
||||||
</span>
|
{{ formatTime(cls.time) }}
|
||||||
</div>
|
</span>
|
||||||
<div class="flex items-center space-x-2 text-gray-900 mt-auto">
|
</div>
|
||||||
<a
|
<div
|
||||||
v-if="user.data?.is_moderator || user.data?.is_evaluator"
|
v-if="cls.date >= dayjs().format('YYYY-MM-DD')"
|
||||||
:href="cls.start_url"
|
class="flex items-center space-x-2 text-gray-900 mt-auto"
|
||||||
target="_blank"
|
|
||||||
class="w-1/2 cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-gray-800 bg-gray-100 hover:bg-gray-200 active:bg-gray-300 focus-visible:ring focus-visible:ring-gray-400 h-7 text-base px-2 rounded"
|
|
||||||
>
|
>
|
||||||
<Monitor class="h-4 w-4 stroke-1.5" />
|
<a
|
||||||
{{ __('Start') }}
|
v-if="user.data?.is_moderator || user.data?.is_evaluator"
|
||||||
</a>
|
:href="cls.start_url"
|
||||||
<a
|
target="_blank"
|
||||||
v-if="cls.date <= dayjs().format('YYYY-MM-DD')"
|
class="w-1/2 cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-gray-800 bg-gray-100 hover:bg-gray-200 active:bg-gray-300 focus-visible:ring focus-visible:ring-gray-400 h-7 text-base px-2 rounded"
|
||||||
:href="cls.join_url"
|
>
|
||||||
target="_blank"
|
<Monitor class="h-4 w-4 stroke-1.5" />
|
||||||
class="w-full cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-gray-800 bg-gray-100 hover:bg-gray-200 active:bg-gray-300 focus-visible:ring focus-visible:ring-gray-400 h-7 text-base px-2 rounded"
|
{{ __('Start') }}
|
||||||
>
|
</a>
|
||||||
<Video class="h-4 w-4 stroke-1.5" />
|
<a
|
||||||
{{ __('Join') }}
|
:href="cls.join_url"
|
||||||
</a>
|
target="_blank"
|
||||||
|
class="w-full cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-gray-800 bg-gray-100 hover:bg-gray-200 active:bg-gray-300 focus-visible:ring focus-visible:ring-gray-400 h-7 text-base px-2 rounded"
|
||||||
|
>
|
||||||
|
<Video class="h-4 w-4 stroke-1.5" />
|
||||||
|
{{ __('Join') }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div v-else class="flex items-center space-x-2 text-yellow-700">
|
||||||
|
<Info class="w-4 h-4 stroke-1.5" />
|
||||||
|
<span>
|
||||||
|
{{ __('This class has ended') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -68,7 +78,7 @@
|
|||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { createListResource, Button } from 'frappe-ui'
|
import { createListResource, Button } from 'frappe-ui'
|
||||||
import { Plus, Clock, Calendar, Video, Monitor } from 'lucide-vue-next'
|
import { Plus, Clock, Calendar, Video, Monitor, Info } from 'lucide-vue-next'
|
||||||
import { inject } from 'vue'
|
import { inject } from 'vue'
|
||||||
import LiveClassModal from '@/components/Modals/LiveClassModal.vue'
|
import LiveClassModal from '@/components/Modals/LiveClassModal.vue'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
@@ -107,3 +117,15 @@ const openLiveClassModal = () => {
|
|||||||
showLiveClassModal.value = true
|
showLiveClassModal.value = true
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
<style>
|
||||||
|
.short-introduction {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
margin: 0.25rem 0 1.5rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
111
frontend/src/components/Modals/BatchStudentProgress.vue
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
v-model="show"
|
||||||
|
:options="{
|
||||||
|
size: 'xl',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #body>
|
||||||
|
<div class="p-5 space-y-10 text-base">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<Avatar :image="student.user_image" size="3xl" />
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<div class="text-xl font-semibold">
|
||||||
|
{{ student.full_name }}
|
||||||
|
</div>
|
||||||
|
<Badge :theme="student.progress === 100 ? 'green' : 'red'">
|
||||||
|
{{ student.progress }}% {{ __('Complete') }}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-700">
|
||||||
|
{{ student.email }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-8">
|
||||||
|
<!-- Assessments -->
|
||||||
|
<div class="space-y-2 text-sm">
|
||||||
|
<div class="flex items-center border-b pb-1 font-medium">
|
||||||
|
<span class="flex-1">
|
||||||
|
{{ __('Assessment') }}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{{ __('Progress') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="assessment in Object.keys(student.assessments)"
|
||||||
|
class="flex items-center text-gray-700 font-medium"
|
||||||
|
>
|
||||||
|
<span class="flex-1">
|
||||||
|
{{ assessment }}
|
||||||
|
</span>
|
||||||
|
<span v-if="isAssignment(student.assessments[assessment])">
|
||||||
|
<Badge :theme="getStatusTheme(student.assessments[assessment])">
|
||||||
|
{{ student.assessments[assessment] }}
|
||||||
|
</Badge>
|
||||||
|
</span>
|
||||||
|
<span v-else>
|
||||||
|
{{ student.assessments[assessment] }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Courses -->
|
||||||
|
<div class="space-y-2 text-sm">
|
||||||
|
<div class="flex items-center border-b pb-1 font-medium">
|
||||||
|
<span class="flex-1">
|
||||||
|
{{ __('Courses') }}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{{ __('Progress') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="course in Object.keys(student.courses)"
|
||||||
|
class="flex items-center text-gray-700 font-medium"
|
||||||
|
>
|
||||||
|
<span class="flex-1">
|
||||||
|
{{ course }}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{{ Math.floor(student.courses[course]) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Heatmap -->
|
||||||
|
<StudentHeatmap :member="student.email" :base_days="120" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { Avatar, Badge, Dialog } from 'frappe-ui'
|
||||||
|
import StudentHeatmap from '@/components/StudentHeatmap.vue'
|
||||||
|
|
||||||
|
const show = defineModel()
|
||||||
|
const props = defineProps({
|
||||||
|
student: {
|
||||||
|
type: Object,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const isAssignment = (value) => {
|
||||||
|
return isNaN(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusTheme = (status) => {
|
||||||
|
if (status === 'Pass') {
|
||||||
|
return 'green'
|
||||||
|
} else if (status == 'Not Graded') {
|
||||||
|
return 'orange'
|
||||||
|
} else {
|
||||||
|
return 'red'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -26,7 +26,7 @@ const props = defineProps({
|
|||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
title: {
|
title: {
|
||||||
type: String,
|
type: [String, null],
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
import { Dialog, createResource } from 'frappe-ui'
|
import { Dialog, createResource } from 'frappe-ui'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import Link from '@/components/Controls/Link.vue'
|
import Link from '@/components/Controls/Link.vue'
|
||||||
|
import { showToast } from '@/utils'
|
||||||
|
|
||||||
const students = defineModel('reloadStudents')
|
const students = defineModel('reloadStudents')
|
||||||
const student = ref()
|
const student = ref()
|
||||||
@@ -61,8 +62,11 @@ const addStudent = (close) => {
|
|||||||
{
|
{
|
||||||
onSuccess() {
|
onSuccess() {
|
||||||
students.value.reload()
|
students.value.reload()
|
||||||
close()
|
|
||||||
student.value = null
|
student.value = null
|
||||||
|
close()
|
||||||
|
},
|
||||||
|
onError(err) {
|
||||||
|
showToast(__('Error'), __(err.messages?.[0] || err), 'x')
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,24 +1,44 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full bg-gray-200 rounded-full h-1 my-2">
|
<Tooltip :text="`${props.progress}%`">
|
||||||
<div
|
<div class="w-full bg-gray-200 rounded-full h-1 my-2">
|
||||||
class="bg-gray-900 h-1 rounded-full"
|
<div
|
||||||
:style="{ width: progressBarWidth }"
|
class="bg-gray-900 rounded-full"
|
||||||
></div>
|
:class="progressBarHeight"
|
||||||
</div>
|
:style="{ width: progressBarWidth }"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
import { Tooltip } from 'frappe-ui'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
progress: {
|
progress: {
|
||||||
type: Number,
|
type: Number,
|
||||||
default: 0,
|
default: 0,
|
||||||
},
|
},
|
||||||
|
size: {
|
||||||
|
type: String,
|
||||||
|
default: 'sm',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const progressBarWidth = computed(() => {
|
const progressBarWidth = computed(() => {
|
||||||
const formattedPercentage = Math.min(Math.ceil(props.progress), 100)
|
const formattedPercentage = Math.min(Math.ceil(props.progress), 100)
|
||||||
return `${formattedPercentage}%`
|
return `${formattedPercentage}%`
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const progressBarHeight = computed(() => {
|
||||||
|
if (props.size === 'sm') {
|
||||||
|
return 'h-1'
|
||||||
|
}
|
||||||
|
if (props.size === 'md') {
|
||||||
|
return 'h-2'
|
||||||
|
}
|
||||||
|
if (props.size === 'lg') {
|
||||||
|
return 'h-3'
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -118,15 +118,17 @@
|
|||||||
class="w-3.5 h-3.5 text-gray-900 rounded-sm focus:ring-gray-200"
|
class="w-3.5 h-3.5 text-gray-900 rounded-sm focus:ring-gray-200"
|
||||||
@change="markAnswer(index)"
|
@change="markAnswer(index)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-else-if="quiz.data.show_answers"
|
v-else-if="quiz.data.show_answers"
|
||||||
v-for="(answer, idx) in showAnswers"
|
v-for="(answer, idx) in showAnswers"
|
||||||
>
|
>
|
||||||
<div v-if="index - 1 == idx">
|
<div v-if="index - 1 == idx">
|
||||||
<CheckCircle v-if="answer" class="w-4 h-4 text-green-500" />
|
<CheckCircle
|
||||||
|
v-if="answer == 1"
|
||||||
|
class="w-4 h-4 text-green-500"
|
||||||
|
/>
|
||||||
<MinusCircle
|
<MinusCircle
|
||||||
v-else-if="questionDetails.data[`is_correct_${index}`]"
|
v-else-if="answer == 2"
|
||||||
class="w-4 h-4 text-green-500"
|
class="w-4 h-4 text-green-500"
|
||||||
/>
|
/>
|
||||||
<XCircle
|
<XCircle
|
||||||
@@ -271,6 +273,7 @@
|
|||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
|
call,
|
||||||
createResource,
|
createResource,
|
||||||
ListView,
|
ListView,
|
||||||
TextEditor,
|
TextEditor,
|
||||||
@@ -280,6 +283,7 @@ import { ref, watch, reactive, inject, computed } from 'vue'
|
|||||||
import { createToast } from '@/utils/'
|
import { createToast } from '@/utils/'
|
||||||
import { CheckCircle, XCircle, MinusCircle } from 'lucide-vue-next'
|
import { CheckCircle, XCircle, MinusCircle } from 'lucide-vue-next'
|
||||||
import { timeAgo } from '@/utils'
|
import { timeAgo } from '@/utils'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
import ProgressBar from '@/components/ProgressBar.vue'
|
import ProgressBar from '@/components/ProgressBar.vue'
|
||||||
|
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
@@ -291,6 +295,7 @@ let questions = reactive([])
|
|||||||
const possibleAnswer = ref(null)
|
const possibleAnswer = ref(null)
|
||||||
const timer = ref(0)
|
const timer = ref(0)
|
||||||
let timerInterval = null
|
let timerInterval = null
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
quizName: {
|
quizName: {
|
||||||
@@ -496,8 +501,8 @@ const checkAnswer = () => {
|
|||||||
selectedOptions.forEach((option, index) => {
|
selectedOptions.forEach((option, index) => {
|
||||||
if (option) {
|
if (option) {
|
||||||
showAnswers[index] = option && data[index]
|
showAnswers[index] = option && data[index]
|
||||||
} else if (questionDetails.data[`is_correct_${index + 1}`]) {
|
} else if (data[index] == 2) {
|
||||||
showAnswers[index] = 0
|
showAnswers[index] = 2
|
||||||
} else {
|
} else {
|
||||||
showAnswers[index] = undefined
|
showAnswers[index] = undefined
|
||||||
}
|
}
|
||||||
@@ -560,6 +565,7 @@ const createSubmission = () => {
|
|||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
|
markLessonProgress()
|
||||||
if (quiz.data && quiz.data.max_attempts) attempts.reload()
|
if (quiz.data && quiz.data.max_attempts) attempts.reload()
|
||||||
if (quiz.data.duration) clearInterval(timerInterval)
|
if (quiz.data.duration) clearInterval(timerInterval)
|
||||||
},
|
},
|
||||||
@@ -583,6 +589,16 @@ const getInstructions = (question) => {
|
|||||||
else return __('Type your answer')
|
else return __('Type your answer')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const markLessonProgress = () => {
|
||||||
|
if (router.currentRoute.value.name == 'Lesson') {
|
||||||
|
call('lms.lms.api.mark_lesson_progress', {
|
||||||
|
course: router.currentRoute.value.params.courseName,
|
||||||
|
chapter_number: router.currentRoute.value.params.chapterNumber,
|
||||||
|
lesson_number: router.currentRoute.value.params.lessonNumber,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const getSubmissionColumns = () => {
|
const getSubmissionColumns = () => {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
<template>
|
|
||||||
<Dialog
|
|
||||||
v-model="show"
|
|
||||||
:options="{
|
|
||||||
size: 'xl',
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<template #body>
|
|
||||||
<div class="p-5 space-y-4">
|
|
||||||
<div class="text-lg font-semibold">
|
|
||||||
{{ __('Add a quiz to your lesson') }}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Link
|
|
||||||
v-model="quiz"
|
|
||||||
doctype="LMS Quiz"
|
|
||||||
:label="__('Select a quiz')"
|
|
||||||
:onCreate="(value, close) => redirectToQuizForm()"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-end space-x-2">
|
|
||||||
<Button variant="solid" @click="addQuiz()">
|
|
||||||
{{ __('Save') }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</Dialog>
|
|
||||||
</template>
|
|
||||||
<script setup>
|
|
||||||
import { Dialog, Button } from 'frappe-ui'
|
|
||||||
import { onMounted, ref, nextTick } from 'vue'
|
|
||||||
import Link from '@/components/Controls/Link.vue'
|
|
||||||
|
|
||||||
const show = ref(false)
|
|
||||||
const quiz = ref(null)
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
onQuizAddition: {
|
|
||||||
type: Function,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
await nextTick()
|
|
||||||
show.value = true
|
|
||||||
})
|
|
||||||
|
|
||||||
const addQuiz = () => {
|
|
||||||
props.onQuizAddition(quiz.value)
|
|
||||||
show.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
const redirectToQuizForm = () => {
|
|
||||||
window.open('/lms/quizzes/new', '_blank')
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
138
frontend/src/components/StudentHeatmap.vue
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="heatmap.data">
|
||||||
|
<div class="text-lg font-semibold mb-2">
|
||||||
|
{{ heatmap.data.total_activities }}
|
||||||
|
{{
|
||||||
|
heatmap.data.total_activities > 1 ? __('activities') : __('activity')
|
||||||
|
}}
|
||||||
|
{{ __('in the last') }}
|
||||||
|
{{ heatmap.data.weeks }}
|
||||||
|
{{ __('weeks') }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ApexChart :options="chartOptions" :series="chartSeries" height="240" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { createResource } from 'frappe-ui'
|
||||||
|
import { computed, inject, onMounted, ref, watch } from 'vue'
|
||||||
|
import ApexChart from 'vue3-apexcharts'
|
||||||
|
import { theme } from '@/utils/theme'
|
||||||
|
|
||||||
|
const user = inject('$user')
|
||||||
|
const labels = ref([])
|
||||||
|
const memberName = ref(null)
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
member: {
|
||||||
|
type: String,
|
||||||
|
},
|
||||||
|
base_days: {
|
||||||
|
type: Number,
|
||||||
|
default: 200,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
memberName.value = props.member || user.data?.name
|
||||||
|
})
|
||||||
|
|
||||||
|
const heatmap = createResource({
|
||||||
|
url: 'lms.lms.api.get_heatmap_data',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
member: values.member,
|
||||||
|
base_days: props.base_days,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
auto: false,
|
||||||
|
cache: ['heatmap', memberName.value],
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(memberName, (newVal) => {
|
||||||
|
heatmap.reload(
|
||||||
|
{
|
||||||
|
member: newVal,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess(data) {
|
||||||
|
labels.value = data.labels
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const chartOptions = computed(() => {
|
||||||
|
return {
|
||||||
|
chart: {
|
||||||
|
type: 'heatmap',
|
||||||
|
toolbar: {
|
||||||
|
show: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
highlightOnHover: false,
|
||||||
|
grid: {
|
||||||
|
show: false,
|
||||||
|
},
|
||||||
|
plotOptions: {
|
||||||
|
heatmap: {
|
||||||
|
radius: 8,
|
||||||
|
shadeIntensity: 0.2,
|
||||||
|
enableShades: true,
|
||||||
|
colorScale: {
|
||||||
|
ranges: [
|
||||||
|
{ from: 0, to: 0, color: theme.colors.gray[400] },
|
||||||
|
{ from: 1, to: 5, color: theme.colors.green[200] },
|
||||||
|
{ from: 6, to: 15, color: theme.colors.green[500] },
|
||||||
|
{ from: 16, to: 30, color: theme.colors.green[700] },
|
||||||
|
{ from: 31, to: 100, color: theme.colors.green[800] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
dataLabels: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
xaxis: {
|
||||||
|
type: 'category',
|
||||||
|
categories: labels.value,
|
||||||
|
position: 'top',
|
||||||
|
axisBorder: {
|
||||||
|
show: false,
|
||||||
|
},
|
||||||
|
axisTicks: {
|
||||||
|
show: false,
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
yaxis: {
|
||||||
|
type: 'category',
|
||||||
|
categories: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
|
||||||
|
reversed: true,
|
||||||
|
tooltip: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
custom: ({ series, seriesIndex, dataPointIndex, w }) => {
|
||||||
|
return `<div class="text-xs bg-gray-900 text-white font-medium p-1">
|
||||||
|
<div class="text-center">${heatmap.data.heatmap_data[seriesIndex].data[dataPointIndex].label}</div>
|
||||||
|
</div>`
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const chartSeries = computed(() => {
|
||||||
|
if (!heatmap.data) return []
|
||||||
|
let series = heatmap.data.heatmap_data.map((row) => {
|
||||||
|
return {
|
||||||
|
name: row.name,
|
||||||
|
data: row.data.map((value) => value.count),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return series
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="mb-10">
|
<div>
|
||||||
<Button v-if="isStudent" @click="openEvalModal" class="float-right">
|
<div class="flex items-center justify-between mb-4">
|
||||||
{{ __('Schedule Evaluation') }}
|
<div class="text-lg font-semibold">
|
||||||
</Button>
|
{{ __('Upcoming Evaluations') }}
|
||||||
<div class="text-lg font-semibold mb-4">
|
</div>
|
||||||
{{ __('Upcoming Evaluations') }}
|
<Button @click="openEvalModal">
|
||||||
|
{{ __('Schedule Evaluation') }}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="upcoming_evals.data?.length">
|
<div v-if="upcoming_evals.data?.length">
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
@@ -67,10 +69,6 @@ const props = defineProps({
|
|||||||
type: Array,
|
type: Array,
|
||||||
default: [],
|
default: [],
|
||||||
},
|
},
|
||||||
isStudent: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
endDate: {
|
endDate: {
|
||||||
type: String,
|
type: String,
|
||||||
default: null,
|
default: null,
|
||||||
|
|||||||
191
frontend/src/pages/AssignmentForm.vue
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
<template>
|
||||||
|
<header
|
||||||
|
class="sticky top-0 z-10 flex items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5"
|
||||||
|
>
|
||||||
|
<Breadcrumbs :items="breadcrumbs" />
|
||||||
|
<div class="space-x-2">
|
||||||
|
<router-link
|
||||||
|
v-if="assignment.doc?.name"
|
||||||
|
:to="{
|
||||||
|
name: 'AssignmentSubmissionList',
|
||||||
|
query: {
|
||||||
|
assignmentID: assignment.doc.name,
|
||||||
|
},
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<Button>
|
||||||
|
{{ __('Submission List') }}
|
||||||
|
</Button>
|
||||||
|
</router-link>
|
||||||
|
<Button variant="solid" @click="saveAssignment()">
|
||||||
|
{{ __('Save') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class="w-3/4 mx-auto py-5">
|
||||||
|
<div class="font-semibold mb-4">
|
||||||
|
{{ __('Details') }}
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-5 mt-4 mb-8">
|
||||||
|
<FormControl
|
||||||
|
v-model="model.title"
|
||||||
|
:label="__('Title')"
|
||||||
|
:required="true"
|
||||||
|
/>
|
||||||
|
<FormControl
|
||||||
|
v-model="model.type"
|
||||||
|
type="select"
|
||||||
|
:options="assignmentOptions"
|
||||||
|
:label="__('Type')"
|
||||||
|
:required="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-xs text-gray-600 mb-2">
|
||||||
|
{{ __('Question') }}
|
||||||
|
<span class="text-ink-red-3">*</span>
|
||||||
|
</div>
|
||||||
|
<TextEditor
|
||||||
|
:content="model.question"
|
||||||
|
@change="(val) => (model.question = val)"
|
||||||
|
:editable="true"
|
||||||
|
:fixedMenu="true"
|
||||||
|
editorClass="prose-sm max-w-none border-b border-x bg-gray-100 rounded-b-md py-1 px-2 min-h-[7rem]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import {
|
||||||
|
Breadcrumbs,
|
||||||
|
Button,
|
||||||
|
createDocumentResource,
|
||||||
|
createResource,
|
||||||
|
FormControl,
|
||||||
|
TextEditor,
|
||||||
|
} from 'frappe-ui'
|
||||||
|
import {
|
||||||
|
computed,
|
||||||
|
inject,
|
||||||
|
onMounted,
|
||||||
|
onBeforeUnmount,
|
||||||
|
reactive,
|
||||||
|
watch,
|
||||||
|
} from 'vue'
|
||||||
|
import { showToast } from '@/utils'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
const user = inject('$user')
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
assignmentID: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const model = reactive({
|
||||||
|
title: '',
|
||||||
|
type: 'PDF',
|
||||||
|
question: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (
|
||||||
|
props.assignmentID == 'new' &&
|
||||||
|
!user.data?.is_moderator &&
|
||||||
|
!user.data?.is_instructor
|
||||||
|
) {
|
||||||
|
router.push({ name: 'Courses' })
|
||||||
|
}
|
||||||
|
if (props.assignmentID !== 'new') {
|
||||||
|
assignment.reload()
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', keyboardShortcut)
|
||||||
|
})
|
||||||
|
|
||||||
|
const keyboardShortcut = (e) => {
|
||||||
|
if (e.key === 's' && (e.ctrlKey || e.metaKey)) {
|
||||||
|
saveAssignment()
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('keydown', keyboardShortcut)
|
||||||
|
})
|
||||||
|
|
||||||
|
const assignment = createDocumentResource({
|
||||||
|
doctype: 'LMS Assignment',
|
||||||
|
name: props.assignmentID,
|
||||||
|
auto: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const newAssignment = createResource({
|
||||||
|
url: 'frappe.client.insert',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
doc: {
|
||||||
|
doctype: 'LMS Assignment',
|
||||||
|
...values,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess(data) {
|
||||||
|
router.push({ name: 'AssignmentForm', params: { assignmentID: data.name } })
|
||||||
|
},
|
||||||
|
onError(err) {
|
||||||
|
showToast(__('Error'), __(err.messages?.[0] || err), 'x')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const saveAssignment = () => {
|
||||||
|
if (props.assignmentID == 'new') {
|
||||||
|
newAssignment.submit({
|
||||||
|
...model,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
assignment.setValue.submit(
|
||||||
|
{
|
||||||
|
...model,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess(data) {
|
||||||
|
showToast(__('Success'), __('Assignment saved successfully'), 'check')
|
||||||
|
assignment.reload()
|
||||||
|
},
|
||||||
|
onError(err) {
|
||||||
|
showToast(__('Error'), __(err.messages?.[0] || err), 'x')
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(assignment, () => {
|
||||||
|
Object.keys(assignment.doc).forEach((key) => {
|
||||||
|
model[key] = assignment.doc[key]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const breadcrumbs = computed(() => [
|
||||||
|
{
|
||||||
|
label: __('Assignments'),
|
||||||
|
route: { name: 'Assignments' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: assignment.doc ? assignment.doc.title : __('New Assignment'),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const assignmentOptions = computed(() => {
|
||||||
|
return [
|
||||||
|
{ label: 'PDF', value: 'PDF' },
|
||||||
|
{ label: 'Image', value: 'Image' },
|
||||||
|
{ label: 'Document', value: 'Document' },
|
||||||
|
{ label: 'Text', value: 'Text' },
|
||||||
|
{ label: 'URL', value: 'URL' },
|
||||||
|
]
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -3,137 +3,20 @@
|
|||||||
class="flex justify-between sticky top-0 z-10 border-b bg-white px-3 py-2.5 sm:px-5"
|
class="flex justify-between sticky top-0 z-10 border-b bg-white px-3 py-2.5 sm:px-5"
|
||||||
>
|
>
|
||||||
<Breadcrumbs :items="breadcrumbs" />
|
<Breadcrumbs :items="breadcrumbs" />
|
||||||
<Button variant="solid" @click="submitAssignment()">
|
|
||||||
{{ __('Save') }}
|
|
||||||
</Button>
|
|
||||||
</header>
|
</header>
|
||||||
<div class="container py-5">
|
<div class="overflow-hidden h-[calc(100vh-3.2rem)]">
|
||||||
<div
|
<Assignment :assignmentID="assignmentID" :submissionName="submissionName" />
|
||||||
v-if="submissionResource.data"
|
|
||||||
class="bg-blue-100 p-2 rounded-md leading-5 text-sm italic"
|
|
||||||
>
|
|
||||||
{{ __("You've successfully submitted the assignment.") }}
|
|
||||||
{{
|
|
||||||
__(
|
|
||||||
"Once the moderator grades your submission, you'll find the details here."
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
{{ __('Feel free to make edits to your submission if needed.') }}
|
|
||||||
</div>
|
|
||||||
<div v-if="assignment.data">
|
|
||||||
<div>
|
|
||||||
<div class="text-xl font-semibold hidden">
|
|
||||||
{{ __('Question') }}
|
|
||||||
</div>
|
|
||||||
<div class="text-sm mt-1 hidden">
|
|
||||||
{{
|
|
||||||
__('Read the question carefully before attempting the assignment.')
|
|
||||||
}}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-html="assignment.data.question"
|
|
||||||
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-gray-300 prose-th:border-gray-300 prose-td:relative prose-th:relative prose-th:bg-gray-100 prose-sm max-w-none !whitespace-normal"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
<div class="">
|
|
||||||
<div class="text-xl font-semibold mt-10">
|
|
||||||
{{ __('Submission') }}
|
|
||||||
</div>
|
|
||||||
<div v-if="showUploader()">
|
|
||||||
<div class="text-sm mt-1 mb-4">
|
|
||||||
{{ __('Add your assignment as {0}').format(assignment.data.type) }}
|
|
||||||
</div>
|
|
||||||
<FileUploader
|
|
||||||
v-if="!submissionFile"
|
|
||||||
:fileTypes="getType()"
|
|
||||||
:validateFile="validateFile"
|
|
||||||
@success="(file) => saveSubmission(file)"
|
|
||||||
>
|
|
||||||
<template
|
|
||||||
#default="{
|
|
||||||
file,
|
|
||||||
uploading,
|
|
||||||
progress,
|
|
||||||
uploaded,
|
|
||||||
message,
|
|
||||||
error,
|
|
||||||
total,
|
|
||||||
success,
|
|
||||||
openFileSelector,
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<Button @click="openFileSelector" :loading="uploading">
|
|
||||||
{{
|
|
||||||
uploading
|
|
||||||
? __('Uploading {0}%').format(progress)
|
|
||||||
: __('Upload File')
|
|
||||||
}}
|
|
||||||
</Button>
|
|
||||||
</template>
|
|
||||||
</FileUploader>
|
|
||||||
<div v-else>
|
|
||||||
<div class="flex items-center">
|
|
||||||
<div class="border rounded-md p-2 mr-2">
|
|
||||||
<FileText class="h-5 w-5 stroke-1.5 text-gray-700" />
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col">
|
|
||||||
<span>
|
|
||||||
{{ submissionFile.file_name }}
|
|
||||||
</span>
|
|
||||||
<span class="text-sm text-gray-500 mt-1">
|
|
||||||
{{ getFileSize(submissionFile.file_size) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<X
|
|
||||||
@click="removeSubmission()"
|
|
||||||
class="bg-gray-200 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-else-if="assignment.data.type == 'URL'">
|
|
||||||
<div class="text-sm mb-4">
|
|
||||||
{{ __('Enter a URL') }}
|
|
||||||
</div>
|
|
||||||
<FormControl v-model="answer" />
|
|
||||||
</div>
|
|
||||||
<div v-else>
|
|
||||||
<div class="text-sm mb-4">
|
|
||||||
{{ __('Write your answer here') }}
|
|
||||||
</div>
|
|
||||||
<TextEditor
|
|
||||||
:content="answer"
|
|
||||||
@change="(val) => (answer = val)"
|
|
||||||
:editable="true"
|
|
||||||
:fixedMenu="true"
|
|
||||||
editorClass="prose-sm max-w-none border-b border-x bg-gray-100 rounded-b-md py-1 px-2 min-h-[7rem]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import {
|
import { Breadcrumbs, createResource } from 'frappe-ui'
|
||||||
Breadcrumbs,
|
import { computed, inject, onMounted } from 'vue'
|
||||||
createResource,
|
import Assignment from '@/components/Assignment.vue'
|
||||||
FileUploader,
|
|
||||||
Button,
|
|
||||||
FormControl,
|
|
||||||
TextEditor,
|
|
||||||
} from 'frappe-ui'
|
|
||||||
import { FileText, X } from 'lucide-vue-next'
|
|
||||||
import { computed, inject, onMounted, ref } from 'vue'
|
|
||||||
import { showToast, getFileSize } from '../utils'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
|
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
const submissionFile = ref(null)
|
|
||||||
const answer = ref(null)
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
assignmentName: {
|
assignmentID: {
|
||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
@@ -143,186 +26,40 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const assignment = createResource({
|
const title = createResource({
|
||||||
url: 'frappe.client.get',
|
url: 'frappe.client.get_value',
|
||||||
params: {
|
params: {
|
||||||
doctype: 'LMS Assignment',
|
doctype: 'LMS Assignment',
|
||||||
name: props.assignmentName,
|
fieldname: 'title',
|
||||||
|
filters: {
|
||||||
|
name: props.assignmentID,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
auto: true,
|
auto: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
const showUploader = () => {
|
|
||||||
return ['PDF', 'Image', 'Document'].includes(assignment.data?.type)
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateSubmission = createResource({
|
|
||||||
url: 'frappe.client.set_value',
|
|
||||||
makeParams(values) {
|
|
||||||
let fieldname = {}
|
|
||||||
if (showUploader()) {
|
|
||||||
fieldname.assignment_attachment = submissionFile.value.file_url
|
|
||||||
} else {
|
|
||||||
fieldname.answer = answer.value
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
doctype: 'LMS Assignment Submission',
|
|
||||||
name: props.submissionName,
|
|
||||||
fieldname: fieldname,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const imageResource = createResource({
|
|
||||||
url: 'lms.lms.api.get_file_info',
|
|
||||||
makeParams(values) {
|
|
||||||
return {
|
|
||||||
file_url: values.image,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
auto: false,
|
|
||||||
onSuccess(data) {
|
|
||||||
submissionFile.value = data
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const newSubmission = createResource({
|
|
||||||
url: 'frappe.client.insert',
|
|
||||||
makeParams(values) {
|
|
||||||
let doc = {
|
|
||||||
doctype: 'LMS Assignment Submission',
|
|
||||||
assignment: props.assignmentName,
|
|
||||||
member: user.data?.name,
|
|
||||||
}
|
|
||||||
if (showUploader()) {
|
|
||||||
doc.assignment_attachment = submissionFile.value.file_url
|
|
||||||
} else {
|
|
||||||
doc.answer = answer.value
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
doc: doc,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const submissionResource = createResource({
|
|
||||||
url: 'frappe.client.get_value',
|
|
||||||
params: {
|
|
||||||
doctype: 'LMS Assignment Submission',
|
|
||||||
fieldname: showUploader() ? 'assignment_attachment' : 'answer',
|
|
||||||
filters: {
|
|
||||||
name: props.submissionName,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
onSuccess(data) {
|
|
||||||
if (data.assignment_attachment)
|
|
||||||
imageResource.reload({ image: data.assignment_attachment })
|
|
||||||
if (data.answer) answer.value = data.answer
|
|
||||||
},
|
|
||||||
})
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (!user.data) {
|
if (!user.data) {
|
||||||
window.location.href = '/login'
|
window.location.href = '/login'
|
||||||
}
|
}
|
||||||
if (props.submissionName != 'new') {
|
|
||||||
submissionResource.reload()
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const submitAssignment = () => {
|
|
||||||
if (props.submissionName != 'new') {
|
|
||||||
updateSubmission.submit(
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
onSuccess(data) {
|
|
||||||
showToast('Success', 'Submission updated successfully.', 'check')
|
|
||||||
},
|
|
||||||
onError(err) {
|
|
||||||
showToast('Error', err.messages?.[0] || err, 'x')
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
addNewSubmission()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const addNewSubmission = () => {
|
|
||||||
newSubmission.submit(
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
onSuccess(data) {
|
|
||||||
showToast('Success', 'Assignment submitted successfully.', 'check')
|
|
||||||
router.push({
|
|
||||||
name: 'AssignmentSubmission',
|
|
||||||
params: {
|
|
||||||
assignmentName: props.assignmentName,
|
|
||||||
submissionName: data.name,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
},
|
|
||||||
onError(err) {
|
|
||||||
showToast('Error', err.messages?.[0] || err, 'x')
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const breadcrumbs = computed(() => {
|
const breadcrumbs = computed(() => {
|
||||||
let crumbs = [
|
let crumbs = [
|
||||||
{
|
{
|
||||||
label: 'Assignment',
|
label: 'Submissions',
|
||||||
|
route: { name: 'AssignmentSubmissionList' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: assignment.data?.title,
|
label: title.data?.title,
|
||||||
route: {
|
route: {
|
||||||
name: 'AssignmentSubmission',
|
name: 'AssignmentSubmission',
|
||||||
params: {
|
params: {
|
||||||
assignmentName: assignment.data?.name,
|
assignmentID: props.assignmentID,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
return crumbs
|
return crumbs
|
||||||
})
|
})
|
||||||
|
|
||||||
const saveSubmission = (file) => {
|
|
||||||
submissionFile.value = file
|
|
||||||
}
|
|
||||||
|
|
||||||
const getType = () => {
|
|
||||||
const type = assignment.data?.type
|
|
||||||
if (type == 'Image') {
|
|
||||||
return ['image/*']
|
|
||||||
} else if (type == 'Document') {
|
|
||||||
return [
|
|
||||||
'.doc',
|
|
||||||
'.docx',
|
|
||||||
'.xml',
|
|
||||||
'application/msword',
|
|
||||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
||||||
]
|
|
||||||
} else if (type == 'PDF') {
|
|
||||||
return ['.pdf']
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const validateFile = (file) => {
|
|
||||||
let type = assignment.data?.type
|
|
||||||
let extension = file.name.split('.').pop().toLowerCase()
|
|
||||||
if (type == 'Image' && !['jpg', 'jpeg', 'png'].includes(extension)) {
|
|
||||||
return 'Only image file is allowed.'
|
|
||||||
} else if (
|
|
||||||
type == 'Document' &&
|
|
||||||
!['doc', 'docx', 'xml'].includes(extension)
|
|
||||||
) {
|
|
||||||
return 'Only document file is allowed.'
|
|
||||||
} else if (type == 'PDF' && !['pdf'].includes(extension)) {
|
|
||||||
return 'Only PDF file is allowed.'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const removeSubmission = () => {
|
|
||||||
submissionFile.value = null
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
217
frontend/src/pages/AssignmentSubmissionList.vue
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
<template>
|
||||||
|
<header
|
||||||
|
class="sticky top-0 z-10 flex items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5"
|
||||||
|
>
|
||||||
|
<Breadcrumbs :items="breadcrumbs" />
|
||||||
|
</header>
|
||||||
|
<div class="md:w-3/4 md:mx-auto py-5 mx-5">
|
||||||
|
<div class="grid grid-cols-3 gap-5 mb-5">
|
||||||
|
<Link
|
||||||
|
doctype="LMS Assignment"
|
||||||
|
v-model="assignmentID"
|
||||||
|
:placeholder="__('Assignment')"
|
||||||
|
/>
|
||||||
|
<Link doctype="User" v-model="member" :placeholder="__('Member')" />
|
||||||
|
<FormControl
|
||||||
|
v-model="status"
|
||||||
|
type="select"
|
||||||
|
:options="statusOptions"
|
||||||
|
:placeholder="__('Status')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ListView
|
||||||
|
v-if="submissions.loading || submissions.data?.length"
|
||||||
|
:columns="submissionColumns"
|
||||||
|
:rows="submissions.data"
|
||||||
|
rowKey="name"
|
||||||
|
>
|
||||||
|
<ListHeader
|
||||||
|
class="mb-2 grid items-center space-x-4 rounded bg-gray-100 p-2"
|
||||||
|
>
|
||||||
|
<ListHeaderItem :item="item" v-for="item in submissionColumns" />
|
||||||
|
</ListHeader>
|
||||||
|
<ListRows>
|
||||||
|
<router-link
|
||||||
|
v-for="row in submissions.data"
|
||||||
|
:to="{
|
||||||
|
name: 'AssignmentSubmission',
|
||||||
|
params: {
|
||||||
|
assignmentID: row.assignment,
|
||||||
|
submissionName: row.name,
|
||||||
|
},
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<ListRow :row="row">
|
||||||
|
<template #default="{ column, item }">
|
||||||
|
<ListRowItem :item="row[column.key]" :align="column.align">
|
||||||
|
<div v-if="column.key == 'status'">
|
||||||
|
<Badge :theme="getStatusTheme(row[column.key])">
|
||||||
|
{{ row[column.key] }}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
{{ row[column.key] }}
|
||||||
|
</div>
|
||||||
|
</ListRowItem>
|
||||||
|
</template>
|
||||||
|
</ListRow>
|
||||||
|
</router-link>
|
||||||
|
</ListRows>
|
||||||
|
</ListView>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="text-center p-5 text-gray-600 mt-52 w-3/4 md:w-1/2 mx-auto space-y-2"
|
||||||
|
>
|
||||||
|
<Pencil class="size-8 mx-auto stroke-1 text-gray-500" />
|
||||||
|
<div class="text-xl font-medium">
|
||||||
|
{{ __('No submissions') }}
|
||||||
|
</div>
|
||||||
|
<div class="leading-5">
|
||||||
|
{{ __('There are no submissions for this assignment.') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Breadcrumbs,
|
||||||
|
createListResource,
|
||||||
|
FormControl,
|
||||||
|
ListView,
|
||||||
|
ListHeader,
|
||||||
|
ListHeaderItem,
|
||||||
|
ListRows,
|
||||||
|
ListRow,
|
||||||
|
ListRowItem,
|
||||||
|
} from 'frappe-ui'
|
||||||
|
import { computed, inject, onMounted, ref, watch } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { Pencil } from 'lucide-vue-next'
|
||||||
|
import Link from '@/components/Controls/Link.vue'
|
||||||
|
|
||||||
|
const user = inject('$user')
|
||||||
|
const dayjs = inject('$dayjs')
|
||||||
|
const router = useRouter()
|
||||||
|
const assignmentID = ref('')
|
||||||
|
const member = ref('')
|
||||||
|
const status = ref('')
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!user.data?.is_instructor && !user.data?.is_moderator) {
|
||||||
|
router.push({ name: 'Courses' })
|
||||||
|
}
|
||||||
|
assignmentID.value = router.currentRoute.value.query.assignmentID
|
||||||
|
member.value = router.currentRoute.value.query.member
|
||||||
|
status.value = router.currentRoute.value.query.status
|
||||||
|
reloadSubmissions()
|
||||||
|
})
|
||||||
|
|
||||||
|
const getAssignmentFilters = () => {
|
||||||
|
let filters = {}
|
||||||
|
if (assignmentID.value) {
|
||||||
|
filters.assignment = assignmentID.value
|
||||||
|
}
|
||||||
|
if (member.value) {
|
||||||
|
filters.member = member.value
|
||||||
|
}
|
||||||
|
if (status.value) {
|
||||||
|
filters.status = status.value
|
||||||
|
}
|
||||||
|
return filters
|
||||||
|
}
|
||||||
|
|
||||||
|
const submissions = createListResource({
|
||||||
|
doctype: 'LMS Assignment Submission',
|
||||||
|
fields: [
|
||||||
|
'name',
|
||||||
|
'assignment',
|
||||||
|
'assignment_title',
|
||||||
|
'member_name',
|
||||||
|
'creation',
|
||||||
|
'status',
|
||||||
|
],
|
||||||
|
orderBy: 'creation desc',
|
||||||
|
transform(data) {
|
||||||
|
return data.map((row) => {
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
creation: dayjs(row.creation).fromNow(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// watch changes in assignmentID, member, and status and if changes in any then reload submissions. Also update the url query params for the same
|
||||||
|
watch([assignmentID, member, status], () => {
|
||||||
|
router.push({
|
||||||
|
query: {
|
||||||
|
assignmentID: assignmentID.value,
|
||||||
|
member: member.value,
|
||||||
|
status: status.value,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
reloadSubmissions()
|
||||||
|
})
|
||||||
|
|
||||||
|
const reloadSubmissions = () => {
|
||||||
|
submissions.update({
|
||||||
|
filters: getAssignmentFilters(),
|
||||||
|
})
|
||||||
|
submissions.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
const submissionColumns = computed(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: 'Member',
|
||||||
|
key: 'member_name',
|
||||||
|
width: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Assignment',
|
||||||
|
key: 'assignment_title',
|
||||||
|
width: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Submitted',
|
||||||
|
key: 'creation',
|
||||||
|
width: 1,
|
||||||
|
align: 'left',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Status',
|
||||||
|
key: 'status',
|
||||||
|
width: 1,
|
||||||
|
align: 'center',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const statusOptions = computed(() => {
|
||||||
|
return [
|
||||||
|
{ label: '', value: '' },
|
||||||
|
{ label: 'Pass', value: 'Pass' },
|
||||||
|
{ label: 'Fail', value: 'Fail' },
|
||||||
|
{ label: 'Not Graded', value: 'Not Graded' },
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const getStatusTheme = (status) => {
|
||||||
|
if (status === 'Pass') {
|
||||||
|
return 'green'
|
||||||
|
} else if (status === 'Not Graded') {
|
||||||
|
return 'blue'
|
||||||
|
} else {
|
||||||
|
return 'red'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const breadcrumbs = computed(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: 'Assignment Submissions',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
</script>
|
||||||
187
frontend/src/pages/Assignments.vue
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
<template>
|
||||||
|
<header
|
||||||
|
class="sticky top-0 z-10 flex items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5"
|
||||||
|
>
|
||||||
|
<Breadcrumbs :items="breadcrumbs" />
|
||||||
|
<router-link
|
||||||
|
:to="{
|
||||||
|
name: 'AssignmentForm',
|
||||||
|
params: {
|
||||||
|
assignmentID: 'new',
|
||||||
|
},
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<Button variant="solid">
|
||||||
|
<template #prefix>
|
||||||
|
<Plus class="w-4 h-4" />
|
||||||
|
</template>
|
||||||
|
{{ __('New') }}
|
||||||
|
</Button>
|
||||||
|
</router-link>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="md:w-3/4 md:mx-auto py-5 mx-5">
|
||||||
|
<div class="grid grid-cols-3 gap-5 mb-5">
|
||||||
|
<FormControl v-model="titleFilter" :placeholder="__('Search by title')" />
|
||||||
|
<FormControl
|
||||||
|
v-model="typeFilter"
|
||||||
|
type="select"
|
||||||
|
:options="assignmentTypes"
|
||||||
|
:placeholder="__('Type')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ListView
|
||||||
|
v-if="assignments.data?.length"
|
||||||
|
:columns="assignmentColumns"
|
||||||
|
:rows="assignments.data"
|
||||||
|
row-key="name"
|
||||||
|
:options="{
|
||||||
|
showTooltip: false,
|
||||||
|
selectable: false,
|
||||||
|
getRowRoute: (row) => ({
|
||||||
|
name: 'AssignmentForm',
|
||||||
|
params: {
|
||||||
|
assignmentID: row.name,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
</ListView>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="text-center p-5 text-gray-600 mt-52 w-3/4 md:w-1/2 mx-auto space-y-2"
|
||||||
|
>
|
||||||
|
<Pencil class="size-10 mx-auto stroke-1 text-gray-500" />
|
||||||
|
<div class="text-xl font-medium">
|
||||||
|
{{ __('No assignments found') }}
|
||||||
|
</div>
|
||||||
|
<div class="leading-5">
|
||||||
|
{{
|
||||||
|
__(
|
||||||
|
'You have not created any assignments yet. To create a new assignment, click on the "New" button above.'
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="assignments.data && assignments.hasNextPage"
|
||||||
|
class="flex justify-center my-5"
|
||||||
|
>
|
||||||
|
<Button @click="assignments.next()">
|
||||||
|
{{ __('Load More') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import {
|
||||||
|
Breadcrumbs,
|
||||||
|
Button,
|
||||||
|
createListResource,
|
||||||
|
FormControl,
|
||||||
|
ListView,
|
||||||
|
} from 'frappe-ui'
|
||||||
|
import { computed, inject, onMounted, ref, watch } from 'vue'
|
||||||
|
import { Plus, Pencil } from 'lucide-vue-next'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
const user = inject('$user')
|
||||||
|
const dayjs = inject('$dayjs')
|
||||||
|
const titleFilter = ref('')
|
||||||
|
const typeFilter = ref('')
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!user.data?.is_moderator && !user.data?.is_instructor) {
|
||||||
|
router.push({ name: 'Courses' })
|
||||||
|
}
|
||||||
|
|
||||||
|
titleFilter.value = router.currentRoute.value.query.title
|
||||||
|
typeFilter.value = router.currentRoute.value.query.type
|
||||||
|
})
|
||||||
|
|
||||||
|
watch([titleFilter, typeFilter], () => {
|
||||||
|
router.push({
|
||||||
|
query: {
|
||||||
|
title: titleFilter.value,
|
||||||
|
type: typeFilter.value,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
reloadAssignments()
|
||||||
|
})
|
||||||
|
|
||||||
|
const reloadAssignments = () => {
|
||||||
|
assignments.update({
|
||||||
|
filters: assignmentFilter.value,
|
||||||
|
})
|
||||||
|
assignments.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
const assignmentFilter = computed(() => {
|
||||||
|
let filters = {}
|
||||||
|
if (titleFilter.value) {
|
||||||
|
filters.title = ['like', `%${titleFilter.value}%`]
|
||||||
|
}
|
||||||
|
if (typeFilter.value) {
|
||||||
|
filters.type = typeFilter.value
|
||||||
|
}
|
||||||
|
if (!user.data?.is_moderator) {
|
||||||
|
filters.owner = user.data?.email
|
||||||
|
}
|
||||||
|
return filters
|
||||||
|
})
|
||||||
|
|
||||||
|
const assignments = createListResource({
|
||||||
|
doctype: 'LMS Assignment',
|
||||||
|
fields: ['name', 'title', 'type', 'creation'],
|
||||||
|
orderBy: 'modified desc',
|
||||||
|
cache: ['assignments'],
|
||||||
|
transform(data) {
|
||||||
|
return data.map((row) => {
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
creation: dayjs(row.creation).fromNow(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const assignmentColumns = computed(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: __('Title'),
|
||||||
|
key: 'title',
|
||||||
|
width: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: __('Type'),
|
||||||
|
key: 'type',
|
||||||
|
width: 1,
|
||||||
|
align: 'left',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: __('Created'),
|
||||||
|
key: 'creation',
|
||||||
|
width: 1,
|
||||||
|
align: 'center',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const assignmentTypes = computed(() => {
|
||||||
|
let types = ['', 'Document', 'Image', 'PDF', 'URL', 'Text']
|
||||||
|
return types.map((type) => {
|
||||||
|
return {
|
||||||
|
label: __(type),
|
||||||
|
value: type,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const breadcrumbs = computed(() => [
|
||||||
|
{
|
||||||
|
label: 'Assignments',
|
||||||
|
route: { name: 'Assignments' },
|
||||||
|
},
|
||||||
|
])
|
||||||
|
</script>
|
||||||
@@ -1,32 +1,33 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="badge.doc">
|
<div v-if="badge.data">
|
||||||
<div class="p-5 flex flex-col items-center mt-40">
|
<div class="p-5 flex flex-col items-center mt-40">
|
||||||
<div class="text-3xl font-semibold">
|
<div class="text-3xl font-semibold">
|
||||||
{{ badge.doc.title }}
|
{{ badge.data.badge }}
|
||||||
</div>
|
</div>
|
||||||
<img :src="badge.doc.image" :alt="badge.doc.title" class="h-60 mt-2" />
|
<img
|
||||||
<div class="text-lg">
|
:src="badge.data.badge_image"
|
||||||
|
:alt="badge.data.badge"
|
||||||
|
class="h-60 mt-2"
|
||||||
|
/>
|
||||||
|
<div class="">
|
||||||
{{
|
{{
|
||||||
__('This badge has been awarded to {0} on {1}.').format(
|
__('This badge has been awarded to {0} on {1}.').format(
|
||||||
userName,
|
badge.data.member_name,
|
||||||
dayjs(issuedOn.data?.issued_on).format('DD MMM YYYY')
|
dayjs(badge.data.issued_on).format('DD MMM YYYY')
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-lg mt-2">
|
<div class="mt-2">
|
||||||
{{ badge.doc.description }}
|
{{ badge.data.badge_description }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { createDocumentResource, createResource, Breadcrumbs } from 'frappe-ui'
|
import { createDocumentResource, createResource } from 'frappe-ui'
|
||||||
import { computed, inject } from 'vue'
|
import { computed, inject } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
|
|
||||||
const allUsers = inject('$allUsers')
|
|
||||||
const dayjs = inject('$dayjs')
|
const dayjs = inject('$dayjs')
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
badgeName: {
|
badgeName: {
|
||||||
@@ -39,33 +40,15 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const badge = createDocumentResource({
|
const badge = createResource({
|
||||||
doctype: 'LMS Badge',
|
url: 'frappe.client.get',
|
||||||
name: props.badgeName,
|
|
||||||
})
|
|
||||||
|
|
||||||
const userName = computed(() => {
|
|
||||||
const user = Object.values(allUsers.data).find(
|
|
||||||
(user) => user.name === props.email
|
|
||||||
)
|
|
||||||
return user ? user.full_name : props.email
|
|
||||||
})
|
|
||||||
|
|
||||||
const issuedOn = createResource({
|
|
||||||
url: 'frappe.client.get_value',
|
|
||||||
makeParams(values) {
|
makeParams(values) {
|
||||||
return {
|
return {
|
||||||
doctype: 'LMS Badge Assignment',
|
doctype: 'LMS Badge Assignment',
|
||||||
filters: {
|
filters: {
|
||||||
member: props.email,
|
|
||||||
badge: props.badgeName,
|
badge: props.badgeName,
|
||||||
|
member: props.email,
|
||||||
},
|
},
|
||||||
fieldname: 'issued_on',
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onSuccess(data) {
|
|
||||||
if (!data.issued_on) {
|
|
||||||
router.push({ name: 'Courses' })
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
auto: true,
|
auto: true,
|
||||||
@@ -77,11 +60,11 @@ const breadcrumbs = computed(() => {
|
|||||||
label: 'Badges',
|
label: 'Badges',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: badge.doc.title,
|
label: badge.data.badge,
|
||||||
route: {
|
route: {
|
||||||
name: 'Badge',
|
name: 'Badge',
|
||||||
params: {
|
params: {
|
||||||
badge: badge.doc.name,
|
badge: badge.data.badge,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div v-if="batch.data" class="grid grid-cols-[70%,30%] h-screen">
|
<div v-if="batch.data" class="grid grid-cols-[70%,30%] h-screen">
|
||||||
<div class="border-r-2">
|
<div class="border-r">
|
||||||
<Tabs
|
<Tabs
|
||||||
v-model="tabIndex"
|
v-model="tabIndex"
|
||||||
:tabs="tabs"
|
:tabs="tabs"
|
||||||
@@ -59,15 +59,15 @@
|
|||||||
<div v-if="tab.label == 'Courses'">
|
<div v-if="tab.label == 'Courses'">
|
||||||
<BatchCourses :batch="batch.data.name" />
|
<BatchCourses :batch="batch.data.name" />
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="tab.label == 'Dashboard'">
|
<div v-else-if="tab.label == 'Dashboard' && isStudent">
|
||||||
<BatchDashboard :batch="batch" :isStudent="isStudent" />
|
<BatchDashboard :batch="batch" :isStudent="isStudent" />
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else-if="tab.label == 'Dashboard'">
|
||||||
|
<BatchStudents :batch="batch.data" />
|
||||||
|
</div>
|
||||||
<div v-else-if="tab.label == 'Live Class'">
|
<div v-else-if="tab.label == 'Live Class'">
|
||||||
<LiveClass :batch="batch.data.name" />
|
<LiveClass :batch="batch.data.name" />
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="tab.label == 'Students'">
|
|
||||||
<BatchStudents :batch="batch.data.name" />
|
|
||||||
</div>
|
|
||||||
<div v-else-if="tab.label == 'Assessments'">
|
<div v-else-if="tab.label == 'Assessments'">
|
||||||
<Assessments :batch="batch.data.name" />
|
<Assessments :batch="batch.data.name" />
|
||||||
</div>
|
</div>
|
||||||
@@ -89,12 +89,12 @@
|
|||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-5">
|
<div class="p-5">
|
||||||
<div class="text-2xl font-semibold mb-2">
|
<div class="text-gray-700 font-semibold mb-4">
|
||||||
{{ batch.data.title }}
|
{{ __('About this batch') }}:
|
||||||
</div>
|
</div>
|
||||||
<div v-html="batch.data.description" class="leading-5 mb-2"></div>
|
<div v-html="batch.data.description" class="leading-5 mb-4"></div>
|
||||||
|
|
||||||
<div class="flex avatar-group overlap mb-5">
|
<div class="flex items-center avatar-group overlap mb-5">
|
||||||
<div
|
<div
|
||||||
class="h-6 mr-1"
|
class="h-6 mr-1"
|
||||||
:class="{
|
:class="{
|
||||||
@@ -195,6 +195,7 @@ import {
|
|||||||
SendIcon,
|
SendIcon,
|
||||||
MessageCircle,
|
MessageCircle,
|
||||||
Globe,
|
Globe,
|
||||||
|
ShieldCheck,
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import { formatTime, updateDocumentTitle } from '@/utils'
|
import { formatTime, updateDocumentTitle } from '@/utils'
|
||||||
import BatchDashboard from '@/components/BatchDashboard.vue'
|
import BatchDashboard from '@/components/BatchDashboard.vue'
|
||||||
@@ -229,7 +230,7 @@ const batch = createResource({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const breadcrumbs = computed(() => {
|
const breadcrumbs = computed(() => {
|
||||||
let crumbs = [{ label: 'All Batches', route: { name: 'Batches' } }]
|
let crumbs = [{ label: 'Batches', route: { name: 'Batches' } }]
|
||||||
if (!isStudent.value) {
|
if (!isStudent.value) {
|
||||||
crumbs.push({
|
crumbs.push({
|
||||||
label: 'Details',
|
label: 'Details',
|
||||||
@@ -259,34 +260,33 @@ const isStudent = computed(() => {
|
|||||||
const tabIndex = ref(0)
|
const tabIndex = ref(0)
|
||||||
const tabs = computed(() => {
|
const tabs = computed(() => {
|
||||||
let batchTabs = []
|
let batchTabs = []
|
||||||
if (isStudent.value) {
|
batchTabs.push({
|
||||||
batchTabs.push({
|
label: 'Dashboard',
|
||||||
label: 'Dashboard',
|
icon: LayoutDashboard,
|
||||||
icon: LayoutDashboard,
|
})
|
||||||
})
|
|
||||||
}
|
batchTabs.push({
|
||||||
|
label: 'Courses',
|
||||||
|
icon: BookOpen,
|
||||||
|
})
|
||||||
|
|
||||||
|
batchTabs.push({
|
||||||
|
label: 'Live Class',
|
||||||
|
icon: Laptop,
|
||||||
|
})
|
||||||
|
|
||||||
if (user.data?.is_moderator) {
|
if (user.data?.is_moderator) {
|
||||||
batchTabs.push({
|
|
||||||
label: 'Students',
|
|
||||||
icon: Contact2,
|
|
||||||
})
|
|
||||||
batchTabs.push({
|
batchTabs.push({
|
||||||
label: 'Assessments',
|
label: 'Assessments',
|
||||||
icon: BookOpenCheck,
|
icon: BookOpenCheck,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
batchTabs.push({
|
|
||||||
label: 'Live Class',
|
|
||||||
icon: Laptop,
|
|
||||||
})
|
|
||||||
batchTabs.push({
|
|
||||||
label: 'Courses',
|
|
||||||
icon: BookOpen,
|
|
||||||
})
|
|
||||||
batchTabs.push({
|
batchTabs.push({
|
||||||
label: 'Announcements',
|
label: 'Announcements',
|
||||||
icon: Mail,
|
icon: Mail,
|
||||||
})
|
})
|
||||||
|
|
||||||
batchTabs.push({
|
batchTabs.push({
|
||||||
label: 'Discussions',
|
label: 'Discussions',
|
||||||
icon: MessageCircle,
|
icon: MessageCircle,
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ const courses = createResource({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const breadcrumbs = computed(() => {
|
const breadcrumbs = computed(() => {
|
||||||
let items = [{ label: 'All Batches', route: { name: 'Batches' } }]
|
let items = [{ label: 'Batches', route: { name: 'Batches' } }]
|
||||||
items.push({
|
items.push({
|
||||||
label: batch?.data?.title,
|
label: batch?.data?.title,
|
||||||
route: { name: 'BatchDetail', params: { batchName: batch?.data?.name } },
|
route: { name: 'BatchDetail', params: { batchName: batch?.data?.name } },
|
||||||
|
|||||||
@@ -252,7 +252,7 @@ import {
|
|||||||
} from 'frappe-ui'
|
} from 'frappe-ui'
|
||||||
import Link from '@/components/Controls/Link.vue'
|
import Link from '@/components/Controls/Link.vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { showToast } from '../utils'
|
import { showToast } from '@/utils'
|
||||||
import { Image } from 'lucide-vue-next'
|
import { Image } from 'lucide-vue-next'
|
||||||
import { capture } from '@/telemetry'
|
import { capture } from '@/telemetry'
|
||||||
import MultiSelect from '@/components/Controls/MultiSelect.vue'
|
import MultiSelect from '@/components/Controls/MultiSelect.vue'
|
||||||
@@ -345,6 +345,10 @@ const batchDetail = createResource({
|
|||||||
data.instructors.forEach((instructor) => {
|
data.instructors.forEach((instructor) => {
|
||||||
instructors.value.push(instructor.instructor)
|
instructors.value.push(instructor.instructor)
|
||||||
})
|
})
|
||||||
|
} else if (['start_time', 'end_time'].includes(key)) {
|
||||||
|
let [hours, minutes, seconds] = data[key].split(':')
|
||||||
|
hours = hours.length == 1 ? '0' + hours : hours
|
||||||
|
batch[key] = `${hours}:${minutes}`
|
||||||
} else if (Object.hasOwn(batch, key)) batch[key] = data[key]
|
} else if (Object.hasOwn(batch, key)) batch[key] = data[key]
|
||||||
})
|
})
|
||||||
let checkboxes = ['published', 'paid_batch', 'allow_self_enrollment']
|
let checkboxes = ['published', 'paid_batch', 'allow_self_enrollment']
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<Tooltip
|
<Tooltip
|
||||||
v-if="course.data.rating"
|
v-if="parseInt(course.data.rating) > 0"
|
||||||
:text="__('Average Rating')"
|
:text="__('Average Rating')"
|
||||||
class="flex items-center"
|
class="flex items-center"
|
||||||
>
|
>
|
||||||
@@ -25,7 +25,9 @@
|
|||||||
{{ course.data.rating }}
|
{{ course.data.rating }}
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<span v-if="course.data.rating" class="mx-3">·</span>
|
<span v-if="parseInt(course.data.rating) > 0" class="mx-3"
|
||||||
|
>·</span
|
||||||
|
>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
v-if="course.data.enrollment_count"
|
v-if="course.data.enrollment_count"
|
||||||
:text="__('Enrolled Students')"
|
:text="__('Enrolled Students')"
|
||||||
@@ -117,7 +119,7 @@ const course = createResource({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const breadcrumbs = computed(() => {
|
const breadcrumbs = computed(() => {
|
||||||
let items = [{ label: 'All Courses', route: { name: 'Courses' } }]
|
let items = [{ label: 'Courses', route: { name: 'Courses' } }]
|
||||||
items.push({
|
items.push({
|
||||||
label: course?.data?.title,
|
label: course?.data?.title,
|
||||||
route: { name: 'CourseDetail', params: { courseName: course?.data?.name } },
|
route: { name: 'CourseDetail', params: { courseName: course?.data?.name } },
|
||||||
|
|||||||
@@ -133,8 +133,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<FormControl
|
<FormControl
|
||||||
v-model="newTag"
|
v-model="newTag"
|
||||||
:placeholder="__('Keywords for the course')"
|
:placeholder="__('Add a keyword and then press enter')"
|
||||||
class="w-52"
|
class="w-72"
|
||||||
@keyup.enter="updateTags()"
|
@keyup.enter="updateTags()"
|
||||||
id="tags"
|
id="tags"
|
||||||
/>
|
/>
|
||||||
@@ -288,6 +288,7 @@ const course = reactive({
|
|||||||
video_link: '',
|
video_link: '',
|
||||||
course_image: null,
|
course_image: null,
|
||||||
tags: '',
|
tags: '',
|
||||||
|
category: '',
|
||||||
published: false,
|
published: false,
|
||||||
published_on: '',
|
published_on: '',
|
||||||
featured: false,
|
featured: false,
|
||||||
|
|||||||
@@ -42,8 +42,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div v-if="jobsList?.length">
|
<div v-if="jobsList?.length">
|
||||||
<div class="divide-y lg:w-3/4 mx-auto p-5">
|
<div class="lg:w-3/4 mx-auto p-5">
|
||||||
<div v-for="job in jobsList">
|
<div class="text-xl font-semibold mb-5">
|
||||||
|
{{ __('Find the perfect job for you') }}
|
||||||
|
</div>
|
||||||
|
<div v-for="job in jobsList" class="divide-y">
|
||||||
<router-link
|
<router-link
|
||||||
:to="{
|
:to="{
|
||||||
name: 'JobDetail',
|
name: 'JobDetail',
|
||||||
|
|||||||
@@ -305,7 +305,7 @@ const progress = createResource({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const breadcrumbs = computed(() => {
|
const breadcrumbs = computed(() => {
|
||||||
let items = [{ label: 'All Courses', route: { name: 'Courses' } }]
|
let items = [{ label: 'Courses', route: { name: 'Courses' } }]
|
||||||
items.push({
|
items.push({
|
||||||
label: lesson?.data?.course_title,
|
label: lesson?.data?.course_title,
|
||||||
route: { name: 'CourseDetail', params: { courseName: props.courseName } },
|
route: { name: 'CourseDetail', params: { courseName: props.courseName } },
|
||||||
|
|||||||
@@ -132,6 +132,7 @@ const renderEditor = (holder) => {
|
|||||||
holder: holder,
|
holder: holder,
|
||||||
tools: getEditorTools(true),
|
tools: getEditorTools(true),
|
||||||
autofocus: true,
|
autofocus: true,
|
||||||
|
defaultBlock: 'markdown',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,9 +28,7 @@
|
|||||||
size="lg"
|
size="lg"
|
||||||
>
|
>
|
||||||
{{ program.members }}
|
{{ program.members }}
|
||||||
{{
|
{{ program.members == 1 ? __('member') : __('members') }}
|
||||||
program.members == 1 ? __(singularize('members')) : __('members')
|
|
||||||
}}
|
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge
|
<Badge
|
||||||
v-if="program.progress"
|
v-if="program.progress"
|
||||||
@@ -133,7 +131,7 @@ import { computed, inject, onMounted, ref } from 'vue'
|
|||||||
import { BookOpen, Edit, Plus, LockKeyhole } from 'lucide-vue-next'
|
import { BookOpen, Edit, Plus, LockKeyhole } from 'lucide-vue-next'
|
||||||
import CourseCard from '@/components/CourseCard.vue'
|
import CourseCard from '@/components/CourseCard.vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { showToast, singularize } from '@/utils'
|
import { showToast } from '@/utils'
|
||||||
import { useSettings } from '@/stores/settings'
|
import { useSettings } from '@/stores/settings'
|
||||||
|
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
|
|||||||
@@ -256,11 +256,7 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const keyboardShortcut = (e) => {
|
const keyboardShortcut = (e) => {
|
||||||
if (
|
if (e.key === 's' && (e.ctrlKey || e.metaKey)) {
|
||||||
e.key === 's' &&
|
|
||||||
(e.ctrlKey || e.metaKey) &&
|
|
||||||
!e.target.classList.contains('ProseMirror')
|
|
||||||
) {
|
|
||||||
submitQuiz()
|
submitQuiz()
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,9 @@
|
|||||||
<Breadcrumbs :items="breadcrumbs" />
|
<Breadcrumbs :items="breadcrumbs" />
|
||||||
</header>
|
</header>
|
||||||
<div v-if="submissions.data?.length" class="md:w-3/4 md:mx-auto py-5 mx-5">
|
<div v-if="submissions.data?.length" class="md:w-3/4 md:mx-auto py-5 mx-5">
|
||||||
|
<div class="text-xl font-semibold mb-5">
|
||||||
|
{{ submissions.data[0].quiz_title }}
|
||||||
|
</div>
|
||||||
<ListView
|
<ListView
|
||||||
:columns="quizColumns"
|
:columns="quizColumns"
|
||||||
:rows="submissions.data"
|
:rows="submissions.data"
|
||||||
@@ -31,12 +34,18 @@
|
|||||||
</router-link>
|
</router-link>
|
||||||
</ListRows>
|
</ListRows>
|
||||||
</ListView>
|
</ListView>
|
||||||
|
<div class="flex justify-center my-5">
|
||||||
|
<Button v-if="submissions.hasNextPage" @click="submissions.next()">
|
||||||
|
{{ __('Load More') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import {
|
import {
|
||||||
createListResource,
|
createListResource,
|
||||||
Breadcrumbs,
|
Breadcrumbs,
|
||||||
|
Button,
|
||||||
ListView,
|
ListView,
|
||||||
ListRow,
|
ListRow,
|
||||||
ListRows,
|
ListRows,
|
||||||
@@ -76,12 +85,7 @@ const quizColumns = computed(() => {
|
|||||||
{
|
{
|
||||||
label: __('Member'),
|
label: __('Member'),
|
||||||
key: 'member_name',
|
key: 'member_name',
|
||||||
width: 2,
|
width: 1,
|
||||||
},
|
|
||||||
{
|
|
||||||
label: __('Quiz'),
|
|
||||||
key: 'quiz_title',
|
|
||||||
width: 2,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: __('Score'),
|
label: __('Score'),
|
||||||
|
|||||||
@@ -46,6 +46,11 @@
|
|||||||
</router-link>
|
</router-link>
|
||||||
</ListRows>
|
</ListRows>
|
||||||
</ListView>
|
</ListView>
|
||||||
|
<div class="flex justify-center my-5">
|
||||||
|
<Button v-if="quizzes.hasNextPage" @click="quizzes.next()">
|
||||||
|
{{ __('Load More') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else
|
v-else
|
||||||
@@ -67,13 +72,13 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import {
|
import {
|
||||||
Breadcrumbs,
|
Breadcrumbs,
|
||||||
|
Button,
|
||||||
createListResource,
|
createListResource,
|
||||||
ListView,
|
ListView,
|
||||||
ListRows,
|
ListRows,
|
||||||
ListRow,
|
ListRow,
|
||||||
ListHeader,
|
ListHeader,
|
||||||
ListHeaderItem,
|
ListHeaderItem,
|
||||||
Button,
|
|
||||||
} from 'frappe-ui'
|
} from 'frappe-ui'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { computed, inject, onMounted } from 'vue'
|
import { computed, inject, onMounted } from 'vue'
|
||||||
@@ -103,9 +108,6 @@ const quizzes = createListResource({
|
|||||||
auto: true,
|
auto: true,
|
||||||
cache: ['quizzes', user.data?.name],
|
cache: ['quizzes', user.data?.name],
|
||||||
orderBy: 'modified desc',
|
orderBy: 'modified desc',
|
||||||
onSuccess(data) {
|
|
||||||
data.forEach((row) => {})
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const quizColumns = computed(() => {
|
const quizColumns = computed(() => {
|
||||||
|
|||||||
@@ -131,12 +131,6 @@ const routes = [
|
|||||||
component: () => import('@/pages/JobCreation.vue'),
|
component: () => import('@/pages/JobCreation.vue'),
|
||||||
props: true,
|
props: true,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: '/assignment-submission/:assignmentName/:submissionName',
|
|
||||||
name: 'AssignmentSubmission',
|
|
||||||
component: () => import('@/pages/AssignmentSubmission.vue'),
|
|
||||||
props: true,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: '/certified-participants',
|
path: '/certified-participants',
|
||||||
name: 'CertifiedParticipants',
|
name: 'CertifiedParticipants',
|
||||||
@@ -193,6 +187,28 @@ const routes = [
|
|||||||
name: 'Programs',
|
name: 'Programs',
|
||||||
component: () => import('@/pages/Programs.vue'),
|
component: () => import('@/pages/Programs.vue'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/assignments',
|
||||||
|
name: 'Assignments',
|
||||||
|
component: () => import('@/pages/Assignments.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/assignments/:assignmentID',
|
||||||
|
name: 'AssignmentForm',
|
||||||
|
component: () => import('@/pages/AssignmentForm.vue'),
|
||||||
|
props: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/assignment-submission/:assignmentID/:submissionName',
|
||||||
|
name: 'AssignmentSubmission',
|
||||||
|
component: () => import('@/pages/AssignmentSubmission.vue'),
|
||||||
|
props: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/assignment-submissions',
|
||||||
|
name: 'AssignmentSubmissionList',
|
||||||
|
component: () => import('@/pages/AssignmentSubmissionList.vue'),
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
let router = createRouter({
|
let router = createRouter({
|
||||||
@@ -212,8 +228,7 @@ router.beforeEach(async (to, from, next) => {
|
|||||||
isLoggedIn &&
|
isLoggedIn &&
|
||||||
(to.name == 'Lesson' ||
|
(to.name == 'Lesson' ||
|
||||||
to.name == 'Batch' ||
|
to.name == 'Batch' ||
|
||||||
to.name == 'Notifications' ||
|
to.name == 'Notifications')
|
||||||
to.name == 'Badge')
|
|
||||||
) {
|
) {
|
||||||
await allUsers.promise
|
await allUsers.promise
|
||||||
}
|
}
|
||||||
|
|||||||
84
frontend/src/utils/assignment.js
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { Pencil } from 'lucide-vue-next'
|
||||||
|
import { createApp, h } from 'vue'
|
||||||
|
import AssessmentPlugin from '@/components/AssessmentPlugin.vue'
|
||||||
|
import AssignmentBlock from '@/components/AssignmentBlock.vue'
|
||||||
|
import translationPlugin from '../translation'
|
||||||
|
import { usersStore } from '@/stores/user'
|
||||||
|
import router from '../router'
|
||||||
|
|
||||||
|
export class Assignment {
|
||||||
|
constructor({ data, api, readOnly }) {
|
||||||
|
this.data = data
|
||||||
|
this.readOnly = readOnly
|
||||||
|
}
|
||||||
|
|
||||||
|
static get toolbox() {
|
||||||
|
const app = createApp({
|
||||||
|
render: () =>
|
||||||
|
h(Pencil, { size: 18, strokeWidth: 1.5, color: 'black' }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const div = document.createElement('div')
|
||||||
|
app.mount(div)
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: __('Assignment'),
|
||||||
|
icon: div.innerHTML,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static get isReadOnlySupported() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
this.wrapper = document.createElement('div')
|
||||||
|
if (Object.keys(this.data).length) {
|
||||||
|
this.renderAssignment(this.data.assignment)
|
||||||
|
} else {
|
||||||
|
this.renderAssignmentModal()
|
||||||
|
}
|
||||||
|
return this.wrapper
|
||||||
|
}
|
||||||
|
|
||||||
|
renderAssignment(assignment) {
|
||||||
|
if (this.readOnly) {
|
||||||
|
const app = createApp(AssignmentBlock, {
|
||||||
|
assignmentID: assignment,
|
||||||
|
})
|
||||||
|
app.use(translationPlugin)
|
||||||
|
app.use(router)
|
||||||
|
const { userResource } = usersStore()
|
||||||
|
app.provide('$user', userResource)
|
||||||
|
app.mount(this.wrapper)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.wrapper.innerHTML = `<div class='border rounded-md p-10 text-center bg-gray-50 mb-2'>
|
||||||
|
<span class="font-medium">
|
||||||
|
Assignment: ${assignment}
|
||||||
|
</span>
|
||||||
|
</div>`
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
renderAssignmentModal() {
|
||||||
|
if (this.readOnly) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const app = createApp(AssessmentPlugin, {
|
||||||
|
type: 'assignment',
|
||||||
|
onAddition: (assignment) => {
|
||||||
|
this.data.assignment = assignment
|
||||||
|
this.renderAssignment(assignment)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
app.use(translationPlugin)
|
||||||
|
app.mount(this.wrapper)
|
||||||
|
}
|
||||||
|
|
||||||
|
save(blockContent) {
|
||||||
|
return {
|
||||||
|
assignment: this.data.assignment,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import { toast } from 'frappe-ui'
|
import { toast } from 'frappe-ui'
|
||||||
import { useTimeAgo } from '@vueuse/core'
|
import { useTimeAgo } from '@vueuse/core'
|
||||||
import { Quiz } from '@/utils/quiz'
|
import { Quiz } from '@/utils/quiz'
|
||||||
|
import { Assignment } from '@/utils/assignment'
|
||||||
import { Upload } from '@/utils/upload'
|
import { Upload } from '@/utils/upload'
|
||||||
|
import { Markdown } from '@/utils/markdownParser'
|
||||||
import Header from '@editorjs/header'
|
import Header from '@editorjs/header'
|
||||||
import Paragraph from '@editorjs/paragraph'
|
import Paragraph from '@editorjs/paragraph'
|
||||||
import { CodeBox } from '@/utils/code'
|
import { CodeBox } from '@/utils/code'
|
||||||
@@ -147,9 +149,16 @@ export function htmlToText(html) {
|
|||||||
|
|
||||||
export function getEditorTools() {
|
export function getEditorTools() {
|
||||||
return {
|
return {
|
||||||
header: Header,
|
header: {
|
||||||
|
class: Header,
|
||||||
|
config: {
|
||||||
|
placeholder: 'Header',
|
||||||
|
},
|
||||||
|
},
|
||||||
quiz: Quiz,
|
quiz: Quiz,
|
||||||
|
assignment: Assignment,
|
||||||
upload: Upload,
|
upload: Upload,
|
||||||
|
markdown: Markdown,
|
||||||
image: SimpleImage,
|
image: SimpleImage,
|
||||||
table: Table,
|
table: Table,
|
||||||
paragraph: {
|
paragraph: {
|
||||||
|
|||||||
156
frontend/src/utils/markdownParser.js
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
export class Markdown {
|
||||||
|
constructor({ data, api, readOnly, config }) {
|
||||||
|
this.api = api
|
||||||
|
this.data = data || {}
|
||||||
|
this.config = config || {}
|
||||||
|
this.text = data.text || ''
|
||||||
|
this.readOnly = readOnly
|
||||||
|
}
|
||||||
|
|
||||||
|
static get isReadOnlySupported() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
static get conversionConfig() {
|
||||||
|
return {
|
||||||
|
export: 'text',
|
||||||
|
import: 'text',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onPaste(event) {
|
||||||
|
const data = {
|
||||||
|
text: event.detail.data.innerHTML,
|
||||||
|
}
|
||||||
|
|
||||||
|
this.data = data
|
||||||
|
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
if (!this.wrapper) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.wrapper.innerHTML = this.data.text || ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
static get pasteConfig() {
|
||||||
|
return {
|
||||||
|
tags: ['P'],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
this.wrapper = document.createElement('div')
|
||||||
|
this.wrapper.classList.add('cdx-block')
|
||||||
|
this.wrapper.classList.add('ce-paragraph')
|
||||||
|
this.wrapper.innerHTML = this.text
|
||||||
|
|
||||||
|
if (!this.readOnly) {
|
||||||
|
this.wrapper.contentEditable = true
|
||||||
|
this.wrapper.innerHTML = this.text
|
||||||
|
|
||||||
|
this.wrapper.addEventListener('keydown', (event) => {
|
||||||
|
const value = event.target.textContent
|
||||||
|
if (event.keyCode === 32 && value.startsWith('#')) {
|
||||||
|
this.convertToHeader(event, value)
|
||||||
|
} else if (event.keyCode === 13) {
|
||||||
|
this.parseContent(event)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.wrapper
|
||||||
|
}
|
||||||
|
|
||||||
|
convertToHeader(event, value) {
|
||||||
|
event.preventDefault()
|
||||||
|
if (['#', '##', '###', '####', '#####', '######'].includes(value)) {
|
||||||
|
let level = value.length
|
||||||
|
event.target.textContent = ''
|
||||||
|
this.convertBlock('header', {
|
||||||
|
level: level,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
parseContent(event) {
|
||||||
|
event.preventDefault()
|
||||||
|
const previousLine = this.wrapper.textContent
|
||||||
|
if (previousLine && this.hasImage(previousLine)) {
|
||||||
|
this.wrapper.textContent = ''
|
||||||
|
this.convertBlock('image')
|
||||||
|
} else if (previousLine && this.hasLink(previousLine)) {
|
||||||
|
const { text, url } = this.extractLink(previousLine)
|
||||||
|
const anchorTag = `<a href="${url}" target="_blank">${text}</a>`
|
||||||
|
this.convertBlock('paragraph', {
|
||||||
|
text: previousLine.replace(/\[.+?\]\(.+?\)/, anchorTag),
|
||||||
|
})
|
||||||
|
} else if (previousLine && previousLine.startsWith('- ')) {
|
||||||
|
this.convertBlock('list', {
|
||||||
|
style: 'unordered',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
content: previousLine.replace('- ', ''),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
} else if (previousLine && previousLine.startsWith('1. ')) {
|
||||||
|
this.convertBlock('list', {
|
||||||
|
style: 'ordered',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
content: previousLine.replace('1. ', ''),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
} else if (previousLine && this.canBeEmbed(previousLine)) {
|
||||||
|
this.wrapper.textContent = ''
|
||||||
|
this.convertBlock('embed', {
|
||||||
|
source: previousLine,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async convertBlock(type, data, index = null) {
|
||||||
|
const currentIndex = this.api.blocks.getCurrentBlockIndex()
|
||||||
|
const currentBlock = this.api.blocks.getBlockByIndex(currentIndex)
|
||||||
|
await this.api.blocks.convert(currentBlock.id, type, data)
|
||||||
|
this.api.caret.focus(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
save(blockContent) {
|
||||||
|
return {
|
||||||
|
text: blockContent.innerHTML,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hasImage(line) {
|
||||||
|
return /!\[.+?\]\(.+?\)/.test(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
extractImage(line) {
|
||||||
|
const match = line.match(/!\[(.+?)\]\((.+?)\)/)
|
||||||
|
if (match) {
|
||||||
|
return { alt: match[1], url: match[2] }
|
||||||
|
}
|
||||||
|
return { alt: '', url: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
hasLink(line) {
|
||||||
|
return /\[.+?\]\(.+?\)/.test(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
extractLink(line) {
|
||||||
|
const match = line.match(/\[(.+?)\]\((.+?)\)/)
|
||||||
|
if (match) {
|
||||||
|
return { text: match[1], url: match[2] }
|
||||||
|
}
|
||||||
|
return { text: '', url: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
canBeEmbed(line) {
|
||||||
|
return /^https?:\/\/.+/.test(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Markdown
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import QuizBlock from '@/components/QuizBlock.vue'
|
import QuizBlock from '@/components/QuizBlock.vue'
|
||||||
import QuizPlugin from '@/components/QuizPlugin.vue'
|
import AssessmentPlugin from '@/components/AssessmentPlugin.vue'
|
||||||
import { createApp, h } from 'vue'
|
import { createApp, h } from 'vue'
|
||||||
import { usersStore } from '../stores/user'
|
import { usersStore } from '../stores/user'
|
||||||
import translationPlugin from '../translation'
|
import translationPlugin from '../translation'
|
||||||
@@ -63,8 +63,9 @@ export class Quiz {
|
|||||||
if (this.readOnly) {
|
if (this.readOnly) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const app = createApp(QuizPlugin, {
|
const app = createApp(AssessmentPlugin, {
|
||||||
onQuizAddition: (quiz) => {
|
type: 'quiz',
|
||||||
|
onAddition: (quiz) => {
|
||||||
this.data.quiz = quiz
|
this.data.quiz = quiz
|
||||||
this.renderQuiz(quiz)
|
this.renderQuiz(quiz)
|
||||||
},
|
},
|
||||||
|
|||||||
5
frontend/src/utils/theme.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import resolveConfig from 'tailwindcss/resolveConfig'
|
||||||
|
import tailwindConfig from 'tailwind.config.js'
|
||||||
|
|
||||||
|
export const config = resolveConfig(tailwindConfig)
|
||||||
|
export const theme = config.theme
|
||||||
@@ -17,6 +17,7 @@ export default defineConfig({
|
|||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': path.resolve(__dirname, 'src'),
|
'@': path.resolve(__dirname, 'src'),
|
||||||
|
'tailwind.config.js': path.resolve(__dirname, 'tailwind.config.js'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
@@ -36,6 +37,11 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
optimizeDeps: {
|
optimizeDeps: {
|
||||||
include: ['frappe-ui > feather-icons', 'showdown', 'engine.io-client'],
|
include: [
|
||||||
|
'feather-icons',
|
||||||
|
'showdown',
|
||||||
|
'engine.io-client',
|
||||||
|
'tailwind.config.js',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -471,6 +471,33 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz#821f8442f4175d8f0467b9daf26e3a18e2d02af2"
|
resolved "https://registry.yarnpkg.com/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz#821f8442f4175d8f0467b9daf26e3a18e2d02af2"
|
||||||
integrity sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==
|
integrity sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==
|
||||||
|
|
||||||
|
"@svgdotjs/svg.draggable.js@^3.0.4":
|
||||||
|
version "3.0.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/@svgdotjs/svg.draggable.js/-/svg.draggable.js-3.0.4.tgz#505430e86b5e73b5b5abba12ac6002633897324e"
|
||||||
|
integrity sha512-vWi/Col5Szo74HJVBgMHz23kLVljt3jvngmh0DzST45iO2ubIZ487uUAHIxSZH2tVRyiaaTL+Phaasgp4gUD2g==
|
||||||
|
|
||||||
|
"@svgdotjs/svg.filter.js@^3.0.8":
|
||||||
|
version "3.0.8"
|
||||||
|
resolved "https://registry.yarnpkg.com/@svgdotjs/svg.filter.js/-/svg.filter.js-3.0.8.tgz#998cb2481a871fa70d7dbaa891c886b335c562d7"
|
||||||
|
integrity sha512-YshF2YDaeRA2StyzAs5nUPrev7npQ38oWD0eTRwnsciSL2KrRPMoUw8BzjIXItb3+dccKGTX3IQOd2NFzmHkog==
|
||||||
|
dependencies:
|
||||||
|
"@svgdotjs/svg.js" "^3.1.1"
|
||||||
|
|
||||||
|
"@svgdotjs/svg.js@^3.1.1", "@svgdotjs/svg.js@^3.2.4":
|
||||||
|
version "3.2.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/@svgdotjs/svg.js/-/svg.js-3.2.4.tgz#4716be92a64c66b29921b63f7235fcfb953fb13a"
|
||||||
|
integrity sha512-BjJ/7vWNowlX3Z8O4ywT58DqbNRyYlkk6Yz/D13aB7hGmfQTvGX4Tkgtm/ApYlu9M7lCQi15xUEidqMUmdMYwg==
|
||||||
|
|
||||||
|
"@svgdotjs/svg.resize.js@^2.0.2":
|
||||||
|
version "2.0.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/@svgdotjs/svg.resize.js/-/svg.resize.js-2.0.5.tgz#732e4cae15d09ad3021adeac63bc9fad0dc7255a"
|
||||||
|
integrity sha512-4heRW4B1QrJeENfi7326lUPYBCevj78FJs8kfeDxn5st0IYPIRXoTtOSYvTzFWgaWWXd3YCDE6ao4fmv91RthA==
|
||||||
|
|
||||||
|
"@svgdotjs/svg.select.js@^4.0.1":
|
||||||
|
version "4.0.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/@svgdotjs/svg.select.js/-/svg.select.js-4.0.2.tgz#80a10409e6c73206218690eac5c9f94f8c8909b5"
|
||||||
|
integrity sha512-5gWdrvoQX3keo03SCmgaBbD+kFftq0F/f2bzCbNnpkkvW6tk4rl4MakORzFuNjvXPWwB4az9GwuvVxQVnjaK2g==
|
||||||
|
|
||||||
"@swc/helpers@^0.5.0":
|
"@swc/helpers@^0.5.0":
|
||||||
version "0.5.15"
|
version "0.5.15"
|
||||||
resolved "https://registry.yarnpkg.com/@swc/helpers/-/helpers-0.5.15.tgz#79efab344c5819ecf83a43f3f9f811fc84b516d7"
|
resolved "https://registry.yarnpkg.com/@swc/helpers/-/helpers-0.5.15.tgz#79efab344c5819ecf83a43f3f9f811fc84b516d7"
|
||||||
@@ -887,6 +914,11 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
vue-demi ">=0.14.8"
|
vue-demi ">=0.14.8"
|
||||||
|
|
||||||
|
"@yr/monotone-cubic-spline@^1.0.3":
|
||||||
|
version "1.0.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/@yr/monotone-cubic-spline/-/monotone-cubic-spline-1.0.3.tgz#7272d89f8e4f6fb7a1600c28c378cc18d3b577b9"
|
||||||
|
integrity sha512-FQXkOta0XBSUPHndIKON2Y9JeQz5ZeMqLYZVVK93FliNBFm7LNMIZmY6FrMEB9XPcDbE2bekMbZD6kzDkxwYjA==
|
||||||
|
|
||||||
ace-builds@^1.36.2:
|
ace-builds@^1.36.2:
|
||||||
version "1.36.5"
|
version "1.36.5"
|
||||||
resolved "https://registry.yarnpkg.com/ace-builds/-/ace-builds-1.36.5.tgz#ae9cc7a32eccc2f484926131c00545cd6b78a6a6"
|
resolved "https://registry.yarnpkg.com/ace-builds/-/ace-builds-1.36.5.tgz#ae9cc7a32eccc2f484926131c00545cd6b78a6a6"
|
||||||
@@ -927,6 +959,18 @@ anymatch@~3.1.2:
|
|||||||
normalize-path "^3.0.0"
|
normalize-path "^3.0.0"
|
||||||
picomatch "^2.0.4"
|
picomatch "^2.0.4"
|
||||||
|
|
||||||
|
apexcharts@^4.3.0:
|
||||||
|
version "4.3.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/apexcharts/-/apexcharts-4.3.0.tgz#eccf28e830ce1b5e018cfc0e99d1c6af0076c9c7"
|
||||||
|
integrity sha512-PfvZQpv91T68hzry9l5zP3Gip7sQvF0nFK91uCBrswIKX7rbIdbVNS4fOks9m9yP3Ppgs6LHgU2M/mjoG4NM0A==
|
||||||
|
dependencies:
|
||||||
|
"@svgdotjs/svg.draggable.js" "^3.0.4"
|
||||||
|
"@svgdotjs/svg.filter.js" "^3.0.8"
|
||||||
|
"@svgdotjs/svg.js" "^3.2.4"
|
||||||
|
"@svgdotjs/svg.resize.js" "^2.0.2"
|
||||||
|
"@svgdotjs/svg.select.js" "^4.0.1"
|
||||||
|
"@yr/monotone-cubic-spline" "^1.0.3"
|
||||||
|
|
||||||
arg@^5.0.2:
|
arg@^5.0.2:
|
||||||
version "5.0.2"
|
version "5.0.2"
|
||||||
resolved "https://registry.yarnpkg.com/arg/-/arg-5.0.2.tgz#c81433cc427c92c4dcf4865142dbca6f15acd59c"
|
resolved "https://registry.yarnpkg.com/arg/-/arg-5.0.2.tgz#c81433cc427c92c4dcf4865142dbca6f15acd59c"
|
||||||
@@ -2164,6 +2208,11 @@ vue-router@^4.0.12:
|
|||||||
dependencies:
|
dependencies:
|
||||||
"@vue/devtools-api" "^6.6.4"
|
"@vue/devtools-api" "^6.6.4"
|
||||||
|
|
||||||
|
vue3-apexcharts@^1.8.0:
|
||||||
|
version "1.8.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/vue3-apexcharts/-/vue3-apexcharts-1.8.0.tgz#1984648d966aa91bc4dc3e87fa847f5289f7f1cf"
|
||||||
|
integrity sha512-5tSD4mXTBbIJ9ir+58qHE6oNtIe0RNgqIRYMKpcsIaxkKtwUww4JhvPkpUFlmiW4OJbbdklgjleXq1lfcM4gdA==
|
||||||
|
|
||||||
vue@^3.4.23:
|
vue@^3.4.23:
|
||||||
version "3.5.13"
|
version "3.5.13"
|
||||||
resolved "https://registry.yarnpkg.com/vue/-/vue-3.5.13.tgz#9f760a1a982b09c0c04a867903fc339c9f29ec0a"
|
resolved "https://registry.yarnpkg.com/vue/-/vue-3.5.13.tgz#9f760a1a982b09c0c04a867903fc339c9f29ec0a"
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
__version__ = "2.16.0"
|
__version__ = "2.19.0"
|
||||||
|
|||||||
150
lms/lms/api.py
@@ -13,10 +13,21 @@ from frappe.translate import get_all_translations
|
|||||||
from frappe import _
|
from frappe import _
|
||||||
from frappe.query_builder import DocType
|
from frappe.query_builder import DocType
|
||||||
from frappe.query_builder.functions import Count
|
from frappe.query_builder.functions import Count
|
||||||
from frappe.utils import time_diff, now_datetime, get_datetime, flt
|
from frappe.utils import (
|
||||||
|
time_diff,
|
||||||
|
now_datetime,
|
||||||
|
get_datetime,
|
||||||
|
cint,
|
||||||
|
flt,
|
||||||
|
now,
|
||||||
|
add_days,
|
||||||
|
format_date,
|
||||||
|
date_diff,
|
||||||
|
)
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from lms.lms.utils import get_average_rating, get_lesson_count
|
from lms.lms.utils import get_average_rating, get_lesson_count
|
||||||
from xml.dom.minidom import parseString
|
from xml.dom.minidom import parseString
|
||||||
|
from lms.lms.doctype.course_lesson.course_lesson import save_progress
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
@@ -168,6 +179,7 @@ def get_user_info():
|
|||||||
user.is_instructor = "Course Creator" in user.roles
|
user.is_instructor = "Course Creator" in user.roles
|
||||||
user.is_moderator = "Moderator" in user.roles
|
user.is_moderator = "Moderator" in user.roles
|
||||||
user.is_evaluator = "Batch Evaluator" in user.roles
|
user.is_evaluator = "Batch Evaluator" in user.roles
|
||||||
|
user.is_student = "LMS Student" in user.roles
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
@@ -592,7 +604,7 @@ def get_categories(doctype, filters):
|
|||||||
def get_members(start=0, search=""):
|
def get_members(start=0, search=""):
|
||||||
"""Get members for the given search term and start index.
|
"""Get members for the given search term and start index.
|
||||||
Args: start (int): Start index for the query.
|
Args: start (int): Start index for the query.
|
||||||
search (str): Search term to filter the results.
|
search (str): Search term to filter the results.
|
||||||
Returns: List of members.
|
Returns: List of members.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -841,8 +853,6 @@ def delete_course(course):
|
|||||||
frappe.delete_doc("Lesson Reference", lesson)
|
frappe.delete_doc("Lesson Reference", lesson)
|
||||||
|
|
||||||
for lesson in lessons:
|
for lesson in lessons:
|
||||||
frappe.db.delete("LMS Course Progress", {"lesson": lesson})
|
|
||||||
|
|
||||||
topics = frappe.get_all(
|
topics = frappe.get_all(
|
||||||
"Discussion Topic",
|
"Discussion Topic",
|
||||||
{"reference_doctype": "Course Lesson", "reference_docname": lesson},
|
{"reference_doctype": "Course Lesson", "reference_docname": lesson},
|
||||||
@@ -862,6 +872,9 @@ def delete_course(course):
|
|||||||
for chapter in chapters:
|
for chapter in chapters:
|
||||||
frappe.delete_doc("Course Chapter", chapter)
|
frappe.delete_doc("Course Chapter", chapter)
|
||||||
|
|
||||||
|
frappe.db.delete("LMS Course Progress", {"course": course})
|
||||||
|
frappe.db.delete("LMS Quiz", {"course": course})
|
||||||
|
frappe.db.delete("LMS Quiz Submission", {"course": course})
|
||||||
frappe.db.delete("LMS Enrollment", {"course": course})
|
frappe.db.delete("LMS Enrollment", {"course": course})
|
||||||
frappe.delete_doc("LMS Course", course)
|
frappe.delete_doc("LMS Course", course)
|
||||||
|
|
||||||
@@ -1029,3 +1042,132 @@ def delete_scorm_package(scorm_package_path):
|
|||||||
scorm_package_path = frappe.get_site_path("public", scorm_package_path[1:])
|
scorm_package_path = frappe.get_site_path("public", scorm_package_path[1:])
|
||||||
if os.path.exists(scorm_package_path):
|
if os.path.exists(scorm_package_path):
|
||||||
shutil.rmtree(scorm_package_path)
|
shutil.rmtree(scorm_package_path)
|
||||||
|
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def mark_lesson_progress(course, chapter_number, lesson_number):
|
||||||
|
chapter_name = frappe.get_value(
|
||||||
|
"Chapter Reference", {"parent": course, "idx": chapter_number}, "chapter"
|
||||||
|
)
|
||||||
|
lesson_name = frappe.get_value(
|
||||||
|
"Lesson Reference", {"parent": chapter_name, "idx": lesson_number}, "lesson"
|
||||||
|
)
|
||||||
|
save_progress(lesson_name, course)
|
||||||
|
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def get_heatmap_data(member=None, base_days=200):
|
||||||
|
if not member:
|
||||||
|
member = frappe.session.user
|
||||||
|
|
||||||
|
base_date, start_date, number_of_days, days = calculate_date_ranges(base_days)
|
||||||
|
date_count = initialize_date_count(days)
|
||||||
|
|
||||||
|
lesson_completions, quiz_submissions, assignment_submissions = fetch_activity_data(
|
||||||
|
member, start_date
|
||||||
|
)
|
||||||
|
count_dates(lesson_completions, date_count)
|
||||||
|
count_dates(quiz_submissions, date_count)
|
||||||
|
count_dates(assignment_submissions, date_count)
|
||||||
|
|
||||||
|
heatmap_data, labels, total_activities, weeks = prepare_heatmap_data(
|
||||||
|
start_date, number_of_days, date_count
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"heatmap_data": heatmap_data,
|
||||||
|
"labels": labels,
|
||||||
|
"total_activities": total_activities,
|
||||||
|
"weeks": weeks,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_date_ranges(base_days):
|
||||||
|
today = format_date(now(), "YYYY-MM-dd")
|
||||||
|
day_today = get_datetime(today).strftime("%w")
|
||||||
|
padding_end = 6 - cint(day_today)
|
||||||
|
|
||||||
|
base_date = add_days(today, -base_days)
|
||||||
|
day_of_base_date = cint(get_datetime(base_date).strftime("%w"))
|
||||||
|
start_date = add_days(base_date, -day_of_base_date)
|
||||||
|
number_of_days = base_days + day_of_base_date + padding_end
|
||||||
|
days = [add_days(start_date, i) for i in range(number_of_days + 1)]
|
||||||
|
|
||||||
|
return base_date, start_date, number_of_days, days
|
||||||
|
|
||||||
|
|
||||||
|
def initialize_date_count(days):
|
||||||
|
return {format_date(day, "YYYY-MM-dd"): 0 for day in days}
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_activity_data(member, start_date):
|
||||||
|
lesson_completions = frappe.get_all(
|
||||||
|
"LMS Course Progress",
|
||||||
|
fields=["creation"],
|
||||||
|
filters={"member": member, "creation": [">=", start_date]},
|
||||||
|
)
|
||||||
|
|
||||||
|
quiz_submissions = frappe.get_all(
|
||||||
|
"LMS Quiz Submission",
|
||||||
|
fields=["creation"],
|
||||||
|
filters={"member": member, "creation": [">=", start_date]},
|
||||||
|
)
|
||||||
|
|
||||||
|
assignment_submissions = frappe.get_all(
|
||||||
|
"LMS Assignment Submission",
|
||||||
|
fields=["creation"],
|
||||||
|
filters={"member": member, "creation": [">=", start_date]},
|
||||||
|
)
|
||||||
|
|
||||||
|
return lesson_completions, quiz_submissions, assignment_submissions
|
||||||
|
|
||||||
|
|
||||||
|
def count_dates(data, date_count):
|
||||||
|
for entry in data:
|
||||||
|
date = format_date(entry.creation, "YYYY-MM-dd")
|
||||||
|
if date in date_count:
|
||||||
|
date_count[date] += 1
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_heatmap_data(start_date, number_of_days, date_count):
|
||||||
|
days_of_week = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
|
||||||
|
heatmap_data = {day: [] for day in days_of_week}
|
||||||
|
week_count = -(number_of_days // -7)
|
||||||
|
labels = [None] * week_count
|
||||||
|
last_seen_month = None
|
||||||
|
sorted_dates = sorted(date_count.keys())
|
||||||
|
|
||||||
|
for date in sorted_dates:
|
||||||
|
activity_count = date_count[date]
|
||||||
|
day_of_week = get_datetime(date).strftime("%a")
|
||||||
|
current_month = get_datetime(date).strftime("%b")
|
||||||
|
column_index = get_week_difference(start_date, date)
|
||||||
|
|
||||||
|
if 0 <= column_index < week_count:
|
||||||
|
heatmap_data[day_of_week].append(
|
||||||
|
{
|
||||||
|
"date": date,
|
||||||
|
"count": activity_count,
|
||||||
|
"label": f"{activity_count} activities on {format_date(date, 'dd MMM')}",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if last_seen_month != current_month:
|
||||||
|
labels[column_index] = current_month
|
||||||
|
last_seen_month = current_month
|
||||||
|
|
||||||
|
for (index, label) in enumerate(labels):
|
||||||
|
if not label:
|
||||||
|
labels[index] = ""
|
||||||
|
|
||||||
|
formatted_heatmap_data = [
|
||||||
|
{"name": day, "data": heatmap_data[day]} for day in days_of_week
|
||||||
|
]
|
||||||
|
|
||||||
|
total_activities = sum(date_count.values())
|
||||||
|
return formatted_heatmap_data, labels, total_activities, week_count
|
||||||
|
|
||||||
|
|
||||||
|
def get_week_difference(start_date, current_date):
|
||||||
|
diff_in_days = date_diff(current_date, start_date)
|
||||||
|
return diff_in_days // 7
|
||||||
|
|||||||
@@ -89,27 +89,25 @@ def save_progress(lesson, course):
|
|||||||
"LMS Enrollment", {"course": course, "member": frappe.session.user}
|
"LMS Enrollment", {"course": course, "member": frappe.session.user}
|
||||||
)
|
)
|
||||||
if not membership:
|
if not membership:
|
||||||
return
|
|
||||||
|
|
||||||
frappe.db.set_value("LMS Enrollment", membership, "current_lesson", lesson)
|
|
||||||
|
|
||||||
if frappe.db.exists(
|
|
||||||
"LMS Course Progress", {"lesson": lesson, "member": frappe.session.user}
|
|
||||||
):
|
|
||||||
return
|
|
||||||
|
|
||||||
quiz_completed = get_quiz_progress(lesson)
|
|
||||||
if not quiz_completed:
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
frappe.get_doc(
|
frappe.db.set_value("LMS Enrollment", membership, "current_lesson", lesson)
|
||||||
{
|
already_completed = frappe.db.exists(
|
||||||
"doctype": "LMS Course Progress",
|
"LMS Course Progress", {"lesson": lesson, "member": frappe.session.user}
|
||||||
"lesson": lesson,
|
)
|
||||||
"status": "Complete",
|
|
||||||
"member": frappe.session.user,
|
quiz_completed = get_quiz_progress(lesson)
|
||||||
}
|
assignment_completed = get_assignment_progress(lesson)
|
||||||
).save(ignore_permissions=True)
|
|
||||||
|
if not already_completed and quiz_completed and assignment_completed:
|
||||||
|
frappe.get_doc(
|
||||||
|
{
|
||||||
|
"doctype": "LMS Course Progress",
|
||||||
|
"lesson": lesson,
|
||||||
|
"status": "Complete",
|
||||||
|
"member": frappe.session.user,
|
||||||
|
}
|
||||||
|
).save(ignore_permissions=True)
|
||||||
|
|
||||||
progress = get_course_progress(course)
|
progress = get_course_progress(course)
|
||||||
capture_progress_for_analytics(progress, course)
|
capture_progress_for_analytics(progress, course)
|
||||||
@@ -159,6 +157,32 @@ def get_quiz_progress(lesson):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def get_assignment_progress(lesson):
|
||||||
|
lesson_details = frappe.db.get_value(
|
||||||
|
"Course Lesson", lesson, ["body", "content"], as_dict=1
|
||||||
|
)
|
||||||
|
assignments = []
|
||||||
|
|
||||||
|
if lesson_details.content:
|
||||||
|
content = json.loads(lesson_details.content)
|
||||||
|
|
||||||
|
for block in content.get("blocks"):
|
||||||
|
if block.get("type") == "assignment":
|
||||||
|
assignments.append(block.get("data").get("assignment"))
|
||||||
|
|
||||||
|
elif lesson_details.body:
|
||||||
|
macros = find_macros(lesson_details.body)
|
||||||
|
assignments = [value for name, value in macros if name == "Assignment"]
|
||||||
|
|
||||||
|
for assignment in assignments:
|
||||||
|
if not frappe.db.exists(
|
||||||
|
"LMS Assignment Submission",
|
||||||
|
{"assignment": assignment, "member": frappe.session.user},
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def get_lesson_info(chapter):
|
def get_lesson_info(chapter):
|
||||||
return frappe.db.get_value("Course Chapter", chapter, "course")
|
return frappe.db.get_value("Course Chapter", chapter, "course")
|
||||||
|
|||||||
@@ -9,10 +9,11 @@
|
|||||||
"engine": "InnoDB",
|
"engine": "InnoDB",
|
||||||
"field_order": [
|
"field_order": [
|
||||||
"title",
|
"title",
|
||||||
"grade_assignment",
|
|
||||||
"question",
|
"question",
|
||||||
"column_break_hmwv",
|
"column_break_hmwv",
|
||||||
"type",
|
"type",
|
||||||
|
"grade_assignment",
|
||||||
|
"section_break_sjti",
|
||||||
"show_answer",
|
"show_answer",
|
||||||
"answer"
|
"answer"
|
||||||
],
|
],
|
||||||
@@ -20,7 +21,8 @@
|
|||||||
{
|
{
|
||||||
"fieldname": "question",
|
"fieldname": "question",
|
||||||
"fieldtype": "Text Editor",
|
"fieldtype": "Text Editor",
|
||||||
"label": "Question"
|
"label": "Question",
|
||||||
|
"reqd": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldname": "type",
|
"fieldname": "type",
|
||||||
@@ -28,14 +30,16 @@
|
|||||||
"in_list_view": 1,
|
"in_list_view": 1,
|
||||||
"in_standard_filter": 1,
|
"in_standard_filter": 1,
|
||||||
"label": "Type",
|
"label": "Type",
|
||||||
"options": "Document\nPDF\nURL\nImage\nText"
|
"options": "Document\nPDF\nURL\nImage\nText",
|
||||||
|
"reqd": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldname": "title",
|
"fieldname": "title",
|
||||||
"fieldtype": "Data",
|
"fieldtype": "Data",
|
||||||
"in_list_view": 1,
|
"in_list_view": 1,
|
||||||
"in_standard_filter": 1,
|
"in_standard_filter": 1,
|
||||||
"label": "Title"
|
"label": "Title",
|
||||||
|
"reqd": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldname": "column_break_hmwv",
|
"fieldname": "column_break_hmwv",
|
||||||
@@ -60,11 +64,15 @@
|
|||||||
"fieldname": "grade_assignment",
|
"fieldname": "grade_assignment",
|
||||||
"fieldtype": "Check",
|
"fieldtype": "Check",
|
||||||
"label": "Grade Assignment"
|
"label": "Grade Assignment"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "section_break_sjti",
|
||||||
|
"fieldtype": "Section Break"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2024-04-05 12:01:36.601160",
|
"modified": "2024-12-24 09:36:31.464508",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "LMS",
|
"module": "LMS",
|
||||||
"name": "LMS Assignment",
|
"name": "LMS Assignment",
|
||||||
|
|||||||
@@ -14,19 +14,17 @@
|
|||||||
"member",
|
"member",
|
||||||
"member_name",
|
"member_name",
|
||||||
"section_break_dlzh",
|
"section_break_dlzh",
|
||||||
"question",
|
|
||||||
"column_break_zvis",
|
|
||||||
"assignment_attachment",
|
"assignment_attachment",
|
||||||
"answer",
|
"answer",
|
||||||
"section_break_rqal",
|
"column_break_oqqy",
|
||||||
"status",
|
|
||||||
"evaluator",
|
"evaluator",
|
||||||
"column_break_esgd",
|
"status",
|
||||||
"comments",
|
"comments",
|
||||||
"section_break_cwaw",
|
"section_break_rqal",
|
||||||
"lesson",
|
"question",
|
||||||
|
"column_break_esgd",
|
||||||
"course",
|
"course",
|
||||||
"column_break_ygdu"
|
"lesson"
|
||||||
],
|
],
|
||||||
"fields": [
|
"fields": [
|
||||||
{
|
{
|
||||||
@@ -89,8 +87,7 @@
|
|||||||
"fieldname": "evaluator",
|
"fieldname": "evaluator",
|
||||||
"fieldtype": "Link",
|
"fieldtype": "Link",
|
||||||
"label": "Evaluator",
|
"label": "Evaluator",
|
||||||
"options": "User",
|
"options": "User"
|
||||||
"read_only": 1
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"depends_on": "eval:!([\"URL\", \"Text\"]).includes(doc.type);",
|
"depends_on": "eval:!([\"URL\", \"Text\"]).includes(doc.type);",
|
||||||
@@ -128,14 +125,6 @@
|
|||||||
"fieldname": "column_break_esgd",
|
"fieldname": "column_break_esgd",
|
||||||
"fieldtype": "Column Break"
|
"fieldtype": "Column Break"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"fieldname": "section_break_cwaw",
|
|
||||||
"fieldtype": "Section Break"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldname": "column_break_ygdu",
|
|
||||||
"fieldtype": "Column Break"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"depends_on": "eval:([\"URL\", \"Text\"]).includes(doc.type);",
|
"depends_on": "eval:([\"URL\", \"Text\"]).includes(doc.type);",
|
||||||
"fieldname": "answer",
|
"fieldname": "answer",
|
||||||
@@ -148,14 +137,14 @@
|
|||||||
"fieldtype": "Section Break"
|
"fieldtype": "Section Break"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldname": "column_break_zvis",
|
"fieldname": "column_break_oqqy",
|
||||||
"fieldtype": "Column Break"
|
"fieldtype": "Column Break"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"make_attachments_public": 1,
|
"make_attachments_public": 1,
|
||||||
"modified": "2024-04-05 15:57:22.758563",
|
"modified": "2024-12-24 21:22:35.212732",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "LMS",
|
"module": "LMS",
|
||||||
"name": "LMS Assignment Submission",
|
"name": "LMS Assignment Submission",
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ from frappe import _
|
|||||||
from frappe.model.document import Document
|
from frappe.model.document import Document
|
||||||
from frappe.utils import validate_url, validate_email_address
|
from frappe.utils import validate_url, validate_email_address
|
||||||
from frappe.email.doctype.email_template.email_template import get_email_template
|
from frappe.email.doctype.email_template.email_template import get_email_template
|
||||||
|
from frappe.desk.doctype.notification_log.notification_log import make_notification_logs
|
||||||
|
|
||||||
|
|
||||||
class LMSAssignmentSubmission(Document):
|
class LMSAssignmentSubmission(Document):
|
||||||
def validate(self):
|
def validate(self):
|
||||||
self.validate_duplicates()
|
self.validate_duplicates()
|
||||||
self.validate_url()
|
self.validate_url()
|
||||||
|
self.validate_status()
|
||||||
|
|
||||||
def after_insert(self):
|
def after_insert(self):
|
||||||
if not frappe.flags.in_test:
|
if not frappe.flags.in_test:
|
||||||
@@ -69,6 +71,28 @@ class LMSAssignmentSubmission(Document):
|
|||||||
header=[subject, "green"],
|
header=[subject, "green"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def validate_status(self):
|
||||||
|
doc_before_save = self.get_doc_before_save()
|
||||||
|
if doc_before_save.status != self.status or doc_before_save.comments != self.comments:
|
||||||
|
self.trigger_update_notification()
|
||||||
|
|
||||||
|
def trigger_update_notification(self):
|
||||||
|
notification = frappe._dict(
|
||||||
|
{
|
||||||
|
"subject": _(
|
||||||
|
"There has been an update on your submission for assignment {0}"
|
||||||
|
).format(self.assignment_title),
|
||||||
|
"email_content": self.comments,
|
||||||
|
"document_type": self.doctype,
|
||||||
|
"document_name": self.name,
|
||||||
|
"for_user": self.owner,
|
||||||
|
"from_user": self.evaluator,
|
||||||
|
"type": "Alert",
|
||||||
|
"link": f"/assignment-submission/{self.assignment}/{self.name}",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
make_notification_logs(notification, [self.member])
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def upload_assignment(
|
def upload_assignment(
|
||||||
|
|||||||
@@ -10,5 +10,11 @@ frappe.ui.form.on("LMS Badge Assignment", {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (frm.doc.name)
|
||||||
|
frm.add_web_link(
|
||||||
|
`/badges/${frm.doc.badge}/${frm.doc.member}`,
|
||||||
|
"See on Website"
|
||||||
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
"engine": "InnoDB",
|
"engine": "InnoDB",
|
||||||
"field_order": [
|
"field_order": [
|
||||||
"member",
|
"member",
|
||||||
|
"member_name",
|
||||||
"issued_on",
|
"issued_on",
|
||||||
"column_break_ugix",
|
"column_break_ugix",
|
||||||
"badge",
|
"badge",
|
||||||
@@ -57,11 +58,18 @@
|
|||||||
"label": "Badge Description",
|
"label": "Badge Description",
|
||||||
"read_only": 1,
|
"read_only": 1,
|
||||||
"reqd": 1
|
"reqd": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fetch_from": "member.full_name",
|
||||||
|
"fieldname": "member_name",
|
||||||
|
"fieldtype": "Data",
|
||||||
|
"label": "Member Name",
|
||||||
|
"read_only": 1
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2024-05-13 20:16:00.191517",
|
"modified": "2025-01-06 12:32:28.450028",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "LMS",
|
"module": "LMS",
|
||||||
"name": "LMS Badge Assignment",
|
"name": "LMS Badge Assignment",
|
||||||
|
|||||||
@@ -132,13 +132,13 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldname": "duration",
|
"fieldname": "duration",
|
||||||
"fieldtype": "Duration",
|
"fieldtype": "Data",
|
||||||
"label": "Duration"
|
"label": "Duration (in minutes)"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2024-10-11 22:39:40.381183",
|
"modified": "2025-01-06 11:02:09.749207",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "LMS",
|
"module": "LMS",
|
||||||
"name": "LMS Quiz",
|
"name": "LMS Quiz",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ class LMSQuizSubmission(Document):
|
|||||||
self.notify_member()
|
self.notify_member()
|
||||||
|
|
||||||
def validate_marks(self):
|
def validate_marks(self):
|
||||||
|
self.score = 0
|
||||||
for row in self.result:
|
for row in self.result:
|
||||||
if cint(row.marks) > cint(row.marks_out_of):
|
if cint(row.marks) > cint(row.marks_out_of):
|
||||||
frappe.throw(
|
frappe.throw(
|
||||||
|
|||||||
156
lms/lms/utils.py
@@ -874,26 +874,6 @@ def is_onboarding_complete():
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def has_submitted_assessment(assessment, type, member=None):
|
|
||||||
if not member:
|
|
||||||
member = frappe.session.user
|
|
||||||
|
|
||||||
doctype = (
|
|
||||||
"LMS Assignment Submission" if type == "LMS Assignment" else "LMS Quiz Submission"
|
|
||||||
)
|
|
||||||
docfield = "assignment" if type == "LMS Assignment" else "quiz"
|
|
||||||
|
|
||||||
filters = {}
|
|
||||||
filters[docfield] = assessment
|
|
||||||
filters["member"] = member
|
|
||||||
return frappe.db.exists(doctype, filters)
|
|
||||||
|
|
||||||
|
|
||||||
def has_graded_assessment(submission):
|
|
||||||
status = frappe.db.get_value("LMS Assignment Submission", submission, "status")
|
|
||||||
return False if status == "Not Graded" else True
|
|
||||||
|
|
||||||
|
|
||||||
def get_evaluator(course, batch):
|
def get_evaluator(course, batch):
|
||||||
evaluator = None
|
evaluator = None
|
||||||
evaluator = frappe.db.get_value(
|
evaluator = frappe.db.get_value(
|
||||||
@@ -1050,6 +1030,7 @@ def get_course_details(course):
|
|||||||
course_details.tags = course_details.tags.split(",") if course_details.tags else []
|
course_details.tags = course_details.tags.split(",") if course_details.tags else []
|
||||||
|
|
||||||
course_details.instructors = get_instructors(course_details.name)
|
course_details.instructors = get_instructors(course_details.name)
|
||||||
|
# course_details.is_instructor = is_instructor(course_details.name)
|
||||||
if course_details.paid_course:
|
if course_details.paid_course:
|
||||||
"""course_details.course_price, course_details.currency = check_multicurrency(
|
"""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
|
||||||
@@ -1068,7 +1049,6 @@ def get_course_details(course):
|
|||||||
["name", "course", "current_lesson", "progress", "member"],
|
["name", "course", "current_lesson", "progress", "member"],
|
||||||
as_dict=1,
|
as_dict=1,
|
||||||
)
|
)
|
||||||
course_details.is_instructor = is_instructor(course_details.name)
|
|
||||||
|
|
||||||
if course_details.membership and course_details.membership.current_lesson:
|
if course_details.membership and course_details.membership.current_lesson:
|
||||||
course_details.current_lesson = get_lesson_index(
|
course_details.current_lesson = get_lesson_index(
|
||||||
@@ -1239,12 +1219,50 @@ def get_batches():
|
|||||||
batch_list = frappe.get_all("LMS Batch", filters)
|
batch_list = frappe.get_all("LMS Batch", filters)
|
||||||
|
|
||||||
for batch in batch_list:
|
for batch in batch_list:
|
||||||
batches.append(get_batch_details(batch.name))
|
batches.append(get_batch_card_details(batch.name))
|
||||||
|
|
||||||
batches = categorize_batches(batches)
|
batches = categorize_batches(batches)
|
||||||
return batches
|
return batches
|
||||||
|
|
||||||
|
|
||||||
|
def get_batch_card_details(batchname):
|
||||||
|
batch = frappe.db.get_value(
|
||||||
|
"LMS Batch",
|
||||||
|
batchname,
|
||||||
|
[
|
||||||
|
"name",
|
||||||
|
"title",
|
||||||
|
"description",
|
||||||
|
"seat_count",
|
||||||
|
"paid_batch",
|
||||||
|
"amount",
|
||||||
|
"amount_usd",
|
||||||
|
"currency",
|
||||||
|
"start_date",
|
||||||
|
"end_date",
|
||||||
|
"start_time",
|
||||||
|
"end_time",
|
||||||
|
"timezone",
|
||||||
|
"published",
|
||||||
|
],
|
||||||
|
as_dict=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
batch.instructors = get_instructors(batchname)
|
||||||
|
students_count = frappe.db.count("Batch Student", {"parent": batchname})
|
||||||
|
|
||||||
|
if batch.seat_count:
|
||||||
|
batch.seats_left = batch.seat_count - students_count
|
||||||
|
|
||||||
|
if batch.paid_batch and batch.start_date >= getdate():
|
||||||
|
batch.amount, batch.currency = check_multicurrency(
|
||||||
|
batch.amount, batch.currency, None, batch.amount_usd
|
||||||
|
)
|
||||||
|
batch.price = fmt_money(batch.amount, 0, batch.currency)
|
||||||
|
|
||||||
|
return batch
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist(allow_guest=True)
|
@frappe.whitelist(allow_guest=True)
|
||||||
def get_batch_details(batch):
|
def get_batch_details(batch):
|
||||||
batch_details = frappe.db.get_value(
|
batch_details = frappe.db.get_value(
|
||||||
@@ -1347,7 +1365,6 @@ def get_question_details(question):
|
|||||||
for i in range(1, 5):
|
for i in range(1, 5):
|
||||||
fields.append(f"option_{i}")
|
fields.append(f"option_{i}")
|
||||||
fields.append(f"explanation_{i}")
|
fields.append(f"explanation_{i}")
|
||||||
fields.append(f"is_correct_{i}")
|
|
||||||
|
|
||||||
question_details = frappe.db.get_value("LMS Question", question, fields, as_dict=1)
|
question_details = frappe.db.get_value("LMS Question", question, fields, as_dict=1)
|
||||||
return question_details
|
return question_details
|
||||||
@@ -1441,7 +1458,7 @@ def get_quiz_details(assessment, member):
|
|||||||
if len(existing_submission):
|
if len(existing_submission):
|
||||||
assessment.submission = existing_submission[0]
|
assessment.submission = existing_submission[0]
|
||||||
assessment.completed = True
|
assessment.completed = True
|
||||||
assessment.status = assessment.submission.score
|
assessment.status = assessment.submission.percentage or assessment.submission.score
|
||||||
else:
|
else:
|
||||||
assessment.status = "Not Attempted"
|
assessment.status = "Not Attempted"
|
||||||
assessment.color = "red"
|
assessment.color = "red"
|
||||||
@@ -1459,13 +1476,11 @@ def get_quiz_details(assessment, member):
|
|||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def get_batch_students(batch):
|
def get_batch_students(batch):
|
||||||
students = []
|
students = []
|
||||||
|
|
||||||
students_list = frappe.get_all(
|
students_list = frappe.get_all(
|
||||||
"Batch Student", filters={"parent": batch}, fields=["student", "name"]
|
"Batch Student", filters={"parent": batch}, fields=["student", "name"]
|
||||||
)
|
)
|
||||||
|
|
||||||
batch_courses = frappe.get_all("Batch Course", {"parent": batch}, pluck="course")
|
batch_courses = frappe.get_all("Batch Course", {"parent": batch}, ["course", "title"])
|
||||||
|
|
||||||
assessments = frappe.get_all(
|
assessments = frappe.get_all(
|
||||||
"LMS Assessment",
|
"LMS Assessment",
|
||||||
filters={"parent": batch},
|
filters={"parent": batch},
|
||||||
@@ -1483,29 +1498,76 @@ def get_batch_students(batch):
|
|||||||
)
|
)
|
||||||
detail.last_active = format_datetime(detail.last_active, "dd MMM YY")
|
detail.last_active = format_datetime(detail.last_active, "dd MMM YY")
|
||||||
detail.name = student.name
|
detail.name = student.name
|
||||||
students.append(detail)
|
detail.courses = frappe._dict()
|
||||||
|
detail.assessments = frappe._dict()
|
||||||
|
|
||||||
|
""" Iterate through courses and track their progress """
|
||||||
for course in batch_courses:
|
for course in batch_courses:
|
||||||
progress = frappe.db.get_value(
|
progress = frappe.db.get_value(
|
||||||
"LMS Enrollment", {"course": course, "member": student.student}, "progress"
|
"LMS Enrollment", {"course": course.course, "member": student.student}, "progress"
|
||||||
)
|
)
|
||||||
|
detail.courses[course.title] = progress
|
||||||
if progress == 100:
|
if progress == 100:
|
||||||
courses_completed += 1
|
courses_completed += 1
|
||||||
|
|
||||||
detail.courses_completed = courses_completed
|
""" Iterate through assessments and track their progress """
|
||||||
|
|
||||||
for assessment in assessments:
|
for assessment in assessments:
|
||||||
if has_submitted_assessment(
|
title = frappe.db.get_value(
|
||||||
|
assessment.assessment_type, assessment.assessment_name, "title"
|
||||||
|
)
|
||||||
|
status = has_submitted_assessment(
|
||||||
assessment.assessment_name, assessment.assessment_type, student.student
|
assessment.assessment_name, assessment.assessment_type, student.student
|
||||||
):
|
)
|
||||||
|
detail.assessments[title] = status
|
||||||
|
if status not in ["Not Attempted", 0]:
|
||||||
assessments_completed += 1
|
assessments_completed += 1
|
||||||
|
|
||||||
|
detail.courses_completed = courses_completed
|
||||||
detail.assessments_completed = assessments_completed
|
detail.assessments_completed = assessments_completed
|
||||||
|
if len(batch_courses) + len(assessments):
|
||||||
|
detail.progress = flt(
|
||||||
|
(
|
||||||
|
(courses_completed + assessments_completed)
|
||||||
|
/ (len(batch_courses) + len(assessments))
|
||||||
|
* 100
|
||||||
|
),
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
detail.progress = 0
|
||||||
|
|
||||||
|
students.append(detail)
|
||||||
|
students = sorted(students, key=lambda x: x.progress, reverse=True)
|
||||||
return students
|
return students
|
||||||
|
|
||||||
|
|
||||||
|
def has_submitted_assessment(assessment, assessment_type, member=None):
|
||||||
|
if not member:
|
||||||
|
member = frappe.session.user
|
||||||
|
|
||||||
|
if assessment_type == "LMS Assignment":
|
||||||
|
doctype = "LMS Assignment Submission"
|
||||||
|
docfield = "assignment"
|
||||||
|
fields = ["status"]
|
||||||
|
not_attempted = "Not Attempted"
|
||||||
|
elif assessment_type == "LMS Quiz":
|
||||||
|
doctype = "LMS Quiz Submission"
|
||||||
|
docfield = "quiz"
|
||||||
|
fields = ["percentage"]
|
||||||
|
not_attempted = 0
|
||||||
|
|
||||||
|
filters = {}
|
||||||
|
filters[docfield] = assessment
|
||||||
|
filters["member"] = member
|
||||||
|
|
||||||
|
attempt = frappe.db.exists(doctype, filters)
|
||||||
|
if attempt:
|
||||||
|
attempt_details = frappe.db.get_value(doctype, filters, fields)
|
||||||
|
return attempt_details
|
||||||
|
else:
|
||||||
|
return not_attempted
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def get_discussion_topics(doctype, docname, single_thread):
|
def get_discussion_topics(doctype, docname, single_thread):
|
||||||
if single_thread:
|
if single_thread:
|
||||||
@@ -1726,31 +1788,31 @@ def enroll_in_batch(batch, payment_name=None):
|
|||||||
if not frappe.db.exists(
|
if not frappe.db.exists(
|
||||||
"Batch Student", {"parent": batch, "student": frappe.session.user}
|
"Batch Student", {"parent": batch, "student": frappe.session.user}
|
||||||
):
|
):
|
||||||
student = frappe.new_doc("Batch Student")
|
batch_doc = frappe.get_doc("LMS Batch", batch)
|
||||||
current_count = frappe.db.count("Batch Student", {"parent": batch})
|
if batch_doc.seat_count and len(batch_doc.students) >= batch_doc.seat_count:
|
||||||
|
frappe.throw(_("The batch is full. Please contact the Administrator."))
|
||||||
|
|
||||||
student.update(
|
new_student = {
|
||||||
{
|
"student": frappe.session.user,
|
||||||
"student": frappe.session.user,
|
"parent": batch,
|
||||||
"parent": batch,
|
"parenttype": "LMS Batch",
|
||||||
"parenttype": "LMS Batch",
|
"parentfield": "students",
|
||||||
"parentfield": "students",
|
"idx": len(batch_doc.students) + 1,
|
||||||
"idx": current_count + 1,
|
}
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
if payment_name:
|
if payment_name:
|
||||||
payment = frappe.db.get_value(
|
payment = frappe.db.get_value(
|
||||||
"LMS Payment", payment_name, ["name", "source"], as_dict=True
|
"LMS Payment", payment_name, ["name", "source"], as_dict=True
|
||||||
)
|
)
|
||||||
student.update(
|
new_student.update(
|
||||||
{
|
{
|
||||||
"payment": payment.name,
|
"payment": payment.name,
|
||||||
"source": payment.source,
|
"source": payment.source,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
student.save(ignore_permissions=True)
|
batch_doc.append("students", new_student)
|
||||||
|
batch_doc.save(ignore_permissions=True)
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
|
|||||||
388
lms/locale/ar.po
388
lms/locale/bs.po
388
lms/locale/de.po
388
lms/locale/eo.po
388
lms/locale/es.po
396
lms/locale/fa.po
388
lms/locale/fr.po
388
lms/locale/hu.po
388
lms/locale/pl.po
388
lms/locale/ru.po
422
lms/locale/sv.po
388
lms/locale/tr.po
388
lms/locale/zh.po
@@ -162,3 +162,14 @@ class SCORMRenderer(BaseRenderer):
|
|||||||
)
|
)
|
||||||
response.mimetype = mimetypes.guess_type(path)[0]
|
response.mimetype = mimetypes.guess_type(path)[0]
|
||||||
return response
|
return response
|
||||||
|
else:
|
||||||
|
path = path.replace(".html", "")
|
||||||
|
if os.path.exists(path) and os.path.isdir(path):
|
||||||
|
index_path = os.path.join(path, "index.html")
|
||||||
|
if os.path.exists(index_path):
|
||||||
|
f = open(index_path, "rb")
|
||||||
|
response = Response(
|
||||||
|
wrap_file(frappe.local.request.environ, f), direct_passthrough=True
|
||||||
|
)
|
||||||
|
response.mimetype = mimetypes.guess_type(index_path)[0]
|
||||||
|
return response
|
||||||
|
|||||||
@@ -95,4 +95,5 @@ lms.patches.v2_0.add_course_statistics #21-10-2024
|
|||||||
lms.patches.v2_0.give_discussions_permissions
|
lms.patches.v2_0.give_discussions_permissions
|
||||||
lms.patches.v2_0.delete_web_forms
|
lms.patches.v2_0.delete_web_forms
|
||||||
lms.patches.v2_0.update_desk_access_for_lms_roles
|
lms.patches.v2_0.update_desk_access_for_lms_roles
|
||||||
lms.patches.v2_0.update_quiz_submission_data
|
lms.patches.v2_0.update_quiz_submission_data
|
||||||
|
lms.patches.v2_0.convert_quiz_duration_to_minutes
|
||||||
10
lms/patches/v2_0/convert_quiz_duration_to_minutes.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import frappe
|
||||||
|
from frappe.utils import ceil, flt
|
||||||
|
|
||||||
|
|
||||||
|
def execute():
|
||||||
|
quizzes = frappe.get_all(
|
||||||
|
"LMS Quiz", fields=["name", "duration"], filters={"duration": [">", 0]}
|
||||||
|
)
|
||||||
|
for quiz in quizzes:
|
||||||
|
frappe.db.set_value("LMS Quiz", quiz.name, "duration", ceil(flt(quiz.duration) / 60))
|
||||||