diff --git a/backend/backend_trama/migrations/0003_course_question_set_sessionquestion_mandatory_and_more.py b/backend/backend_trama/migrations/0003_course_question_set_sessionquestion_mandatory_and_more.py new file mode 100644 index 0000000000000000000000000000000000000000..a69a171146cde384a5e926d4e1b2b19cf065a22e --- /dev/null +++ b/backend/backend_trama/migrations/0003_course_question_set_sessionquestion_mandatory_and_more.py @@ -0,0 +1,62 @@ +# Generated by Django 5.2.5 on 2025-08-28 08:02 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("backend_trama", "0002_rename_organisation_unit_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="course", + name="question_set", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="backend_trama.templatequestionset", + ), + ), + migrations.AddField( + model_name="sessionquestion", + name="mandatory", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="templatequestion", + name="mandatory", + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name="sessionquestion", + name="type", + field=models.CharField( + choices=[ + ("single", "Single choice"), + ("multi", "Multiple choice"), + ("text", "Free text"), + ("yesno", "Yes/No"), + ("file", "File upload"), + ], + max_length=6, + ), + ), + migrations.AlterField( + model_name="templatequestion", + name="type", + field=models.CharField( + choices=[ + ("single", "Single choice"), + ("multi", "Multiple choice"), + ("text", "Free text"), + ("yesno", "Yes/No"), + ("file", "File upload"), + ], + max_length=6, + ), + ), + ] diff --git a/backend/backend_trama/models/db.py b/backend/backend_trama/models/db.py index ec3a2746cf1739fb3efc232453fc439dcb387f68..edb9667ec272d9f0815c3b25ab0bbd11d918bbab 100644 --- a/backend/backend_trama/models/db.py +++ b/backend/backend_trama/models/db.py @@ -47,6 +47,9 @@ class Course(models.Model): level = models.CharField(choices=LEVEL_CHOICES) learning_outcomes = models.TextField() code = models.CharField() + question_set = models.ForeignKey( + "TemplateQuestionSet", on_delete=models.SET_NULL, null=True, blank=True + ) class CourseSession(models.Model): @@ -94,6 +97,7 @@ class SessionQuestion(models.Model): ) options = models.JSONField() required = models.BooleanField(default=False) + mandatory = models.BooleanField(default=False) class TemplateQuestionSet(models.Model): @@ -112,6 +116,7 @@ class TemplateQuestion(models.Model): ) options = models.JSONField() required = models.BooleanField(default=False) + mandatory = models.BooleanField(default=False) class Registration(TimestampModel): diff --git a/backend/backend_trama/models/db_choices.py b/backend/backend_trama/models/db_choices.py index 28e2fc3504ba6980da8194e8e750611caf3dd546..a681314dfa813514600881174c7251e238bff310 100644 --- a/backend/backend_trama/models/db_choices.py +++ b/backend/backend_trama/models/db_choices.py @@ -49,4 +49,5 @@ ANSWER_QUESTION_CHOICES = [ ("multi", "Multiple choice"), ("text", "Free text"), ("yesno", "Yes/No"), + ("file", "File upload"), ] diff --git a/backend/tests/test_api_validate_questions.py b/backend/tests/test_api_validate_questions.py index e2ce1ad1c506536e63249de422787e7d9a200cfd..4df1cbbbab6892e60b37c869eb3c857fb6aef4ca 100644 --- a/backend/tests/test_api_validate_questions.py +++ b/backend/tests/test_api_validate_questions.py @@ -30,24 +30,28 @@ class QuestionPayloadTest(TestCase): "type": "single", "options": ["Option 1", "Option 2"], "required": True, + "mandatory": True, }, { "question": "Some other question?", "type": "multi", "options": ["Option 1", "Option 2"], "required": False, + "mandatory": True, }, { "question": "Yet another question?", "type": "yesno", "options": [], "required": True, + "mandatory": False, }, { "question": "And a text question?", "type": "text", "options": [], "required": False, + "mandatory": False, }, ] response = self.client.post( diff --git a/backend/trama/api/courses.py b/backend/trama/api/courses.py index 5fc3baa972e5538a85c6d3126e5c1d05bdff300a..4d82c1aeaf254ed87af653b6549c4f44d0800040 100644 --- a/backend/trama/api/courses.py +++ b/backend/trama/api/courses.py @@ -1,3 +1,4 @@ +import base64 from collections import defaultdict from backend_trama.models.db import ( @@ -9,6 +10,7 @@ from backend_trama.models.db import ( TemplateQuestion, TemplateQuestionSet, ) +from django.conf import settings from django.contrib.auth.models import User from django.shortcuts import get_object_or_404 from django.utils import timezone @@ -17,9 +19,11 @@ from ninja.errors import HttpError, ValidationError from trama.api.utils import nested_dict # , sort_nested_dict from .schemas import ( + CourseCreateSchema, CourseSchema, CourseSessionCreateSchema, CourseSessionSchema, + QuestionSchema, RegistrationCreateSchema, RegistrationResponseSchema, SessionQuestionSchema, @@ -33,16 +37,30 @@ router = Router() @router.get("/", response=list[CourseSchema]) def list_courses(request): - return Course.objects.all() + courses = Course.objects.select_related("question_set").prefetch_related( + "question_set__questions" + ) + return list(courses) @router.post("/") -def create_course(request, data: CourseSchema): +def create_course(request, data: CourseCreateSchema): data_dict = data.dict() + question_set_id = data_dict.pop("question_set_id", None) - # rename the key to avoid the database lookup of the unit by id - data_dict["unit_id"] = data_dict.pop("unit") new_course = Course.objects.create(**data_dict) + + if question_set_id: + try: + qs = TemplateQuestionSet.objects.get(pk=question_set_id) + new_course.question_set = qs + new_course.save() + except TemplateQuestionSet.DoesNotExist: + raise HttpError( + 400, + f"Internal Server Error: Question set {question_set_id} does not exist anymore", + ) + return {"id": new_course.id} @@ -76,6 +94,22 @@ def create_course_session(request, data: CourseSessionCreateSchema): trainers = data_dict.pop("trainers", []) location = data_dict.pop("location", []) + # Validate questions + for q in questions_data: + question = q.get("question", "").strip() + if not question: + raise HttpError(400, "Question text cannot be empty") + + q_type = q.get("type") + options = q.get("options", []) + + if q_type in ("single", "multi"): + if not options or any(not opt.strip() for opt in options): + raise HttpError( + 400, + "Single or multi-choice questions must have at least one non-empty option", + ) + # Create session new_session = CourseSession.objects.create(**data_dict) new_session.trainers.set(User.objects.filter(id__in=trainers)) @@ -119,11 +153,46 @@ def list_question_sets(request): return TemplateQuestionSet.objects.prefetch_related("questions").all() +@router.get("/question-sets/{id}", response=TemplateQuestionSetSchema) +def get_course_question_set(request, id: int): + qs = get_object_or_404(TemplateQuestionSet, id=id) + return TemplateQuestionSetSchema( + id=qs.id, + name=qs.name, + questions=[ + QuestionSchema( + question=q.question, + type=q.type, + options=q.options, + required=q.required, + mandatory=q.mandatory, + ) + for q in qs.questions.all() + ], + ) + + @router.post("/question-sets/", response=TemplateQuestionSetSchema) def create_question_set(request, data: TemplateQuestionSetSchema): if TemplateQuestionSet.objects.filter(name=data.name).exists(): raise HttpError(400, "Question Set with this name already exists") + # Validate questions + for q in data.questions: + if not q.question or not q.question.strip(): + raise HttpError(400, "Question text cannot be empty") + if q.type in ("single", "multi"): + options = q.options or [] + if len(options) == 0: + raise HttpError( + 400, + "Single or multi-choice questions must have at least one option", + ) + if any(not opt.strip() for opt in options): + raise HttpError( + 400, "Single or multi-choice questions cannot have empty options" + ) + new_set = TemplateQuestionSet.objects.create(name=data.name) for q in data.questions: @@ -145,6 +214,22 @@ def update_question_set(request, id: int, data: TemplateQuestionSetUpdateSchema) qs.questions.all().delete() + # Validate questions + for q in data.questions: + if not q.question or not q.question.strip(): + raise HttpError(400, "Question text cannot be empty") + if q.type in ("single", "multi"): + options = q.options or [] + if len(options) == 0: + raise HttpError( + 400, + "Single or multi-choice questions must have at least one option", + ) + if any(not opt.strip() for opt in options): + raise HttpError( + 400, "Single or multi-choice questions cannot have empty options" + ) + for q in data.questions: TemplateQuestion.objects.create(set=qs, **q.dict()) @@ -177,6 +262,29 @@ def register_for_session(request, session_id: int, payload: RegistrationCreateSc if Registration.objects.filter(course_session=session, participant=user).exists(): raise HttpError(409, "You are already registered for this session.") + for q_text, value in payload.answers.items(): + # Text character threshold + if isinstance(value, str): + if len(value) > settings.TEXT_CHAR_LIMIT: + raise HttpError( + 400, + f"Text answer for '{q_text}' too long " + f"(max {settings.TEXT_CHAR_LIMIT} characters)", + ) + + # File threshold + elif isinstance(value, dict) and "content" in value: + try: + # FIXME: Avoid storing file in RAM for this operation + raw = base64.b64decode(value["content"]) + except Exception: + raise HttpError(400, f"Invalid base64 content for file '{q_text}'") + + if len(raw) > settings.FILE_BYTE_LIMIT: + raise HttpError( + 400, f"File '{q_text}' too large (max {settings.FILE_BYTE_LIMIT})" + ) + Registration.objects.create( course_session=session, participant=user, @@ -210,7 +318,7 @@ def list_session_registrations(request, session_id: int): ) questions = session.questions.all().values( - "id", "question", "type", "required", "options" + "id", "question", "type", "required", "options", "mandatory" ) return { diff --git a/backend/trama/api/schemas.py b/backend/trama/api/schemas.py index 56488a19efb75bf0e193ae930dc2f1b24a97ad1d..9e782241e75936ad88d2d7bd39f171e71adc73e1 100644 --- a/backend/trama/api/schemas.py +++ b/backend/trama/api/schemas.py @@ -15,17 +15,18 @@ from ninja import ModelSchema, Schema from pydantic import EmailStr, Field -class CourseSchema(ModelSchema): +class UnitSchema(ModelSchema): class Meta: - model = Course + model = Unit fields = "__all__" class QuestionSchema(Schema): question: str - type: Literal["single", "multi", "text", "yesno"] + type: Literal["single", "multi", "text", "yesno", "file"] options: list[str] = Field(default_factory=list) required: bool + mandatory: bool class SessionQuestionSchema(ModelSchema): @@ -46,12 +47,6 @@ class LocationSchema(ModelSchema): fields = "__all__" -class UnitSchema(ModelSchema): - class Meta: - model = Unit - fields = "__all__" - - class ThemeSchema(ModelSchema): class Meta: model = Theme @@ -127,6 +122,7 @@ class TemplateQuestionSchema(Schema): type: str options: list[str] required: bool + mandatory: bool class TemplateQuestionSetUpdateSchema(Schema): @@ -134,6 +130,28 @@ class TemplateQuestionSetUpdateSchema(Schema): questions: list[TemplateQuestionSchema] +class CourseSchema(ModelSchema): + unit: UnitSchema + question_set: TemplateQuestionSetSchema | None = None + + class Meta: + model = Course + fields = "__all__" + + +class CourseCreateSchema(Schema): + id: int | None = None + title: str + description: str + long_description: str + unit_id: int + keywords: str + level: str + learning_outcomes: str + code: str + question_set_id: int | None = None + + class RegistrationCreateSchema(Schema): answers: dict[str, Any] diff --git a/backend/trama/settings.py b/backend/trama/settings.py index 0a7b8a55c24b880858a6f36f09a26658463a20aa..f47874223a3ed0149012feded9659dc8388b4a0a 100644 --- a/backend/trama/settings.py +++ b/backend/trama/settings.py @@ -12,6 +12,7 @@ https://docs.djangoproject.com/en/5.0/ref/settings/ from os import getenv from pathlib import Path +from bitmath import MiB from dotenv import load_dotenv # Build paths inside the project like this: BASE_DIR / 'subdir'. @@ -155,3 +156,9 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" # TraMa specific settings CERTIFICATE_ATTENDANCE_THRESHOLD = 80 + +# Thresholds for Question Answers +TEXT_CHAR_LIMIT = 500 + +# Note: Maximum file upload allowed. Default unit is Mebibyte (from bitmath module) +FILE_BYTE_LIMIT = MiB(15) diff --git a/backend/utils/requirements.txt b/backend/utils/requirements.txt index 7f8cd347b690adb9f471cc29a854b0c267c038d5..2648bf82106df03633093158b705ae7746bc3026 100644 --- a/backend/utils/requirements.txt +++ b/backend/utils/requirements.txt @@ -9,3 +9,4 @@ django-cors-headers ~= 4.7.0 django-ninja-extra ~= 0.30.1 django-ninja-jwt ~= 5.3.7 pydantic[email] ~= 2.11.7 +bitmath ~= 1.3.3.1 diff --git a/docs/events/static/question_handling.png b/docs/events/static/question_handling.png new file mode 100644 index 0000000000000000000000000000000000000000..9acea2b4e09a45e15c63f9dbe621960d25fe7233 Binary files /dev/null and b/docs/events/static/question_handling.png differ diff --git a/docs/events/static/questionset_question.png b/docs/events/static/questionset_question.png new file mode 100644 index 0000000000000000000000000000000000000000..7ca06f5314cc050c2f92c80f6b36a2be3137699c Binary files /dev/null and b/docs/events/static/questionset_question.png differ diff --git a/docs/product_docs/history_files.md b/docs/product_docs/history_files.md index a46135c70eafba2218d2f1f8b6d1f285e2c9e7cd..430f51d36dee21516be2d31bf4f062240a49c332 100644 --- a/docs/product_docs/history_files.md +++ b/docs/product_docs/history_files.md @@ -4,7 +4,7 @@ ### Google Docs (in Use) -You can find the link to the Google Docs [here](https://drive.google.com/drive/folders/12JCFL1zktqglaKEbd48JBIV5hgLXhKtf). Since August 2025, the [PrimeVue Mockups](https://docs.google.com/presentation/d/1WsTwzoN9RQ4xwbgO-Ge6kpLYXH1v4kkE/edit?slide=id.p1#slide=id.p1) by Kristen are in use and open for comments or discussions during the weekly meetings. +You can find the link to the Google Docs [here](https://drive.google.com/drive/folders/12JCFL1zktqglaKEbd48JBIV5hgLXhKtf). Since August 2025, the [PrimeVue Mockups](https://docs.google.com/presentation/d/1eUz8xKdn_HyNGeJlMOGNlOMmi3ukUTA9/) by Kristen are in use and open for comments or discussions during the weekly meetings. Additionally, the Docs includes: diff --git a/docs/tech/question_handling.md b/docs/tech/question_handling.md new file mode 100644 index 0000000000000000000000000000000000000000..5e23b62b0819d37b4e4ad7e0ce44b49e0366aaf1 --- /dev/null +++ b/docs/tech/question_handling.md @@ -0,0 +1,51 @@ +# Question Handling Across Multiple Levels + +## Background and Current Structure + +During our review of the existing question handling process, we considered the following currently implemented components: + +- System-wide mandatory questions +- Course-level mandatory questions +- The Question Set Editor +- The final Session Questions + +This review highlighted the large number of models and tables involved (as noted by Renato), which led to an in-depth discussion about possible improvements. From this, we developed a new proposal for handling questions consistently across different hierarchy levels. + +## Proposed Question Handling Model + +We suggest the following hierarchy of layers: + + System > Unit > Course > Session > Registration + +At each level, the responsibilities would be: + +- **System**: *(not in scope anymore)* +- **Unit**: **Creation** of Question Sets (including both mandatory and optional questions) by the Unit Administrator +- **Course**: **Selection** of a single Question Set. No editing or adding of questions is allowed, but switching to a different Question Set is possible +- **Session**: **Display** of questions from the selected Question Set. Questions may be edited except for mandatory ones, and new questions may be added +- **Registration**: **Display** of the saved questions for that specific Session + +## Diagram + +![Questions](../events/static/question_handling.png) + +## Open Discussion Points + +### :question: Should the Course level support the Selection of Multiple Question Sets? + +One idea considered was allowing a course to include multiple Question Sets. However, several arguments speak against this approach: + +- Allowing multiple Question Sets per Course would complicate the planning of structure and content. This would require continuous coordination between the Unit Administrator and Course Administrator, potentially slowing down the process. + +- Since LEA cannot control how these Question Sets are structured, there is a risk of ending up with too many, disorganized Sets. :arrow_right: This would place an additional burden on the Unit Administrator to manage them. + +- Specific complications include: + - Duplicate or overlapping questions could appear within the same Course. Because Course Administrators are not allowed to edit Question Sets, this could lead to duplicated mandatory questions. These either need to be manually removed every time a Session is created, or — if both are mandatory — cannot be removed at all. This undermines the overall goal of reducing workload. + - Introducing a feature that automatically detects and reconciles duplicate questions across multiple Sets would require a new system architecture: + + ![Question Relations](../events/static/questionset_question.png) + +- Building an architecture that automatically detects duplicate questions across multiple Sets would require complex cross-referencing, conflict-resolution rules and continuous re-checking. :arrow_right: High development and maintenance costs for very little benefit. + +- Restricting Courses to a **single Question Set** ensures a clear and consistent structure across all Sessions. If specific questions need to be added or removed, the Unit Administrator should adjust the Question Set accordingly. +- The only practical reason for multiple Question Sets would be to allow mid-course adjustments between Sessions. However, this use case is already addressed through the concept of **Course Flavours**, each with its own Question Set. Enforcing a single selectable Question Set per Course therefore encourages the proper use of Course Flavours and maintains consistency. diff --git a/frontend/src/api/coursesApi.ts b/frontend/src/api/coursesApi.ts index 2bdde87c468958af5e9353636ee05e9d7c29cdcc..bce441a68e41376f102ac52b3721ed5bdacdec29 100644 --- a/frontend/src/api/coursesApi.ts +++ b/frontend/src/api/coursesApi.ts @@ -1,5 +1,13 @@ import $axios from '@/interceptors/axios' +export interface Question { + question: string + type: 'single' | 'multi' | 'text' | 'yesno' | 'file' + options: string[] + required: boolean + isCustom?: boolean +} + export async function registerCourse(courseData: { title: string description: string @@ -9,6 +17,7 @@ export async function registerCourse(courseData: { level: string learning_outcomes: string code: string + question_set: number | null }) { const response = await $axios.post('/api/courses/', courseData) return response.data diff --git a/frontend/src/components/CourseRegistrationForm.vue b/frontend/src/components/CourseRegistrationForm.vue index da631a19aad622d0d0d3b4a0ca0c41a1314f5308..c36ad75f5c3a5c02222ad3649ef4bc25a5e99142 100644 --- a/frontend/src/components/CourseRegistrationForm.vue +++ b/frontend/src/components/CourseRegistrationForm.vue @@ -4,6 +4,9 @@ import { useRouter } from 'vue-router' import $axios from '@/interceptors/axios' import { useToastHandler } from '@/composables/useToastHandler' import { getSessionQuestions, getSessionDetails } from '@/api/coursesApi' +// TODO +// import FileUpload from 'primevue/fileupload' +// import { useBase64 } from '@vueuse/core' const props = defineProps({ sessionId: { @@ -52,6 +55,30 @@ async function loadSessionDetails() { } } +async function handleFileUpload(file: File, qId: number) { + // @ adeel: is it possible to somehow import the settings from the backend? In backend/trama/settings.py the limits are already set, which should be usable globally + const FILE_BYTE_LIMIT = 15 * 1024 * 1024 // change first factor for number of MB + + if (file.size > FILE_BYTE_LIMIT) { + const fileMBLimit = FILE_BYTE_LIMIT / (1024 * 1024) + + showToast({ + severity: 'warn', + summary: 'File too large', + detail: `The selected file exceeds the ${fileMBLimit}MB limit.` + }) + return + } + // @ adeel: this is working, better useBase64 from @vueuse/core? + const reader = new FileReader() // in-built file reader + // onload gets triggert after the file is fully read with readAsDataURL, asynchronous process + reader.onload = () => { + const base64 = (reader.result as string).split(',')[1] // cuts the part in URI that is the actual base64 content + answers.value[qId] = { name: file.name, content: base64 } + } + reader.readAsDataURL(file) // Asynchronous reading process, produces data URI like 'data:image/png;base64,iVBORw0KGgoAAAANS', after that, onload gets triggert +} + async function submitForm() { for (const q of questions.value) { const ans = answers.value[q.id] @@ -171,6 +198,22 @@ onMounted(() => { + +
+ +
+ +
+ + + +
+ No questions added yet. +
+
+

+ {{ q.question }} +

+

Type: {{ q.type }}

+
    +
  • + {{ opt }} +
  • +
+

Required: {{ q.required ? 'Yes' : 'No' }}

+

Mandatory: {{ q.mandatory ? 'Yes' : 'No' }}

+
+
diff --git a/frontend/src/components/RegisterCourseSessionForm.vue b/frontend/src/components/RegisterCourseSessionForm.vue index 37df2e50049d44436e72f349520e7ba9bcaa56c2..c8097990806a5a6593c1b4725fd6c1145711e24c 100644 --- a/frontend/src/components/RegisterCourseSessionForm.vue +++ b/frontend/src/components/RegisterCourseSessionForm.vue @@ -5,14 +5,23 @@ import { useToastHandler } from '@/composables/useToastHandler' import { useRouter } from 'vue-router' const selectedCourseId = ref(undefined) -const courses = ref<{ value: number; label: string }[]>([]) - +const courses = ref< + Array<{ value: number; label: string; question_set_id?: number | null; questions: Question[] }> +>([]) const locationsList = ref<{ value: number; label: string }[]>([]) const trainersList = ref<{ value: number; label: string }[]>([]) +const courseQuestions = ref([]) +const mandatoryCourseQuestions = ref([]) +const optionalCourseQuestions = ref([]) interface Course { id: number title: string + question_set?: { + id: number + name: string + questions: Question[] + } | null } interface User { @@ -33,6 +42,14 @@ interface Unit { name: string } +interface Question { + question: string + type: 'single' | 'multi' | 'text' | 'yesno' | 'file' + options: string[] + required: boolean + mandatory: boolean +} + const keywords = ref('') const description = ref('') const longDescription = ref('') @@ -56,23 +73,6 @@ const confirmationDate = ref('') const attendanceThreshold = ref(80) const trainerIds = ref([]) const unitsList = ref<{ value: number; label: string }[]>([]) - -interface Question { - question: string - type: 'single' | 'multi' | 'text' | 'yesno' - options: string[] - required: boolean -} - -const questionSets = ref< - Array<{ - id: number - name: string - questions: Question[] - }> ->([]) - -const selectedQuestionSet = ref('custom') const questions = ref([]) const { showToast } = useToastHandler() @@ -83,8 +83,10 @@ onMounted(async () => { // Load courses const responseCourses = await $axios.get(`/api/courses/`) courses.value = (responseCourses.data as Course[]).map((course) => ({ - value: (course as { id: number; title: string }).id, - label: (course as { id: number; title: string }).title + value: course.id, + label: course.title, + question_set_id: course.question_set?.id ?? null, + questions: course.question_set?.questions ?? [] })) // Load users for trainers @@ -107,16 +109,6 @@ onMounted(async () => { value: org.id, label: org.name })) - - // Load question sets from backend - const responseSets = await $axios.get(`/api/courses/question-sets/`) - questionSets.value = responseSets.data - - // Default to custom or first loaded set - if (questionSets.value.length > 0) { - selectedQuestionSet.value = 'custom' - loadQuestionsFromSet('custom') - } } catch { showToast({ severity: 'error', @@ -125,27 +117,49 @@ onMounted(async () => { } }) -function loadQuestionsFromSet(setName: string) { - if (setName === 'custom') { - questions.value = [] +async function onCourseSelected(courseId: number | string) { + courseId = Number(courseId) + const course = courses.value.find((c) => c.value === courseId) + if (!course) { + courseQuestions.value = [] + mandatoryCourseQuestions.value = [] + optionalCourseQuestions.value = [] return } - const set = questionSets.value.find((s) => s.name === setName) - if (set) { - questions.value = JSON.parse(JSON.stringify(set.questions)) + if (course.question_set_id) { + try { + const response = await $axios.get(`/api/courses/question-sets/${course.question_set_id}`) + const qs = response.data + courseQuestions.value = qs.questions.map((q: Question) => ({ + question: q.question, + type: q.type, + options: Array.isArray(q.options) ? [...q.options] : [], + required: Boolean(q.required), + mandatory: Boolean(q.mandatory) + })) + mandatoryCourseQuestions.value = courseQuestions.value.filter((q) => q.mandatory) + optionalCourseQuestions.value = courseQuestions.value.filter((q) => !q.mandatory) + questions.value = [...optionalCourseQuestions.value] + } catch { + showToast({ + severity: 'error', + summary: 'Failed to load question set' + }) + courseQuestions.value = [] + mandatoryCourseQuestions.value = [] + optionalCourseQuestions.value = [] + questions.value = [] + } } } -function onQuestionSetChange() { - loadQuestionsFromSet(selectedQuestionSet.value) -} - function addQuestion() { questions.value.push({ question: '', type: 'single', options: [''], - required: false + required: false, + mandatory: false }) } @@ -179,7 +193,7 @@ function toISOStringWithTimezone(localDateTimeString: string) { } async function registerCourseSession() { - for (const [idx, q] of questions.value.entries()) { + for (const [idx, q] of [...questions.value, ...courseQuestions.value].entries()) { if (!q.question.trim()) { showToast({ severity: 'error', @@ -220,7 +234,7 @@ async function registerCourseSession() { confirmation_deadline: toISOStringWithTimezone(confirmationDate.value), attendance_threshold: Number(attendanceThreshold.value) || 80, trainers: trainerIds.value, - questions: questions.value + questions: [...mandatoryCourseQuestions.value, ...questions.value] } try { @@ -243,14 +257,20 @@ async function registerCourseSession() {