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(() => { + +
Informational
++ Select a Question Set to use as a template, or add individual questions. These questions + will be shown to every user registering for a Session of this Course. +
++ These questions cannot be edited and will be asked during registration in each session of + this course. +
+ +- {{ 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.' }}+
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 +