diff --git a/backend/backend_trama/migrations/0003_systemquestionset_systemquestion.py b/backend/backend_trama/migrations/0003_systemquestionset_systemquestion.py new file mode 100644 index 0000000000000000000000000000000000000000..143c3e9693d5cdc335d75fd3c3b1cb7d2138d24d --- /dev/null +++ b/backend/backend_trama/migrations/0003_systemquestionset_systemquestion.py @@ -0,0 +1,77 @@ +# Generated by Django 5.2.4 on 2025-08-14 11:56 + +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.CreateModel( + name="SystemQuestionSet", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(unique=True)), + ( + "question_type", + models.CharField( + choices=[ + ("R", "Registration"), + ("P", "Pre-workshop feedback"), + ("O", "Post-workshop feedback"), + ("I", "Impact feedback"), + ] + ), + ), + ], + ), + migrations.CreateModel( + name="SystemQuestion", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("question", models.CharField(max_length=500)), + ( + "type", + models.CharField( + choices=[ + ("single", "Single choice"), + ("multi", "Multiple choice"), + ("text", "Free text"), + ("yesno", "Yes/No"), + ], + max_length=6, + ), + ), + ("options", models.JSONField()), + ("required", models.BooleanField(default=False)), + ( + "set", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="system_questions", + to="backend_trama.systemquestionset", + ), + ), + ], + ), + ] diff --git a/backend/backend_trama/migrations/0004_coursequestion.py b/backend/backend_trama/migrations/0004_coursequestion.py new file mode 100644 index 0000000000000000000000000000000000000000..8fea7c1d0a21a032b1b83a252ee964024d855f09 --- /dev/null +++ b/backend/backend_trama/migrations/0004_coursequestion.py @@ -0,0 +1,51 @@ +# Generated by Django 5.2.4 on 2025-08-15 13:39 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("backend_trama", "0003_systemquestionset_systemquestion"), + ] + + operations = [ + migrations.CreateModel( + name="CourseQuestion", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("question", models.CharField(max_length=500)), + ( + "type", + models.CharField( + choices=[ + ("single", "Single choice"), + ("multi", "Multiple choice"), + ("text", "Free text"), + ("yesno", "Yes/No"), + ], + max_length=6, + ), + ), + ("options", models.JSONField()), + ("required", models.BooleanField(default=False)), + ( + "course", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="course_questions", + to="backend_trama.course", + ), + ), + ], + ), + ] diff --git a/backend/backend_trama/migrations/0005_alter_coursequestion_type_alter_sessionquestion_type_and_more.py b/backend/backend_trama/migrations/0005_alter_coursequestion_type_alter_sessionquestion_type_and_more.py new file mode 100644 index 0000000000000000000000000000000000000000..1a460743eae97a92e312e583a00bf07c6452f981 --- /dev/null +++ b/backend/backend_trama/migrations/0005_alter_coursequestion_type_alter_sessionquestion_type_and_more.py @@ -0,0 +1,69 @@ +# Generated by Django 5.2.4 on 2025-08-19 07:31 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("backend_trama", "0004_coursequestion"), + ] + + operations = [ + migrations.AlterField( + model_name="coursequestion", + 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="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="systemquestion", + 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..0436a835bf949e06234b881775483f10771018cd 100644 --- a/backend/backend_trama/models/db.py +++ b/backend/backend_trama/models/db.py @@ -114,6 +114,37 @@ class TemplateQuestion(models.Model): required = models.BooleanField(default=False) +class SystemQuestionSet(models.Model): + name = models.CharField(unique=True) + question_type = models.CharField(choices=QUESTION_TYPES) + + +class SystemQuestion(models.Model): + set = models.ForeignKey( + SystemQuestionSet, on_delete=models.CASCADE, related_name="system_questions" + ) + question = models.CharField(max_length=500) + type = models.CharField( + max_length=6, + choices=ANSWER_QUESTION_CHOICES, + ) + options = models.JSONField() + required = models.BooleanField(default=False) + + +class CourseQuestion(models.Model): + course = models.ForeignKey( + Course, on_delete=models.CASCADE, related_name="course_questions" + ) + question = models.CharField(max_length=500) + type = models.CharField( + max_length=6, + choices=ANSWER_QUESTION_CHOICES, + ) + options = models.JSONField() + required = models.BooleanField(default=False) + + class Registration(TimestampModel): course_session = models.ForeignKey(CourseSession, on_delete=models.CASCADE) participant = models.ForeignKey(User, on_delete=models.CASCADE) 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_file_upload.py b/backend/tests/test_file_upload.py new file mode 100644 index 0000000000000000000000000000000000000000..d0de669414723398f0837c2a43c8082d46bf9bdf --- /dev/null +++ b/backend/tests/test_file_upload.py @@ -0,0 +1,93 @@ +import base64 + +from backend_trama.models.db import Course, CourseSession, Theme, Unit +from django.conf import settings +from django.contrib.auth.models import User +from django.test import TestCase +from django.utils import timezone +from ninja.testing import TestClient +from ninja_jwt.tokens import AccessToken +from trama.api.courses import router + + +class FileUploadRegistrationTest(TestCase): + def setUp(self): + self.client = TestClient(router) + self.user = User.objects.create_user( + username="fileuser", password="password123" + ) + token = AccessToken.for_user(self.user) + self.auth_headers = {"Authorization": f"Bearer {token}"} + theme = Theme.objects.create(name="Default Theme", primary_color="#000000") + unit = Unit.objects.create( + name="Unit1", + contact_email="u@u.de", + webaddress="u.de", + currency="EUR", + theme=theme, + ) + course = Course.objects.create( + title="Test Course", + description="desc", + long_description="long", + unit=unit, + keywords="kw", + level="beginner", + learning_outcomes="lo", + code="C001", + ) + self.session = CourseSession.objects.create( + course=course, + keywords="kw", + description="desc", + long_description="long", + level="beginner", + learning_outcomes="lo", + contact_email="c@c.de", + unit=unit, + title="Session 1", + start=timezone.now() + timezone.timedelta(days=1), + end=timezone.now() + timezone.timedelta(days=2), + queue_handling="fifo", + delivery_format="online", + price=0, + currency="EUR", + published=True, + ) + + def test_register_with_file(self): + content = base64.b64encode(b"hello world").decode("utf-8") + payload = { + "answers": { + "Upload CV": { + "filename": "cv.txt", + "content": content, + } + } + } + response = self.client.post( + f"/sessions/{self.session.id}/user-register/", + json=payload, + headers=self.auth_headers, + ) + self.assertEqual(response.status_code, 200) + self.assertIn("Registered successfully", response.json()["message"]) + + def test_register_with_too_large_file(self): + big_file = settings.FILE_BYTE_LIMIT.bytes + 1 + big_content = base64.b64encode(b"a" * big_file).decode("utf-8") + payload = { + "answers": { + "Upload CV": { + "filename": "huge.txt", + "content": big_content, + } + } + } + response = self.client.post( + f"/sessions/{self.session.id}/user-register/", + json=payload, + headers=self.auth_headers, + ) + self.assertEqual(response.status_code, 400) + self.assertIn("too large", response.json()["detail"]) diff --git a/backend/trama/api/courses.py b/backend/trama/api/courses.py index 5fc3baa972e5538a85c6d3126e5c1d05bdff300a..e89db4793ceaf81c7231bb9b39cfff21d7454028 100644 --- a/backend/trama/api/courses.py +++ b/backend/trama/api/courses.py @@ -1,14 +1,19 @@ +import base64 from collections import defaultdict from backend_trama.models.db import ( Course, + CourseQuestion, CourseSession, Location, Registration, SessionQuestion, + SystemQuestion, + SystemQuestionSet, 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,12 +22,15 @@ 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, + TemplateQuestionSchema, TemplateQuestionSetSchema, TemplateQuestionSetUpdateSchema, UserResponseSchema, @@ -33,19 +41,48 @@ router = Router() @router.get("/", response=list[CourseSchema]) def list_courses(request): - return Course.objects.all() + return Course.objects.select_related("unit").all() @router.post("/") -def create_course(request, data: CourseSchema): +def create_course(request, data: CourseCreateSchema): data_dict = data.dict() + questions_data = data_dict.pop("questions", []) + + # Rename unit key + data_dict["unit_id"] = data_dict.pop("unit_id") + + # Validate questions + for q in questions_data: + if not q["question"].strip(): + raise HttpError(400, "Question text cannot be empty") + if q["type"] in ("single", "multi"): + options = q.get("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" + ) - # 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) + + # Create questions + for q in questions_data: + CourseQuestion.objects.create(course=new_course, **q) + return {"id": new_course.id} +@router.get("/{course_id}/questions/", response=list[QuestionSchema]) +def get_questions_for_course(request, course_id: int): + course = get_object_or_404(Course, id=course_id) + return course.course_questions.all() + + @router.get("/sessions/", response=list[CourseSessionSchema]) def list_course_sessions(request): return CourseSession.objects.prefetch_related("questions").all() @@ -76,6 +113,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)) @@ -124,6 +177,22 @@ 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.bytes: + raise HttpError( + 400, f"File '{q_text}' too large (max {settings.FILE_BYTE_LIMIT})" + ) + Registration.objects.create( course_session=session, participant=user, @@ -223,3 +331,87 @@ def list_session_registrations(request, session_id: int): for reg in registrations ], } + + +# System-wide mandatory questions +@router.get("/system-question-set/", response=TemplateQuestionSetSchema | None) +def get_system_question_set(request): + qs_set = SystemQuestionSet.objects.prefetch_related("system_questions").first() + if not qs_set: + return None + return TemplateQuestionSetSchema( + id=qs_set.id, + name=qs_set.name, + questions=[ + TemplateQuestionSchema( + question=q.question, type=q.type, options=q.options, required=True + ) + for q in qs_set.system_questions.all() + ], + ) + + +@router.post("/system-question-set/", response=TemplateQuestionSetSchema) +def create_system_question_set(request, data: TemplateQuestionSetUpdateSchema): + if SystemQuestionSet.objects.exists(): + raise HttpError( + 400, + "Internal Server Error: A system question set already exists but can not be loaded.", + ) + + new_set = SystemQuestionSet.objects.create(name=data.name) + for q in data.questions: + SystemQuestion.objects.create( + set=new_set, + question=q.question, + type=q.type, + options=q.options, + required=True, + ) + + return TemplateQuestionSetSchema( + id=new_set.id, + name=new_set.name, + questions=[ + TemplateQuestionSchema( + question=q.question, type=q.type, options=q.options, required=True + ) + for q in new_set.system_questions.all() + ], + ) + + +@router.put("/system-question-set/{id}", response=TemplateQuestionSetSchema) +def update_system_question_set(request, id: int, data: TemplateQuestionSetUpdateSchema): + qs_set = get_object_or_404(SystemQuestionSet, id=id) + qs_set.system_questions.all().delete() + + for q in data.questions: + SystemQuestion.objects.create( + set=qs_set, + question=q.question, + type=q.type, + options=q.options, + required=True, + ) + + qs_set.name = data.name + qs_set.save() + + return TemplateQuestionSetSchema( + id=qs_set.id, + name=qs_set.name, + questions=[ + TemplateQuestionSchema( + question=q.question, type=q.type, options=q.options, required=True + ) + for q in qs_set.system_questions.all() + ], + ) + + +@router.delete("/system-question-set/{id}") +def delete_system_question_set(request, id: int): + qs_set = get_object_or_404(SystemQuestionSet, id=id) + qs_set.delete() + return {"message": f"System Question Set '{id}' deleted successfully"} diff --git a/backend/trama/api/schemas.py b/backend/trama/api/schemas.py index 56488a19efb75bf0e193ae930dc2f1b24a97ad1d..70db658160977d74cb3cbfba0f32afa9a6d83c4b 100644 --- a/backend/trama/api/schemas.py +++ b/backend/trama/api/schemas.py @@ -15,7 +15,15 @@ from ninja import ModelSchema, Schema from pydantic import EmailStr, Field +class UnitSchema(ModelSchema): + class Meta: + model = Unit + fields = "__all__" + + class CourseSchema(ModelSchema): + unit: UnitSchema + class Meta: model = Course fields = "__all__" @@ -23,11 +31,24 @@ class CourseSchema(ModelSchema): 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 +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 + questions: list[QuestionSchema] = [] + + class SessionQuestionSchema(ModelSchema): class Meta: model = SessionQuestion @@ -46,12 +67,6 @@ class LocationSchema(ModelSchema): fields = "__all__" -class UnitSchema(ModelSchema): - class Meta: - model = Unit - fields = "__all__" - - class ThemeSchema(ModelSchema): class Meta: model = Theme 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/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/frontend/src/api/coursesApi.ts b/frontend/src/api/coursesApi.ts index 2bdde87c468958af5e9353636ee05e9d7c29cdcc..b10d3c14b07c0402b2ca990407bc0a1959d87b65 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' + 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 + questions: Question[] }) { 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. +
+ +
+ + + +
+ +
+ + +
+ +
+ + + + +
+ + +
diff --git a/frontend/src/components/RegisterCourseSessionForm.vue b/frontend/src/components/RegisterCourseSessionForm.vue index 37df2e50049d44436e72f349520e7ba9bcaa56c2..aeb01bd84c317ba2332217436ce07e27580ec29c 100644 --- a/frontend/src/components/RegisterCourseSessionForm.vue +++ b/frontend/src/components/RegisterCourseSessionForm.vue @@ -4,11 +4,15 @@ import $axios from '@/interceptors/axios' import { useToastHandler } from '@/composables/useToastHandler' import { useRouter } from 'vue-router' +import MultiSelect from 'primevue/multiselect' + const selectedCourseId = ref(undefined) const courses = ref<{ value: number; label: string }[]>([]) const locationsList = ref<{ value: number; label: string }[]>([]) const trainersList = ref<{ value: number; label: string }[]>([]) +const systemQuestions = ref([]) +const courseQuestions = ref([]) interface Course { id: number @@ -59,7 +63,7 @@ const unitsList = ref<{ value: number; label: string }[]>([]) interface Question { question: string - type: 'single' | 'multi' | 'text' | 'yesno' + type: 'single' | 'multi' | 'text' | 'yesno' | 'file' options: string[] required: boolean } @@ -72,7 +76,7 @@ const questionSets = ref< }> >([]) -const selectedQuestionSet = ref('custom') +const selectedQuestionSets = ref<[]>([]) const questions = ref([]) const { showToast } = useToastHandler() @@ -108,15 +112,15 @@ onMounted(async () => { label: org.name })) + // Load mandatory system-wide questions + const responseSystemQs = await $axios.get(`/api/courses/system-question-set/`) + if (responseSystemQs.data && responseSystemQs.data.questions) { + systemQuestions.value = responseSystemQs.data.questions + } + // 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,19 +129,39 @@ onMounted(async () => { } }) -function loadQuestionsFromSet(setName: string) { - if (setName === 'custom') { - questions.value = [] - return +async function loadCourseQuestions(courseId: number) { + try { + const response = await $axios.get(`/api/courses/${courseId}/questions/`) + courseQuestions.value = response.data + } catch (error) { + courseQuestions.value = [] + showToast({ + severity: 'error', + summary: 'Failed to load course questions', + detail: error.response?.data?.message || error.message + }) } - const set = questionSets.value.find((s) => s.name === setName) - if (set) { - questions.value = JSON.parse(JSON.stringify(set.questions)) +} + +async function onCourseChange(courseId: number) { + if (!courseId) { + courseQuestions.value = [] + return } + await loadCourseQuestions(courseId) } -function onQuestionSetChange() { - loadQuestionsFromSet(selectedQuestionSet.value) +function onQuestionSetsChange() { + const combinedQuestions = [] + + selectedQuestionSets.value.forEach((set) => { + if (set.name === 'custom') return // skip the custom option if needed + combinedQuestions.push(...JSON.parse(JSON.stringify(set.questions))) + }) + + // Preserve existing custom questions if any + const customQuestions = questions.value.filter((q) => q.isCustom) + questions.value = [...combinedQuestions, ...customQuestions] } function addQuestion() { @@ -179,7 +203,11 @@ function toISOStringWithTimezone(localDateTimeString: string) { } async function registerCourseSession() { - for (const [idx, q] of questions.value.entries()) { + for (const [idx, q] of [ + ...systemQuestions.value, + ...questions.value, + ...courseQuestions.value + ].entries()) { if (!q.question.trim()) { showToast({ severity: 'error', @@ -220,7 +248,7 @@ async function registerCourseSession() { confirmation_deadline: toISOStringWithTimezone(confirmationDate.value), attendance_threshold: Number(attendanceThreshold.value) || 80, trainers: trainerIds.value, - questions: questions.value + questions: [...systemQuestions.value, ...questions.value, ...courseQuestions.value] } try { @@ -250,6 +278,7 @@ async function registerCourseSession() { name="course_id" :options="courses" validation="required" + @change="onCourseChange(selectedCourseId)" /> + +
+

Mandatory Questions

+

+ These questions cannot be edited and will be asked during registration in each session of + this course. +

+ +
+
+ {{ q.question }} +
+
Type: {{ q.type }}
+
+
• {{ opt }}
+
+
Answer: Yes / No
+
Answer: (free text)
+
+
+
+ {{ q.question }} +
+
Type: {{ q.type }}
+
+
• {{ opt }}
+
+
Answer: Yes / No
+
Answer: (free text)
+
+
- +
No questions added yet. @@ -435,6 +501,7 @@ async function registerCourseSession() { +
diff --git a/frontend/src/components/SystemQuestionSetEditor.vue b/frontend/src/components/SystemQuestionSetEditor.vue new file mode 100644 index 0000000000000000000000000000000000000000..d57c406e5e2a94f90de0c89d87a9b7b8f6b309f6 --- /dev/null +++ b/frontend/src/components/SystemQuestionSetEditor.vue @@ -0,0 +1,237 @@ + + + diff --git a/frontend/src/components/auth/SignupForm.vue b/frontend/src/components/auth/SignupForm.vue index f9277b3cabe5d80f294b6ccd342ebca4abf073b7..80b8d0001cfbde640c64a15a3d7d8480a3d65899 100644 --- a/frontend/src/components/auth/SignupForm.vue +++ b/frontend/src/components/auth/SignupForm.vue @@ -25,7 +25,7 @@ async function registerUser() { showToast({ severity: 'success', - summary: 'Registration successful! refirecting to homepage' + summary: 'Registration successful! Redirecting to homepage' }) router.push({ name: 'HomeView' }) } catch (error) { diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 37fea08a52859f3e6520fb2ee49b51717c43086b..4167aee5408fd083dc2e0875886b485284beec0b 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -13,6 +13,7 @@ import SessionView from '../views/SessionView.vue' import QuestionSetView from '../views/QuestionSetView.vue' import CourseRegistrationView from '../views/CourseRegistrationView.vue' import CourseView from '../views/CourseView.vue' +import SystemQuestionSetView from '@/views/SystemQuestionSetView.vue' const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), @@ -77,6 +78,11 @@ const router = createRouter({ path: '/courses/:courseId', name: 'CourseView', component: CourseView + }, + { + path: '/system-question-sets', + name: 'SystemQuestionSets', + component: SystemQuestionSetView } ] }) diff --git a/frontend/src/views/CourseListView.vue b/frontend/src/views/CourseListView.vue index f4cc77432582bc4a1b823a00d418bd21ae9d1f9f..046de8b02fce8137e94e9b496965330788937b0c 100644 --- a/frontend/src/views/CourseListView.vue +++ b/frontend/src/views/CourseListView.vue @@ -70,9 +70,9 @@ function getImage(i) { />
- +
@@ -131,7 +131,7 @@ function getImage(i) { {{ course.title }}

- {{ course.unit_name || 'Unknown Unit' }} + {{ course?.unit?.name || 'Unknown Unit' }}

diff --git a/frontend/src/views/CourseView.vue b/frontend/src/views/CourseView.vue index 9552e0d24d3ea3e2595fb7d67815b78321ea59bd..c9948423c19d3d102127d75d3a2d240ab3459f34 100644 --- a/frontend/src/views/CourseView.vue +++ b/frontend/src/views/CourseView.vue @@ -73,11 +73,15 @@ onMounted(async () => { {{ course?.long_description || 'No long description available for this course.' }}
+
+ Organizing Unit: + {{ course?.unit?.name || 'Unknown Unit' }} +
-

+

Level: {{ course?.level || 'No level available.' }}

Learning outcomes: {{ course?.learning_outcomes || 'No learning outcomes available.' }} diff --git a/frontend/src/views/HomeView.vue b/frontend/src/views/HomeView.vue index 5994c54f3a6c837c6addb2f01336c43ac149a4ab..05e6cfc7c6f694ff0133192eee19144e5061123a 100644 --- a/frontend/src/views/HomeView.vue +++ b/frontend/src/views/HomeView.vue @@ -41,6 +41,11 @@ import { RouterLink } from 'vue-router' Manage Question Sets +

  • + + Manage System-Wide Question Sets + +
  • diff --git a/frontend/src/views/SessionView.vue b/frontend/src/views/SessionView.vue index 96a6163ab77011261c0cec07588f99ab4d329b49..1b57a4626baa563d418ef7ce21b6c4bc7df1d5dd 100644 --- a/frontend/src/views/SessionView.vue +++ b/frontend/src/views/SessionView.vue @@ -311,7 +311,22 @@ onMounted(async () => { {{ r.participant.email }} - {{ r.answers[q.question] || '—' }} + + diff --git a/frontend/src/views/SystemQuestionSetView.vue b/frontend/src/views/SystemQuestionSetView.vue new file mode 100644 index 0000000000000000000000000000000000000000..6bf2de59a2617f4b9ac90b526d01ce51a59d23c8 --- /dev/null +++ b/frontend/src/views/SystemQuestionSetView.vue @@ -0,0 +1,8 @@ + + + diff --git a/mkdocs.yml b/mkdocs.yml index 3125dce1d36ef82642b52e23f951a2a6fd9ff1a3..305be21384271255b0ad6fd5318787ed4cf1cabc 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -18,6 +18,8 @@ nav: - Product Docs: product_docs - Technical Docs: tech - Work in Progress: in_progress.md + - Events: events + - Glossary: glossary.md plugins: - include_dir_to_nav