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
@@ -15,6 +15,7 @@
import { ContentType } from '@cio/utils/constants/content';
import { PREMIUM_QUESTION_TYPE_KEYS, QUESTION_TYPES } from '$features/ui/question/constants';
import { Button } from '@cio/ui/base/button';
import { Badge } from '@cio/ui/base/badge';
import { ExerciseQuestion } from '@cio/ui';
import OrderModal from './order-modal.svelte';
@@ -40,11 +41,20 @@
interface Props {
shouldDeleteExercise?: boolean;
exerciseId: any;
goBack?: any;
exerciseId: string;
goBack?: () => void;
/** Self-paced + all auto-gradable question types: enforce points > 0 */
requiresPositivePointsForAutoGrade?: boolean;
selfPacedCourse?: boolean;
}
let { shouldDeleteExercise = $bindable(false), exerciseId, goBack = () => {} }: Props = $props();
let {
shouldDeleteExercise = $bindable(false),
exerciseId,
goBack = () => {},
requiresPositivePointsForAutoGrade = false,
selfPacedCourse = false
}: Props = $props();
let questionIdToDelete = $state(null);
let isDeleting = $state(false);
@@ -173,9 +183,10 @@
}
async function handleDelete() {
if (!courseApi.course?.id) return;
isDeleting = true;
await exerciseApi.delete(courseApi.course?.id!, exerciseId);
await exerciseApi.delete(courseApi.course.id, exerciseId);
if (exerciseApi.success) {
courseApi.removeContentItem(exerciseId, ContentType.Exercise);
@@ -234,6 +245,9 @@
bind:points={question.points}
hasError={!!errors[question.id]}
errorMsg={getQuestionErrorMsg(errors, question, 'points')}
pointsHint={requiresPositivePointsForAutoGrade && Number(question.points) === 0
? $t('course.navItem.lessons.exercises.all_exercises.points_required_auto_grade')
: null}
onPointsChange={() => {
question.isDirty = true;
}}
@@ -272,7 +286,7 @@
{getQuestionTypeLabel(question?.questionType)}
</Select.Trigger>
<Select.Content>
{#each QUESTION_TYPES as type, i (type.key)}
{#each QUESTION_TYPES as type (type.key)}
{#if $isFreePlan && PREMIUM_QUESTION_TYPE_KEYS.has(type.key)}
<button
type="button"
@@ -288,7 +302,27 @@
<span>{getQuestionTypeLabel(type)}</span>
</button>
{:else}
<Select.Item value={type.id.toString()} label={getQuestionTypeLabel(type)} />
<Select.Item value={type.id.toString()} label={getQuestionTypeLabel(type)}>
{#snippet children({ selected: _sel })}
<span class="flex w-full min-w-0 items-center justify-between gap-2">
<span class="truncate">{getQuestionTypeLabel(type)}</span>
{#if selfPacedCourse}
<Badge
variant={type.autoGradable ? 'success' : 'warning'}
class="ui:shrink-0 ui:text-[10px] ui:font-normal"
>
{type.autoGradable
? $t(
'course.navItem.lessons.exercises.all_exercises.edit_mode.question_type_auto_gradable'
)
: $t(
'course.navItem.lessons.exercises.all_exercises.edit_mode.question_type_manual_grading'
)}
</Badge>
{/if}
</span>
{/snippet}
</Select.Item>
{/if}
{/each}
</Select.Content>
@@ -0,0 +1,56 @@
<script lang="ts">
import { Switch } from '@cio/ui/base/switch';
import { Label } from '@cio/ui/base/label';
import { Button } from '@cio/ui/base/button';
import { questionnaire } from './store';
import { exerciseApi } from '$features/course/api';
import { courseApi } from '$features/course/api';
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$features/ui/snackbar/store';
type Props = {
exerciseId: string;
};
let { exerciseId }: Props = $props();
let saving = $state(false);
async function saveSettings() {
if (!courseApi.course?.id) return;
saving = true;
await exerciseApi.update(courseApi.course.id, exerciseId, {
allowMultipleAttempts: !!$questionnaire.allowMultipleAttempts
});
saving = false;
if (exerciseApi.success) {
snackbar.success('snackbar.exercise.success');
}
}
</script>
<div class="max-w-xl space-y-6">
<div class="flex items-center justify-between gap-4 rounded-lg border border-neutral-200 p-4 dark:border-neutral-700">
<div class="space-y-1">
<Label for="allow-multiple" class="text-sm font-medium dark:text-gray-100">
{$t('course.navItem.lessons.exercises.all_exercises.settings_allow_multiple')}
</Label>
<p class="ui:text-muted-foreground text-sm">
{$t('course.navItem.lessons.exercises.all_exercises.settings_allow_multiple_helper')}
</p>
</div>
<Switch
id="allow-multiple"
checked={!!$questionnaire.allowMultipleAttempts}
onCheckedChange={(checked) => {
questionnaire.update((q) => ({ ...q, allowMultipleAttempts: checked }));
}}
/>
</div>
<Button onclick={saveSettings} loading={saving} disabled={!courseApi.course?.id}>
{$t('course.navItem.lessons.exercises.all_exercises.settings_save')}
</Button>
</div>
@@ -54,6 +54,7 @@ export const questionnaire: Writable<{
isDescriptionDirty?: boolean;
questions: Question[];
totalSubmissions: number;
allowMultipleAttempts?: boolean;
}> = writable({
title: '',
dueBy: '',
@@ -62,7 +63,8 @@ export const questionnaire: Writable<{
description: '',
isDescriptionDirty: false,
questions: [],
totalSubmissions: 0
totalSubmissions: 0,
allowMultipleAttempts: false
});
export function clearQuestionnaireValidation() {
@@ -88,6 +90,15 @@ export function reset() {
clearQuestionnaireValidation();
}
/** Clears take-exercise state so the student can start another attempt (when allowed). */
export function resetStudentExerciseTake() {
questionnaireMetaData.set({
...initAnswerState,
exerciseId: null
});
clearQuestionnaireValidation();
}
/**
* Maps Zod validation errors to UI format: { questionId: { option: 'error message' } }
* This is used to display errors in the edit-mode component
@@ -29,9 +29,10 @@
import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$features/ui/snackbar/store';
import { ContentType } from '@cio/utils/constants/content';
import { getOrderedNavigableContent } from '$features/course/utils/content';
import { globalStore } from '$lib/utils/store/app';
import { openCourseCompletionModal } from '$features/course/store/course-completion-modal';
import { ContentType } from '@cio/utils/constants/content';
import { toExerciseQuestionModel } from './question-type-utils';
import { getExerciseQuestionLabels } from './question-labels';
import axios from 'axios';
@@ -150,17 +151,13 @@
}));
}
courseApi.updateContentItem(exerciseId, ContentType.Exercise, { isComplete: true });
if (
$globalStore.isStudent &&
courseApi.course?.id &&
submitResult?.data &&
'certification' in submitResult.data
) {
const certification = (submitResult.data as { certification?: { isNewCompletion?: boolean } | null })
.certification;
if (certification?.isNewCompletion) {
openCourseCompletionModal(courseApi.course.id);
}
const allContentItems = getOrderedNavigableContent(courseApi.course);
const allComplete =
$globalStore.isStudent && allContentItems.length > 0 && allContentItems.every((item) => item.isComplete);
if (allComplete && courseApi.course?.id) {
openCourseCompletionModal(courseApi.course.id);
}
}