mirror of
https://github.com/frappe/lms.git
synced 2026-05-06 07:29:32 +03:00
Compare commits
159 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c2e8ca112 | |||
| 4771ebbcfd | |||
| 315ec3d655 | |||
| 484c3d7402 | |||
| e7ce850691 | |||
| 593c70affb | |||
| 3a1a7db386 | |||
| a5e948bba8 | |||
| 2331ddfc67 | |||
| afe9674a6a | |||
| 5b22ef46c0 | |||
| 8f1604e237 | |||
| a9f4eb1291 | |||
| fa7e59b4ad | |||
| 5fcd3ddabe | |||
| a9dd43d0ea | |||
| 22e005f19c | |||
| 9b0a7f5fa5 | |||
| aa93375e6c | |||
| e8edf33be6 | |||
| 619f02a74b | |||
| 61d13aeb12 | |||
| 7b2a4fe24a | |||
| c3b2907ebf | |||
| 48c5b82c73 | |||
| 3b80ccd8db | |||
| 6484d551d1 | |||
| 719d7b5e88 | |||
| 3cd9d89f0b | |||
| 6e44da1993 | |||
| 0e382f77ef | |||
| 7a1a247113 | |||
| 2c6ab3c331 | |||
| 79dba165c5 | |||
| d83f3464cd | |||
| e2ef8f732d | |||
| 919904a7f1 | |||
| 8453226f29 | |||
| 03759ca3c3 | |||
| c1608f8cc4 | |||
| 73b20653f0 | |||
| 7e683f8b44 | |||
| eba1815390 | |||
| 7564f0418b | |||
| 7e9cca2782 | |||
| dbc7e7d6d4 | |||
| ae25cfae6e | |||
| 970635430b | |||
| fe869a5988 | |||
| 7ea8040790 | |||
| 9f6f717585 | |||
| ee73d8db86 | |||
| c7b5f9a04d | |||
| fa4c3a8ad7 | |||
| 71318bff04 | |||
| 186ddc93c8 | |||
| 2f1d9a8690 | |||
| 5fc7c52bfe | |||
| d0da6e7401 | |||
| a437c197a5 | |||
| 80a9f2abe2 | |||
| c30b21e5ae | |||
| 3e3afa63c2 | |||
| c00cb100a9 | |||
| f824ac3c28 | |||
| 2dea096fa0 | |||
| f1853a3c97 | |||
| 4995f8e3fd | |||
| 560ac8d5c4 | |||
| d370ca796f | |||
| a4035168be | |||
| 70872857d1 | |||
| 332334b556 | |||
| 1d91baa9c5 | |||
| 1e8040ef7b | |||
| ad6e0a3b80 | |||
| 8f6810923d | |||
| 990db83ab3 | |||
| 01f08ba449 | |||
| 7a3701cc10 | |||
| f021ddd84c | |||
| 0e3157c57e | |||
| 22eb8b9f3f | |||
| 9609398643 | |||
| cd0d4c413d | |||
| 1bbdff9aaf | |||
| 8754d0498c | |||
| 395ac52740 | |||
| 29cdbe5b8b | |||
| 0677c21dc7 | |||
| 1a58e2669f | |||
| 3fa27024f9 | |||
| 04c4069c75 | |||
| dd77b01ff1 | |||
| 085614bca6 | |||
| ef2606c41a | |||
| 1d95361587 | |||
| 6ead16edf0 | |||
| 31d21bf689 | |||
| c5ee140551 | |||
| 8e97b2f5bb | |||
| 19171a8019 | |||
| 3f49cf0c9c | |||
| 8f9cc536e2 | |||
| 573bc74a41 | |||
| bb2552b30c | |||
| 20ac312f57 | |||
| f58842438b | |||
| a34f99ed49 | |||
| 44b7243f75 | |||
| d2f7d80114 | |||
| 192b246381 | |||
| 17d9a3991e | |||
| 3407a02046 | |||
| be3546e79c | |||
| 556067de7a | |||
| 4ce08af516 | |||
| 8a4477a01f | |||
| 21d868a355 | |||
| 68a2cc1003 | |||
| 5a288836e0 | |||
| 4eab5e2867 | |||
| 6082093fb6 | |||
| 66c26c2a2c | |||
| f7eaf3faaa | |||
| 82a43b4f24 | |||
| 5bd33a1536 | |||
| a063c0735c | |||
| 732db8290d | |||
| 2b1d57f2bc | |||
| c8c051c1de | |||
| 13139bc2de | |||
| 9814abf55f | |||
| 249ecb8c4c | |||
| 582540e7f0 | |||
| 2f3fa7c295 | |||
| 3b49aac1b3 | |||
| dc25b408e6 | |||
| c8d9b97ab7 | |||
| 754d3cf2ca | |||
| e4268d0437 | |||
| 8febe21aa8 | |||
| 5384b26610 | |||
| 2a4650e5ed | |||
| 737993c543 | |||
| 58b49e3608 | |||
| 27553464d6 | |||
| be76268c70 | |||
| df2f2e6603 | |||
| fb1e1ec2e4 | |||
| ac81d1817b | |||
| da33e1d3bd | |||
| 24a511f48e | |||
| 14e669435f | |||
| 0407f01016 | |||
| 2a63f781ac | |||
| a882432702 | |||
| f8b6dfc981 | |||
| e8768d5687 |
@@ -3,6 +3,8 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
- main-hotfix
|
||||
pull_request: {}
|
||||
jobs:
|
||||
tests:
|
||||
|
||||
@@ -18,9 +18,9 @@ jobs:
|
||||
owner: frappe
|
||||
repo: lms
|
||||
title: |-
|
||||
"chore: merge 'develop' into 'main'"
|
||||
"chore: merge 'main-hotfix' into 'main'"
|
||||
body: "Automated weekly release"
|
||||
base: main
|
||||
head: develop
|
||||
head: main-hotfix
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
@@ -4,7 +4,10 @@ on:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [ main ]
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
- main-hotfix
|
||||
|
||||
permissions:
|
||||
# Do not change this as GITHUB_TOKEN is being used by roulette
|
||||
|
||||
+2
-1
@@ -13,4 +13,5 @@ package-lock.json
|
||||
lms/public/frontend
|
||||
lms/www/lms.html
|
||||
lms/www/_lms.html
|
||||
frappe-ui
|
||||
frappe-ui
|
||||
frappe-semgrep-rules
|
||||
@@ -0,0 +1,30 @@
|
||||
pull_request_rules:
|
||||
- name: backport to develop
|
||||
conditions:
|
||||
- label="backport develop"
|
||||
actions:
|
||||
backport:
|
||||
branches:
|
||||
- develop
|
||||
assignees:
|
||||
- "{{ author }}"
|
||||
|
||||
- name: backport to main-hotfix
|
||||
conditions:
|
||||
- label="backport main-hotfix"
|
||||
actions:
|
||||
backport:
|
||||
branches:
|
||||
- main-hotfix
|
||||
assignees:
|
||||
- "{{ author }}"
|
||||
|
||||
- name: backport to main
|
||||
conditions:
|
||||
- label="backport main"
|
||||
actions:
|
||||
backport:
|
||||
branches:
|
||||
- main
|
||||
assignees:
|
||||
- "{{ author }}"
|
||||
@@ -0,0 +1,2 @@
|
||||
ignore:
|
||||
- "**/test_helper.py"
|
||||
@@ -128,12 +128,9 @@ describe("Batch Creation", () => {
|
||||
.should("be.visible");
|
||||
cy.get("span").contains("IST").should("be.visible");
|
||||
cy.get("a").contains("Evaluator").should("be.visible");
|
||||
cy.get("div")
|
||||
.contains("10")
|
||||
.should("be.visible")
|
||||
.get("span")
|
||||
.contains("Seats Left")
|
||||
.should("be.visible");
|
||||
cy.contains("div:visible", "10 Seats Left").should(
|
||||
"be.visible"
|
||||
);
|
||||
});
|
||||
cy.get(`a[href='/lms/batches/details/${batchName}'`).click();
|
||||
});
|
||||
@@ -162,8 +159,12 @@ describe("Batch Creation", () => {
|
||||
/* Add student to batch */
|
||||
cy.get("button").contains("Students").click();
|
||||
cy.get("button").contains("Add").click();
|
||||
cy.get('div[role="dialog"]').first().find("button").eq(1).click();
|
||||
cy.get("input[id^='headlessui-combobox-input-v-']").type(randomEmail);
|
||||
cy.get('div[role="dialog"]')
|
||||
.first()
|
||||
.find("input[id^='headlessui-combobox-input-v-']")
|
||||
.first()
|
||||
.click();
|
||||
cy.get("input[placeholder='Search']").type(randomEmail);
|
||||
cy.get("div").contains(randomEmail).click();
|
||||
cy.get("button").contains("Submit").click();
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ describe("Course Creation", () => {
|
||||
.contains("Category")
|
||||
.parent()
|
||||
.within(() => {
|
||||
cy.get("button").click();
|
||||
cy.get("input").click();
|
||||
});
|
||||
cy.get("[id^=headlessui-combobox-option-")
|
||||
.should("be.visible")
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
|
||||
<AxisChart
|
||||
v-if="showProgressChart"
|
||||
class="border"
|
||||
class="border rounded-lg p-3 min-h-[300px]"
|
||||
:config="{
|
||||
data: filteredChartData,
|
||||
title: __('Batch Summary'),
|
||||
|
||||
@@ -208,12 +208,12 @@ const canAddAssessments = () => {
|
||||
const getAssessmentColumns = () => {
|
||||
let columns = [
|
||||
{
|
||||
label: 'Assessment',
|
||||
label: __('Assessment'),
|
||||
key: 'title',
|
||||
width: '25rem',
|
||||
},
|
||||
{
|
||||
label: 'Type',
|
||||
label: __('Type'),
|
||||
key: 'assessment_type',
|
||||
width: '15rem',
|
||||
},
|
||||
@@ -221,7 +221,7 @@ const getAssessmentColumns = () => {
|
||||
|
||||
if (!user.data?.is_moderator) {
|
||||
columns.push({
|
||||
label: 'Status/Percentage',
|
||||
label: __('Status/Percentage'),
|
||||
key: 'status',
|
||||
align: 'left',
|
||||
width: '10rem',
|
||||
|
||||
@@ -141,7 +141,7 @@
|
||||
:uploadArgs="{
|
||||
private: true,
|
||||
}"
|
||||
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
|
||||
editorClass="prose-sm max-w-none border-b border-x border-outline-gray-modals bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -190,7 +190,7 @@
|
||||
:uploadArgs="{
|
||||
private: true,
|
||||
}"
|
||||
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
|
||||
editorClass="prose-sm max-w-none border-b border-x border-outline-gray-modals bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,24 +6,26 @@
|
||||
<div class="text-lg leading-5 font-semibold mb-2 text-ink-gray-9">
|
||||
{{ batch.title }}
|
||||
</div>
|
||||
<div
|
||||
<Badge
|
||||
v-if="batch.seat_count && batch.seats_left > 0"
|
||||
class="text-xs bg-green-100 text-green-700 self-start px-2 py-0.5 rounded-md"
|
||||
>
|
||||
{{ batch.seats_left }}
|
||||
<span v-if="batch.seats_left > 1">
|
||||
{{ __('Seats Left') }}
|
||||
</span>
|
||||
<span v-else-if="batch.seats_left == 1">
|
||||
{{ __('Seat Left') }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
variant="subtle"
|
||||
theme="green"
|
||||
size="md"
|
||||
class="self-start"
|
||||
:label="
|
||||
batch.seats_left +
|
||||
' ' +
|
||||
(batch.seats_left > 1 ? __('Seats Left') : __('Seat Left'))
|
||||
"
|
||||
/>
|
||||
<Badge
|
||||
v-else-if="batch.seat_count && batch.seats_left <= 0"
|
||||
class="text-xs bg-red-100 text-red-700 self-start px-2 py-0.5 rounded-md"
|
||||
>
|
||||
{{ __('Sold Out') }}
|
||||
</div>
|
||||
variant="subtle"
|
||||
theme="red"
|
||||
size="md"
|
||||
class="self-start"
|
||||
:label="__('Sold Out')"
|
||||
/>
|
||||
<div class="short-introduction text-sm text-ink-gray-7">
|
||||
{{ batch.description }}
|
||||
</div>
|
||||
@@ -70,6 +72,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Badge } from 'frappe-ui'
|
||||
import { formatTime } from '@/utils'
|
||||
import { Clock, Globe } from 'lucide-vue-next'
|
||||
import DateRange from '@/components/Common/DateRange.vue'
|
||||
|
||||
@@ -1,28 +1,29 @@
|
||||
<template>
|
||||
<div v-if="batch.data" class="border-2 rounded-md p-5 lg:w-72">
|
||||
<div
|
||||
<Badge
|
||||
v-if="batch.data.seat_count && batch.data.seats_left > 0"
|
||||
class="text-sm bg-green-100 text-green-700 px-2 py-1 rounded-md"
|
||||
variant="subtle"
|
||||
theme="green"
|
||||
size="md"
|
||||
:class="
|
||||
batch.data.amount || batch.data.courses.length
|
||||
? 'float-right'
|
||||
: 'w-fit mb-4'
|
||||
"
|
||||
>
|
||||
{{ batch.data.seats_left }}
|
||||
<span v-if="batch.data.seats_left > 1">
|
||||
{{ __('Seats Left') }}
|
||||
</span>
|
||||
<span v-else-if="batch.data.seats_left == 1">
|
||||
{{ __('Seat Left') }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
:label="
|
||||
batch.data.seats_left +
|
||||
' ' +
|
||||
(batch.data.seats_left > 1 ? __('Seats Left') : __('Seat Left'))
|
||||
"
|
||||
/>
|
||||
<Badge
|
||||
v-else-if="batch.data.seat_count && batch.data.seats_left <= 0"
|
||||
class="text-xs bg-red-100 text-red-700 float-right px-2 py-0.5 rounded-md"
|
||||
>
|
||||
{{ __('Sold Out') }}
|
||||
</div>
|
||||
variant="subtle"
|
||||
theme="red"
|
||||
size="md"
|
||||
class="float-right"
|
||||
:label="__('Sold Out')"
|
||||
/>
|
||||
<div
|
||||
v-if="batch.data.amount"
|
||||
class="text-lg font-semibold mb-3 text-ink-gray-9"
|
||||
@@ -136,7 +137,7 @@
|
||||
</template>
|
||||
<script setup>
|
||||
import { inject, computed } from 'vue'
|
||||
import { Button, createResource, toast } from 'frappe-ui'
|
||||
import { Badge, Button, createResource, toast } from 'frappe-ui'
|
||||
import {
|
||||
BookOpen,
|
||||
Clock,
|
||||
|
||||
@@ -1,138 +1,95 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Label -->
|
||||
<div v-if="label" class="text-xs text-ink-gray-5 mb-1">
|
||||
{{ __(label) }}
|
||||
<span class="text-ink-red-3" v-if="attrs.required">*</span>
|
||||
</div>
|
||||
<Combobox
|
||||
v-model="selectedValue"
|
||||
nullable
|
||||
v-slot="{ open: isComboboxOpen }"
|
||||
>
|
||||
<Popover class="w-full" v-model:show="showOptions">
|
||||
<template #target="{ open: openPopover, togglePopover }">
|
||||
<slot name="target" v-bind="{ open: openPopover, togglePopover }">
|
||||
<div class="w-full">
|
||||
<button
|
||||
class="flex w-full items-center justify-between focus:outline-none"
|
||||
:class="inputClasses"
|
||||
@click="() => togglePopover()"
|
||||
:disabled="attrs.readonly"
|
||||
<Combobox v-model="selectedValue" nullable v-slot="{ open }">
|
||||
<div class="relative w-full">
|
||||
<ComboboxInput
|
||||
class="form-input w-full"
|
||||
:class="inputClasses"
|
||||
type="text"
|
||||
:value="selectedValue"
|
||||
autocomplete="off"
|
||||
@click="onFocus"
|
||||
/>
|
||||
<ComboboxButton ref="trigger" class="hidden" />
|
||||
|
||||
<!-- Dropdown -->
|
||||
<ComboboxOptions
|
||||
class="absolute z-20 mt-1 w-full rounded-lg bg-surface-modal py-1 text-base border-2 border-outline-gray-modals shadow-lg"
|
||||
>
|
||||
<input
|
||||
ref="search"
|
||||
v-model="query"
|
||||
class="form-input w-[98%] rounded-tl-lg rounded-tr-lg mb-1 mx-1"
|
||||
type="text"
|
||||
placeholder="Search"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<!-- Options -->
|
||||
<div class="my-1 max-h-[12rem] overflow-y-auto px-1.5">
|
||||
<template v-for="group in groups" :key="group.key">
|
||||
<div
|
||||
v-if="group.group && !group.hideLabel"
|
||||
class="px-2.5 py-1.5 text-sm font-medium text-ink-gray-4"
|
||||
>
|
||||
<div class="flex items-center w-[90%]">
|
||||
<slot name="prefix" />
|
||||
<span
|
||||
class="block truncate text-base leading-5"
|
||||
v-if="selectedValue"
|
||||
>
|
||||
{{ displayValue(selectedValue) }}
|
||||
</span>
|
||||
<span class="text-base leading-5 text-ink-gray-4" v-else>
|
||||
{{ placeholder || '' }}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronDown class="h-4 w-4 stroke-1.5" />
|
||||
</button>
|
||||
</div>
|
||||
</slot>
|
||||
</template>
|
||||
<template #body="{ isOpen }">
|
||||
<div v-show="isOpen" class="">
|
||||
<div
|
||||
class="mt-1 rounded-lg bg-surface-white py-1 text-base border-2"
|
||||
>
|
||||
<div class="relative px-1.5 pt-0.5">
|
||||
<ComboboxInput
|
||||
ref="search"
|
||||
class="form-input w-full"
|
||||
type="text"
|
||||
@change="
|
||||
(e) => {
|
||||
query = e.target.value
|
||||
}
|
||||
"
|
||||
:value="query"
|
||||
autocomplete="off"
|
||||
placeholder="Search"
|
||||
/>
|
||||
<button
|
||||
class="absolute right-1.5 inline-flex h-7 w-7 items-center justify-center"
|
||||
@click="selectedValue = null"
|
||||
>
|
||||
<X class="h-4 w-4 stroke-1.5 text-ink-gray-7" />
|
||||
</button>
|
||||
{{ group.group }}
|
||||
</div>
|
||||
<ComboboxOptions
|
||||
class="my-1 max-h-[12rem] overflow-y-auto px-1.5"
|
||||
static
|
||||
|
||||
<ComboboxOption
|
||||
v-for="option in group.items"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
v-slot="{ active }"
|
||||
>
|
||||
<div
|
||||
class="mt-1.5"
|
||||
v-for="group in groups"
|
||||
:key="group.key"
|
||||
v-show="group.items.length > 0"
|
||||
>
|
||||
<div
|
||||
v-if="group.group && !group.hideLabel"
|
||||
class="px-2.5 py-1.5 text-sm font-medium text-ink-gray-4"
|
||||
>
|
||||
{{ group.group }}
|
||||
</div>
|
||||
<ComboboxOption
|
||||
as="template"
|
||||
v-for="option in group.items"
|
||||
:key="option.value"
|
||||
:value="option"
|
||||
v-slot="{ active, selected }"
|
||||
>
|
||||
<li
|
||||
:class="[
|
||||
'flex items-center rounded px-2.5 py-2 text-base',
|
||||
{ 'bg-surface-gray-2': active },
|
||||
]"
|
||||
>
|
||||
<slot
|
||||
name="item-prefix"
|
||||
v-bind="{ active, selected, option }"
|
||||
/>
|
||||
<slot
|
||||
name="item-label"
|
||||
v-bind="{ active, selected, option }"
|
||||
>
|
||||
<div class="flex flex-col space-y-1 text-ink-gray-8">
|
||||
<div>
|
||||
{{ option.label }}
|
||||
</div>
|
||||
<div
|
||||
v-if="
|
||||
option.description &&
|
||||
option.description != option.label
|
||||
"
|
||||
class="text-xs text-ink-gray-7"
|
||||
v-html="option.description"
|
||||
></div>
|
||||
</div>
|
||||
</slot>
|
||||
</li>
|
||||
</ComboboxOption>
|
||||
</div>
|
||||
<li
|
||||
v-if="groups.length == 0"
|
||||
class="mt-1.5 rounded-md px-2.5 py-1.5 text-base text-ink-gray-5"
|
||||
:class="[
|
||||
'flex items-center rounded px-2.5 py-2 text-base cursor-pointer',
|
||||
{ 'bg-surface-gray-2': active },
|
||||
]"
|
||||
>
|
||||
No results found
|
||||
<div class="flex flex-col gap-1 p-1">
|
||||
<div class="text-base font-medium text-ink-gray-8">
|
||||
{{
|
||||
option.value === option.label
|
||||
? option.description
|
||||
: option.label
|
||||
}}
|
||||
</div>
|
||||
<div class="text-sm text-ink-gray-5">
|
||||
{{ option.value }}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ComboboxOptions>
|
||||
<div v-if="slots.footer" class="border-t p-1.5 pb-0.5">
|
||||
<slot
|
||||
name="footer"
|
||||
v-bind="{ value: search?.el._value, close }"
|
||||
></slot>
|
||||
</div>
|
||||
</ComboboxOption>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-if="groups.length === 0"
|
||||
class="mt-1.5 rounded-md px-2.5 py-1.5 text-base text-ink-gray-5"
|
||||
>
|
||||
{{ __('No results found') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
|
||||
<!-- Footer -->
|
||||
<div
|
||||
v-if="slots.footer"
|
||||
class="border-t border-outline-gray-modals p-1.5 pb-0.5"
|
||||
>
|
||||
<slot
|
||||
name="footer"
|
||||
v-bind="{
|
||||
value: selectedValue,
|
||||
close,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</ComboboxOptions>
|
||||
</div>
|
||||
</Combobox>
|
||||
</div>
|
||||
</template>
|
||||
@@ -143,15 +100,15 @@ import {
|
||||
ComboboxInput,
|
||||
ComboboxOptions,
|
||||
ComboboxOption,
|
||||
ComboboxButton,
|
||||
} from '@headlessui/vue'
|
||||
import { Popover } from 'frappe-ui'
|
||||
import { ChevronDown, X } from 'lucide-vue-next'
|
||||
import { ref, computed, useAttrs, useSlots, watch, nextTick } from 'vue'
|
||||
import { watchDebounced } from '@vueuse/core'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: '',
|
||||
type: [String, Object],
|
||||
default: null,
|
||||
},
|
||||
options: {
|
||||
type: Array,
|
||||
@@ -182,109 +139,95 @@ const props = defineProps({
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'update:query', 'change'])
|
||||
|
||||
const query = ref('')
|
||||
const showOptions = ref(false)
|
||||
const trigger = ref(null)
|
||||
const search = ref(null)
|
||||
|
||||
const attrs = useAttrs()
|
||||
const slots = useSlots()
|
||||
|
||||
const selectedValue = ref(props.modelValue)
|
||||
const query = ref('')
|
||||
const valuePropPassed = computed(() => 'value' in attrs)
|
||||
|
||||
const selectedValue = computed({
|
||||
get() {
|
||||
return valuePropPassed.value ? attrs.value : props.modelValue
|
||||
},
|
||||
set(val) {
|
||||
query.value = ''
|
||||
if (val) {
|
||||
showOptions.value = false
|
||||
}
|
||||
emit(valuePropPassed.value ? 'change' : 'update:modelValue', val)
|
||||
},
|
||||
watch(selectedValue, (val) => {
|
||||
query.value = ''
|
||||
emit(valuePropPassed.value ? 'change' : 'update:modelValue', val)
|
||||
})
|
||||
|
||||
function close() {
|
||||
showOptions.value = false
|
||||
function clearValue() {
|
||||
emit('update:modelValue', null)
|
||||
}
|
||||
|
||||
const groups = computed(() => {
|
||||
if (!props.options || props.options.length == 0) return []
|
||||
if (!props.options?.length) return []
|
||||
|
||||
let groups = props.options[0]?.group
|
||||
const normalized = props.options[0]?.group
|
||||
? props.options
|
||||
: [{ group: '', items: props.options }]
|
||||
|
||||
return groups
|
||||
.map((group, i) => {
|
||||
return {
|
||||
key: i,
|
||||
group: group.group,
|
||||
hideLabel: group.hideLabel || false,
|
||||
items: props.filterable ? filterOptions(group.items) : group.items,
|
||||
}
|
||||
})
|
||||
return normalized
|
||||
.map((group, i) => ({
|
||||
key: i,
|
||||
group: group.group,
|
||||
hideLabel: group.hideLabel || false,
|
||||
items: props.filterable ? filterOptions(group.items) : group.items,
|
||||
}))
|
||||
.filter((group) => group.items.length > 0)
|
||||
})
|
||||
|
||||
function filterOptions(options) {
|
||||
if (!query.value) {
|
||||
return options
|
||||
}
|
||||
return options.filter((option) => {
|
||||
let searchTexts = [option.label, option.value]
|
||||
return searchTexts.some((text) =>
|
||||
(text || '').toString().toLowerCase().includes(query.value.toLowerCase())
|
||||
)
|
||||
if (!query.value) return options
|
||||
const q = query.value.toLowerCase()
|
||||
return options.filter((option) =>
|
||||
[option.label, option.value]
|
||||
.filter(Boolean)
|
||||
.some((text) => text.toString().toLowerCase().includes(q))
|
||||
)
|
||||
}
|
||||
|
||||
watchDebounced(
|
||||
query,
|
||||
(val) => {
|
||||
emit('update:query', val)
|
||||
},
|
||||
{ debounce: 300 }
|
||||
)
|
||||
|
||||
const onFocus = () => {
|
||||
trigger.value?.$el.click()
|
||||
nextTick(() => {
|
||||
search.value?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
function displayValue(option) {
|
||||
if (typeof option === 'string') {
|
||||
let allOptions = groups.value.flatMap((group) => group.items)
|
||||
let selectedOption = allOptions.find((o) => o.value === option)
|
||||
return selectedOption?.label || option
|
||||
}
|
||||
return option?.label
|
||||
const close = () => {
|
||||
selectedValue.value = null
|
||||
trigger.value?.$el.click()
|
||||
}
|
||||
|
||||
watch(query, (q) => {
|
||||
emit('update:query', q)
|
||||
})
|
||||
|
||||
watch(showOptions, (val) => {
|
||||
if (val) {
|
||||
nextTick(() => {
|
||||
search.value.el.focus()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const textColor = computed(() => {
|
||||
return props.disabled ? 'text-ink-gray-5' : 'text-ink-gray-8'
|
||||
})
|
||||
const textColor = computed(() =>
|
||||
props.disabled ? 'text-ink-gray-5' : 'text-ink-gray-8'
|
||||
)
|
||||
|
||||
const inputClasses = computed(() => {
|
||||
let sizeClasses = {
|
||||
const sizeClasses = {
|
||||
sm: 'text-base rounded h-7',
|
||||
md: 'text-base rounded h-8',
|
||||
lg: 'text-lg rounded-md h-10',
|
||||
xl: 'text-xl rounded-md h-10',
|
||||
}[props.size]
|
||||
|
||||
let paddingClasses = {
|
||||
const paddingClasses = {
|
||||
sm: 'py-1.5 px-2',
|
||||
md: 'py-1.5 px-2.5',
|
||||
lg: 'py-1.5 px-3',
|
||||
xl: 'py-1.5 px-3',
|
||||
}[props.size]
|
||||
|
||||
let variant = props.disabled ? 'disabled' : props.variant
|
||||
let variantClasses = {
|
||||
const variant = props.disabled ? 'disabled' : props.variant
|
||||
|
||||
const variantClasses = {
|
||||
subtle:
|
||||
'border border-gray-100 bg-surface-gray-2 placeholder-ink-gray-4 hover:border-outline-gray-modals hover:bg-surface-gray-3 focus:bg-surface-white focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3',
|
||||
'border border-outline-gray-modals bg-surface-gray-2 placeholder-ink-gray-4 hover:border-outline-gray-modals hover:bg-surface-gray-3 focus:bg-surface-white focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3',
|
||||
outline:
|
||||
'border border-outline-gray-2 bg-surface-white placeholder-ink-gray-4 hover:border-outline-gray-3 hover:shadow-sm focus:bg-surface-white focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3',
|
||||
disabled: [
|
||||
@@ -303,6 +246,4 @@ const inputClasses = computed(() => {
|
||||
'transition-colors w-full',
|
||||
]
|
||||
})
|
||||
|
||||
defineExpose({ query })
|
||||
</script>
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
<div class="text-xs text-ink-gray-5 mb-2">
|
||||
{{ label }}
|
||||
</div>
|
||||
<div class="overflow-visible border rounded-md">
|
||||
<div class="overflow-visible border border-outline-gray-modals rounded-md">
|
||||
<div class="overflow-x-auto">
|
||||
<div
|
||||
class="grid items-center space-x-4 p-2 border-b"
|
||||
class="grid items-center space-x-4 p-2 border-b border-outline-gray-modals"
|
||||
:style="{ gridTemplateColumns: getGridTemplateColumns() }"
|
||||
>
|
||||
<div
|
||||
@@ -28,7 +28,7 @@
|
||||
<input
|
||||
v-if="showKey(key)"
|
||||
v-model="row[key]"
|
||||
class="py-1.5 px-2 border-none focus:ring-0 focus:border focus:border-gray-300 focus:bg-surface-gray-2 rounded-md text-sm focus:outline-none"
|
||||
class="py-1.5 px-2 w-full border-none bg-transparent text-ink-gray-8 focus:ring-0 focus:border focus:border-outline-gray-3 focus:bg-surface-gray-2 rounded-md text-sm focus:outline-none"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
<div
|
||||
v-if="menuOpenIndex === rowIndex"
|
||||
ref="menuRef"
|
||||
class="absolute right-0 w-32 z-50 bg-surface-white border border-outline-gray-1 rounded-md shadow-sm"
|
||||
class="absolute right-0 w-32 z-50 bg-surface-modal border border-outline-gray-modals rounded-md shadow-sm"
|
||||
:class="
|
||||
rowIndex == (rows?.length ?? 0) - 1
|
||||
? 'bottom-full mb-1'
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
:size="attrs.size || 'sm'"
|
||||
:variant="attrs.variant"
|
||||
:placeholder="attrs.placeholder"
|
||||
:filterable="false"
|
||||
:readonly="attrs.readonly"
|
||||
>
|
||||
<template #target="{ open, togglePopover }">
|
||||
@@ -96,8 +95,7 @@ const value = computed({
|
||||
get: () => (valuePropPassed.value ? attrs.value : props.modelValue),
|
||||
set: (val) => {
|
||||
return (
|
||||
val?.value &&
|
||||
emit(valuePropPassed.value ? 'change' : 'update:modelValue', val?.value)
|
||||
val && emit(valuePropPassed.value ? 'change' : 'update:modelValue', val)
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,177 +1,145 @@
|
||||
<template>
|
||||
<div>
|
||||
<label class="block mb-1" :class="labelClasses" v-if="label">
|
||||
<label v-if="label" class="block mb-1" :class="labelClasses">
|
||||
{{ label }}
|
||||
<span class="text-ink-red-3" v-if="required">*</span>
|
||||
<span v-if="required" class="text-ink-red-3">*</span>
|
||||
</label>
|
||||
<div class="w-full">
|
||||
<Combobox v-model="selectedValue" nullable>
|
||||
<Popover class="w-full" v-model:show="showOptions">
|
||||
<template #target="{ togglePopover }">
|
||||
<ComboboxInput
|
||||
ref="search"
|
||||
class="search-input form-input w-full focus-visible:!ring-0"
|
||||
type="text"
|
||||
:value="query"
|
||||
@change="
|
||||
(e) => {
|
||||
query = e.target.value
|
||||
showOptions = true
|
||||
}
|
||||
"
|
||||
@click="
|
||||
(e) => {
|
||||
showOptions = true
|
||||
nextTick(() => {
|
||||
setFocus()
|
||||
})
|
||||
}
|
||||
"
|
||||
@focus="
|
||||
() => {
|
||||
if (!filterOptions.data || filterOptions.data.length === 0) {
|
||||
reload('')
|
||||
}
|
||||
}
|
||||
"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</template>
|
||||
<template #body="{ isOpen, close }">
|
||||
<div v-show="isOpen">
|
||||
<div
|
||||
class="flex flex-col mt-1 rounded-lg bg-surface-white py-1 text-base border-2 max-h-[13rem]"
|
||||
<Combobox v-model="selectedValue" nullable v-slot="{ open }">
|
||||
<div class="relative w-full">
|
||||
<ComboboxInput
|
||||
ref="search"
|
||||
class="form-input w-full focus-visible:!ring-0"
|
||||
type="text"
|
||||
@change="
|
||||
(e) => {
|
||||
query = e.target.value
|
||||
}
|
||||
"
|
||||
autocomplete="off"
|
||||
@focus="onFocus"
|
||||
/>
|
||||
<ComboboxButton ref="trigger" class="hidden" />
|
||||
<ComboboxOptions
|
||||
v-show="open"
|
||||
static
|
||||
class="absolute z-20 mt-1 w-full rounded-lg bg-surface-modal border-2 border-outline-gray-modals max-h-[13rem] flex flex-col"
|
||||
>
|
||||
<div
|
||||
class="flex-1 my-1 overflow-y-auto px-1.5"
|
||||
:class="options.length ? 'min-h-[6rem]' : 'min-h-[3.8rem]'"
|
||||
>
|
||||
<template v-if="options.length">
|
||||
<ComboboxOption
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
:value="option"
|
||||
v-slot="{ active }"
|
||||
>
|
||||
<ComboboxOptions
|
||||
class="flex-1 my-1 overflow-y-auto px-1.5"
|
||||
:class="options.length ? 'min-h-[6rem]' : 'min-h-[3.8rem]'"
|
||||
static
|
||||
<li
|
||||
:class="[
|
||||
'flex cursor-pointer items-center rounded px-2 py-1 text-base',
|
||||
{ 'bg-surface-gray-2': active },
|
||||
]"
|
||||
>
|
||||
<ComboboxOption
|
||||
v-if="options.length"
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
:value="option"
|
||||
v-slot="{ active }"
|
||||
>
|
||||
<li
|
||||
:class="[
|
||||
'flex cursor-pointer items-center rounded px-2 py-1 text-base',
|
||||
{ 'bg-surface-gray-2': active },
|
||||
]"
|
||||
>
|
||||
<div class="flex flex-col gap-1 p-1">
|
||||
<div class="text-base font-medium text-ink-gray-8">
|
||||
{{
|
||||
option.value == option.label
|
||||
? option.description
|
||||
: option.label
|
||||
}}
|
||||
</div>
|
||||
<div class="text-sm text-ink-gray-5">
|
||||
{{ option.value }}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ComboboxOption>
|
||||
<div v-else class="text-ink-gray-7 px-4">
|
||||
{{ __('No results found') }}
|
||||
<div class="flex flex-col gap-1 p-1">
|
||||
<div class="text-base font-medium text-ink-gray-8">
|
||||
{{
|
||||
option.value === option.label
|
||||
? option.description
|
||||
: option.label
|
||||
}}
|
||||
</div>
|
||||
<div class="text-sm text-ink-gray-5">
|
||||
{{ option.value }}
|
||||
</div>
|
||||
</div>
|
||||
</ComboboxOptions>
|
||||
<div v-if="attrs.onCreate" class="px-1 pt-2 bg-white border-t">
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="w-full !justify-start"
|
||||
:label="__('Create New')"
|
||||
@click="attrs.onCreate(close)"
|
||||
>
|
||||
<template #prefix>
|
||||
<Plus class="h-4 w-4 stroke-1.5" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ComboboxOption>
|
||||
</template>
|
||||
|
||||
<div v-else class="text-ink-gray-7 px-4 py-2">
|
||||
{{ __('No results found') }}
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</Combobox>
|
||||
</div>
|
||||
<div v-if="values.length" class="grid grid-cols-2 gap-2 mt-1">
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="attrs.onCreate"
|
||||
class="p-1 bg-surface-white border-t rounded-b-lg"
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="w-full !justify-start"
|
||||
:label="__('Create New')"
|
||||
@click="attrs.onCreate()"
|
||||
>
|
||||
<template #prefix>
|
||||
<Plus class="h-4 w-4 stroke-1.5" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</ComboboxOptions>
|
||||
</div>
|
||||
</Combobox>
|
||||
|
||||
<!-- Selected values -->
|
||||
<div v-if="values?.length" class="grid grid-cols-2 gap-2 mt-1">
|
||||
<div
|
||||
v-for="value in values"
|
||||
class="flex items-center justify-between break-all bg-surface-gray-2 text-ink-gray-7 word-wrap p-2 rounded-md mr-2"
|
||||
:key="value"
|
||||
class="flex items-center justify-between break-all bg-surface-gray-2 text-ink-gray-7 p-2 rounded-md"
|
||||
>
|
||||
<span class="break-all">
|
||||
{{ value }}
|
||||
</span>
|
||||
<span>{{ value }}</span>
|
||||
<X
|
||||
class="size-4 stroke-1.5 cursor-pointer"
|
||||
@click="removeValue(value)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- <ErrorMessage class="mt-2 pl-2" v-if="error" :message="error" /> -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxButton,
|
||||
ComboboxInput,
|
||||
ComboboxOptions,
|
||||
ComboboxOption,
|
||||
} from '@headlessui/vue'
|
||||
import { createResource, Popover, Button } from 'frappe-ui'
|
||||
import { ref, computed, nextTick, useAttrs } from 'vue'
|
||||
import { set, watchDebounced } from '@vueuse/core'
|
||||
import { createResource, Button } from 'frappe-ui'
|
||||
import { ref, computed, useAttrs, watch } from 'vue'
|
||||
import { watchDebounced } from '@vueuse/core'
|
||||
import { X, Plus } from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: 'sm',
|
||||
},
|
||||
doctype: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
filters: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
validate: {
|
||||
type: Function,
|
||||
default: null,
|
||||
},
|
||||
label: String,
|
||||
size: { type: String, default: 'sm' },
|
||||
doctype: { type: String, required: true },
|
||||
filters: { type: Object, default: () => ({}) },
|
||||
validate: Function,
|
||||
errorMessage: {
|
||||
type: Function,
|
||||
default: (value) => `${value} is an Invalid value`,
|
||||
},
|
||||
required: {
|
||||
type: Boolean,
|
||||
},
|
||||
required: Boolean,
|
||||
})
|
||||
|
||||
const values = defineModel()
|
||||
const attrs = useAttrs()
|
||||
const search = ref(null)
|
||||
const error = ref(null)
|
||||
const trigger = ref(null)
|
||||
const query = ref('')
|
||||
const text = ref('')
|
||||
const showOptions = ref(false)
|
||||
const selectedValue = ref(null)
|
||||
const error = ref(null)
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const selectedValue = computed({
|
||||
get: () => query.value || '',
|
||||
set: (val) => {
|
||||
query.value = ''
|
||||
val?.value && addValue(val.value)
|
||||
showOptions.value = false
|
||||
emit('update:modelValue', values.value)
|
||||
},
|
||||
watch(selectedValue, (val) => {
|
||||
if (!val?.value) return
|
||||
query.value = ''
|
||||
addValue(val.value)
|
||||
selectedValue.value = null
|
||||
emit('update:modelValue', values.value)
|
||||
})
|
||||
|
||||
watchDebounced(
|
||||
@@ -188,7 +156,6 @@ watchDebounced(
|
||||
const filterOptions = createResource({
|
||||
url: 'frappe.desk.search.search_link',
|
||||
method: 'POST',
|
||||
cache: [text.value, props.doctype],
|
||||
auto: true,
|
||||
params: {
|
||||
txt: text.value,
|
||||
@@ -197,7 +164,6 @@ const filterOptions = createResource({
|
||||
})
|
||||
|
||||
const options = computed(() => {
|
||||
setFocus()
|
||||
const allOptions = filterOptions.data || []
|
||||
return allOptions.filter((option) => !values.value?.includes(option.value))
|
||||
})
|
||||
@@ -212,52 +178,46 @@ function reload(val) {
|
||||
filterOptions.reload()
|
||||
}
|
||||
|
||||
const addValue = (value) => {
|
||||
error.value = null
|
||||
if (value) {
|
||||
const splitValues = value.split(',')
|
||||
splitValues.forEach((value) => {
|
||||
value = value.trim()
|
||||
if (value) {
|
||||
// check if value is not already in the values array
|
||||
if (!values.value?.includes(value)) {
|
||||
// check if value is valid
|
||||
if (value && props.validate && !props.validate(value)) {
|
||||
error.value = props.errorMessage(value)
|
||||
return
|
||||
}
|
||||
// add value to values array
|
||||
if (!values.value) {
|
||||
values.value = [value]
|
||||
} else {
|
||||
values.value.push(value)
|
||||
}
|
||||
value = value.replace(value, '')
|
||||
}
|
||||
}
|
||||
})
|
||||
!error.value && (value = '')
|
||||
function onFocus() {
|
||||
if (!filterOptions.data?.length) {
|
||||
reload('')
|
||||
}
|
||||
trigger.value?.$el.click()
|
||||
}
|
||||
|
||||
const removeValue = (value) => {
|
||||
values.value = values.value.filter((v) => v !== value)
|
||||
function addValue(value) {
|
||||
error.value = null
|
||||
|
||||
if (!value) return
|
||||
|
||||
const splitValues = value.split(',')
|
||||
|
||||
splitValues.forEach((val) => {
|
||||
val = val.trim()
|
||||
|
||||
if (!val) return
|
||||
if (values.value?.includes(val)) return
|
||||
|
||||
if (props.validate && !props.validate(val)) {
|
||||
error.value = props.errorMessage(val)
|
||||
return
|
||||
}
|
||||
|
||||
if (!values.value) values.value = [val]
|
||||
else values.value.push(val)
|
||||
})
|
||||
}
|
||||
|
||||
function removeValue(value) {
|
||||
let indexToRemove = values.value.indexOf(value)
|
||||
if (indexToRemove > -1) {
|
||||
values.value.splice(indexToRemove, 1)
|
||||
}
|
||||
emit('update:modelValue', values.value)
|
||||
}
|
||||
|
||||
function setFocus() {
|
||||
search.value.$el.focus()
|
||||
}
|
||||
|
||||
defineExpose({ setFocus })
|
||||
|
||||
const labelClasses = computed(() => {
|
||||
return [
|
||||
{
|
||||
sm: 'text-xs',
|
||||
md: 'text-base',
|
||||
}[props.size || 'sm'],
|
||||
'text-ink-gray-5',
|
||||
]
|
||||
})
|
||||
const labelClasses = computed(() => [
|
||||
{ sm: 'text-xs', md: 'text-base' }[props.size || 'sm'],
|
||||
'text-ink-gray-5',
|
||||
])
|
||||
</script>
|
||||
|
||||
@@ -93,7 +93,7 @@
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
class="font-medium text-ink-gray-9"
|
||||
:class="{ 'mt-8': course.data.membership && !readOnlyMode }"
|
||||
:class="{ 'mt-8': !readOnlyMode }"
|
||||
>
|
||||
{{ __('This course has:') }}
|
||||
</div>
|
||||
|
||||
@@ -112,6 +112,14 @@
|
||||
v-else-if="lesson.icon === 'icon-quiz'"
|
||||
class="h-4 w-4 stroke-1 mr-2"
|
||||
/>
|
||||
<NotebookPen
|
||||
v-else-if="lesson.icon === 'icon-assignment'"
|
||||
class="h-4 w-4 stroke-1 mr-2"
|
||||
/>
|
||||
<SquareCode
|
||||
v-else-if="lesson.icon === 'icon-code'"
|
||||
class="h-4 w-4 stroke-1 mr-2"
|
||||
/>
|
||||
<FileText
|
||||
v-else-if="lesson.icon === 'icon-list'"
|
||||
class="h-4 w-4 text-ink-gray-9 stroke-1 mr-2"
|
||||
@@ -177,8 +185,11 @@ import {
|
||||
FilePenLine,
|
||||
HelpCircle,
|
||||
MonitorPlay,
|
||||
NotebookPen,
|
||||
Plus,
|
||||
SquareCode,
|
||||
Trash2,
|
||||
Notebook,
|
||||
} from 'lucide-vue-next'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import ChapterModal from '@/components/Modals/ChapterModal.vue'
|
||||
|
||||
@@ -80,7 +80,7 @@ const props = defineProps({
|
||||
required: true,
|
||||
},
|
||||
membership: {
|
||||
type: Object,
|
||||
type: Object || null,
|
||||
required: false,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -93,11 +93,19 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { createResource, TextEditor, Button, Dropdown, toast } from 'frappe-ui'
|
||||
import {
|
||||
call,
|
||||
createResource,
|
||||
TextEditor,
|
||||
Button,
|
||||
Dropdown,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
import { timeAgo } from '@/utils'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import { ChevronLeft, MoreHorizontal } from 'lucide-vue-next'
|
||||
import { ref, inject, onMounted, onUnmounted } from 'vue'
|
||||
import { useTelemetry } from 'frappe-ui/frappe'
|
||||
|
||||
const showTopics = defineModel('showTopics')
|
||||
const newReply = ref('')
|
||||
@@ -107,6 +115,7 @@ const allUsers = inject('$allUsers')
|
||||
const mentionUsers = ref([])
|
||||
const renderEditor = ref(false)
|
||||
const readOnlyMode = window.read_only_mode
|
||||
const { capture } = useTelemetry()
|
||||
|
||||
const props = defineProps({
|
||||
topic: {
|
||||
@@ -143,19 +152,6 @@ const replies = createResource({
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const newReplyResource = createResource({
|
||||
url: 'frappe.client.insert',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doc: {
|
||||
doctype: 'Discussion Reply',
|
||||
reply: newReply.value,
|
||||
topic: props.topic.name,
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const fetchMentionUsers = () => {
|
||||
if (user.data?.is_student) {
|
||||
renderEditor.value = true
|
||||
@@ -178,78 +174,61 @@ const fetchMentionUsers = () => {
|
||||
}
|
||||
|
||||
const postReply = () => {
|
||||
newReplyResource.submit(
|
||||
{},
|
||||
{
|
||||
validate() {
|
||||
if (!newReply.value) {
|
||||
return 'Reply cannot be empty'
|
||||
}
|
||||
},
|
||||
onSuccess() {
|
||||
newReply.value = ''
|
||||
replies.reload()
|
||||
},
|
||||
onError(err) {
|
||||
toast.error(err.messages?.[0] || err)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const editReplyResource = createResource({
|
||||
url: 'frappe.client.set_value',
|
||||
makeParams(values) {
|
||||
return {
|
||||
if (!newReply.value) {
|
||||
toast.error(__('Reply cannot be empty.'))
|
||||
return
|
||||
}
|
||||
call('frappe.client.insert', {
|
||||
doc: {
|
||||
doctype: 'Discussion Reply',
|
||||
name: values.name,
|
||||
fieldname: 'reply',
|
||||
value: values.reply,
|
||||
}
|
||||
},
|
||||
})
|
||||
reply: newReply.value,
|
||||
topic: props.topic.name,
|
||||
},
|
||||
})
|
||||
.then((data) => {
|
||||
newReply.value = ''
|
||||
replies.reload()
|
||||
capture('discussion_reply_created')
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.messages?.[0] || err)
|
||||
console.error(err)
|
||||
})
|
||||
}
|
||||
|
||||
const postEdited = (reply) => {
|
||||
editReplyResource.submit(
|
||||
{
|
||||
name: reply.name,
|
||||
reply: reply.reply,
|
||||
},
|
||||
{
|
||||
validate() {
|
||||
if (!reply.reply) {
|
||||
return 'Reply cannot be empty'
|
||||
}
|
||||
},
|
||||
onSuccess() {
|
||||
reply.editable = false
|
||||
replies.reload()
|
||||
},
|
||||
}
|
||||
)
|
||||
if (!reply.reply) {
|
||||
toast.error(__('Reply cannot be empty.'))
|
||||
return
|
||||
}
|
||||
call('frappe.client.set_value', {
|
||||
doctype: 'Discussion Reply',
|
||||
name: reply.name,
|
||||
fieldname: 'reply',
|
||||
value: reply.reply,
|
||||
})
|
||||
.then(() => {
|
||||
reply.editable = false
|
||||
replies.reload()
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.messages?.[0] || err)
|
||||
console.error(err)
|
||||
})
|
||||
}
|
||||
|
||||
const deleteReplyResource = createResource({
|
||||
url: 'frappe.client.delete',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doctype: 'Discussion Reply',
|
||||
name: values.name,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const deleteReply = (reply) => {
|
||||
deleteReplyResource.submit(
|
||||
{
|
||||
name: reply.name,
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
replies.reload()
|
||||
},
|
||||
}
|
||||
)
|
||||
call('frappe.client.delete', {
|
||||
doctype: 'Discussion Reply',
|
||||
name: reply.name,
|
||||
})
|
||||
.then(() => {
|
||||
replies.reload()
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.messages?.[0] || err)
|
||||
console.error(err)
|
||||
})
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
|
||||
@@ -15,20 +15,18 @@
|
||||
>
|
||||
<template #body-content>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="">
|
||||
<div class="mb-1.5 text-sm text-ink-gray-5">
|
||||
{{ __('Subject') }}
|
||||
<span class="text-ink-red-3">*</span>
|
||||
</div>
|
||||
<Input type="text" v-model="announcement.subject" />
|
||||
</div>
|
||||
<div class="">
|
||||
<div class="mb-1.5 text-sm text-ink-gray-5">
|
||||
{{ __('Reply To') }}
|
||||
<span class="text-ink-red-3">*</span>
|
||||
</div>
|
||||
<Input type="text" v-model="announcement.replyTo" />
|
||||
</div>
|
||||
<FormControl
|
||||
:label="__('Subject')"
|
||||
type="text"
|
||||
v-model="announcement.subject"
|
||||
:required="true"
|
||||
/>
|
||||
<FormControl
|
||||
:label="__('Reply To')"
|
||||
type="text"
|
||||
v-model="announcement.replyTo"
|
||||
:required="true"
|
||||
/>
|
||||
<div class="mb-4">
|
||||
<div class="mb-1.5 text-sm text-ink-gray-5">
|
||||
{{ __('Announcement') }}
|
||||
@@ -45,7 +43,13 @@
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Dialog, Input, TextEditor, createResource, toast } from 'frappe-ui'
|
||||
import {
|
||||
Dialog,
|
||||
FormControl,
|
||||
TextEditor,
|
||||
createResource,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
import { reactive } from 'vue'
|
||||
|
||||
const show = defineModel()
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
@change="(val) => (assignment.question = val)"
|
||||
:editable="true"
|
||||
:fixedMenu="true"
|
||||
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem] max-h-[18rem] overflow-y-auto"
|
||||
editorClass="prose-sm max-w-none border-b border-x border-outline-gray-modals bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem] max-h-[18rem] overflow-y-auto"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
title: __('Add a course'),
|
||||
title: __('Add Course'),
|
||||
size: 'sm',
|
||||
actions: [
|
||||
{
|
||||
@@ -19,6 +19,7 @@
|
||||
v-model="course"
|
||||
:label="__('Course')"
|
||||
:required="true"
|
||||
:filters="{ published: 1 }"
|
||||
:onCreate="
|
||||
(value, close) => {
|
||||
close()
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
@change="(val) => (topic.reply = val)"
|
||||
:editable="true"
|
||||
:fixedMenu="true"
|
||||
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
|
||||
editorClass="prose-sm max-w-none border-b border-x border-outline-gray-modals bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -34,17 +34,13 @@
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
Dialog,
|
||||
FormControl,
|
||||
TextEditor,
|
||||
createResource,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
import { call, Dialog, FormControl, TextEditor, toast } from 'frappe-ui'
|
||||
import { reactive } from 'vue'
|
||||
import { singularize } from '@/utils'
|
||||
import { useTelemetry } from 'frappe-ui/frappe'
|
||||
|
||||
const topics = defineModel('reloadTopics')
|
||||
const { capture } = useTelemetry()
|
||||
|
||||
const props = defineProps({
|
||||
title: {
|
||||
@@ -66,64 +62,50 @@ const topic = reactive({
|
||||
reply: '',
|
||||
})
|
||||
|
||||
const topicResource = createResource({
|
||||
url: 'frappe.client.insert',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doc: {
|
||||
doctype: 'Discussion Topic',
|
||||
reference_doctype: props.doctype,
|
||||
reference_docname: props.docname,
|
||||
title: topic.title,
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const replyResource = createResource({
|
||||
url: 'frappe.client.insert',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doc: {
|
||||
doctype: 'Discussion Reply',
|
||||
topic: values.topic,
|
||||
reply: topic.reply,
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const submitTopic = (close) => {
|
||||
topicResource.submit(
|
||||
{},
|
||||
{
|
||||
validate() {
|
||||
if (!topic.title) {
|
||||
return 'Title cannot be empty.'
|
||||
}
|
||||
if (!topic.reply) {
|
||||
return 'Reply cannot be empty.'
|
||||
}
|
||||
},
|
||||
onSuccess(data) {
|
||||
replyResource.submit(
|
||||
{
|
||||
topic: data.name,
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
topic.title = ''
|
||||
topic.reply = ''
|
||||
topics.value.reload()
|
||||
close()
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
onError(err) {
|
||||
toast.error(err.messages?.[0] || err)
|
||||
},
|
||||
}
|
||||
)
|
||||
if (!topic.title) {
|
||||
toast.error(__('Title cannot be empty.'))
|
||||
return
|
||||
}
|
||||
if (!topic.reply) {
|
||||
toast.error(__('Details cannot be empty.'))
|
||||
return
|
||||
}
|
||||
call('frappe.client.insert', {
|
||||
doc: {
|
||||
doctype: 'Discussion Topic',
|
||||
reference_doctype: props.doctype,
|
||||
reference_docname: props.docname,
|
||||
title: topic.title,
|
||||
},
|
||||
})
|
||||
.then((data) => {
|
||||
createReply(data.name, close)
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.messages?.[0] || err)
|
||||
console.error(err)
|
||||
})
|
||||
}
|
||||
|
||||
const createReply = (topicName, close) => {
|
||||
call('frappe.client.insert', {
|
||||
doc: {
|
||||
doctype: 'Discussion Reply',
|
||||
topic: topicName,
|
||||
reply: topic.reply,
|
||||
},
|
||||
})
|
||||
.then((data) => {
|
||||
topic.title = ''
|
||||
topic.reply = ''
|
||||
topics.value.reload()
|
||||
capture('discussion_topic_created')
|
||||
close()
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.messages?.[0] || err)
|
||||
console.error(err)
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
'Dear {{ member_name }},\n\nYou have been enrolled in our upcoming batch {{ batch_name }}.\n\nThanks,\nFrappe Learning'
|
||||
)
|
||||
"
|
||||
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem] max-h-[18rem] overflow-y-auto"
|
||||
editorClass="prose-sm max-w-none border-b border-x border-outline-gray-modals bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem] max-h-[18rem] overflow-y-auto"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
@change="(val) => (question.question = val)"
|
||||
:editable="true"
|
||||
:fixedMenu="true"
|
||||
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
|
||||
editorClass="prose-sm max-w-none border-b border-x border-outline-gray-modals bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-8 mt-4">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
title: __('Add a Student'),
|
||||
title: __('Enroll a Student'),
|
||||
size: 'sm',
|
||||
actions: [
|
||||
{
|
||||
@@ -18,10 +18,24 @@
|
||||
<Link
|
||||
doctype="User"
|
||||
v-model="student"
|
||||
:filters="{ ignore_user_type: 1 }"
|
||||
placeholder=" "
|
||||
:label="__('Student')"
|
||||
:onCreate="
|
||||
(value, close) => {
|
||||
openSettings('Members', close)
|
||||
() => {
|
||||
openSettings('Members')
|
||||
show = false
|
||||
}
|
||||
"
|
||||
:required="true"
|
||||
/>
|
||||
<Link
|
||||
doctype="LMS Payment"
|
||||
v-model="payment"
|
||||
placeholder=" "
|
||||
:label="__('Payment')"
|
||||
:onCreate="
|
||||
() => {
|
||||
openSettings('Transactions')
|
||||
show = false
|
||||
}
|
||||
"
|
||||
@@ -31,15 +45,16 @@
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Dialog, createResource, toast } from 'frappe-ui'
|
||||
import { call, Dialog, toast } from 'frappe-ui'
|
||||
import { ref, inject } from 'vue'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import { useOnboarding } from 'frappe-ui/frappe'
|
||||
import { openSettings } from '@/utils'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
|
||||
const students = defineModel('reloadStudents')
|
||||
const batchModal = defineModel('batchModal')
|
||||
const student = ref()
|
||||
const student = ref(null)
|
||||
const payment = ref(null)
|
||||
const user = inject('$user')
|
||||
const { updateOnboardingStep } = useOnboarding('learning')
|
||||
const show = defineModel()
|
||||
@@ -51,36 +66,28 @@ const props = defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
const studentResource = createResource({
|
||||
url: 'frappe.client.insert',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doc: {
|
||||
doctype: 'LMS Batch Enrollment',
|
||||
batch: props.batch,
|
||||
member: student.value,
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const addStudent = (close) => {
|
||||
studentResource.submit(
|
||||
{},
|
||||
{
|
||||
onSuccess() {
|
||||
if (user.data?.is_system_manager)
|
||||
updateOnboardingStep('add_batch_student')
|
||||
call('frappe.client.insert', {
|
||||
doc: {
|
||||
doctype: 'LMS Batch Enrollment',
|
||||
batch: props.batch,
|
||||
member: student.value,
|
||||
payment: payment.value,
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
if (user.data?.is_system_manager)
|
||||
updateOnboardingStep('add_batch_student')
|
||||
|
||||
students.value.reload()
|
||||
batchModal.value.reload()
|
||||
student.value = null
|
||||
close()
|
||||
},
|
||||
onError(err) {
|
||||
toast.error(err.messages?.[0] || err)
|
||||
},
|
||||
}
|
||||
)
|
||||
students.value.reload()
|
||||
batchModal.value.reload()
|
||||
student.value = null
|
||||
payment.value = null
|
||||
close()
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.messages?.[0] || err)
|
||||
console.error(err)
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<slot name="prefix" />
|
||||
<div class="font-semibold text-2xl">
|
||||
<div class="font-semibold text-ink-gray-9 text-2xl">
|
||||
{{ value }}
|
||||
</div>
|
||||
<slot name="suffix" />
|
||||
|
||||
@@ -205,7 +205,7 @@
|
||||
@change="(val) => (possibleAnswer = val)"
|
||||
:editable="true"
|
||||
:fixedMenu="true"
|
||||
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
|
||||
editorClass="prose-sm max-w-none border-b border-x border-outline-gray-modals bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between mt-4">
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
</div>
|
||||
|
||||
<div class="overflow-y-scroll">
|
||||
<div class="divide-y space-y-2">
|
||||
<div class="divide-y divide-outline-gray-modals space-y-2">
|
||||
<div
|
||||
v-for="(cat, index) in categories.data"
|
||||
:key="cat.name"
|
||||
@@ -53,9 +53,9 @@
|
||||
>
|
||||
<div
|
||||
v-if="editing?.name !== cat.name"
|
||||
class="flex items-center justify-between group text-sm"
|
||||
class="flex items-center justify-between group text-sm text-ink-gray-9"
|
||||
>
|
||||
<div @dblclick="allowEdit(cat, index)">
|
||||
<div class="text-ink-gray-9" @dblclick="allowEdit(cat, index)">
|
||||
{{ cat.category }}
|
||||
</div>
|
||||
<Button
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
</template>
|
||||
</FormControl>
|
||||
<div class="overflow-auto h-[60vh]">
|
||||
<div class="divide-y">
|
||||
<div class="divide-y divide-outline-gray-modals">
|
||||
<div
|
||||
v-for="evaluator in evaluators.data"
|
||||
:key="evaluator.evaluator"
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
</template>
|
||||
</FormControl>
|
||||
<div class="overflow-y-scroll h-[60vh]">
|
||||
<ul class="divide-y">
|
||||
<ul class="divide-y divide-outline-gray-modals">
|
||||
<li
|
||||
v-for="member in memberList"
|
||||
class="flex items-center justify-between py-2 cursor-pointer"
|
||||
@@ -58,7 +58,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center space-x-1 bg-surface-gray-2 px-2 py-1.5 rounded-md"
|
||||
class="flex items-center text-ink-gray-9 space-x-1 bg-surface-gray-2 px-2 py-1.5 rounded-md"
|
||||
v-if="member.role && member.role !== 'LMS Student'"
|
||||
>
|
||||
<Shield class="size-4 stroke-1.5" />
|
||||
@@ -117,12 +117,21 @@
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { Avatar, Button, createResource, Dialog, FormControl } from 'frappe-ui'
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
call,
|
||||
createResource,
|
||||
Dialog,
|
||||
FormControl,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ref, watch, reactive, inject } from 'vue'
|
||||
import { RefreshCw, Plus, Search, Shield } from 'lucide-vue-next'
|
||||
import { useOnboarding } from 'frappe-ui/frappe'
|
||||
import type { User } from '@/components/Settings/types'
|
||||
import { useTelemetry } from 'frappe-ui/frappe'
|
||||
|
||||
type Member = {
|
||||
username: string
|
||||
@@ -141,6 +150,7 @@ const hasNextPage = ref(false)
|
||||
const showForm = ref(false)
|
||||
const user = inject<User | null>('$user')
|
||||
const { updateOnboardingStep } = useOnboarding('learning')
|
||||
const { capture } = useTelemetry()
|
||||
|
||||
const member = reactive({
|
||||
email: '',
|
||||
@@ -184,34 +194,30 @@ const openProfile = (username: string) => {
|
||||
})
|
||||
}
|
||||
|
||||
const newMember = createResource({
|
||||
url: 'frappe.client.insert',
|
||||
makeParams() {
|
||||
return {
|
||||
doc: {
|
||||
doctype: 'User',
|
||||
first_name: member.first_name,
|
||||
email: member.email,
|
||||
},
|
||||
}
|
||||
},
|
||||
auto: false,
|
||||
onSuccess(data: Member) {
|
||||
show.value = false
|
||||
if (user?.data?.is_system_manager) updateOnboardingStep('invite_students')
|
||||
|
||||
router.push({
|
||||
name: 'ProfileRoles',
|
||||
params: {
|
||||
username: data.username,
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const addMember = (close: () => void) => {
|
||||
newMember.reload()
|
||||
close()
|
||||
call('frappe.client.insert', {
|
||||
doc: {
|
||||
doctype: 'User',
|
||||
first_name: member.first_name,
|
||||
email: member.email,
|
||||
},
|
||||
})
|
||||
.then((data: Member) => {
|
||||
if (user?.data?.is_system_manager) updateOnboardingStep('invite_students')
|
||||
capture('user_added')
|
||||
show.value = false
|
||||
router.push({
|
||||
name: 'ProfileRoles',
|
||||
params: {
|
||||
username: data.username,
|
||||
},
|
||||
})
|
||||
close()
|
||||
})
|
||||
.catch((err: any) => {
|
||||
console.error(err)
|
||||
toast.error(__(err.messages?.[0] || err))
|
||||
})
|
||||
}
|
||||
|
||||
watch(search, () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="mb-5 divide-y overflow-y-auto">
|
||||
<div class="mb-5 divide-y divide-outline-gray-modals overflow-y-auto">
|
||||
<div v-for="(section, index) in sections" class="py-5">
|
||||
<div v-if="section.label" class="font-semibold text-ink-gray-9 mb-4">
|
||||
{{ section.label }}
|
||||
@@ -65,7 +65,7 @@
|
||||
<div v-else>
|
||||
<div class="flex items-center text-sm space-x-2">
|
||||
<div
|
||||
class="flex items-center justify-center rounded border border-outline-gray-1 bg-surface-gray-2"
|
||||
class="flex items-center justify-center rounded border border-outline-gray-modals bg-surface-gray-2"
|
||||
:class="field.size == 'lg' ? 'px-5 py-5' : 'px-20 py-8'"
|
||||
>
|
||||
<img
|
||||
@@ -90,7 +90,7 @@
|
||||
</div>
|
||||
<X
|
||||
@click="data[field.name] = null"
|
||||
class="border text-ink-gray-7 border-outline-gray-3 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
|
||||
class="border text-ink-gray-7 border-outline-gray-modals rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
</template>
|
||||
<template #body>
|
||||
<div
|
||||
class="grid grid-cols-3 justify-between mx-3 p-2 rounded-lg border border-gray-100 bg-surface-white shadow-xl"
|
||||
class="grid grid-cols-3 justify-between mx-3 p-2 rounded-lg bg-surface-modal shadow-2xl ring-1 ring-black ring-opacity-5"
|
||||
>
|
||||
<div v-for="app in apps.data" key="name">
|
||||
<a
|
||||
|
||||
@@ -1,26 +1,48 @@
|
||||
<template>
|
||||
<div class="grid grid-cols-3 justify-between bg-surface-white">
|
||||
<div key="name" class="py-1 px-2 hover:bg-surface-gray-2 rounded">
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'DataImportList',
|
||||
query: {
|
||||
step: 'list',
|
||||
},
|
||||
}"
|
||||
<Popover placement="right-start" trigger="hover" class="flex w-full">
|
||||
<template #target="{ togglePopover }">
|
||||
<button
|
||||
:class="[
|
||||
'group w-full flex h-7 items-center justify-between rounded px-2 text-base text-ink-gray-7 hover:bg-surface-gray-2',
|
||||
]"
|
||||
>
|
||||
<div class="flex flex-col items-center space-y-1">
|
||||
<ArrowDownToLine
|
||||
class="size-9 text-ink-gray-7 p-2 bg-surface-gray-2 rounded-md"
|
||||
/>
|
||||
<div class="text-sm text-ink-gray-7">
|
||||
{{ __('Import') }}
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Wrench class="size-4 stroke-1.5" />
|
||||
<span class="whitespace-nowrap">
|
||||
{{ __('Configuration') }}
|
||||
</span>
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight class="h-4 w-4 stroke-1.5" />
|
||||
</button>
|
||||
</template>
|
||||
<template #body>
|
||||
<div
|
||||
class="grid grid-cols-3 justify-between mx-3 p-2 rounded-lg bg-surface-modal shadow-2xl ring-1 ring-black ring-opacity-5"
|
||||
>
|
||||
<div key="name" class="py-1 px-2 hover:bg-surface-gray-2 rounded">
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'DataImportList',
|
||||
query: {
|
||||
step: 'list',
|
||||
},
|
||||
}"
|
||||
>
|
||||
<div class="flex flex-col items-center space-y-1">
|
||||
<ArrowDownToLine
|
||||
class="size-9 text-ink-gray-7 p-2 bg-surface-gray-2 rounded-md"
|
||||
/>
|
||||
<div class="text-sm text-ink-gray-7">
|
||||
{{ __('Import') }}
|
||||
</div>
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ArrowDownToLine } from 'lucide-vue-next'
|
||||
import { Popover } from 'frappe-ui'
|
||||
import { ArrowDownToLine, Wrench, ChevronRight } from 'lucide-vue-next'
|
||||
</script>
|
||||
|
||||
@@ -85,7 +85,6 @@ import {
|
||||
User,
|
||||
Settings,
|
||||
Sun,
|
||||
Wrench,
|
||||
Zap,
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
@@ -171,13 +170,7 @@ const userDropdownOptions = computed(() => {
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Configuration',
|
||||
icon: Wrench,
|
||||
submenu: [
|
||||
{
|
||||
component: markRaw(Configuration),
|
||||
},
|
||||
],
|
||||
component: markRaw(Configuration),
|
||||
condition: () => {
|
||||
return userResource.data?.is_moderator
|
||||
},
|
||||
|
||||
@@ -137,11 +137,12 @@ import {
|
||||
} from 'lucide-vue-next'
|
||||
import { inject, ref, getCurrentInstance, computed } from 'vue'
|
||||
import { formatTime } from '@/utils'
|
||||
import { Button, createResource, call } from 'frappe-ui'
|
||||
import { Button, createListResource, call, toast } from 'frappe-ui'
|
||||
import EvaluationModal from '@/components/Modals/EvaluationModal.vue'
|
||||
import { Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/vue'
|
||||
|
||||
const dayjs = inject('$dayjs')
|
||||
const user = inject('$user')
|
||||
const showEvalModal = ref(false)
|
||||
const app = getCurrentInstance()
|
||||
const { $dialog } = app.appContext.config.globalProperties
|
||||
@@ -165,12 +166,27 @@ const props = defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
const upcoming_evals = createResource({
|
||||
url: 'lms.lms.utils.get_upcoming_evals',
|
||||
params: {
|
||||
courses: props.courses.map((course) => course.course),
|
||||
batch: props.batch,
|
||||
const upcoming_evals = createListResource({
|
||||
doctype: 'LMS Certificate Request',
|
||||
filters: {
|
||||
course: props.courses?.length
|
||||
? ['in', props.courses.map((course) => course.course)]
|
||||
: undefined,
|
||||
batch_name: props.batch || undefined,
|
||||
status: 'Upcoming',
|
||||
member: user?.data?.name,
|
||||
date: ['>=', dayjs().format('YYYY-MM-DD')],
|
||||
},
|
||||
fields: [
|
||||
'name',
|
||||
'date',
|
||||
'start_time',
|
||||
'evaluator_name',
|
||||
'course_title',
|
||||
'member',
|
||||
'google_meet_link',
|
||||
],
|
||||
orderBy: 'date',
|
||||
auto: true,
|
||||
})
|
||||
|
||||
@@ -212,11 +228,15 @@ const cancelEvaluation = (evl) => {
|
||||
theme: 'red',
|
||||
variant: 'solid',
|
||||
onClick(close) {
|
||||
call('lms.lms.api.cancel_evaluation', { evaluation: evl }).then(
|
||||
() => {
|
||||
call('lms.lms.api.cancel_evaluation', { evaluation: evl })
|
||||
.then(() => {
|
||||
upcoming_evals.reload()
|
||||
}
|
||||
)
|
||||
toast.success(__('Evaluation cancelled successfully'))
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(__(err.messages?.[0] || err))
|
||||
console.error(err)
|
||||
})
|
||||
close()
|
||||
},
|
||||
},
|
||||
|
||||
@@ -59,7 +59,7 @@ onMounted(() => {
|
||||
const breadcrumbs = computed(() => {
|
||||
let crumbs = [
|
||||
{
|
||||
label: 'Submissions',
|
||||
label: __('Submissions'),
|
||||
route: { name: 'AssignmentSubmissionList' },
|
||||
},
|
||||
{
|
||||
|
||||
@@ -140,9 +140,6 @@ const assignmentFilter = computed(() => {
|
||||
if (typeFilter.value) {
|
||||
filters.type = typeFilter.value
|
||||
}
|
||||
if (!user.data?.is_moderator) {
|
||||
filters.owner = user.data?.email
|
||||
}
|
||||
return filters
|
||||
})
|
||||
|
||||
@@ -203,7 +200,7 @@ const assignmentTypes = computed(() => {
|
||||
|
||||
const breadcrumbs = computed(() => [
|
||||
{
|
||||
label: 'Assignments',
|
||||
label: __('Assignments'),
|
||||
route: { name: 'Assignments' },
|
||||
},
|
||||
])
|
||||
|
||||
@@ -59,7 +59,7 @@ const badge = createResource({
|
||||
const breadcrumbs = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: 'Badges',
|
||||
label: __('Badges'),
|
||||
},
|
||||
{
|
||||
label: badge.data.badge,
|
||||
|
||||
@@ -330,10 +330,10 @@ const batch = createResource({
|
||||
})
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
let crumbs = [{ label: 'Batches', route: { name: 'Batches' } }]
|
||||
let crumbs = [{ label: __('Batches'), route: { name: 'Batches' } }]
|
||||
if (!isStudent.value) {
|
||||
crumbs.push({
|
||||
label: 'Details',
|
||||
label: __('Details'),
|
||||
route: {
|
||||
name: 'BatchDetail',
|
||||
params: {
|
||||
|
||||
@@ -120,12 +120,12 @@ const courses = createResource({
|
||||
})
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
let items = [{ label: 'Batches', route: { name: 'Batches' } }]
|
||||
items.push({
|
||||
let crumbs = [{ label: __('Batches'), route: { name: 'Batches' } }]
|
||||
crumbs.push({
|
||||
label: batch?.data?.title,
|
||||
route: { name: 'BatchDetail', params: { batchName: batch?.data?.name } },
|
||||
})
|
||||
return items
|
||||
return crumbs
|
||||
})
|
||||
|
||||
usePageMeta(() => {
|
||||
|
||||
@@ -138,7 +138,7 @@
|
||||
@change="(val) => (batch.batch_details = val)"
|
||||
:editable="true"
|
||||
:fixedMenu="true"
|
||||
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem] max-h-[20rem] overflow-y-scroll mb-4"
|
||||
editorClass="prose-sm max-w-none border-b border-x border-outline-gray-modals bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem] max-h-[20rem] overflow-y-scroll mb-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -559,7 +559,7 @@ const trashBatch = (close) => {
|
||||
const breadcrumbs = computed(() => {
|
||||
let crumbs = [
|
||||
{
|
||||
label: 'Batches',
|
||||
label: __('Batches'),
|
||||
route: {
|
||||
name: 'Batches',
|
||||
},
|
||||
@@ -577,7 +577,7 @@ const breadcrumbs = computed(() => {
|
||||
})
|
||||
}
|
||||
crumbs.push({
|
||||
label: props.batchName == 'new' ? 'New Batch' : 'Edit Batch',
|
||||
label: props.batchName == 'new' ? __('New Batch') : __('Edit Batch'),
|
||||
route: { name: 'BatchForm', params: { batchName: props.batchName } },
|
||||
})
|
||||
return crumbs
|
||||
|
||||
@@ -155,7 +155,7 @@ const title = ref('')
|
||||
const certification = ref(false)
|
||||
const filters = ref({})
|
||||
const is_student = computed(() => user.data?.is_student)
|
||||
const currentTab = ref(is_student.value ? 'All' : 'Upcoming')
|
||||
const currentTab = ref(is_student.value ? 'all' : 'upcoming')
|
||||
const orderBy = ref('start_date')
|
||||
const readOnlyMode = window.read_only_mode
|
||||
const router = useRouter()
|
||||
@@ -245,7 +245,7 @@ const updateTabFilter = () => {
|
||||
if (!user.data) {
|
||||
return
|
||||
}
|
||||
if (currentTab.value == 'Enrolled' && is_student.value) {
|
||||
if (currentTab.value == 'enrolled' && is_student.value) {
|
||||
filters.value['enrolled'] = 1
|
||||
delete filters.value['start_date']
|
||||
delete filters.value['published']
|
||||
@@ -256,20 +256,20 @@ const updateTabFilter = () => {
|
||||
delete filters.value['start_date']
|
||||
delete filters.value['published']
|
||||
orderBy.value = 'start_date desc'
|
||||
if (currentTab.value == 'Upcoming') {
|
||||
if (currentTab.value == 'upcoming') {
|
||||
filters.value['start_date'] = ['>=', dayjs().format('YYYY-MM-DD')]
|
||||
filters.value['published'] = 1
|
||||
orderBy.value = 'start_date'
|
||||
} else if (currentTab.value == 'Archived') {
|
||||
} else if (currentTab.value == 'archived') {
|
||||
filters.value['start_date'] = ['<=', dayjs().format('YYYY-MM-DD')]
|
||||
} else if (currentTab.value == 'Unpublished') {
|
||||
} else if (currentTab.value == 'unpublished') {
|
||||
filters.value['published'] = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const updateStudentFilter = () => {
|
||||
if (!user.data || (is_student.value && currentTab.value != 'Enrolled')) {
|
||||
if (!user.data || (is_student.value && currentTab.value != 'enrolled')) {
|
||||
filters.value['start_date'] = ['>=', dayjs().format('YYYY-MM-DD')]
|
||||
filters.value['published'] = 1
|
||||
}
|
||||
@@ -319,6 +319,7 @@ const batchTabs = computed(() => {
|
||||
let tabs = [
|
||||
{
|
||||
label: __('All'),
|
||||
value: 'all',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -327,11 +328,11 @@ const batchTabs = computed(() => {
|
||||
user.data?.is_instructor ||
|
||||
user.data?.is_evaluator
|
||||
) {
|
||||
tabs.push({ label: __('Upcoming') })
|
||||
tabs.push({ label: __('Archived') })
|
||||
tabs.push({ label: __('Unpublished') })
|
||||
tabs.push({ label: __('Upcoming'), value: 'upcoming' })
|
||||
tabs.push({ label: __('Archived'), value: 'archived' })
|
||||
tabs.push({ label: __('Unpublished'), value: 'unpublished' })
|
||||
} else if (user.data) {
|
||||
tabs.push({ label: __('Enrolled') })
|
||||
tabs.push({ label: __('Enrolled'), value: 'enrolled' })
|
||||
}
|
||||
return tabs
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="p-5">
|
||||
<div class="grid grid-cols-4 gap-5 mb-5">
|
||||
<div class="grid grid-cols-4 gap-5 mb-5 text-ink-gray-9">
|
||||
<NumberChartGraph
|
||||
:title="__('Enrolled')"
|
||||
:value="formatAmount(course.data?.enrollments)"
|
||||
@@ -20,9 +20,9 @@
|
||||
<NumberChartGraph :title="__('Lessons')" :value="course.data?.lessons" />
|
||||
</div>
|
||||
<div class="grid grid-cols-[2fr_1fr] gap-5 items-start">
|
||||
<div v-if="course.data?.enrollments" class="border rounded-lg py-3 px-4">
|
||||
<div class="border rounded-lg py-3 px-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="text-lg font-semibold">
|
||||
<div class="text-lg text-ink-gray-9 font-semibold">
|
||||
{{ __('Students') }}
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
@@ -63,50 +63,52 @@
|
||||
</ListHeaderItem>
|
||||
</ListHeader>
|
||||
<ListRows v-for="row in progressList.data" class="max-h-[500px]">
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'Profile',
|
||||
params: { username: row.member_username },
|
||||
}"
|
||||
<ListRow
|
||||
:row="row"
|
||||
@click="
|
||||
() => {
|
||||
showProgressModal = true
|
||||
currentStudent = row
|
||||
}
|
||||
"
|
||||
class="cursor-pointer"
|
||||
>
|
||||
<ListRow :row="row">
|
||||
<template #default="{ column, item }">
|
||||
<ListRowItem
|
||||
:item="row[column.key]"
|
||||
:align="column.align"
|
||||
class="w-full"
|
||||
>
|
||||
<template #prefix>
|
||||
<div v-if="column.key == 'member_name'">
|
||||
<Avatar
|
||||
class="flex items-center"
|
||||
:image="row['member_image']"
|
||||
:label="item"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<ProgressBar
|
||||
v-else-if="column.key == 'progress'"
|
||||
:progress="Math.ceil(row[column.key])"
|
||||
class="!mx-0 !mr-4"
|
||||
<template #default="{ column, item }">
|
||||
<ListRowItem
|
||||
:item="row[column.key]"
|
||||
:align="column.align"
|
||||
class="w-full"
|
||||
>
|
||||
<template #prefix>
|
||||
<div v-if="column.key == 'member_name'">
|
||||
<Avatar
|
||||
class="flex items-center"
|
||||
:image="row['member_image']"
|
||||
:label="item"
|
||||
size="sm"
|
||||
/>
|
||||
</template>
|
||||
<div v-if="column.key == 'creation'">
|
||||
{{ dayjs(row[column.key]).format('DD MMM YYYY') }}
|
||||
</div>
|
||||
<div
|
||||
<ProgressBar
|
||||
v-else-if="column.key == 'progress'"
|
||||
class="text-xs !mx-0 w-5"
|
||||
>
|
||||
{{ Math.ceil(row[column.key]) }}%
|
||||
</div>
|
||||
<div v-else>
|
||||
{{ row[column.key].toString() }}
|
||||
</div>
|
||||
</ListRowItem>
|
||||
</template>
|
||||
</ListRow>
|
||||
</router-link>
|
||||
:progress="Math.ceil(row[column.key])"
|
||||
class="!mx-0 !mr-4"
|
||||
/>
|
||||
</template>
|
||||
<div v-if="column.key == 'creation'">
|
||||
{{ dayjs(row[column.key]).format('DD MMM YYYY') }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="column.key == 'progress'"
|
||||
class="text-xs !mx-0 w-5"
|
||||
>
|
||||
{{ Math.ceil(row[column.key]) }}%
|
||||
</div>
|
||||
<div v-else>
|
||||
{{ row[column.key].toString() }}
|
||||
</div>
|
||||
</ListRowItem>
|
||||
</template>
|
||||
</ListRow>
|
||||
</ListRows>
|
||||
</ListView>
|
||||
<div
|
||||
@@ -127,10 +129,12 @@
|
||||
<div class="text-ink-gray-5 mb-4">
|
||||
{{ __('Progress Summary') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-[2fr_1fr] items-center justify-between">
|
||||
<div
|
||||
class="grid grid-cols-[2fr_1fr] items-center justify-between text-ink-gray-9"
|
||||
>
|
||||
<div class="flex flex-col space-y-4 flex-1 text-sm">
|
||||
<div
|
||||
class="flex items-center"
|
||||
class="flex items-center text-ink-gray-7"
|
||||
v-for="row in chartDetails.data?.progress_distribution"
|
||||
>
|
||||
<div
|
||||
@@ -142,6 +146,8 @@
|
||||
? 'red'
|
||||
: row.name.startsWith('In')
|
||||
? 'amber'
|
||||
: row.name.startsWith('Adv')
|
||||
? 'blue'
|
||||
: 'green'
|
||||
][400],
|
||||
}"
|
||||
@@ -151,11 +157,13 @@
|
||||
{{ row.name.split('(')[0] }}
|
||||
</div>
|
||||
</Tooltip>
|
||||
<div class="ml-auto">
|
||||
{{
|
||||
Math.round((row.value / course.data?.enrollments) * 100)
|
||||
}}%
|
||||
</div>
|
||||
<Tooltip :text="row.value">
|
||||
<div class="ml-auto">
|
||||
{{
|
||||
Math.round((row.value / course.data?.enrollments) * 100)
|
||||
}}%
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<ECharts
|
||||
@@ -205,10 +213,12 @@
|
||||
class="!w-32"
|
||||
/>
|
||||
</div>
|
||||
<div class="divide-y max-h-[43vh] overflow-y-auto">
|
||||
<div
|
||||
class="divide-y max-h-[43vh divide-outline-gray-modals text-ink-gray-7 overflow-y-auto"
|
||||
>
|
||||
<div
|
||||
v-for="progress in lessonProgress.data"
|
||||
class="flex justify-between text-sm py-2 my-1"
|
||||
class="flex justify-between text-sm py-2 my-1 text-ink-gray-9"
|
||||
>
|
||||
<div class="">
|
||||
<span class="mr-3 text-xs">
|
||||
@@ -238,6 +248,14 @@
|
||||
v-if="showEnrollmentModal"
|
||||
v-model="showEnrollmentModal"
|
||||
:course="course"
|
||||
:students="progressList"
|
||||
/>
|
||||
<StudentCourseProgress
|
||||
v-if="showProgressModal"
|
||||
v-model="showProgressModal"
|
||||
:course="course"
|
||||
:student="currentStudent"
|
||||
:lessons="lessonProgress"
|
||||
/>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
@@ -260,12 +278,13 @@ import {
|
||||
Tooltip,
|
||||
} from 'frappe-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { ChevronDown, Plus, Star } from 'lucide-vue-next'
|
||||
import { Plus, Star } from 'lucide-vue-next'
|
||||
import { formatAmount } from '@/utils'
|
||||
import colors from '@/utils/frappe-ui-colors.json'
|
||||
import CourseEnrollmentModal from '@/pages/Courses/CourseEnrollmentModal.vue'
|
||||
import NumberChartGraph from '@/components/NumberChartGraph.vue'
|
||||
import ProgressBar from '@/components/ProgressBar.vue'
|
||||
import StudentCourseProgress from '@/pages/Courses/StudentCourseProgress.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
course: any
|
||||
@@ -273,6 +292,8 @@ const props = defineProps<{
|
||||
|
||||
const showEnrollmentModal = ref(false)
|
||||
const searchFilter = ref<string | null>(null)
|
||||
const showProgressModal = ref(false)
|
||||
const currentStudent = ref<any>(null)
|
||||
const theme = ref<'darkMode' | 'lightMode'>(
|
||||
localStorage.getItem('theme') == 'dark' ? 'darkMode' : 'lightMode'
|
||||
)
|
||||
@@ -307,6 +328,7 @@ const progressList = createListResource({
|
||||
],
|
||||
pageLength: 100,
|
||||
auto: true,
|
||||
cache: ['courseProgress', props.course.data?.name],
|
||||
})
|
||||
|
||||
const lessonProgress = createResource({
|
||||
@@ -357,6 +379,7 @@ const progressColors = computed(() => {
|
||||
let colorList = []
|
||||
colorList.push(colors[theme.value]['red'][400])
|
||||
colorList.push(colors[theme.value]['amber'][400])
|
||||
colorList.push(colors[theme.value]['blue'][400])
|
||||
colorList.push(colors[theme.value]['green'][400])
|
||||
return colorList
|
||||
})
|
||||
|
||||
@@ -140,12 +140,12 @@ const isAdmin = computed(() => {
|
||||
})
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
let items = [{ label: 'Courses', route: { name: 'Courses' } }]
|
||||
items.push({
|
||||
let crumbs = [{ label: __('Courses'), route: { name: 'Courses' } }]
|
||||
crumbs.push({
|
||||
label: course?.data?.title,
|
||||
route: { name: 'CourseDetail', params: { courseName: course?.data?.name } },
|
||||
})
|
||||
return items
|
||||
return crumbs
|
||||
})
|
||||
|
||||
usePageMeta(() => {
|
||||
|
||||
@@ -19,8 +19,7 @@
|
||||
placeholder=" "
|
||||
v-model="student"
|
||||
:required="true"
|
||||
:allowCreate="true"
|
||||
@create="
|
||||
:onCreate="
|
||||
() => {
|
||||
openSettings('Members')
|
||||
show = false
|
||||
@@ -33,8 +32,7 @@
|
||||
:label="__('Payment')"
|
||||
placeholder=" "
|
||||
v-model="payment"
|
||||
:allowCreate="true"
|
||||
@create="
|
||||
:onCreate="
|
||||
() => {
|
||||
openSettings('Transactions')
|
||||
show = false
|
||||
@@ -54,12 +52,13 @@
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { Button, call, Dialog, FormControl, toast } from 'frappe-ui'
|
||||
import { Link } from 'frappe-ui/frappe'
|
||||
import { ref } from 'vue'
|
||||
import { openSettings } from '@/utils'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
|
||||
const show = defineModel<boolean>({ required: true, default: false })
|
||||
const student = ref<string | null>(null)
|
||||
const students = defineModel<any[]>('students')
|
||||
const payment = ref<string | null>(null)
|
||||
const purchasedCertificate = ref<boolean>(false)
|
||||
|
||||
@@ -81,6 +80,7 @@ const enrollStudent = (close: () => void) => {
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
students.value?.reload()
|
||||
toast.success(__('Student enrolled successfully'))
|
||||
close()
|
||||
})
|
||||
|
||||
@@ -155,7 +155,7 @@
|
||||
"
|
||||
:editable="true"
|
||||
:fixedMenu="true"
|
||||
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
|
||||
editorClass="prose-sm max-w-none border-b border-x border-outline-gray-modals bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
<CourseReviews
|
||||
:courseName="course.data.name"
|
||||
:avg_rating="course.data.rating"
|
||||
:membership="course.data.membership"
|
||||
:membership="course.data.membership || null"
|
||||
/>
|
||||
</div>
|
||||
<div class="hidden md:block">
|
||||
|
||||
@@ -147,7 +147,7 @@ const currentCategory = ref(null)
|
||||
const title = ref('')
|
||||
const certification = ref(false)
|
||||
const filters = ref({})
|
||||
const currentTab = ref('Live')
|
||||
const currentTab = ref('live')
|
||||
const { brand } = sessionStore()
|
||||
const courseCount = ref(0)
|
||||
const router = useRouter()
|
||||
@@ -267,35 +267,35 @@ const updateTabFilter = () => {
|
||||
delete filters.value['published_on']
|
||||
delete filters.value['upcoming']
|
||||
|
||||
if (currentTab.value == 'Enrolled' && user.data?.is_student) {
|
||||
if (currentTab.value == 'enrolled' && user.data?.is_student) {
|
||||
filters.value['enrolled'] = 1
|
||||
delete filters.value['published']
|
||||
} else {
|
||||
delete filters.value['published']
|
||||
delete filters.value['enrolled']
|
||||
|
||||
if (currentTab.value == 'Live') {
|
||||
if (currentTab.value == 'live') {
|
||||
filters.value['published'] = 1
|
||||
filters.value['upcoming'] = 0
|
||||
filters.value['live'] = 1
|
||||
} else if (currentTab.value == 'Upcoming') {
|
||||
} else if (currentTab.value == 'upcoming') {
|
||||
filters.value['upcoming'] = 1
|
||||
} else if (currentTab.value == 'New') {
|
||||
} else if (currentTab.value == 'new') {
|
||||
filters.value['published'] = 1
|
||||
filters.value['published_on'] = [
|
||||
'>=',
|
||||
dayjs().add(-3, 'month').format('YYYY-MM-DD'),
|
||||
]
|
||||
} else if (currentTab.value == 'Created') {
|
||||
} else if (currentTab.value == 'created') {
|
||||
filters.value['created'] = 1
|
||||
} else if (currentTab.value == 'Unpublished') {
|
||||
} else if (currentTab.value == 'unpublished') {
|
||||
filters.value['published'] = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const updateStudentFilter = () => {
|
||||
if (!user.data || (user.data?.is_student && currentTab.value != 'Enrolled')) {
|
||||
if (!user.data || (user.data?.is_student && currentTab.value != 'enrolled')) {
|
||||
filters.value['published'] = 1
|
||||
}
|
||||
}
|
||||
@@ -345,12 +345,15 @@ const courseTabs = computed(() => {
|
||||
let tabs = [
|
||||
{
|
||||
label: __('Live'),
|
||||
value: 'live',
|
||||
},
|
||||
{
|
||||
label: __('New'),
|
||||
value: 'new',
|
||||
},
|
||||
{
|
||||
label: __('Upcoming'),
|
||||
value: 'upcoming',
|
||||
},
|
||||
]
|
||||
if (
|
||||
@@ -358,10 +361,10 @@ const courseTabs = computed(() => {
|
||||
user.data?.is_instructor ||
|
||||
user.data?.is_evaluator
|
||||
) {
|
||||
tabs.push({ label: __('Created') })
|
||||
tabs.push({ label: __('Unpublished') })
|
||||
tabs.push({ label: __('Created'), value: 'created' })
|
||||
tabs.push({ label: __('Unpublished'), value: 'unpublished' })
|
||||
} else if (user.data) {
|
||||
tabs.push({ label: __('Enrolled') })
|
||||
tabs.push({ label: __('Enrolled'), value: 'enrolled' })
|
||||
}
|
||||
return tabs
|
||||
})
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
@change="(val: string) => (course.description = val)"
|
||||
:editable="true"
|
||||
:fixedMenu="true"
|
||||
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[10rem]"
|
||||
editorClass="prose-sm max-w-none border-b border-x border-outline-gray-modals bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[10rem]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -76,9 +76,9 @@
|
||||
<script setup lang="ts">
|
||||
import { Button, Dialog, FormControl, TextEditor, toast } from 'frappe-ui'
|
||||
import { Link, useOnboarding, useTelemetry } from 'frappe-ui/frappe'
|
||||
import { inject, onMounted, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { inject, onMounted, onBeforeUnmount, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { openSettings } from '@/utils'
|
||||
import { cleanError, openSettings } from '@/utils'
|
||||
import MultiSelect from '@/components/Controls/MultiSelect.vue'
|
||||
import Uploader from '@/components/Controls/Uploader.vue'
|
||||
|
||||
@@ -125,6 +125,10 @@ const saveCourse = (close: () => void = () => {}) => {
|
||||
})
|
||||
}
|
||||
},
|
||||
onError(err: any) {
|
||||
toast.error(cleanError(err.messages?.[0]))
|
||||
console.error(err)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -144,13 +148,13 @@ const keyboardShortcut = (e: KeyboardEvent) => {
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', keyboardShortcut)
|
||||
capture('course_form_opened')
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', keyboardShortcut)
|
||||
})
|
||||
|
||||
watch(show, () => {
|
||||
capture('course_form_opened')
|
||||
capture('course_form_closed', {
|
||||
data: course.value,
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
title: __('Student Progress'),
|
||||
size: hasAssessmentData ? '3xl' : 'xl',
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<div class="text-base text-ink-gray-9 max-h-[70vh] overflow-y-auto">
|
||||
<div class="flex justify-between mb-5 px-2">
|
||||
<div class="flex items-center space-x-2">
|
||||
<Avatar
|
||||
:image="student?.member_image"
|
||||
:label="student?.member_name"
|
||||
size="xl"
|
||||
/>
|
||||
<div>
|
||||
<div class="font-semibold">
|
||||
{{ student?.member_name }}
|
||||
</div>
|
||||
<div class="text-ink-gray-5">
|
||||
{{ student.member }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-25 space-y-2">
|
||||
<div class="text-ink-gray-5 text-sm">
|
||||
{{ Math.round(student.progress) }}% {{ __('completed') }}
|
||||
</div>
|
||||
<ProgressBar
|
||||
:label="__('Course Progress')"
|
||||
:progress="student.progress"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-5" :class="hasAssessmentData ? 'grid-cols-2' : ''">
|
||||
<div
|
||||
v-if="lessons.data"
|
||||
class="border border-outline-gray-modals rounded-lg px-3 pt-3 max-h-[60vh] overflow-y-auto"
|
||||
>
|
||||
<div>
|
||||
<div class="text-ink-gray-5 mb-5">
|
||||
{{ __('Lesson Progress') }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-for="progress in lessons.data"
|
||||
class="flex justify-between text-sm py-2 my-1"
|
||||
>
|
||||
<div class="">
|
||||
<span class="mr-3 text-xs">
|
||||
{{ progress.chapter_idx }}.{{ progress.idx }}
|
||||
</span>
|
||||
<span>
|
||||
{{ progress.title }}
|
||||
</span>
|
||||
</div>
|
||||
<Tooltip
|
||||
v-if="getLessonStatus(progress) == 'Complete'"
|
||||
:text="__('Complete')"
|
||||
>
|
||||
<Check class="text-ink-green-3 size-4" />
|
||||
</Tooltip>
|
||||
<Tooltip v-else :text="__('Pending')">
|
||||
<Minus class="text-ink-amber-2 size-4" />
|
||||
</Tooltip>
|
||||
<!-- <Badge :theme="getLessonStatusTheme(progress)">
|
||||
{{ getLessonStatus(progress) }}
|
||||
</Badge> -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-if="assessmentProgress.data?.quizzes?.length"
|
||||
class="border border-outline-gray-modals rounded-lg px-3 pt-3 h-fit"
|
||||
>
|
||||
<div>
|
||||
<div class="text-ink-gray-5 mb-5">
|
||||
{{ __('Quiz Progress') }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-for="quiz in assessmentProgress.data.quizzes"
|
||||
class="flex justify-between text-sm py-2 my-1"
|
||||
>
|
||||
<div>
|
||||
{{ quiz.quiz_title }}
|
||||
</div>
|
||||
<div>
|
||||
{{ quiz.score }}
|
||||
</div>
|
||||
<div>{{ quiz.percentage }}%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="assessmentProgress.data?.assignments?.length"
|
||||
class="border border-outline-gray-modals rounded-lg px-3 pt-3 h-fit"
|
||||
>
|
||||
<div>
|
||||
<div class="text-ink-gray-5 mb-5">
|
||||
{{ __('Assignment Progress') }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-for="assignment in assessmentProgress.data.assignments"
|
||||
class="flex justify-between text-sm py-2 my-1"
|
||||
>
|
||||
<div>
|
||||
{{ assignment.assignment_title }}
|
||||
</div>
|
||||
<Badge :theme="getAssessmentStatusTheme(assignment.status)">
|
||||
{{ assignment.status }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="assessmentProgress.data?.exercises?.length"
|
||||
class="border border-outline-gray-modals rounded-lg px-3 pt-3 h-fit"
|
||||
>
|
||||
<div>
|
||||
<div class="text-ink-gray-5 mb-5">
|
||||
{{ __('Programming Exercise Progress') }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-for="exercise in assessmentProgress.data.exercises"
|
||||
class="flex justify-between text-sm py-2 my-1"
|
||||
>
|
||||
<div>
|
||||
{{ exercise.exercise_title }}
|
||||
</div>
|
||||
<Badge :theme="getAssessmentStatusTheme(exercise.status)">
|
||||
{{ exercise.status }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
createListResource,
|
||||
createResource,
|
||||
Dialog,
|
||||
Tooltip,
|
||||
} from 'frappe-ui'
|
||||
import ProgressBar from '@/components/ProgressBar.vue'
|
||||
import { computed } from 'vue'
|
||||
import { Check, Minus } from 'lucide-vue-next'
|
||||
|
||||
const show = defineModel<boolean>({ required: true, default: false })
|
||||
|
||||
const props = defineProps<{
|
||||
course: any
|
||||
student: any
|
||||
lessons: any
|
||||
}>()
|
||||
|
||||
const lessonProgress = createListResource({
|
||||
doctype: 'LMS Course Progress',
|
||||
filters: {
|
||||
course: ['=', props.course.data?.name],
|
||||
member: ['=', props.student?.member],
|
||||
},
|
||||
fields: ['name', 'lesson', 'status'],
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const assessmentProgress = createResource({
|
||||
url: 'lms.lms.api.get_course_assessment_progress',
|
||||
params: {
|
||||
course: props.course.data?.name,
|
||||
member: props.student?.member,
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const getLessonStatus = (lesson: any) => {
|
||||
return (
|
||||
lessonProgress.data?.find((lp: any) => lp.lesson === lesson.lesson)
|
||||
?.status || __('Pending')
|
||||
)
|
||||
}
|
||||
|
||||
const getLessonStatusTheme = (lesson: any) => {
|
||||
const status = getLessonStatus(lesson)
|
||||
if (status === 'Complete') {
|
||||
return 'green'
|
||||
} else {
|
||||
return 'orange'
|
||||
}
|
||||
}
|
||||
|
||||
const getAssessmentStatusTheme = (status: string) => {
|
||||
if (status.includes('Pass')) return 'green'
|
||||
else if (status.includes('Fail')) return 'red'
|
||||
else return 'orange'
|
||||
}
|
||||
|
||||
const hasAssessmentData = computed(() => {
|
||||
return (
|
||||
(assessmentProgress.data?.quizzes &&
|
||||
assessmentProgress.data.quizzes.length > 0) ||
|
||||
(assessmentProgress.data?.assignments &&
|
||||
assessmentProgress.data.assignments.length > 0) ||
|
||||
(assessmentProgress.data?.exercises &&
|
||||
assessmentProgress.data.exercises.length > 0)
|
||||
)
|
||||
})
|
||||
</script>
|
||||
@@ -60,8 +60,15 @@ const currentTab = ref<'student' | 'instructor'>('instructor')
|
||||
const showStreakModal = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
call('lms.lms.utils.get_upcoming_evals').then((data: any) => {
|
||||
evalCount.value = data.length
|
||||
call('frappe.client.get_count', {
|
||||
doctype: 'LMS Certificate Request',
|
||||
filters: {
|
||||
member: user?.data?.name,
|
||||
status: 'Upcoming',
|
||||
date: ['>=', inject<any>('$dayjs')().format('YYYY-MM-DD')],
|
||||
},
|
||||
}).then((data: any) => {
|
||||
evalCount.value = data
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,78 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="myCourses.data?.length" class="mt-10">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-10 md:gap-5 mt-10">
|
||||
<UpcomingEvaluations :forHome="true" />
|
||||
<div v-if="myLiveClasses.data?.length">
|
||||
<div class="font-semibold text-lg mb-3 text-ink-gray-9">
|
||||
{{ __('Upcoming Live Classes') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<div
|
||||
v-for="cls in myLiveClasses.data"
|
||||
class="border rounded-md hover:border-outline-gray-3 p-2"
|
||||
>
|
||||
<div class="font-semibold text-ink-gray-9 text-lg leading-5 mb-1">
|
||||
{{ cls.title }}
|
||||
</div>
|
||||
<div class="text-ink-gray-5 leading-5 mb-4">
|
||||
{{ cls.description }}
|
||||
</div>
|
||||
<div class="mt-auto space-y-4 text-ink-gray-7">
|
||||
<div class="flex items-center space-x-2">
|
||||
<Calendar class="w-4 h-4 stroke-1.5" />
|
||||
<span>
|
||||
{{ dayjs(cls.date).format('DD MMMM YYYY') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<Clock class="w-4 h-4 stroke-1.5" />
|
||||
<span>
|
||||
{{ formatTime(cls.time) }} -
|
||||
{{ dayjs(getClassEnd(cls)).format('HH:mm A') }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="canAccessClass(cls)"
|
||||
class="flex items-center space-x-2 text-ink-gray-9 mt-auto"
|
||||
>
|
||||
<a
|
||||
v-if="user.data?.is_moderator || user.data?.is_evaluator"
|
||||
:href="cls.start_url"
|
||||
target="_blank"
|
||||
class="cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-ink-gray-8 bg-surface-gray-2 hover:bg-surface-gray-3 active:bg-surface-gray-4 focus-visible:ring focus-visible:ring-outline-gray-3 h-7 text-base px-2 rounded"
|
||||
:class="cls.join_url ? 'w-full' : 'w-1/2'"
|
||||
>
|
||||
<Monitor class="h-4 w-4 stroke-1.5" />
|
||||
{{ __('Start') }}
|
||||
</a>
|
||||
<a
|
||||
:href="cls.join_url"
|
||||
target="_blank"
|
||||
class="w-full cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-ink-gray-8 bg-surface-gray-2 hover:bg-surface-gray-3 active:bg-surface-gray-4 focus-visible:ring focus-visible:ring-outline-gray-3 h-7 text-base px-2 rounded"
|
||||
>
|
||||
<Video class="h-4 w-4 stroke-1.5" />
|
||||
{{ __('Join') }}
|
||||
</a>
|
||||
</div>
|
||||
<Tooltip
|
||||
v-else-if="hasClassEnded(cls)"
|
||||
:text="__('This class has ended')"
|
||||
placement="right"
|
||||
>
|
||||
<div class="flex items-center space-x-2 text-ink-amber-3 w-fit">
|
||||
<Info class="w-4 h-4 stroke-1.5" />
|
||||
<span>
|
||||
{{ __('Ended') }}
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="myCourses.data?.length">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<span class="font-semibold text-lg text-ink-gray-9">
|
||||
{{
|
||||
@@ -63,78 +135,6 @@
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-10 md:gap-5 mt-10">
|
||||
<UpcomingEvaluations :forHome="true" />
|
||||
<div v-if="myLiveClasses.data?.length">
|
||||
<div class="font-semibold text-lg mb-3 text-ink-gray-9">
|
||||
{{ __('Upcoming Live Classes') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<div
|
||||
v-for="cls in myLiveClasses.data"
|
||||
class="border rounded-md hover:border-outline-gray-3 p-2"
|
||||
>
|
||||
<div class="font-semibold text-ink-gray-9 text-lg leading-5 mb-1">
|
||||
{{ cls.title }}
|
||||
</div>
|
||||
<div class="text-ink-gray-7 text-sm leading-5 mb-4">
|
||||
{{ cls.description }}
|
||||
</div>
|
||||
<div class="mt-auto space-y-3 text-ink-gray-7 text-sm">
|
||||
<div class="flex items-center space-x-2">
|
||||
<Calendar class="w-4 h-4 stroke-1.5" />
|
||||
<span>
|
||||
{{ dayjs(cls.date).format('DD MMMM YYYY') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<Clock class="w-4 h-4 stroke-1.5" />
|
||||
<span>
|
||||
{{ formatTime(cls.time) }} -
|
||||
{{ dayjs(getClassEnd(cls)).format('HH:mm A') }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="canAccessClass(cls)"
|
||||
class="flex items-center space-x-2 text-ink-gray-9 mt-auto"
|
||||
>
|
||||
<a
|
||||
v-if="user.data?.is_moderator || user.data?.is_evaluator"
|
||||
:href="cls.start_url"
|
||||
target="_blank"
|
||||
class="cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-ink-gray-8 bg-surface-gray-2 hover:bg-surface-gray-3 active:bg-surface-gray-4 focus-visible:ring focus-visible:ring-outline-gray-3 h-7 text-base px-2 rounded"
|
||||
:class="cls.join_url ? 'w-full' : 'w-1/2'"
|
||||
>
|
||||
<Monitor class="h-4 w-4 stroke-1.5" />
|
||||
{{ __('Start') }}
|
||||
</a>
|
||||
<a
|
||||
:href="cls.join_url"
|
||||
target="_blank"
|
||||
class="w-full cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-ink-gray-8 bg-surface-gray-2 hover:bg-surface-gray-3 active:bg-surface-gray-4 focus-visible:ring focus-visible:ring-outline-gray-3 h-7 text-base px-2 rounded"
|
||||
>
|
||||
<Video class="h-4 w-4 stroke-1.5" />
|
||||
{{ __('Join') }}
|
||||
</a>
|
||||
</div>
|
||||
<Tooltip
|
||||
v-else-if="hasClassEnded(cls)"
|
||||
:text="__('This class has ended')"
|
||||
placement="right"
|
||||
>
|
||||
<div class="flex items-center space-x-2 text-ink-amber-3 w-fit">
|
||||
<Info class="w-4 h-4 stroke-1.5" />
|
||||
<span>
|
||||
{{ __('Ended') }}
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
|
||||
@@ -141,7 +141,7 @@
|
||||
@change="(val) => (emailForm.message = val)"
|
||||
:editable="true"
|
||||
:fixedMenu="true"
|
||||
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
|
||||
editorClass="prose-sm max-w-none border-b border-x border-outline-gray-modals bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -101,7 +101,7 @@
|
||||
@change="(val) => (job.description = val)"
|
||||
:editable="true"
|
||||
:fixedMenu="true"
|
||||
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem] mb-4"
|
||||
editorClass="prose-sm max-w-none border-b border-x border-outline-gray-modals bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem] mb-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -298,11 +298,11 @@ const jobStatuses = computed(() => {
|
||||
const breadcrumbs = computed(() => {
|
||||
let crumbs = [
|
||||
{
|
||||
label: 'Jobs',
|
||||
label: __('Jobs'),
|
||||
route: { name: 'Jobs' },
|
||||
},
|
||||
{
|
||||
label: props.jobName == 'new' ? 'New Job' : 'Edit Job',
|
||||
label: props.jobName == 'new' ? __('New Job') : __('Edit Job'),
|
||||
route: { name: 'JobForm' },
|
||||
},
|
||||
]
|
||||
@@ -311,7 +311,7 @@ const breadcrumbs = computed(() => {
|
||||
|
||||
usePageMeta(() => {
|
||||
return {
|
||||
title: props.jobName == 'new' ? 'New Job' : jobDetail.data?.job_title,
|
||||
title: props.jobName == 'new' ? __('New Job') : jobDetail.data?.job_title,
|
||||
icon: brand.favicon,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
</template>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Button v-if="canSeeStats()" @click="showVideoStats()">
|
||||
<Button v-if="isAdmin" @click="showVideoStats()">
|
||||
<template #icon>
|
||||
<TrendingUp class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
@@ -326,7 +326,7 @@
|
||||
@updateNotes="updateNotes"
|
||||
/>
|
||||
<VideoStatistics
|
||||
v-if="showStatsDialog"
|
||||
v-if="isAdmin"
|
||||
v-model="showStatsDialog"
|
||||
:lessonName="lesson.data?.name"
|
||||
:lessonTitle="lesson.data?.title"
|
||||
@@ -524,7 +524,14 @@ const renderEditor = (holder, content) => {
|
||||
|
||||
const markProgress = () => {
|
||||
if (user.data && lesson.data && !lesson.data.progress) {
|
||||
progress.submit()
|
||||
progress.submit(
|
||||
{},
|
||||
{
|
||||
onError(err) {
|
||||
console.error(err)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -559,12 +566,12 @@ const notes = createListResource({
|
||||
})
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
let items = [{ label: 'Courses', route: { name: 'Courses' } }]
|
||||
items.push({
|
||||
let crumbs = [{ label: __('Courses'), route: { name: 'Courses' } }]
|
||||
crumbs.push({
|
||||
label: lesson?.data?.course_title,
|
||||
route: { name: 'CourseDetail', params: { courseName: props.courseName } },
|
||||
})
|
||||
items.push({
|
||||
crumbs.push({
|
||||
label: lesson?.data?.title,
|
||||
route: {
|
||||
name: 'Lesson',
|
||||
@@ -575,7 +582,7 @@ const breadcrumbs = computed(() => {
|
||||
},
|
||||
},
|
||||
})
|
||||
return items
|
||||
return crumbs
|
||||
})
|
||||
|
||||
const switchLesson = (direction) => {
|
||||
@@ -605,7 +612,6 @@ watch(
|
||||
plyrSources.value = []
|
||||
await nextTick()
|
||||
resetLessonState(newChapterNumber, newLessonNumber)
|
||||
startTimer()
|
||||
updateNotes()
|
||||
checkIfDiscussionsAllowed()
|
||||
checkQuiz()
|
||||
@@ -674,6 +680,7 @@ watch(
|
||||
() => lesson.data,
|
||||
async (data) => {
|
||||
setupLesson(data)
|
||||
startTimer()
|
||||
getPlyrSource()
|
||||
updateNotes()
|
||||
if (data.icon == 'icon-youtube') clearInterval(timerInterval)
|
||||
@@ -769,17 +776,19 @@ const checkIfDiscussionsAllowed = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const isAdmin = computed(() => {
|
||||
let isInstructor = lesson.data?.instructors?.includes(user.data?.name)
|
||||
return user.data?.is_moderator || isInstructor
|
||||
})
|
||||
|
||||
const allowEdit = () => {
|
||||
if (window.read_only_mode) return false
|
||||
if (user.data?.is_moderator) return true
|
||||
if (lesson.data?.instructors?.includes(user.data?.name)) return true
|
||||
return false
|
||||
return isAdmin.value
|
||||
}
|
||||
|
||||
const allowInstructorContent = () => {
|
||||
if (user.data?.is_moderator) return true
|
||||
if (lesson.data?.instructors?.includes(user.data?.name)) return true
|
||||
return false
|
||||
if (window.read_only_mode) return false
|
||||
return isAdmin.value
|
||||
}
|
||||
|
||||
const enrollment = createResource({
|
||||
@@ -819,11 +828,6 @@ const toggleInlineMenu = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const canSeeStats = () => {
|
||||
if (user.data?.is_moderator || user.data?.is_instructor) return true
|
||||
return false
|
||||
}
|
||||
|
||||
const showVideoStats = () => {
|
||||
showStatsDialog.value = true
|
||||
}
|
||||
|
||||
@@ -466,7 +466,7 @@ const validateLesson = () => {
|
||||
const breadcrumbs = computed(() => {
|
||||
let crumbs = [
|
||||
{
|
||||
label: 'Courses',
|
||||
label: __('Courses'),
|
||||
route: { name: 'Courses' },
|
||||
},
|
||||
{
|
||||
@@ -493,7 +493,9 @@ const breadcrumbs = computed(() => {
|
||||
})
|
||||
}
|
||||
crumbs.push({
|
||||
label: lessonDetails?.data?.lesson ? 'Edit Lesson' : 'Create Lesson',
|
||||
label: lessonDetails?.data?.lesson
|
||||
? __('Edit Lesson')
|
||||
: __('Create Lesson'),
|
||||
route: {
|
||||
name: 'LessonForm',
|
||||
params: {
|
||||
@@ -510,7 +512,7 @@ usePageMeta(() => {
|
||||
return {
|
||||
title: lessonDetails?.data?.lesson
|
||||
? lessonDetails.data.lesson.title
|
||||
: 'New Lesson',
|
||||
: __('New Lesson'),
|
||||
icon: brand.favicon,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -263,12 +263,17 @@ const isEvaluatorOrModerator = () => {
|
||||
}
|
||||
|
||||
const getTabButtons = () => {
|
||||
let buttons = [{ label: 'About' }, { label: 'Certificates' }]
|
||||
if ($user.data?.is_moderator) buttons.push({ label: 'Roles' })
|
||||
let buttons = [
|
||||
{ label: __('About'), value: 'About' },
|
||||
{ label: __('Certificates'), value: 'Certificates' },
|
||||
]
|
||||
if ($user.data?.is_moderator) {
|
||||
buttons.push({ label: __('Roles'), value: 'Roles' })
|
||||
}
|
||||
|
||||
if (currentUserHasHigherAccess() && isEvaluatorOrModerator()) {
|
||||
buttons.push({ label: 'Slots' })
|
||||
buttons.push({ label: 'Schedule' })
|
||||
buttons.push({ label: __('Slots'), value: 'Slots' })
|
||||
buttons.push({ label: __('Schedule'), value: 'Schedule' })
|
||||
}
|
||||
return buttons
|
||||
}
|
||||
@@ -288,7 +293,7 @@ const navigateTo = (url) => {
|
||||
const breadcrumbs = computed(() => {
|
||||
let crumbs = [
|
||||
{
|
||||
label: 'People',
|
||||
label: __('People'),
|
||||
},
|
||||
{
|
||||
label: profile.data?.full_name,
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
@change="(val: string) => (exercise.problem_statement = val)"
|
||||
:editable="true"
|
||||
:fixedMenu="true"
|
||||
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem] max-h-[21rem] overflow-y-auto"
|
||||
editorClass="prose-sm max-w-none border-b border-x border-outline-gray-modals bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem] max-h-[21rem] overflow-y-auto"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -229,7 +229,7 @@ const setupSCORMAPI = () => {
|
||||
const breadcrumbs = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: 'Courses',
|
||||
label: __('Courses'),
|
||||
route: { name: 'Courses' },
|
||||
},
|
||||
{
|
||||
|
||||
@@ -150,7 +150,7 @@ const { brand } = sessionStore()
|
||||
const breadcrumbs = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: 'Statistics',
|
||||
label: __('Statistics'),
|
||||
route: {
|
||||
name: 'Statistics',
|
||||
},
|
||||
|
||||
+15
-13
@@ -162,20 +162,21 @@ export function getEditorTools() {
|
||||
config: {
|
||||
services: {
|
||||
youtube: {
|
||||
regex: /(?:https?:\/\/)?(?:www\.)?(?:(?:youtu\.be\/)|(?:youtube\.com)\/(?:v\/|u\/\w\/|embed\/|watch))(?:(?:\?v=)?([^#&?=]*))?((?:[?&]\w*=\w*)*)/,
|
||||
regex: /^(?:https?:\/\/)?(?:www\.)?(?:(?:youtu\.be\/)|(?:youtube\.com)\/(?:v\/|u\/\w\/|embed\/|watch))(?:(?:\?v=)?([^#&?=]*))?((?:[?&]\w*=\w*)*)$/,
|
||||
embedUrl: '<%= remote_id %>',
|
||||
/* 'https://www.youtube.com/embed/<%= remote_id %>?origin=https://plyr.io&iv_load_policy=3&modestbranding=1&playsinline=1&showinfo=0&rel=0&enablejsapi=1' */
|
||||
html: `<div class="video-player" data-plyr-provider="youtube"></div>`,
|
||||
id: ([id]) => id,
|
||||
},
|
||||
vimeo: {
|
||||
regex: /(?:http[s]?:\/\/)?(?:www\.)?vimeo\.com\/(\d+)/,
|
||||
embedUrl: '<%= remote_id %>',
|
||||
regex: /^(?:http[s]?:\/\/)?(?:www\.)?vimeo\.com\/(\d+)(?:\/([a-zA-Z0-9]+))?(?:\?[^\s]*)?$/,
|
||||
embedUrl:
|
||||
'https://player.vimeo.com/video/<%= remote_id %>',
|
||||
html: `<div class="video-player" data-plyr-provider="vimeo"></div>`,
|
||||
id: ([id]) => id,
|
||||
id: ([id, hash]) => (hash ? `${id}?h=${hash}` : id),
|
||||
},
|
||||
cloudflareStream: {
|
||||
regex: /https:\/\/customer-[a-z0-9]+\.cloudflarestream\.com\/([a-f0-9]{32})\/watch/,
|
||||
regex: /^https:\/\/customer-[a-z0-9]+\.cloudflarestream\.com\/([a-f0-9]{32})\/watch$/,
|
||||
embedUrl:
|
||||
'https://iframe.videodelivery.net/<%= remote_id %>',
|
||||
html: `<iframe style="width:100%; height: ${
|
||||
@@ -183,7 +184,7 @@ export function getEditorTools() {
|
||||
};" frameborder="0" allowfullscreen></iframe>`,
|
||||
},
|
||||
bunnyStream: {
|
||||
regex: /https:\/\/(?:iframe\.mediadelivery\.net|video\.bunnycdn\.com)\/play\/([a-zA-Z0-9]+\/[a-zA-Z0-9-]+)/,
|
||||
regex: /^https:\/\/(?:iframe\.mediadelivery\.net|video\.bunnycdn\.com)\/play\/([a-zA-Z0-9]+\/[a-zA-Z0-9-]+)$/,
|
||||
embedUrl:
|
||||
'https://iframe.mediadelivery.net/embed/<%= remote_id %>',
|
||||
html: `<iframe style="width:100%; height: ${
|
||||
@@ -192,7 +193,7 @@ export function getEditorTools() {
|
||||
},
|
||||
codepen: true,
|
||||
aparat: {
|
||||
regex: /(?:http[s]?:\/\/)?(?:www.)?aparat\.com\/v\/([^\/\?\&]+)\/?/,
|
||||
regex: /^(?:http[s]?:\/\/)?(?:www.)?aparat\.com\/v\/([^\/\?\&]+)\/?$/,
|
||||
embedUrl:
|
||||
'https://www.aparat.com/video/video/embed/videohash/<%= remote_id %>/vt/frame',
|
||||
html: `<iframe style="margin: 0 auto; width: 100%; height: ${
|
||||
@@ -201,7 +202,7 @@ export function getEditorTools() {
|
||||
},
|
||||
github: true,
|
||||
slides: {
|
||||
regex: /https:\/\/docs\.google\.com\/presentation\/d\/([A-Za-z0-9_-]+)\/pub/,
|
||||
regex: /^https:\/\/docs\.google\.com\/presentation\/d\/([A-Za-z0-9_-]+)\/pub$/,
|
||||
embedUrl:
|
||||
'https://docs.google.com/presentation/d/<%= remote_id %>/embed',
|
||||
html: `<iframe style='width: 100%; height: ${
|
||||
@@ -209,7 +210,7 @@ export function getEditorTools() {
|
||||
}; border: 1px solid #D3D3D3; border-radius: 12px; margin: 1rem 0' frameborder='0' allowfullscreen='true'></iframe>`,
|
||||
},
|
||||
drive: {
|
||||
regex: /https:\/\/drive\.google\.com\/file\/d\/([A-Za-z0-9_-]+)\/view(\?.+)?/,
|
||||
regex: /^https:\/\/drive\.google\.com\/file\/d\/([A-Za-z0-9_-]+)\/view(\?.+)?$/,
|
||||
embedUrl:
|
||||
'https://drive.google.com/file/d/<%= remote_id %>/preview',
|
||||
html: `<iframe style='width: 100%; height: ${
|
||||
@@ -217,19 +218,19 @@ export function getEditorTools() {
|
||||
}; border: 1px solid #D3D3D3; border-radius: 12px;' frameborder='0' allowfullscreen='true'></iframe>`,
|
||||
},
|
||||
docsPublic: {
|
||||
regex: /https:\/\/docs\.google\.com\/document\/d\/([A-Za-z0-9_-]+)\/edit(\?.+)?/,
|
||||
regex: /^https:\/\/docs\.google\.com\/document\/d\/([A-Za-z0-9_-]+)\/edit(\?.+)?$/,
|
||||
embedUrl:
|
||||
'https://docs.google.com/document/d/<%= remote_id %>/preview',
|
||||
html: "<iframe style='width: 100%; height: 40rem; border: 1px solid #D3D3D3; border-radius: 12px;' frameborder='0' allowfullscreen='true'></iframe>",
|
||||
},
|
||||
sheetsPublic: {
|
||||
regex: /https:\/\/docs\.google\.com\/spreadsheets\/d\/([A-Za-z0-9_-]+)\/edit(\?.+)?/,
|
||||
regex: /^https:\/\/docs\.google\.com\/spreadsheets\/d\/([A-Za-z0-9_-]+)\/edit(\?.+)?$/,
|
||||
embedUrl:
|
||||
'https://docs.google.com/spreadsheets/d/<%= remote_id %>/preview',
|
||||
html: "<iframe style='width: 100%; height: 40rem; border: 1px solid #D3D3D3; border-radius: 12px;' frameborder='0' allowfullscreen='true'></iframe>",
|
||||
},
|
||||
slidesPublic: {
|
||||
regex: /https:\/\/docs\.google\.com\/presentation\/d\/([A-Za-z0-9_-]+)\/edit(\?.+)?/,
|
||||
regex: /^https:\/\/docs\.google\.com\/presentation\/d\/([A-Za-z0-9_-]+)\/edit(\?.+)?$/,
|
||||
embedUrl:
|
||||
'https://docs.google.com/presentation/d/<%= remote_id %>/embed',
|
||||
html: "<iframe style='width: 100%; height: 30rem; border: 1px solid #D3D3D3; border-radius: 12px; margin: 1rem 0;' frameborder='0' allowfullscreen='true'></iframe>",
|
||||
@@ -513,7 +514,8 @@ const getSidebarItems = () => {
|
||||
: settings.data?.contact_us_email,
|
||||
condition: () => {
|
||||
return (
|
||||
settings?.data?.contact_us_email ||
|
||||
(settings?.data?.contact_us_email &&
|
||||
userResource?.data) ||
|
||||
settings?.data?.contact_us_url
|
||||
)
|
||||
},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { CodeXml } from 'lucide-vue-next'
|
||||
import { createApp, h } from 'vue'
|
||||
import { escapeHTML } from '@/utils'
|
||||
|
||||
export class Markdown {
|
||||
constructor({ data, api, readOnly, config }) {
|
||||
@@ -301,7 +302,7 @@ export class Markdown {
|
||||
_parseInlineMarkdown(text) {
|
||||
if (!text) return ''
|
||||
|
||||
let html = this._escapeHtml(text)
|
||||
let html = escapeHTML(text)
|
||||
|
||||
html = html.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>')
|
||||
|
||||
@@ -316,15 +317,6 @@ export class Markdown {
|
||||
return html
|
||||
}
|
||||
|
||||
_escapeHtml(text) {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
_togglePlaceholder() {
|
||||
const blocks = document.querySelectorAll(
|
||||
'.cdx-block.ce-paragraph[data-placeholder]'
|
||||
@@ -429,16 +421,6 @@ export class Markdown {
|
||||
return { alt: '', url: '' }
|
||||
}
|
||||
|
||||
_isLink(text) {
|
||||
return /\[.+?\]\(.+?\)/.test(text)
|
||||
}
|
||||
|
||||
_extractLink(text) {
|
||||
const match = text.match(/\[(.+?)\]\((.+?)\)/)
|
||||
if (match) return { text: match[1], url: match[2] }
|
||||
return { text: '', url: '' }
|
||||
}
|
||||
|
||||
_isEmbed(text) {
|
||||
return /^https?:\/\/.+/.test(text.trim())
|
||||
}
|
||||
|
||||
+626
-633
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1 +1 @@
|
||||
__version__ = "2.44.0"
|
||||
__version__ = "2.45.2"
|
||||
|
||||
@@ -47,6 +47,7 @@ ALLOWED_PATHS = [
|
||||
"/api/method/frappe.core.doctype.user.user.reset_password",
|
||||
"/api/method/frappe.desk.doctype.notification_log.notification_log.mark_as_read",
|
||||
"/api/method/frappe.desk.doctype.notification_log.notification_log.mark_all_as_read",
|
||||
"/api/method/frappe.sessions.clear",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ def search_sqlite(query: str):
|
||||
return prepare_search_results(result)
|
||||
|
||||
|
||||
def prepare_search_results(result):
|
||||
def prepare_search_results(result: dict):
|
||||
groups = get_grouped_results(result)
|
||||
|
||||
out = []
|
||||
|
||||
+2
-1
@@ -3,7 +3,7 @@ import frappe
|
||||
from . import __version__ as app_version
|
||||
|
||||
app_name = "frappe_lms"
|
||||
app_title = "Frappe LMS"
|
||||
app_title = "Learning"
|
||||
app_publisher = "Frappe"
|
||||
app_description = "Frappe LMS App"
|
||||
app_icon_url = "/assets/lms/images/lms-logo.png"
|
||||
@@ -277,3 +277,4 @@ add_to_apps_screen = [
|
||||
|
||||
sqlite_search = ["lms.sqlite.LearningSearch"]
|
||||
auth_hooks = ["lms.auth.authenticate"]
|
||||
require_type_annotated_api_methods = True
|
||||
|
||||
+34
-7
@@ -7,6 +7,7 @@ from lms.lms.api import give_discussions_permission
|
||||
def after_install():
|
||||
create_batch_source()
|
||||
give_discussions_permission()
|
||||
give_user_list_permission()
|
||||
|
||||
|
||||
def after_sync():
|
||||
@@ -27,13 +28,6 @@ def create_lms_roles():
|
||||
create_lms_student_role()
|
||||
|
||||
|
||||
def delete_lms_roles():
|
||||
roles = ["Course Creator", "Moderator"]
|
||||
for role in roles:
|
||||
if frappe.db.exists("Role", role):
|
||||
frappe.db.delete("Role", role)
|
||||
|
||||
|
||||
def create_course_creator_role():
|
||||
if frappe.db.exists("Role", "Course Creator"):
|
||||
frappe.db.set_value("Role", "Course Creator", "desk_access", 0)
|
||||
@@ -185,3 +179,36 @@ def give_lms_roles_to_admin():
|
||||
doc.parentfield = "roles"
|
||||
doc.role = role
|
||||
doc.save()
|
||||
|
||||
|
||||
def give_user_list_permission():
|
||||
doctype = "User"
|
||||
roles = ["Course Creator", "Moderator", "Batch Evaluator"]
|
||||
for role in roles:
|
||||
permlevel = 0
|
||||
create_role(doctype, role, permlevel)
|
||||
create_role(doctype, "System Manager", 1)
|
||||
|
||||
|
||||
def create_role(doctype, role, permlevel):
|
||||
if not frappe.db.exists("Custom DocPerm", {"parent": doctype, "role": role, "permlevel": permlevel}):
|
||||
doc = frappe.new_doc("Custom DocPerm")
|
||||
doc.update(
|
||||
{
|
||||
"doctype": "Custom DocPerm",
|
||||
"parent": doctype,
|
||||
"role": role,
|
||||
"read": 1,
|
||||
"write": 1 if role in ["Moderator", "System Manager"] else 0,
|
||||
"create": 1 if role == "Moderator" else 0,
|
||||
"permlevel": permlevel,
|
||||
}
|
||||
)
|
||||
doc.save()
|
||||
|
||||
|
||||
def delete_lms_roles():
|
||||
roles = ["Course Creator", "Moderator", "Batch Evaluator", "LMS Student"]
|
||||
for role in roles:
|
||||
if frappe.db.exists("Role", role):
|
||||
frappe.db.delete("Role", role)
|
||||
|
||||
@@ -35,7 +35,7 @@ def update_job_openings():
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def report(job, reason):
|
||||
def report(job: str, reason: str):
|
||||
system_managers = get_system_managers(only_name=True)
|
||||
user = frappe.db.get_value("User", frappe.session.user, "full_name")
|
||||
subject = _("User {0} has reported the job post {1}").format(user, job)
|
||||
|
||||
+235
-101
@@ -80,7 +80,7 @@ def get_translations():
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def validate_billing_access(billing_type, name):
|
||||
def validate_billing_access(billing_type: str, name: str):
|
||||
doctype = "LMS Batch" if billing_type == "batch" else "LMS Course"
|
||||
access, message = verify_billing_access(doctype, name, billing_type)
|
||||
|
||||
@@ -160,7 +160,7 @@ def verify_billing_access(doctype, name, billing_type):
|
||||
|
||||
|
||||
@frappe.whitelist(allow_guest=True)
|
||||
def get_job_details(job):
|
||||
def get_job_details(job: str):
|
||||
return frappe.db.get_value(
|
||||
"Job Opportunity",
|
||||
job,
|
||||
@@ -183,7 +183,7 @@ def get_job_details(job):
|
||||
|
||||
|
||||
@frappe.whitelist(allow_guest=True)
|
||||
def get_job_opportunities(filters=None, orFilters=None):
|
||||
def get_job_opportunities(filters: dict = None, orFilters: dict = None):
|
||||
if not filters:
|
||||
filters = {}
|
||||
|
||||
@@ -257,7 +257,7 @@ def get_branding():
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_unsplash_photos(keyword=None):
|
||||
def get_unsplash_photos(keyword: str = None):
|
||||
from lms.unsplash import get_by_keyword, get_list
|
||||
|
||||
if keyword:
|
||||
@@ -267,7 +267,7 @@ def get_unsplash_photos(keyword=None):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_evaluator_details(evaluator):
|
||||
def get_evaluator_details(evaluator: str):
|
||||
frappe.only_for("Batch Evaluator")
|
||||
|
||||
if not frappe.db.exists("Google Calendar", {"user": evaluator}):
|
||||
@@ -294,7 +294,7 @@ def get_evaluator_details(evaluator):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_certified_participants(filters=None, start=0, page_length=100):
|
||||
def get_certified_participants(filters: dict = None, start: int = 0, page_length: int = 100):
|
||||
query = get_certification_query(filters)
|
||||
query = query.orderby("issue_date", order=frappe.qb.desc).offset(start).limit(page_length)
|
||||
participants = query.run(as_dict=True)
|
||||
@@ -306,7 +306,7 @@ def get_certified_participants(filters=None, start=0, page_length=100):
|
||||
return participants
|
||||
|
||||
|
||||
def get_certified_participant_details(member):
|
||||
def get_certified_participant_details(member: str):
|
||||
count = frappe.db.count("LMS Certificate", {"member": member})
|
||||
details = frappe.db.get_value(
|
||||
"User",
|
||||
@@ -318,13 +318,13 @@ def get_certified_participant_details(member):
|
||||
return details
|
||||
|
||||
|
||||
def get_certification_query(filters):
|
||||
def get_certification_query(filters: dict = None):
|
||||
Certificate = frappe.qb.DocType("LMS Certificate")
|
||||
User = frappe.qb.DocType("User")
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(Certificate)
|
||||
.select(Certificate.member)
|
||||
.select(Certificate.member, Certificate.issue_date)
|
||||
.distinct()
|
||||
.join(User)
|
||||
.on(Certificate.member == User.name)
|
||||
@@ -348,7 +348,7 @@ def get_certification_query(filters):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_count_of_certified_members(filters=None):
|
||||
def get_count_of_certified_members(filters: dict = None):
|
||||
query = get_certification_query(filters)
|
||||
result = query.run(as_dict=True)
|
||||
return len(result) or 0
|
||||
@@ -424,7 +424,7 @@ def get_sidebar_settings():
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def update_sidebar_item(webpage, icon):
|
||||
def update_sidebar_item(webpage: str, icon: str):
|
||||
frappe.only_for("Moderator")
|
||||
filters = {
|
||||
"web_page": webpage,
|
||||
@@ -443,7 +443,7 @@ def update_sidebar_item(webpage, icon):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def delete_sidebar_item(webpage):
|
||||
def delete_sidebar_item(webpage: str):
|
||||
frappe.only_for("Moderator")
|
||||
return frappe.db.delete(
|
||||
"LMS Sidebar Item",
|
||||
@@ -457,7 +457,7 @@ def delete_sidebar_item(webpage):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def delete_lesson(lesson, chapter):
|
||||
def delete_lesson(lesson: str, chapter: str):
|
||||
course = frappe.db.get_value("Course Chapter", chapter, "course")
|
||||
if not can_modify_course(course):
|
||||
frappe.throw(_("You do not have permission to delete this lesson."), frappe.PermissionError)
|
||||
@@ -477,7 +477,7 @@ def delete_lesson(lesson, chapter):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def update_lesson_index(lesson, sourceChapter, targetChapter, idx):
|
||||
def update_lesson_index(lesson: str, sourceChapter: str, targetChapter: str, idx: int):
|
||||
course = frappe.db.get_value("Course Chapter", sourceChapter, "course")
|
||||
if not can_modify_course(course):
|
||||
frappe.throw(_("You do not have permission to modify this lesson."), frappe.PermissionError)
|
||||
@@ -488,7 +488,7 @@ def update_lesson_index(lesson, sourceChapter, targetChapter, idx):
|
||||
update_target_chapter(lesson, targetChapter, idx)
|
||||
|
||||
|
||||
def update_source_chapter(lesson, chapter, idx, hasMoved=False):
|
||||
def update_source_chapter(lesson: str, chapter: str, idx: int, hasMoved: bool = False):
|
||||
lessons = frappe.get_all(
|
||||
"Lesson Reference",
|
||||
{
|
||||
@@ -507,7 +507,7 @@ def update_source_chapter(lesson, chapter, idx, hasMoved=False):
|
||||
update_index(lessons, chapter)
|
||||
|
||||
|
||||
def update_target_chapter(lesson, chapter, idx):
|
||||
def update_target_chapter(lesson: str, chapter: str, idx: int):
|
||||
lessons = frappe.get_all(
|
||||
"Lesson Reference",
|
||||
{
|
||||
@@ -531,7 +531,7 @@ def update_target_chapter(lesson, chapter, idx):
|
||||
update_index(lessons, chapter)
|
||||
|
||||
|
||||
def update_index(lessons, chapter):
|
||||
def update_index(lessons: list, chapter: str):
|
||||
for row in lessons:
|
||||
frappe.db.set_value(
|
||||
"Lesson Reference", {"lesson": row, "parent": chapter}, "idx", lessons.index(row) + 1
|
||||
@@ -539,7 +539,7 @@ def update_index(lessons, chapter):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def update_chapter_index(chapter, course, idx):
|
||||
def update_chapter_index(chapter: str, course: str, idx: int):
|
||||
"""Update the index of a chapter within a course"""
|
||||
|
||||
if not can_modify_course(course):
|
||||
@@ -562,7 +562,7 @@ def update_chapter_index(chapter, course, idx):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_members(start=0, search=""):
|
||||
def get_members(start: int = 0, search: str = None):
|
||||
frappe.only_for(["Moderator"])
|
||||
filters = {"enabled": 1, "name": ["not in", ["Administrator", "Guest"]]}
|
||||
or_filters = {}
|
||||
@@ -616,16 +616,16 @@ def check_app_permission():
|
||||
|
||||
@frappe.whitelist()
|
||||
def save_evaluation_details(
|
||||
member,
|
||||
course,
|
||||
batch_name,
|
||||
evaluator,
|
||||
date,
|
||||
start_time,
|
||||
end_time,
|
||||
status,
|
||||
rating,
|
||||
summary,
|
||||
member: str,
|
||||
course: str,
|
||||
date: str,
|
||||
start_time: str,
|
||||
end_time: str,
|
||||
status: str,
|
||||
batch_name: str = None,
|
||||
evaluator: str = None,
|
||||
rating: float = 0,
|
||||
summary: str = None,
|
||||
):
|
||||
"""
|
||||
Save evaluation details for a member against a course.
|
||||
@@ -662,14 +662,14 @@ def save_evaluation_details(
|
||||
|
||||
@frappe.whitelist()
|
||||
def save_certificate_details(
|
||||
member,
|
||||
course,
|
||||
batch_name,
|
||||
evaluator,
|
||||
issue_date,
|
||||
expiry_date,
|
||||
template,
|
||||
published=True,
|
||||
member: str,
|
||||
issue_date: str,
|
||||
template: str,
|
||||
course: str = None,
|
||||
batch_name: str = None,
|
||||
evaluator: str = None,
|
||||
expiry_date: str = None,
|
||||
published: bool = True,
|
||||
):
|
||||
"""
|
||||
Save certificate details for a member against a course.
|
||||
@@ -703,14 +703,14 @@ def save_certificate_details(
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def delete_documents(doctype, documents):
|
||||
def delete_documents(doctype: str, documents: list):
|
||||
frappe.only_for("Moderator")
|
||||
for doc in documents:
|
||||
frappe.delete_doc(doctype, doc)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_payment_gateway_details(payment_gateway):
|
||||
def get_payment_gateway_details(payment_gateway: str):
|
||||
frappe.only_for("Moderator")
|
||||
gateway = frappe.get_doc("Payment Gateway", payment_gateway)
|
||||
|
||||
@@ -741,7 +741,7 @@ def get_payment_gateway_details(payment_gateway):
|
||||
}
|
||||
|
||||
|
||||
def get_transformed_fields(meta, data=None):
|
||||
def get_transformed_fields(meta: list, data: dict = None):
|
||||
transformed_fields = []
|
||||
for row in meta:
|
||||
if row.fieldtype not in ["Column Break", "Section Break"]:
|
||||
@@ -766,7 +766,7 @@ def get_transformed_fields(meta, data=None):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_new_gateway_fields(doctype):
|
||||
def get_new_gateway_fields(doctype: str):
|
||||
frappe.only_for("Moderator")
|
||||
try:
|
||||
meta = frappe.get_meta(doctype).fields
|
||||
@@ -797,7 +797,7 @@ def update_course_statistics():
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_announcements(batch):
|
||||
def get_announcements(batch: str):
|
||||
roles = frappe.get_roles()
|
||||
is_batch_student = frappe.db.exists(
|
||||
"LMS Batch Enrollment", {"batch": batch, "member": frappe.session.user}
|
||||
@@ -835,7 +835,7 @@ def get_announcements(batch):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def delete_course(course):
|
||||
def delete_course(course: str):
|
||||
if not can_modify_course(course):
|
||||
frappe.throw(_("You do not have permission to delete this course."), frappe.PermissionError)
|
||||
|
||||
@@ -872,7 +872,7 @@ def delete_course(course):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def delete_batch(batch):
|
||||
def delete_batch(batch: str):
|
||||
if not can_modify_batch(batch):
|
||||
frappe.throw(_("You do not have permission to delete this batch."), frappe.PermissionError)
|
||||
|
||||
@@ -885,7 +885,7 @@ def delete_batch(batch):
|
||||
frappe.db.delete("LMS Batch", batch)
|
||||
|
||||
|
||||
def delete_batch_discussions(batch):
|
||||
def delete_batch_discussions(batch: str):
|
||||
topics = frappe.get_all(
|
||||
"Discussion Topic",
|
||||
{"reference_doctype": "LMS Batch", "reference_docname": batch},
|
||||
@@ -914,11 +914,13 @@ def give_discussions_permission():
|
||||
"delete": 1,
|
||||
"if_owner": 0 if role == "Moderator" else 1,
|
||||
}
|
||||
).save(ignore_permissions=True)
|
||||
).save()
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def upsert_chapter(title, course, is_scorm_package, scorm_package, name=None):
|
||||
def upsert_chapter(
|
||||
title: str, course: str, is_scorm_package: bool, scorm_package: dict = None, name: str = None
|
||||
):
|
||||
if not can_modify_course(course):
|
||||
frappe.throw(_("You do not have permission to modify this chapter."), frappe.PermissionError)
|
||||
|
||||
@@ -951,7 +953,7 @@ def upsert_chapter(title, course, is_scorm_package, scorm_package, name=None):
|
||||
return chapter
|
||||
|
||||
|
||||
def extract_package(course, title, scorm_package):
|
||||
def extract_package(course: str, title: str, scorm_package: dict):
|
||||
package = frappe.get_doc("File", scorm_package.name)
|
||||
zip_path = package.get_full_path()
|
||||
# check_for_malicious_code(zip_path)
|
||||
@@ -983,7 +985,7 @@ def check_for_malicious_code(zip_path):
|
||||
frappe.throw(_("Suspicious pattern found in {0}: {1}").format(file_name, pattern))
|
||||
|
||||
|
||||
def get_manifest_file(extract_path):
|
||||
def get_manifest_file(extract_path: str):
|
||||
manifest_file = None
|
||||
for root, _dirs, files in os.walk(extract_path):
|
||||
for file in files:
|
||||
@@ -995,7 +997,7 @@ def get_manifest_file(extract_path):
|
||||
return manifest_file
|
||||
|
||||
|
||||
def get_launch_file(extract_path):
|
||||
def get_launch_file(extract_path: str):
|
||||
launch_file = None
|
||||
manifest_file = get_manifest_file(extract_path)
|
||||
|
||||
@@ -1018,7 +1020,7 @@ def get_launch_file(extract_path):
|
||||
return launch_file
|
||||
|
||||
|
||||
def add_lesson(title, chapter, course, idx):
|
||||
def add_lesson(title: str, chapter: str, course: str, idx: int):
|
||||
lesson = frappe.new_doc("Course Lesson")
|
||||
lesson.update(
|
||||
{
|
||||
@@ -1043,7 +1045,7 @@ def add_lesson(title, chapter, course, idx):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def delete_chapter(chapter):
|
||||
def delete_chapter(chapter: str):
|
||||
course = frappe.db.get_value("Course Chapter", chapter, "course")
|
||||
if not can_modify_course(course):
|
||||
frappe.throw(_("You do not have permission to delete this chapter."), frappe.PermissionError)
|
||||
@@ -1074,14 +1076,14 @@ def delete_chapter(chapter):
|
||||
i += 1
|
||||
|
||||
|
||||
def delete_scorm_package(scorm_package_path):
|
||||
def delete_scorm_package(scorm_package_path: str):
|
||||
scorm_package_path = frappe.get_site_path("public", scorm_package_path[1:])
|
||||
if os.path.exists(scorm_package_path):
|
||||
shutil.rmtree(scorm_package_path)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def mark_lesson_progress(course, chapter_number, lesson_number):
|
||||
def mark_lesson_progress(course: str, chapter_number: int, lesson_number: int):
|
||||
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"
|
||||
@@ -1090,7 +1092,7 @@ def mark_lesson_progress(course, chapter_number, lesson_number):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_heatmap_data(member, base_days=200):
|
||||
def get_heatmap_data(member: str, base_days: int = 200):
|
||||
if not (has_course_instructor_role() or has_moderator_role() or has_evaluator_role()):
|
||||
frappe.throw(_("You do not have permission to access heatmap data."), frappe.PermissionError)
|
||||
|
||||
@@ -1114,7 +1116,7 @@ def get_heatmap_data(member, base_days=200):
|
||||
}
|
||||
|
||||
|
||||
def calculate_date_ranges(base_days):
|
||||
def calculate_date_ranges(base_days: int):
|
||||
today = format_date(now(), "YYYY-MM-dd")
|
||||
day_today = get_datetime(today).strftime("%w")
|
||||
padding_end = 6 - cint(day_today)
|
||||
@@ -1128,11 +1130,11 @@ def calculate_date_ranges(base_days):
|
||||
return base_date, start_date, number_of_days, days
|
||||
|
||||
|
||||
def initialize_date_count(days):
|
||||
def initialize_date_count(days: list):
|
||||
return {format_date(day, "YYYY-MM-dd"): 0 for day in days}
|
||||
|
||||
|
||||
def fetch_activity_data(member, start_date):
|
||||
def fetch_activity_data(member: str, start_date: str):
|
||||
lesson_completions = frappe.get_all(
|
||||
"LMS Course Progress",
|
||||
fields=["creation"],
|
||||
@@ -1154,14 +1156,14 @@ def fetch_activity_data(member, start_date):
|
||||
return lesson_completions, quiz_submissions, assignment_submissions
|
||||
|
||||
|
||||
def count_dates(data, date_count):
|
||||
def count_dates(data: list, date_count: dict):
|
||||
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):
|
||||
def prepare_heatmap_data(start_date: str, number_of_days: int, date_count: dict):
|
||||
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)
|
||||
@@ -1198,13 +1200,13 @@ def prepare_heatmap_data(start_date, number_of_days, date_count):
|
||||
return formatted_heatmap_data, labels, total_activities, week_count
|
||||
|
||||
|
||||
def get_week_difference(start_date, current_date):
|
||||
def get_week_difference(start_date: str, current_date: str) -> int:
|
||||
diff_in_days = date_diff(current_date, start_date)
|
||||
return diff_in_days // 7
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_notifications(filters):
|
||||
def get_notifications(filters: dict = None):
|
||||
filters = frappe._dict(filters or {})
|
||||
filters.for_user = frappe.session.user
|
||||
notifications = frappe.get_all(
|
||||
@@ -1232,7 +1234,7 @@ def get_notifications(filters):
|
||||
return notifications
|
||||
|
||||
|
||||
def update_user_details(notification):
|
||||
def update_user_details(notification: dict) -> dict:
|
||||
if (
|
||||
notification.document_details
|
||||
and len(notification.document_details.get("instructors", []))
|
||||
@@ -1247,7 +1249,7 @@ def update_user_details(notification):
|
||||
return notification
|
||||
|
||||
|
||||
def is_mention(notification):
|
||||
def is_mention(notification: dict) -> bool:
|
||||
if notification.type == "Mention":
|
||||
return True
|
||||
if "mentioned you" in notification.subject.lower():
|
||||
@@ -1255,7 +1257,7 @@ def is_mention(notification):
|
||||
return False
|
||||
|
||||
|
||||
def update_document_details(notification):
|
||||
def update_document_details(notification: dict) -> dict:
|
||||
if notification.document_type == "LMS Course":
|
||||
details = frappe.db.get_value(
|
||||
"LMS Course", notification.document_name, ["title", "video_link", "short_introduction"], as_dict=1
|
||||
@@ -1304,8 +1306,9 @@ def get_lms_settings():
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def cancel_evaluation(evaluation):
|
||||
def cancel_evaluation(evaluation: dict):
|
||||
evaluation = frappe._dict(evaluation)
|
||||
print(evaluation.member, frappe.session.user)
|
||||
if evaluation.member != frappe.session.user:
|
||||
frappe.throw(_("You do not have permission to cancel this evaluation."), frappe.PermissionError)
|
||||
|
||||
@@ -1336,7 +1339,7 @@ def cancel_evaluation(evaluation):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_certification_details(course):
|
||||
def get_certification_details(course: str):
|
||||
membership = None
|
||||
filters = {"course": course, "member": frappe.session.user}
|
||||
|
||||
@@ -1364,7 +1367,7 @@ def get_certification_details(course):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def save_role(user, role, value):
|
||||
def save_role(user: str, role: str, value: int):
|
||||
frappe.only_for("Moderator")
|
||||
if cint(value):
|
||||
doc = frappe.get_doc(
|
||||
@@ -1384,7 +1387,7 @@ def save_role(user, role, value):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def add_an_evaluator(email):
|
||||
def add_an_evaluator(email: str):
|
||||
frappe.only_for("Moderator")
|
||||
if not frappe.db.exists("User", email):
|
||||
user = frappe.new_doc("User")
|
||||
@@ -1406,7 +1409,7 @@ def add_an_evaluator(email):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def capture_user_persona(responses):
|
||||
def capture_user_persona(responses: str):
|
||||
frappe.only_for("System Manager")
|
||||
data = frappe.parse_json(responses)
|
||||
data = json.dumps(data)
|
||||
@@ -1420,7 +1423,7 @@ def capture_user_persona(responses):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_meta_info(type, route):
|
||||
def get_meta_info(type: str, route: str):
|
||||
if frappe.db.exists("Website Meta Tag", {"parent": f"{type}/{route}"}):
|
||||
meta_tags = frappe.get_all(
|
||||
"Website Meta Tag",
|
||||
@@ -1436,7 +1439,7 @@ def get_meta_info(type, route):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def update_meta_info(meta_type, route, meta_tags):
|
||||
def update_meta_info(meta_type: str, route: str, meta_tags: list):
|
||||
frappe.only_for(["Course Creator", "Batch Evaluator", "Moderator"])
|
||||
validate_meta_data_permissions(meta_type)
|
||||
validate_meta_tags(meta_tags)
|
||||
@@ -1473,12 +1476,12 @@ def update_meta_info(meta_type, route, meta_tags):
|
||||
create_meta_tag(tag_properties)
|
||||
|
||||
|
||||
def validate_meta_tags(meta_tags):
|
||||
def validate_meta_tags(meta_tags: list):
|
||||
if not isinstance(meta_tags, list):
|
||||
frappe.throw(_("Meta tags should be a list."))
|
||||
|
||||
|
||||
def create_meta(parent_name, tag_properties):
|
||||
def create_meta(parent_name: str, tag_properties: dict):
|
||||
route_meta = frappe.new_doc("Website Route Meta")
|
||||
route_meta.update(
|
||||
{
|
||||
@@ -1489,13 +1492,13 @@ def create_meta(parent_name, tag_properties):
|
||||
route_meta.insert()
|
||||
|
||||
|
||||
def create_meta_tag(tag_properties):
|
||||
def create_meta_tag(tag_properties: dict):
|
||||
new_tag = frappe.new_doc("Website Meta Tag")
|
||||
new_tag.update(tag_properties)
|
||||
new_tag.insert()
|
||||
|
||||
|
||||
def validate_meta_data_permissions(meta_type):
|
||||
def validate_meta_data_permissions(meta_type: str):
|
||||
roles = frappe.get_roles()
|
||||
|
||||
if meta_type == "courses":
|
||||
@@ -1508,14 +1511,15 @@ def validate_meta_data_permissions(meta_type):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def create_programming_exercise_submission(exercise, submission, code, test_cases):
|
||||
def create_programming_exercise_submission(exercise: str, submission: str, code: str, test_cases: list):
|
||||
frappe.only_for(["Moderator", "Course Creator", "Batch Evaluator"])
|
||||
if submission == "new":
|
||||
return make_new_exercise_submission(exercise, code, test_cases)
|
||||
else:
|
||||
update_exercise_submission(submission, code, test_cases)
|
||||
|
||||
|
||||
def make_new_exercise_submission(exercise, code, test_cases):
|
||||
def make_new_exercise_submission(exercise: str, code: str, test_cases: list):
|
||||
submission = frappe.new_doc("LMS Programming Exercise Submission")
|
||||
submission.exercise = exercise
|
||||
submission.member = frappe.session.user
|
||||
@@ -1537,7 +1541,7 @@ def make_new_exercise_submission(exercise, code, test_cases):
|
||||
return submission.name
|
||||
|
||||
|
||||
def update_exercise_submission(submission, code, test_cases):
|
||||
def update_exercise_submission(submission: str, code: str, test_cases: list):
|
||||
member = frappe.db.get_value("LMS Programming Exercise Submission", submission, "member")
|
||||
if member != frappe.session.user:
|
||||
frappe.throw(_("You do not have permission to update this submission."), frappe.PermissionError)
|
||||
@@ -1547,7 +1551,7 @@ def update_exercise_submission(submission, code, test_cases):
|
||||
frappe.db.set_value("LMS Programming Exercise Submission", submission, {"status": status, "code": code})
|
||||
|
||||
|
||||
def get_exercise_status(test_cases):
|
||||
def get_exercise_status(test_cases: list):
|
||||
if not test_cases:
|
||||
return "Failed"
|
||||
|
||||
@@ -1557,7 +1561,7 @@ def get_exercise_status(test_cases):
|
||||
return "Failed"
|
||||
|
||||
|
||||
def update_test_cases(test_cases, submission):
|
||||
def update_test_cases(test_cases: list, submission: str):
|
||||
frappe.db.delete("LMS Test Case Submission", {"parent": submission})
|
||||
for row in test_cases:
|
||||
test_case = frappe.new_doc("LMS Test Case Submission")
|
||||
@@ -1576,7 +1580,7 @@ def update_test_cases(test_cases, submission):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def track_video_watch_duration(lesson, videos):
|
||||
def track_video_watch_duration(lesson: str, videos: list):
|
||||
"""
|
||||
Track the watch duration of videos in a lesson.
|
||||
"""
|
||||
@@ -1603,7 +1607,7 @@ def track_video_watch_duration(lesson, videos):
|
||||
track_new_watch_time(lesson, video)
|
||||
|
||||
|
||||
def track_new_watch_time(lesson, video):
|
||||
def track_new_watch_time(lesson: str, video: dict):
|
||||
doc = frappe.new_doc("LMS Video Watch Duration")
|
||||
doc.lesson = lesson
|
||||
doc.source = video.get("source")
|
||||
@@ -1613,7 +1617,7 @@ def track_new_watch_time(lesson, video):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_course_progress_distribution(course):
|
||||
def get_course_progress_distribution(course: str):
|
||||
if not can_modify_course(course):
|
||||
frappe.throw(
|
||||
_("You do not have permission to access this course's progress data."), frappe.PermissionError
|
||||
@@ -1636,14 +1640,14 @@ def get_course_progress_distribution(course):
|
||||
}
|
||||
|
||||
|
||||
def get_average_course_progress(progress_list):
|
||||
def get_average_course_progress(progress_list: list):
|
||||
if not progress_list:
|
||||
return 0
|
||||
average_progress = sum(progress_list) / len(progress_list)
|
||||
return flt(average_progress, frappe.get_system_settings("float_precision") or 3)
|
||||
|
||||
|
||||
def get_progress_distribution(progressList):
|
||||
def get_progress_distribution(progressList: list):
|
||||
distribution = [
|
||||
{
|
||||
"name": "Just Started (0-30%)",
|
||||
@@ -1654,8 +1658,12 @@ def get_progress_distribution(progressList):
|
||||
"value": len([p for p in progressList if 30 <= p < 60]),
|
||||
},
|
||||
{
|
||||
"name": "Advanced (60-100%)",
|
||||
"value": len([p for p in progressList if 60 <= p <= 100]),
|
||||
"name": "Advanced (60-99%)",
|
||||
"value": len([p for p in progressList if 60 <= p < 100]),
|
||||
},
|
||||
{
|
||||
"name": "Completed (100%)",
|
||||
"value": len([p for p in progressList if p == 100]),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -1686,7 +1694,7 @@ def get_pwa_manifest():
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_profile_details(username):
|
||||
def get_profile_details(username: str):
|
||||
details = frappe.db.get_value(
|
||||
"User",
|
||||
{"username": username},
|
||||
@@ -1725,7 +1733,7 @@ def get_streak_info():
|
||||
}
|
||||
|
||||
|
||||
def fetch_activity_dates(user):
|
||||
def fetch_activity_dates(user: str):
|
||||
doctypes = [
|
||||
"LMS Course Progress",
|
||||
"LMS Quiz Submission",
|
||||
@@ -1740,7 +1748,7 @@ def fetch_activity_dates(user):
|
||||
return sorted({d.date() if hasattr(d, "date") else d for d in all_dates})
|
||||
|
||||
|
||||
def calculate_streaks(all_dates):
|
||||
def calculate_streaks(all_dates: list):
|
||||
streak = 0
|
||||
longest_streak = 0
|
||||
prev_day = None
|
||||
@@ -1764,7 +1772,7 @@ def calculate_streaks(all_dates):
|
||||
return streak, longest_streak
|
||||
|
||||
|
||||
def calculate_current_streak(all_dates, streak):
|
||||
def calculate_current_streak(all_dates: list, streak: int):
|
||||
if not all_dates:
|
||||
return 0
|
||||
|
||||
@@ -2030,14 +2038,14 @@ def get_upcoming_batches():
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def delete_programming_exercise(exercise):
|
||||
frappe.only_for(["Moderator", "Course Creator"])
|
||||
def delete_programming_exercise(exercise: str):
|
||||
frappe.only_for(["Moderator", "Course Creator", "Batch Evaluator"])
|
||||
frappe.db.delete("LMS Programming Exercise Submission", {"exercise": exercise})
|
||||
frappe.db.delete("LMS Programming Exercise", exercise)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_lesson_completion_stats(course):
|
||||
def get_lesson_completion_stats(course: str):
|
||||
roles = frappe.get_roles()
|
||||
if "Course Creator" not in roles and "Moderator" not in roles:
|
||||
frappe.throw(_("You do not have permission to access lesson completion stats."))
|
||||
@@ -2048,13 +2056,17 @@ def get_lesson_completion_stats(course):
|
||||
Lesson = frappe.qb.DocType("Course Lesson")
|
||||
|
||||
rows = (
|
||||
frappe.qb.from_(CourseProgress)
|
||||
.join(LessonReference)
|
||||
.on(CourseProgress.lesson == LessonReference.lesson)
|
||||
frappe.qb.from_(LessonReference)
|
||||
.join(ChapterReference)
|
||||
.on(LessonReference.parent == ChapterReference.chapter)
|
||||
.join(Lesson)
|
||||
.on(CourseProgress.lesson == Lesson.name)
|
||||
.on(LessonReference.lesson == Lesson.name)
|
||||
.left_join(CourseProgress)
|
||||
.on(
|
||||
(CourseProgress.lesson == LessonReference.lesson)
|
||||
& (CourseProgress.course == course)
|
||||
& (CourseProgress.status == "Complete")
|
||||
)
|
||||
.select(
|
||||
LessonReference.idx,
|
||||
ChapterReference.idx.as_("chapter_idx"),
|
||||
@@ -2063,10 +2075,132 @@ def get_lesson_completion_stats(course):
|
||||
Lesson.name.as_("lesson_name"),
|
||||
fn.Count(CourseProgress.name).as_("completion_count"),
|
||||
)
|
||||
.where((CourseProgress.course == course) & (CourseProgress.status == "Complete"))
|
||||
.groupby(CourseProgress.lesson)
|
||||
.where(ChapterReference.parent == course)
|
||||
.groupby(LessonReference.lesson)
|
||||
.orderby(ChapterReference.idx, LessonReference.idx)
|
||||
.run(as_dict=True)
|
||||
)
|
||||
|
||||
return rows
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_course_assessment_progress(course: str, member: str):
|
||||
if not can_modify_course(course):
|
||||
frappe.throw(
|
||||
_("You do not have permission to access this course's assessment data."), frappe.PermissionError
|
||||
)
|
||||
|
||||
quizzes = get_course_quiz_progress(course, member)
|
||||
assignments = get_course_assignment_progress(course, member)
|
||||
programming_exercises = get_course_programming_exercise_progress(course, member)
|
||||
|
||||
return {
|
||||
"quizzes": quizzes,
|
||||
"assignments": assignments,
|
||||
"exercises": programming_exercises,
|
||||
}
|
||||
|
||||
|
||||
def get_course_quiz_progress(course: str, member: str):
|
||||
quizzes = get_assessment_from_lesson(course, "quiz")
|
||||
attempts = []
|
||||
|
||||
for quiz in quizzes:
|
||||
submissions = frappe.get_all(
|
||||
"LMS Quiz Submission",
|
||||
{
|
||||
"quiz": quiz,
|
||||
"member": member,
|
||||
},
|
||||
["name", "score", "percentage", "quiz", "quiz_title"],
|
||||
order_by="creation desc",
|
||||
limit=1,
|
||||
)
|
||||
if len(submissions):
|
||||
attempts.append(submissions[0])
|
||||
else:
|
||||
attempts.append(
|
||||
{
|
||||
"quiz": quiz,
|
||||
"quiz_title": frappe.db.get_value("LMS Quiz", quiz, "title"),
|
||||
"score": 0,
|
||||
"percentage": 0,
|
||||
}
|
||||
)
|
||||
|
||||
return attempts
|
||||
|
||||
|
||||
def get_course_assignment_progress(course: str, member: str):
|
||||
assignments = get_assessment_from_lesson(course, "assignment")
|
||||
submissions = []
|
||||
|
||||
for assignment in assignments:
|
||||
assignment_subs = frappe.get_all(
|
||||
"LMS Assignment Submission",
|
||||
{
|
||||
"assignment": assignment,
|
||||
"member": member,
|
||||
},
|
||||
["name", "status", "assignment", "assignment_title"],
|
||||
order_by="creation desc",
|
||||
limit=1,
|
||||
)
|
||||
if len(assignment_subs):
|
||||
submissions.append(assignment_subs[0])
|
||||
else:
|
||||
submissions.append(
|
||||
{
|
||||
"assignment": assignment,
|
||||
"assignment_title": frappe.db.get_value("LMS Assignment", assignment, "title"),
|
||||
"status": "Not Submitted",
|
||||
}
|
||||
)
|
||||
|
||||
return submissions
|
||||
|
||||
|
||||
def get_course_programming_exercise_progress(course: str, member: str):
|
||||
exercises = get_assessment_from_lesson(course, "program")
|
||||
submissions = []
|
||||
|
||||
for exercise in exercises:
|
||||
exercise_subs = frappe.get_all(
|
||||
"LMS Programming Exercise Submission",
|
||||
{
|
||||
"exercise": exercise,
|
||||
"member": member,
|
||||
},
|
||||
["name", "status", "exercise", "exercise_title"],
|
||||
order_by="creation desc",
|
||||
limit=1,
|
||||
)
|
||||
if len(exercise_subs):
|
||||
submissions.append(exercise_subs[0])
|
||||
else:
|
||||
submissions.append(
|
||||
{
|
||||
"exercise": exercise,
|
||||
"exercise_title": frappe.db.get_value("LMS Programming Exercise", exercise, "title"),
|
||||
"status": "Not Attempted",
|
||||
}
|
||||
)
|
||||
|
||||
return submissions
|
||||
|
||||
|
||||
def get_assessment_from_lesson(course: str, assessmentType: str):
|
||||
assessments = []
|
||||
lessons = frappe.get_all("Course Lesson", {"course": course}, ["name", "title", "content"])
|
||||
|
||||
for lesson in lessons:
|
||||
if lesson.content:
|
||||
content = json.loads(lesson.content)
|
||||
for block in content.get("blocks", []):
|
||||
if block.get("type") == assessmentType:
|
||||
data_field = "exercise" if assessmentType == "program" else assessmentType
|
||||
quiz_name = block.get("data", {}).get(data_field)
|
||||
assessments.append(quiz_name)
|
||||
|
||||
return assessments
|
||||
|
||||
@@ -1,9 +1,39 @@
|
||||
# Copyright (c) 2021, FOSS United and Contributors
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
import unittest
|
||||
import frappe
|
||||
|
||||
from lms.lms.api import delete_chapter
|
||||
from lms.lms.test_helpers import BaseTestUtils
|
||||
|
||||
|
||||
class TestCourseChapter(unittest.TestCase):
|
||||
pass
|
||||
class TestCourseChapter(BaseTestUtils):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.instructor = self._create_user(
|
||||
"frappe@example.com", "Frappe", "Admin", ["Moderator", "Course Creator"]
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
return super().tearDown()
|
||||
|
||||
def test_chapter_deletion_and_renumbering(self):
|
||||
course = self._create_course(f"Test Renumbering Course {frappe.generate_hash()[:8]}")
|
||||
chapters = []
|
||||
|
||||
for i in range(1, 4):
|
||||
chapter = self._create_chapter(f"Chapter {i}", course.name)
|
||||
chapters.append(chapter)
|
||||
self._create_chapter_reference(course.name, chapter.name, i)
|
||||
self.assertEqual(self._get_chapter_index(course.name, chapter.name), i)
|
||||
|
||||
delete_chapter(chapters[1].name)
|
||||
|
||||
idx_ch1 = self._get_chapter_index(course.name, chapters[0].name)
|
||||
idx_ch3 = self._get_chapter_index(course.name, chapters[2].name)
|
||||
|
||||
self.assertEqual(idx_ch1, 1, "Chapter 1 index should remain 1")
|
||||
self.assertEqual(idx_ch3, 2, "Chapter 3 index should be renumbered to 2 after deleting Chapter 2")
|
||||
|
||||
def _get_chapter_index(self, course, chapter):
|
||||
return frappe.db.get_value("Chapter Reference", {"parent": course, "chapter": chapter}, "idx")
|
||||
|
||||
@@ -58,7 +58,7 @@ class CourseEvaluator(Document):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_schedule(course, batch=None):
|
||||
def get_schedule(course: str, batch: str = None):
|
||||
evaluator = get_evaluator(course, batch)
|
||||
start_date = nowdate()
|
||||
end_date = get_schedule_range_end_date(start_date, batch)
|
||||
|
||||
@@ -46,7 +46,7 @@ class CourseLesson(Document):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def save_progress(lesson, course, scorm_details=None):
|
||||
def save_progress(lesson: str, course: str, scorm_details: dict = None):
|
||||
"""
|
||||
Note: Pass the argument scorm_details as a dict if it is SCORM related save_progress
|
||||
"""
|
||||
@@ -103,7 +103,7 @@ def save_progress(lesson, course, scorm_details=None):
|
||||
)
|
||||
|
||||
progress = get_course_progress(course)
|
||||
capture_progress_for_analytics(progress, course)
|
||||
capture_progress_for_analytics()
|
||||
|
||||
# Had to get doc, as on_change doesn't trigger when you use set_value. The trigger is necessary for badge to get assigned.
|
||||
enrollment = frappe.get_doc("LMS Enrollment", membership)
|
||||
@@ -121,9 +121,8 @@ def save_progress(lesson, course, scorm_details=None):
|
||||
return progress
|
||||
|
||||
|
||||
def capture_progress_for_analytics(progress, course):
|
||||
if progress in [25, 50, 75, 100]:
|
||||
capture("course_progress", "lms", properties={"course": course, "progress": progress})
|
||||
def capture_progress_for_analytics():
|
||||
capture("course_progress", "lms")
|
||||
|
||||
|
||||
def get_quiz_progress(lesson):
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_import": 1,
|
||||
"allow_rename": 1,
|
||||
"autoname": "format: ASG-{#####}",
|
||||
"creation": "2023-05-26 19:41:26.025081",
|
||||
@@ -79,8 +80,13 @@
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2025-12-19 16:30:58.531722",
|
||||
"links": [
|
||||
{
|
||||
"link_doctype": "LMS Assignment Submission",
|
||||
"link_fieldname": "assignment"
|
||||
}
|
||||
],
|
||||
"modified": "2026-02-05 11:37:36.492016",
|
||||
"modified_by": "sayali@frappe.io",
|
||||
"module": "LMS",
|
||||
"name": "LMS Assignment",
|
||||
@@ -104,6 +110,7 @@
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"import": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
@@ -124,6 +131,7 @@
|
||||
"create": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"import": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
@@ -135,6 +143,7 @@
|
||||
"create": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"import": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
|
||||
@@ -9,18 +9,3 @@ from lms.lms.utils import has_course_instructor_role, has_moderator_role
|
||||
|
||||
class LMSAssignment(Document):
|
||||
pass
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def save_assignment(assignment, title, type, question):
|
||||
if not has_moderator_role() or not has_course_instructor_role():
|
||||
return
|
||||
|
||||
if assignment:
|
||||
doc = frappe.get_doc("LMS Assignment", assignment)
|
||||
else:
|
||||
doc = frappe.get_doc({"doctype": "LMS Assignment"})
|
||||
|
||||
doc.update({"title": title, "type": type, "question": question})
|
||||
doc.save(ignore_permissions=True)
|
||||
return doc.name
|
||||
|
||||
@@ -42,7 +42,8 @@
|
||||
"fieldname": "assignment",
|
||||
"fieldtype": "Link",
|
||||
"label": "Assignment",
|
||||
"options": "LMS Assignment"
|
||||
"options": "LMS Assignment",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "member",
|
||||
@@ -150,7 +151,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"make_attachments_public": 1,
|
||||
"modified": "2025-12-17 14:47:22.944223",
|
||||
"modified": "2026-02-05 11:38:03.792865",
|
||||
"modified_by": "sayali@frappe.io",
|
||||
"module": "LMS",
|
||||
"name": "LMS Assignment Submission",
|
||||
|
||||
@@ -78,79 +78,3 @@ class LMSAssignmentSubmission(Document):
|
||||
}
|
||||
)
|
||||
make_notification_logs(notification, [self.member])
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def upload_assignment(
|
||||
assignment_attachment=None,
|
||||
answer=None,
|
||||
assignment=None,
|
||||
lesson=None,
|
||||
status="Not Graded",
|
||||
comments=None,
|
||||
submission=None,
|
||||
):
|
||||
if frappe.session.user == "Guest":
|
||||
return
|
||||
|
||||
assignment_details = frappe.db.get_value(
|
||||
"LMS Assignment", assignment, ["type", "grade_assignment"], as_dict=1
|
||||
)
|
||||
assignment_type = assignment_details.type
|
||||
|
||||
if assignment_type in ["URL", "Text"] and not answer:
|
||||
frappe.throw(_("Please enter the URL for assignment submission."))
|
||||
|
||||
if assignment_type == "File" and not assignment_attachment:
|
||||
frappe.throw(_("Please upload the assignment file."))
|
||||
|
||||
if assignment_type == "URL" and not validate_url(answer):
|
||||
frappe.throw(_("Please enter a valid URL."))
|
||||
|
||||
if submission:
|
||||
doc = frappe.get_doc("LMS Assignment Submission", submission)
|
||||
else:
|
||||
doc = frappe.get_doc(
|
||||
{
|
||||
"doctype": "LMS Assignment Submission",
|
||||
"assignment": assignment,
|
||||
"lesson": lesson,
|
||||
"member": frappe.session.user,
|
||||
"type": assignment_type,
|
||||
}
|
||||
)
|
||||
|
||||
doc.update(
|
||||
{
|
||||
"assignment_attachment": assignment_attachment,
|
||||
"status": "Not Applicable"
|
||||
if assignment_type == "Text" and not assignment_details.grade_assignment
|
||||
else status,
|
||||
"comments": comments,
|
||||
"answer": answer,
|
||||
}
|
||||
)
|
||||
doc.save(ignore_permissions=True)
|
||||
return doc.name
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_assignment(lesson):
|
||||
assignment = frappe.db.get_value(
|
||||
"LMS Assignment Submission",
|
||||
{"lesson": lesson, "member": frappe.session.user},
|
||||
["name", "lesson", "member", "assignment_attachment", "comments", "status"],
|
||||
as_dict=True,
|
||||
)
|
||||
assignment.file_name = frappe.db.get_value(
|
||||
"File", {"file_url": assignment.assignment_attachment}, "file_name"
|
||||
)
|
||||
return assignment
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def grade_assignment(name, result, comments):
|
||||
doc = frappe.get_doc("LMS Assignment Submission", name)
|
||||
doc.status = result
|
||||
doc.comments = comments
|
||||
doc.save(ignore_permissions=True)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_import": 1,
|
||||
"allow_rename": 1,
|
||||
"autoname": "field:title",
|
||||
"creation": "2024-04-30 11:29:53.548647",
|
||||
@@ -99,8 +100,8 @@
|
||||
"link_fieldname": "badge"
|
||||
}
|
||||
],
|
||||
"modified": "2025-07-04 13:02:19.048994",
|
||||
"modified_by": "Administrator",
|
||||
"modified": "2026-02-03 10:52:37.122370",
|
||||
"modified_by": "sayali@frappe.io",
|
||||
"module": "LMS",
|
||||
"name": "LMS Badge",
|
||||
"naming_rule": "By fieldname",
|
||||
@@ -118,13 +119,26 @@
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"import": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Moderator",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "All",
|
||||
"role": "LMS Student",
|
||||
"share": 1
|
||||
}
|
||||
],
|
||||
|
||||
@@ -61,7 +61,7 @@ def eval_condition(doc, condition):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def assign_badge(badge):
|
||||
def assign_badge(badge: str, user: str):
|
||||
badge = frappe._dict(json.loads(badge))
|
||||
if not badge.event == "Auto Assign":
|
||||
return
|
||||
|
||||
@@ -12,6 +12,14 @@ frappe.ui.form.on("LMS Batch", {
|
||||
};
|
||||
});
|
||||
|
||||
frm.set_query("course", "courses", function () {
|
||||
return {
|
||||
filters: {
|
||||
published: 1,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
frm.set_query("assessment_type", "assessment", function () {
|
||||
let doctypes = ["LMS Quiz", "LMS Assignment"];
|
||||
return {
|
||||
|
||||
@@ -203,15 +203,15 @@ def send_system_notification_for_published_batch(batch):
|
||||
|
||||
@frappe.whitelist()
|
||||
def create_live_class(
|
||||
batch_name,
|
||||
zoom_account,
|
||||
title,
|
||||
duration,
|
||||
date,
|
||||
time,
|
||||
timezone,
|
||||
auto_recording,
|
||||
description=None,
|
||||
batch_name: str,
|
||||
zoom_account: str,
|
||||
title: str,
|
||||
duration: int,
|
||||
date: str,
|
||||
time: str,
|
||||
timezone: str,
|
||||
auto_recording: str,
|
||||
description: str = None,
|
||||
):
|
||||
payload = {
|
||||
"topic": title,
|
||||
@@ -280,7 +280,7 @@ def authenticate(zoom_account):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_batch_timetable(batch):
|
||||
def get_batch_timetable(batch: str):
|
||||
timetable = frappe.get_all(
|
||||
"LMS Batch Timetable",
|
||||
filters={"parent": batch},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_import": 1,
|
||||
"allow_rename": 1,
|
||||
"creation": "2025-02-10 11:17:12.462368",
|
||||
"doctype": "DocType",
|
||||
@@ -73,7 +74,7 @@
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2026-01-14 08:53:16.672825",
|
||||
"modified": "2026-02-03 10:51:28.475356",
|
||||
"modified_by": "sayali@frappe.io",
|
||||
"module": "LMS",
|
||||
"name": "LMS Batch Enrollment",
|
||||
@@ -96,6 +97,7 @@
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"import": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
@@ -114,6 +116,7 @@
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"import": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
|
||||
@@ -26,7 +26,7 @@ class LMSBatchEnrollment(Document):
|
||||
if self.owner == self.member:
|
||||
return
|
||||
|
||||
roles = frappe.get_roles(self.owner)
|
||||
roles = frappe.get_roles()
|
||||
if "Moderator" not in roles and "Batch Evaluator" not in roles:
|
||||
frappe.throw(_("You must be a Moderator or Batch Evaluator to enroll users in a batch."))
|
||||
|
||||
@@ -106,7 +106,7 @@ class LMSBatchEnrollment(Document):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def send_confirmation_email(doc):
|
||||
def send_confirmation_email(doc: Document):
|
||||
if isinstance(doc, str):
|
||||
doc = frappe._dict(json.loads(doc))
|
||||
|
||||
|
||||
@@ -18,8 +18,8 @@ class LMSCertificate(Document):
|
||||
self.name = make_autoname("hash", self.doctype)
|
||||
|
||||
def after_insert(self):
|
||||
self.send_certification_email()
|
||||
capture("certificate_issued", "lms")
|
||||
self.send_certification_email()
|
||||
|
||||
def send_certification_email(self):
|
||||
outgoing_email_account = frappe.get_cached_value(
|
||||
@@ -123,7 +123,7 @@ def is_certified(course):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def create_certificate(course):
|
||||
def create_certificate(course: str):
|
||||
if is_certified(course):
|
||||
return frappe.db.get_value(
|
||||
"LMS Certificate", certificate, ["name", "course", "template"], as_dict=True
|
||||
|
||||
@@ -25,7 +25,7 @@ def has_website_permission(doc, ptype, user, verbose=False):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def create_lms_certificate(source_name, target_doc=None):
|
||||
def create_lms_certificate(source_name: str, target_doc: dict = None):
|
||||
doc = get_mapped_doc(
|
||||
"LMS Certificate Evaluation",
|
||||
source_name,
|
||||
|
||||
@@ -174,7 +174,7 @@ def schedule_evals():
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def setup_calendar_event(eval):
|
||||
def setup_calendar_event(eval: str):
|
||||
if isinstance(eval, str):
|
||||
eval = frappe._dict(json.loads(eval))
|
||||
|
||||
@@ -186,7 +186,7 @@ def setup_calendar_event(eval):
|
||||
update_meeting_details(eval, event, calendar)
|
||||
|
||||
|
||||
def create_event(eval):
|
||||
def create_event(eval: dict):
|
||||
event = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Event",
|
||||
@@ -199,7 +199,7 @@ def create_event(eval):
|
||||
return event
|
||||
|
||||
|
||||
def add_participants(eval, event):
|
||||
def add_participants(eval: dict, event: Document):
|
||||
participants = [eval.member, eval.evaluator]
|
||||
for participant in participants:
|
||||
contact_name = frappe.db.get_value("Contact", {"email_id": participant}, "name")
|
||||
@@ -216,7 +216,7 @@ def add_participants(eval, event):
|
||||
).save()
|
||||
|
||||
|
||||
def update_meeting_details(eval, event, calendar):
|
||||
def update_meeting_details(eval: dict, event: Document, calendar: str):
|
||||
event.reload()
|
||||
event.update(
|
||||
{
|
||||
@@ -232,7 +232,7 @@ def update_meeting_details(eval, event, calendar):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def create_lms_certificate_evaluation(source_name, target_doc=None):
|
||||
def create_lms_certificate_evaluation(source_name: str, target_doc: dict = None):
|
||||
frappe.only_for(["Moderator", "Batch Evaluator", "System Manager"])
|
||||
doc = get_mapped_doc(
|
||||
"LMS Certificate Request",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_import": 1,
|
||||
"allow_rename": 1,
|
||||
"autoname": "hash",
|
||||
"creation": "2025-10-11 21:39:11.456420",
|
||||
@@ -113,7 +114,7 @@
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2025-10-27 19:52:11.835042",
|
||||
"modified": "2026-02-03 10:50:23.387175",
|
||||
"modified_by": "sayali@frappe.io",
|
||||
"module": "LMS",
|
||||
"name": "LMS Coupon",
|
||||
@@ -149,6 +150,7 @@
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"import": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
@@ -161,6 +163,7 @@
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"import": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
@@ -173,6 +176,7 @@
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"import": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
|
||||
@@ -7,15 +7,3 @@ from frappe.model.document import Document
|
||||
|
||||
class LMSCourseInterest(Document):
|
||||
pass
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def capture_interest(course):
|
||||
data = {
|
||||
"doctype": "LMS Course Interest",
|
||||
"course": course,
|
||||
"user": frappe.session.user,
|
||||
}
|
||||
if not frappe.db.exists(data):
|
||||
frappe.get_doc(data).save(ignore_permissions=True)
|
||||
return "OK"
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2026-01-29 16:10:47.787285",
|
||||
"modified": "2026-02-23 16:21:18.503806",
|
||||
"modified_by": "sayali@frappe.io",
|
||||
"module": "LMS",
|
||||
"name": "LMS Course Review",
|
||||
|
||||
@@ -20,16 +20,3 @@ class LMSCourseReview(Document):
|
||||
def validate_if_already_reviewed(self):
|
||||
if frappe.db.exists("LMS Course Review", {"course": self.course, "owner": self.owner}):
|
||||
frappe.throw(_("You have already reviewed this course"))
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def submit_review(rating, review, course):
|
||||
out_of_ratings = frappe.db.get_all(
|
||||
"DocField", {"parent": "LMS Course Review", "fieldtype": "Rating"}, ["options"]
|
||||
)
|
||||
out_of_ratings = (len(out_of_ratings) and out_of_ratings[0].options) or 5
|
||||
rating = cint(rating) / out_of_ratings
|
||||
frappe.get_doc(
|
||||
{"doctype": "LMS Course Review", "rating": rating, "review": review, "course": course}
|
||||
).save(ignore_permissions=True)
|
||||
return "OK"
|
||||
|
||||
@@ -11,6 +11,12 @@ class LMSEnrollment(Document):
|
||||
def before_insert(self):
|
||||
self.validate_duplicate_enrollment()
|
||||
self.validate_course_enrollment_eligibility()
|
||||
self.validate_owner()
|
||||
|
||||
def validate_owner(self):
|
||||
"""Makes the member as the owner of the document so that users can update their progress"""
|
||||
if self.owner != self.member:
|
||||
self.owner = self.member
|
||||
|
||||
def on_update(self):
|
||||
update_program_progress(self.member)
|
||||
@@ -21,6 +27,7 @@ class LMSEnrollment(Document):
|
||||
{
|
||||
"course": self.course,
|
||||
"member": self.member,
|
||||
"name": ["!=", self.name],
|
||||
},
|
||||
)
|
||||
|
||||
@@ -45,7 +52,7 @@ class LMSEnrollment(Document):
|
||||
if self.enrollment_from_batch:
|
||||
return
|
||||
|
||||
if not course_details.published:
|
||||
if not course_details.published and not is_admin():
|
||||
frappe.throw(_("You cannot enroll in an unpublished course."))
|
||||
|
||||
if course_details.paid_course:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_import": 1,
|
||||
"allow_rename": 1,
|
||||
"creation": "2023-03-02 10:59:01.741349",
|
||||
"default_view": "List",
|
||||
@@ -177,7 +178,7 @@
|
||||
"link_fieldname": "live_class"
|
||||
}
|
||||
],
|
||||
"modified": "2026-01-14 08:54:07.684781",
|
||||
"modified": "2026-02-03 10:54:39.198916",
|
||||
"modified_by": "sayali@frappe.io",
|
||||
"module": "LMS",
|
||||
"name": "LMS Live Class",
|
||||
@@ -200,6 +201,7 @@
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"import": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
@@ -221,6 +223,7 @@
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"import": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_import": 1,
|
||||
"allow_rename": 1,
|
||||
"creation": "2023-08-24 17:46:52.065763",
|
||||
"default_view": "List",
|
||||
@@ -201,7 +202,7 @@
|
||||
"link_fieldname": "payment"
|
||||
}
|
||||
],
|
||||
"modified": "2025-12-19 17:55:25.968384",
|
||||
"modified": "2026-02-03 10:54:12.361407",
|
||||
"modified_by": "sayali@frappe.io",
|
||||
"module": "LMS",
|
||||
"name": "LMS Payment",
|
||||
@@ -218,6 +219,19 @@
|
||||
"role": "System Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"import": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Moderator",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_import": 1,
|
||||
"allow_rename": 1,
|
||||
"autoname": "field:title",
|
||||
"creation": "2024-11-18 12:27:13.283169",
|
||||
@@ -92,7 +93,7 @@
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2025-12-04 12:56:14.249363",
|
||||
"modified": "2026-02-03 10:51:50.616781",
|
||||
"modified_by": "sayali@frappe.io",
|
||||
"module": "LMS",
|
||||
"name": "LMS Program",
|
||||
@@ -116,6 +117,7 @@
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"import": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
@@ -128,6 +130,7 @@
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"import": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_import": 1,
|
||||
"allow_rename": 1,
|
||||
"creation": "2025-06-18 15:02:36.198855",
|
||||
"doctype": "DocType",
|
||||
@@ -33,7 +34,7 @@
|
||||
"fieldname": "language",
|
||||
"fieldtype": "Select",
|
||||
"label": "Language",
|
||||
"options": "Python\nJavaScript",
|
||||
"options": "Python\nJavaScript\nRust\nGo",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
@@ -63,7 +64,7 @@
|
||||
"link_fieldname": "exercise"
|
||||
}
|
||||
],
|
||||
"modified": "2025-06-24 14:42:27.463492",
|
||||
"modified": "2026-02-03 10:45:23.687185",
|
||||
"modified_by": "sayali@frappe.io",
|
||||
"module": "LMS",
|
||||
"name": "LMS Programming Exercise",
|
||||
@@ -74,6 +75,7 @@
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"import": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
@@ -86,6 +88,7 @@
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"import": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
@@ -98,6 +101,7 @@
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"import": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
@@ -110,6 +114,7 @@
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"import": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
|
||||
@@ -93,18 +93,3 @@ def get_correct_options(question):
|
||||
correct_options.append(field)
|
||||
|
||||
return correct_options
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_question_details(question):
|
||||
if not has_course_instructor_role() or not has_moderator_role():
|
||||
return
|
||||
|
||||
fields = ["question", "type", "name"]
|
||||
for i in range(1, 5):
|
||||
fields.append(f"option_{i}")
|
||||
fields.append(f"is_correct_{i}")
|
||||
fields.append(f"explanation_{i}")
|
||||
fields.append(f"possibility_{i}")
|
||||
|
||||
return frappe.db.get_value("LMS Question", question, fields, as_dict=1)
|
||||
|
||||
@@ -93,7 +93,7 @@ class LMSQuiz(Document):
|
||||
return result[0]
|
||||
|
||||
|
||||
def set_total_marks(questions):
|
||||
def set_total_marks(questions: list) -> int:
|
||||
marks = 0
|
||||
for question in questions:
|
||||
marks += question.get("marks")
|
||||
@@ -101,7 +101,7 @@ def set_total_marks(questions):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def quiz_summary(quiz, results):
|
||||
def quiz_summary(quiz: str, results: str):
|
||||
results = results and json.loads(results)
|
||||
percentage = 0
|
||||
|
||||
@@ -141,7 +141,7 @@ def quiz_summary(quiz, results):
|
||||
}
|
||||
|
||||
|
||||
def process_results(results, quiz_details):
|
||||
def process_results(results: list, quiz_details: dict):
|
||||
score = 0
|
||||
is_open_ended = False
|
||||
|
||||
@@ -188,7 +188,7 @@ def process_results(results, quiz_details):
|
||||
}
|
||||
|
||||
|
||||
def _save_file(match):
|
||||
def _save_file(match: re.Match) -> str:
|
||||
data = match.group(1).split("data:")[1]
|
||||
headers, content = data.split(",")
|
||||
mtype = headers.split(";", 1)[0]
|
||||
@@ -231,7 +231,7 @@ def get_corrupted_image_msg():
|
||||
return _("Image: Corrupted Data Stream")
|
||||
|
||||
|
||||
def create_submission(quiz, results, score_out_of, passing_percentage):
|
||||
def create_submission(quiz: str, results: list, score_out_of: int, passing_percentage: float):
|
||||
submission = frappe.new_doc("LMS Quiz Submission")
|
||||
# Score and percentage are calculated by the controller function
|
||||
submission.update(
|
||||
@@ -250,7 +250,7 @@ def create_submission(quiz, results, score_out_of, passing_percentage):
|
||||
return submission
|
||||
|
||||
|
||||
def save_progress_after_quiz(quiz_details, percentage):
|
||||
def save_progress_after_quiz(quiz_details: dict, percentage: float):
|
||||
if percentage >= quiz_details.passing_percentage and quiz_details.lesson and quiz_details.course:
|
||||
save_progress(quiz_details.lesson, quiz_details.course)
|
||||
elif not quiz_details.passing_percentage:
|
||||
@@ -258,21 +258,7 @@ def save_progress_after_quiz(quiz_details, percentage):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_question_details(question):
|
||||
if frappe.db.exists("LMS Quiz Question", question):
|
||||
fields = ["name", "question", "type"]
|
||||
for num in range(1, 5):
|
||||
fields.append(f"option_{cstr(num)}")
|
||||
fields.append(f"is_correct_{cstr(num)}")
|
||||
fields.append(f"explanation_{cstr(num)}")
|
||||
fields.append(f"possibility_{cstr(num)}")
|
||||
|
||||
return frappe.db.get_value("LMS Quiz Question", question, fields, as_dict=1)
|
||||
return
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def check_answer(question, type, answers):
|
||||
def check_answer(question: str, type: str, answers: str):
|
||||
answers = json.loads(answers)
|
||||
if type == "Choices":
|
||||
return check_choice_answers(question, answers)
|
||||
@@ -280,7 +266,7 @@ def check_answer(question, type, answers):
|
||||
return check_input_answers(question, answers[0])
|
||||
|
||||
|
||||
def check_choice_answers(question, answers):
|
||||
def check_choice_answers(question: str, answers: list):
|
||||
fields = ["multiple"]
|
||||
is_correct = []
|
||||
for num in range(1, 5):
|
||||
@@ -300,7 +286,7 @@ def check_choice_answers(question, answers):
|
||||
return is_correct
|
||||
|
||||
|
||||
def check_input_answers(question, answer):
|
||||
def check_input_answers(question: str, answer: str):
|
||||
fields = []
|
||||
for num in range(1, 5):
|
||||
fields.append(f"possibility_{cstr(num)}")
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user