Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions codecov.yml
Original file line number Diff line number Diff line change
@@ -1,2 +1,9 @@
coverage:
status:
project:
default:
target: auto
threshold: 1%

ignore:
- "**/test_helper.py"
5 changes: 4 additions & 1 deletion cypress/e2e/course_creation.cy.js
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,10 @@ describe("Course Creation", () => {
cy.get("div").contains("Test Course").click();
cy.get("button").contains("Settings").click();
cy.get("header").within(() => {
cy.get("svg.lucide.lucide-trash2-icon").click();
cy.get("svg.lucide.lucide-ellipsis-icon").click();
});
cy.get("div[role=menu]").within(() => {
cy.get("span").contains("Delete").click();
});
cy.get("span").contains("Delete").click();
cy.wait(500);
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/Modals/Event.vue
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@ const evaluationResource = createResource({
member: props.event.member,
course: props.event.course,
batch_name: props.event.batch_name,
date: props.event.date,
date_value: props.event.date,
start_time: props.event.start_time,
end_time: props.event.end_time,
status: evaluation.status,
Expand Down
98 changes: 91 additions & 7 deletions frontend/src/pages/Courses/CourseDetail.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,19 @@
class="sticky top-0 z-10 flex items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5"
>
<Breadcrumbs class="h-7" :items="breadcrumbs" />
<div v-if="tabIndex == 2" class="flex items-center space-x-2">
<div v-if="tabIndex == 2 && isAdmin" class="flex items-center space-x-2">
<Badge v-if="childRef?.isDirty" theme="orange">
{{ __('Not Saved') }}
</Badge>
<Button @click="childRef.trashCourse()">
<template #icon>
<Trash2 class="w-4 h-4 stroke-1.5" />
<Dropdown :options="courseMenu" side="left">
<template v-slot="{ open }">
<Button>
<template #icon>
<Ellipsis class="w-4 h-4 stroke-1.5" />
</template>
</Button>
</template>
</Button>
</Dropdown>
<Button variant="solid" @click="childRef.submitCourse()">
{{ __('Save') }}
</Button>
Expand All @@ -31,16 +35,26 @@
<script setup>
import {
Badge,
Breadcrumbs,
Button,
call,
createResource,
Breadcrumbs,
Dropdown,
Tabs,
toast,
usePageMeta,
} from 'frappe-ui'
import { computed, inject, markRaw, onMounted, ref, watch } from 'vue'
import { sessionStore } from '@/stores/session'
import { useRouter, useRoute } from 'vue-router'
import { List, Settings2, Trash2, TrendingUp } from 'lucide-vue-next'
import {
Download,
Ellipsis,
List,
Settings2,
Trash2,
TrendingUp,
} from 'lucide-vue-next'
import CourseOverview from '@/pages/Courses/CourseOverview.vue'
import CourseDashboard from '@/pages/Courses/CourseDashboard.vue'
import CourseForm from '@/pages/Courses/CourseForm.vue'
Expand Down Expand Up @@ -139,6 +153,76 @@ const isAdmin = computed(() => {
return user.data?.is_moderator || isInstructor()
})

const exportCourse = async () => {
try {
const response = await fetch(
'/api/method/lms.lms.api.export_course_as_zip',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
course_name: course.data.name,
}),
credentials: 'include',
}
)

if (!response.ok) {
throw new Error('Download failed')
}

const blob = await response.blob()
const disposition = response.headers.get('Content-Disposition')
let filename = 'course.zip'
if (disposition && disposition.includes('filename=')) {
filename = disposition.split('filename=')[1].replace(/"/g, '')
}

const url = window.URL.createObjectURL(blob)

const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()

a.remove()
window.URL.revokeObjectURL(url)
} catch (err) {
console.error(err)
toast.error('Export failed')
}
}

const download_course_zip = (data) => {
const a = document.createElement('a')
a.href = data.export_url
a.download = data.name
a.click()
}

const courseMenu = computed(() => {
let options = [
{
label: __('Export'),
onClick() {
exportCourse()
},
icon: Download,
},
{
label: __('Delete'),
onClick() {
childRef.value.trashCourse()
},
icon: Trash2,
},
]
return options
})

const breadcrumbs = computed(() => {
let crumbs = [{ label: __('Courses'), route: { name: 'Courses' } }]
crumbs.push({
Expand Down
201 changes: 201 additions & 0 deletions frontend/src/pages/Courses/CourseImportModal.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
<template>
<Dialog
v-model="show"
:options="{
title: __('Import Course from ZIP'),
}"
>
<template #body-content>
<div class="text-p-base">
<div
v-if="!zip"
@dragover.prevent
@drop.prevent="(e) => uploadFile(e)"
class="h-[120px] flex flex-col items-center justify-center bg-surface-gray-1 border border-dashed border-outline-gray-3 rounded-md"
>
<div v-if="!uploading" class="w-4/5 text-center">
<UploadCloud
class="size-6 stroke-1.5 text-ink-gray-6 mx-auto mb-2.5"
/>
<input
ref="fileInput"
type="file"
class="hidden"
accept=".zip"
@change="(e) => uploadFile(e)"
/>
<div class="leading-5 text-ink-gray-9">
{{ __('Drag and drop a ZIP file, or upload from your') }}
<span
@click="openFileSelector"
class="cursor-pointer font-semibold hover:underline"
>
{{ __('Device') }}
</span>
</div>
</div>
<div
v-else-if="uploading"
class="w-fit bg-surface-white border rounded-md p-2 my-4"
>
<div class="space-y-2">
<div class="font-medium">
{{ uploadingFile.name }}
</div>
<div class="text-ink-gray-6">
{{ convertToMB(uploaded) }} of {{ convertToMB(total) }}
</div>
</div>
<div class="w-full bg-surface-gray-1 h-1 rounded-full mt-3">
<div
class="bg-surface-gray-7 h-1 rounded-full transition-all duration-500 ease-in-out"
:style="`width: ${uploadProgress}%`"
></div>
</div>
</div>
</div>
<div
v-else-if="zip"
class="h-[120px] flex items-center justify-center bg-surface-gray-1 border border-dashed border-outline-gray-3 rounded-md"
>
<div
class="w-fit bg-surface-white border rounded-md p-2 flex items-center justify-between items-center space-x-4"
>
<div class="space-y-2">
<div class="font-medium leading-5 text-ink-gray-9">
{{ zip.file_name || zip.name }}
</div>
<div v-if="zip.file_size" class="text-ink-gray-6">
{{ convertToMB(zip.file_size) }}
</div>
</div>
<Trash2
class="size-4 stroke-1.5 text-ink-red-3 cursor-pointer"
@click="deleteFile"
/>
</div>
</div>
</div>
</template>
<template #actions>
<div class="flex justify-end">
<Button variant="solid" @click="importZip">
{{ __('Import') }}
</Button>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { Button, call, Dialog, FileUploadHandler, toast } from 'frappe-ui'
import { computed, ref } from 'vue'
import { Trash2, UploadCloud } from 'lucide-vue-next'
import { useRouter } from 'vue-router'

const fileInput = ref<HTMLInputElement | null>(null)
const show = defineModel<boolean>({ required: true, default: false })
const zip = ref<any | null>(null)
const uploaded = ref(0)
const total = ref(0)
const uploading = ref(false)
const uploadingFile = ref<any | null>(null)
const router = useRouter()

const openFileSelector = () => {
fileInput.value?.click()
}

const uploadProgress = computed(() => {
if (total.value === 0) return 0
return Math.floor((uploaded.value / total.value) * 100)
})

const extractFile = (e: Event): File | null => {
const inputFiles = (e.target as HTMLInputElement)?.files
const dt = (e as DragEvent).dataTransfer?.files

return inputFiles?.[0] || dt?.[0] || null
}

const validateFile = (file: File) => {
const extension = file.name.split('.').pop()?.toLowerCase()
if (extension !== 'zip') {
toast.error('Please upload a valid ZIP file.')
console.error('Please upload a valid ZIP file.')
}
return extension
}

const uploadFile = (e: Event) => {
const file = extractFile(e)
if (!file) return

let fileType = validateFile(file)
if (fileType !== 'zip') return

uploadingFile.value = file
const uploader = new FileUploadHandler()

uploader.on('start', () => {
uploading.value = true
})

uploader.on('progress', (data: { uploaded: number; total: number }) => {
uploaded.value = data.uploaded
total.value = data.total
})

uploader.on('error', (error: any) => {
uploading.value = false
toast.error(__('File upload failed. Please try again.'))
console.error('File upload error:', error)
})

uploader.on('finish', () => {
uploading.value = false
})
uploader
.upload(file, {
private: 1,
})
.then((data: any) => {
zip.value = data
})
.catch((error: any) => {
console.error('File upload error:', error)
toast.error(__('File upload failed. Please try again.'))
uploading.value = false
uploadingFile.value = null
uploaded.value = 0
total.value = 0
})
}

const importZip = () => {
if (!zip.value) return
call('lms.lms.api.import_course_from_zip', {
zip_file_path: zip.value.file_url,
})
.then((data: any) => {
toast.success('Course imported successfully!')
show.value = false
deleteFile()
router.push({
name: 'CourseDetail',
params: { courseName: data },
})
})
.catch((error: any) => {
toast.error('Error importing course: ' + error.message)
console.error('Error importing course:', error)
})
}

const deleteFile = () => {
zip.value = null
}

const convertToMB = (bytes: number) => {
return (bytes / 1024 / 1024).toFixed(2) + ' MB'
}
</script>
Loading
Loading