feat: certification rules, content sanitization, handle draft course updates and reset content, better redis logging

This commit is contained in:
Rotimi Best
2026-03-30 08:24:55 +01:00
parent ff2ad59377
commit 5a8f50f67b
131 changed files with 5643 additions and 2134 deletions
@@ -1,14 +1,20 @@
<script lang="ts">
import IssueCertificateModal from '$features/course/components/ceritficate/issue-certificate-modal.svelte';
import { RoleBasedSecurity } from '$features/ui';
import Design from '$features/course/components/ceritficate/design.svelte';
import CertificateEdit from '$features/course/components/ceritficate/certificate-edit.svelte';
import StudentCertificate from '$features/course/components/ceritficate/student-certificate/index.svelte';
type Props = {
errors: Record<string, string>;
};
let { errors }: Props = $props();
</script>
<IssueCertificateModal />
<RoleBasedSecurity allowedRoles={[1, 2]}>
<Design />
<CertificateEdit {errors} />
</RoleBasedSecurity>
<RoleBasedSecurity onlyStudent allowedRoles={[3]}>
<StudentCertificate />
@@ -110,20 +110,18 @@
{/if}
{@render filterControls?.()}
{#if !isLMS}
{#if $courseMetaDeta.view === 'list'}
<IconButton onclick={() => setViewPreference('grid')}>
<GridIcon size={16} />
</IconButton>
{:else}
<IconButton onclick={() => setViewPreference('list')}>
<ListIcon size={16} />
</IconButton>
{/if}
{#if $courseMetaDeta.view === 'list'}
<IconButton onclick={() => setViewPreference('grid')}>
<GridIcon size={16} />
</IconButton>
{:else}
<IconButton onclick={() => setViewPreference('list')}>
<ListIcon size={16} />
</IconButton>
{/if}
</Page.BodyHeader>
<div class="mx-auto w-full flex-1">
<div class="mx-auto mt-4 w-full flex-1">
{#if isLoading}
<section class={`${isLoading || courses ? 'cards-container' : ''} `}>
<CourseCardLoader />
@@ -146,15 +144,18 @@
<Table.Head class="w-[10%]">{$t('courses.course_card.list_view.type')}</Table.Head>
<Table.Head class="w-3/12 min-w-0">{$t('courses.course_card.list_view.tags')}</Table.Head>
<Table.Head class="w-[8%]">{$t('courses.course_card.list_view.lessons')}</Table.Head>
<Table.Head class="w-[8%]">{$t('courses.course_card.list_view.students')}</Table.Head>
<Table.Head class="w-[10%]">{$t('courses.course_card.list_view.published')}</Table.Head>
<Table.Head class="w-[6%]"></Table.Head>
{#if !isLMS}
<Table.Head class="w-[8%]">{$t('courses.course_card.list_view.students')}</Table.Head>
<Table.Head class="w-[10%]">{$t('courses.course_card.list_view.published')}</Table.Head>
<Table.Head class="w-[6%]"></Table.Head>
{/if}
</Table.Row>
</Table.Header>
<Table.Body>
{#each courses as courseData (courseData.id)}
<CourseList
id={courseData.id}
slug={courseData.slug ?? ''}
title={courseData.title}
type={$t(`course.navItem.settings.${courseData.type?.toLowerCase()}`)}
description={courseData.description}
@@ -167,6 +168,8 @@
slug: string;
color?: string | null;
}>}
{isExplore}
{isLMS}
/>
{/each}
</Table.Body>
@@ -30,6 +30,7 @@
import { exerciseApi } from '$features/course/api';
import { courseApi } from '$features/course/api';
import EditMode from '$features/course/components/exercise/edit-mode.svelte';
import ExerciseSettingsTab from '$features/course/components/exercise/exercise-settings-tab.svelte';
import ViewMode from '$features/course/components/exercise/view-mode.svelte';
import Submissions from '$features/course/components/exercise/submissions/submissions.svelte';
import { IconButton } from '@cio/ui/custom/icon-button';
@@ -41,17 +42,19 @@
import VideoIcon from '@lucide/svelte/icons/video';
import {
getQuestionTypeId,
getQuestionTypeKeyFromId,
getQuestionTypeOptionById
} from '$features/course/components/exercise/question-type-utils';
import type { SubmissionListItem } from '$features/course/utils/types';
import { Badge } from '@cio/ui/base/badge';
import { isAutoGradableQuestionType } from '@cio/question-types';
interface Props {
exerciseId?: string;
goBack?: () => void;
isFetching?: boolean;
submissions: SubmissionListItem[];
mySubmission: SubmissionListItem | null;
mySubmissions?: SubmissionListItem[];
}
let {
@@ -59,15 +62,18 @@
goBack = () => {},
isFetching = false,
submissions,
mySubmission
mySubmissions = []
}: Props = $props();
type ExerciseTab = 'questions' | 'submissions';
type ExerciseTab = 'questions' | 'settings' | 'submissions';
function normalizeExerciseTab(tabParam: string | null): ExerciseTab {
if (tabParam === 'submissions' || tabParam === 'analytics') {
return 'submissions';
}
if (tabParam === 'settings') {
return 'settings';
}
return 'questions';
}
@@ -109,6 +115,26 @@
return;
}
if (requiresPositivePointsForAutoGrade) {
const zeroPointQuestions = ($questionnaire.questions ?? []).filter((q) => !q.deletedAt && Number(q.points) === 0);
if (zeroPointQuestions.length > 0) {
const pointErrors: Record<string, { option?: string; title?: string; points?: string }> = {};
for (const q of zeroPointQuestions) {
pointErrors[q.id] = {
points: t.get('course.navItem.lessons.exercises.all_exercises.points_required_auto_grade')
};
}
questionnaireValidation.set(pointErrors);
snackbar.error('snackbar.exercise.points_required_auto_grade');
return;
}
}
isSaving = true;
reset();
@@ -204,11 +230,41 @@
)
);
const isExerciseUnlocked = $derived(exerciseContentItem?.isUnlocked ?? false);
const isSelfPacedCourse = $derived(courseApi.course?.type === 'SELF_PACED');
const activeQuestionTypeIds = $derived(
($questionnaire.questions ?? []).filter((q) => !q.deletedAt).map((q) => getQuestionTypeId(q))
);
function isExerciseFullyAutoGradable(typeIds: number[]): boolean {
return typeIds.length > 0 && typeIds.every((id) => isAutoGradableQuestionType(getQuestionTypeKeyFromId(id)));
}
/** When true, every question must have points &gt; 0 before save */
const requiresPositivePointsForAutoGrade = $derived(
isSelfPacedCourse && isExerciseFullyAutoGradable(activeQuestionTypeIds)
);
const teacherAutoGradeBadge = $derived.by(() => {
if ($globalStore.isStudent || !isSelfPacedCourse || activeQuestionTypeIds.length === 0) return null;
return isExerciseFullyAutoGradable(activeQuestionTypeIds) ? ('auto' as const) : ('manual' as const);
});
</script>
<Page.Header isSticky={true} class="z-250! min-h-[36px]">
<Page.HeaderContent>
<Page.Title>{$questionnaire.title || 'Exercise'}</Page.Title>
<Page.Title class="flex flex-wrap items-center gap-2">
<span>{$questionnaire.title || 'Exercise'}</span>
<RoleBasedSecurity allowedRoles={[1, 2]}>
{#if teacherAutoGradeBadge === 'auto'}
<Badge variant="success" class="font-normal">
{$t('course.navItem.lessons.exercises.all_exercises.auto_graded_badge')}
</Badge>
{:else if teacherAutoGradeBadge === 'manual'}
<Badge variant="warning" class="font-normal">
{$t('course.navItem.lessons.exercises.all_exercises.manual_grading_required_badge')}
</Badge>
{/if}
</RoleBasedSecurity>
</Page.Title>
</Page.HeaderContent>
<Page.Action>
<div class="flex items-center gap-2">
@@ -278,7 +334,7 @@
<div class="overflow-x-hidden">
{#if $globalStore.isStudent}
{#if isExerciseUnlocked}
<ViewMode {preview} {exerciseId} isFetchingExercise={isFetching} {mySubmission} />
<ViewMode {preview} {exerciseId} isFetchingExercise={isFetching} {mySubmissions} />
{:else}
<Empty
title={$t('course.navItem.lessons.content_locked_title')}
@@ -290,10 +346,13 @@
{/if}
{:else}
<UnderlineTabs.Root bind:value={selectedTab} class="mb-4">
<UnderlineTabs.List class="grid w-full max-w-xs grid-cols-2">
<UnderlineTabs.List class="grid w-full max-w-lg grid-cols-3">
<UnderlineTabs.Trigger value="questions">
{$t('course.navItem.lessons.exercises.all_exercises.questions')}
</UnderlineTabs.Trigger>
<UnderlineTabs.Trigger value="settings">
{$t('course.navItem.lessons.exercises.all_exercises.settings')}
</UnderlineTabs.Trigger>
<UnderlineTabs.Trigger value="submissions">
{$t('course.navItem.lessons.exercises.all_exercises.analytics.submissions')}
@@ -303,11 +362,20 @@
<UnderlineTabs.Content value="questions">
<UpdateDescription {preview} />
{#if !preview}
<EditMode bind:shouldDeleteExercise {exerciseId} {goBack} />
<EditMode
bind:shouldDeleteExercise
{exerciseId}
{goBack}
{requiresPositivePointsForAutoGrade}
selfPacedCourse={isSelfPacedCourse}
/>
{:else}
<ViewMode {preview} {exerciseId} isFetchingExercise={isFetching} {mySubmission} />
<ViewMode {preview} {exerciseId} isFetchingExercise={isFetching} {mySubmissions} />
{/if}
</UnderlineTabs.Content>
<UnderlineTabs.Content value="settings">
<ExerciseSettingsTab {exerciseId} />
</UnderlineTabs.Content>
<UnderlineTabs.Content value="submissions">
<Submissions bind:exerciseId {submissions} />
</UnderlineTabs.Content>
@@ -35,6 +35,12 @@
import { currentOrgDomain, currentOrgPath, isFreePlan } from '$lib/utils/store/org';
import { page } from '$app/stores';
interface Props {
hasUnsavedChanges?: boolean;
}
let { hasUnsavedChanges = $bindable(false) }: Props = $props();
let isLoading = $state(false);
let isDeleting = $state(false);
let errors: {
@@ -45,7 +51,6 @@
description: undefined
});
let avatar: string | undefined;
let hasUnsavedChanges = $state(false);
let openDeleteModal = $state(false);
let selectedTagIds = $state<string[]>([]);
let initialTagIds = $state<string[]>([]);