diff --git a/compose/django/docker_start.sh b/compose/django/docker_start.sh index 4e30ebbe..fe215a17 100644 --- a/compose/django/docker_start.sh +++ b/compose/django/docker_start.sh @@ -24,5 +24,8 @@ fi # Create Prüfungslehrgang python /app/manage.py create_vermittler_pruefung +# Create Motorfahrzeug Prüfungslehrgang +python /app/manage.py create_motorfahrzeug_pruefung + # Set the command to run supervisord /home/django/.local/bin/supervisord -c /app/supervisord.conf diff --git a/server/vbv_lernwelt/course/admin.py b/server/vbv_lernwelt/course/admin.py index 581266a9..5777b9be 100644 --- a/server/vbv_lernwelt/course/admin.py +++ b/server/vbv_lernwelt/course/admin.py @@ -1,8 +1,15 @@ from django.contrib import admin from vbv_lernwelt.course.models import Course, CourseSession, CourseSessionUser +from vbv_lernwelt.feedback.services import ( + get_feedbacks_for_course_sessions, + get_feedbacks_for_courses, +) from vbv_lernwelt.learnpath.models import Circle +get_feedbacks_for_course_sessions.short_description = "Feedback export" +get_feedbacks_for_courses.short_description = "Feedback export" + @admin.register(Course) class CourseAdmin(admin.ModelAdmin): @@ -12,6 +19,7 @@ class CourseAdmin(admin.ModelAdmin): "category_name", "slug", ] + actions = [get_feedbacks_for_courses] @admin.register(CourseSession) @@ -26,6 +34,7 @@ class CourseSessionAdmin(admin.ModelAdmin): "created_at", "updated_at", ] + actions = [get_feedbacks_for_course_sessions] @admin.register(CourseSessionUser) diff --git a/server/vbv_lernwelt/course/consts.py b/server/vbv_lernwelt/course/consts.py index 39fc79c9..f31bc968 100644 --- a/server/vbv_lernwelt/course/consts.py +++ b/server/vbv_lernwelt/course/consts.py @@ -9,3 +9,4 @@ COURSE_UK_TRAINING_IT = -9 COURSE_VERSICHERUNGSVERMITTLERIN_FR_ID = -10 COURSE_VERSICHERUNGSVERMITTLERIN_IT_ID = -11 COURSE_VERSICHERUNGSVERMITTLERIN_PRUEFUNG_ID = -12 +COURSE_MOTORFAHRZEUG_PRUEFUNG_ID = -13 diff --git a/server/vbv_lernwelt/course/management/commands/create_default_courses.py b/server/vbv_lernwelt/course/management/commands/create_default_courses.py index af21c29e..cb106e08 100644 --- a/server/vbv_lernwelt/course/management/commands/create_default_courses.py +++ b/server/vbv_lernwelt/course/management/commands/create_default_courses.py @@ -49,6 +49,7 @@ from vbv_lernwelt.core.constants import TEST_MENTOR1_USER_ID from vbv_lernwelt.core.create_default_users import default_users from vbv_lernwelt.core.models import User from vbv_lernwelt.course.consts import ( + COURSE_MOTORFAHRZEUG_PRUEFUNG_ID, COURSE_TEST_ID, COURSE_UK, COURSE_UK_FR, @@ -95,6 +96,7 @@ from vbv_lernwelt.importer.services import ( ) from vbv_lernwelt.learning_mentor.models import LearningMentor from vbv_lernwelt.learnpath.create_vv_new_learning_path import ( + create_vv_motorfahrzeug_pruefung_learning_path, create_vv_new_learning_path, create_vv_pruefung_learning_path, ) @@ -309,6 +311,34 @@ def create_versicherungsvermittlerin_pruefung_course( create_vv_pruefung_learning_path(course_id=course_id) +def create_motorfahrzeug_pruefung_course( + course_id=COURSE_MOTORFAHRZEUG_PRUEFUNG_ID, language="de" +): + names = { + "de": "Motorfahrzeug Versicherungsvermittler/-in VBV Prüfung", + "fr": "Véhicules à moteur Intermédiaire d’assurance AFA Examen", + "it": "Veicolo a motore Intermediario/a assicurativo/a AFA Esame", + } + # Versicherungsvermittler/in mit neuen Circles + course = create_versicherungsvermittlerin_with_categories( + course_id=course_id, + title=names[language], + ) + + # assignments create assignments parent page + _assignment_list_page = AssignmentListPageFactory( + parent=course.coursepage, + ) + + create_vv_new_competence_profile(course_id=course_id) + create_default_media_library(course_id=course_id) + create_vv_reflection(course_id=course_id) + + CourseSession.objects.create(course_id=course_id, title=names[language]) + + create_vv_motorfahrzeug_pruefung_learning_path(course_id=course_id) + + def create_course_uk_de(course_id=COURSE_UK, lang="de"): names = { "de": "Überbetriebliche Kurse", diff --git a/server/vbv_lernwelt/course/management/commands/create_motorfahrzeug_pruefung.py b/server/vbv_lernwelt/course/management/commands/create_motorfahrzeug_pruefung.py new file mode 100644 index 00000000..1a14534a --- /dev/null +++ b/server/vbv_lernwelt/course/management/commands/create_motorfahrzeug_pruefung.py @@ -0,0 +1,23 @@ +import djclick as click + +from vbv_lernwelt.course.consts import COURSE_MOTORFAHRZEUG_PRUEFUNG_ID +from vbv_lernwelt.course.management.commands.create_default_courses import ( + create_motorfahrzeug_pruefung_course, +) +from vbv_lernwelt.course.models import Course + +ADMIN_EMAILS = ["info@iterativ.ch", "admin"] + + +@click.command() +def command(): + print( + "Creating Motorfahrzeug Vermittler Prüfung course", + COURSE_MOTORFAHRZEUG_PRUEFUNG_ID, + ) + + if Course.objects.filter(id=COURSE_MOTORFAHRZEUG_PRUEFUNG_ID).exists(): + print("Course already exists, skipping") + return + + create_motorfahrzeug_pruefung_course() diff --git a/server/vbv_lernwelt/feedback/management/__init__.py b/server/vbv_lernwelt/feedback/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/server/vbv_lernwelt/feedback/management/commands/__init__.py b/server/vbv_lernwelt/feedback/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/server/vbv_lernwelt/feedback/management/commands/export_feedback.py b/server/vbv_lernwelt/feedback/management/commands/export_feedback.py new file mode 100644 index 00000000..790661d9 --- /dev/null +++ b/server/vbv_lernwelt/feedback/management/commands/export_feedback.py @@ -0,0 +1,18 @@ +import djclick as click +import structlog + +from vbv_lernwelt.feedback.services import export_feedback + +logger = structlog.get_logger(__name__) + + +@click.command() +@click.argument("course_session_id") +@click.option( + "--save-as-file/--no-save-as-file", + default=True, + help="`save-as-file` to save the file, `no-save-as-file` returns bytes. Default is `save-as-file`.", +) +def command(course_session_id, save_as_file): + # using the output from call_command was a bit cumbersome, so this is just a wrapper for the actual function + export_feedback([course_session_id], save_as_file) diff --git a/server/vbv_lernwelt/feedback/services.py b/server/vbv_lernwelt/feedback/services.py index 6d6979f7..cd597e4e 100644 --- a/server/vbv_lernwelt/feedback/services.py +++ b/server/vbv_lernwelt/feedback/services.py @@ -1,6 +1,12 @@ +from datetime import datetime +from io import BytesIO +from itertools import groupby +from operator import attrgetter from typing import Union import structlog +from django.http import HttpResponse +from openpyxl import Workbook from vbv_lernwelt.core.models import User from vbv_lernwelt.course.models import CourseCompletionStatus, CourseSession @@ -13,6 +19,47 @@ from vbv_lernwelt.learnpath.models import ( logger = structlog.get_logger(__name__) +VV_FEEDBACK_QUESTIONS = [ + ("satisfaction", "Zufriedenheit insgesamt"), + ("goal_attainment", "Zielerreichung insgesamt"), + ( + "proficiency", + "Wie beurteilst du deine Sicherheit bezüglichen den Themen nach dem Circle?", + ), + ("preparation_task_clarity", "Waren die Praxisaufträge klar und verständlich?"), + ("would_recommend", "Würdest du den Circle weiterempfehlen?"), + ("course_positive_feedback", "Was hat dir besonders gut gefallen?"), + ("course_negative_feedback", "Wo siehst du Verbesserungspotential?"), +] + +UK_FEEDBACK_QUESTIONS = [ + ("satisfaction", "Zufriedenheit insgesamt"), + ("goal_attainment", "Zielerreichung insgesamt"), + ( + "proficiency", + "Wie beurteilst du deine Sicherheit bezüglichen den Themen nach dem Kurs?", + ), + ( + "preparation_task_clarity", + "Waren die Vorbereitungsaufträge klar und verständlich?", + ), + ( + "instructor_competence", + "Wie beurteilst du die Themensicherheit und Fachkompetenz des Kursleiters/der Kursleiterin?", + ), + ( + "instructor_respect", + "Wurden Fragen und Anregungen der Kursteilnehmenden ernst genommen und aufgegriffen?", + ), + ( + "instructor_open_feedback", + "Was möchtest du dem Kursleiter/der Kursleiterin sonst noch sagen?", + ), + ("would_recommend", "Würdest du den Kurs weiterempfehlen?"), + ("course_positive_feedback", "Was hat dir besonders gut gefallen?"), + ("course_negative_feedback", "Wo siehst du Verbesserungspotential?"), +] + def update_feedback_response( feedback_user: User, @@ -100,3 +147,104 @@ def initial_data_for_feedback_page( "feedback_type": "vv", } return {} + + +def export_feedback(course_session_ids: list[str], save_as_file: bool): + wb = Workbook() + + # remove the first sheet is just easier than keeping track of the active sheet + wb.remove_sheet(wb.active) + + feedbacks = FeedbackResponse.objects.filter( + course_session_id__in=course_session_ids, + submitted=True, + ).order_by("circle", "course_session", "updated_at") + grouped_feedbacks = groupby(feedbacks, key=attrgetter("circle")) + + for circle, group_feedbacks in grouped_feedbacks: + group_feedbacks = list(group_feedbacks) + logger.debug( + "export_feedback_for_circle", + data={ + "circle": circle.id, + "course_session_ids": course_session_ids, + "count": len(group_feedbacks), + }, + label="feedback_export", + ) + _create_sheet(wb, circle.title, group_feedbacks) + + if save_as_file: + wb.save(make_export_filename()) + else: + output = BytesIO() + wb.save(output) + + output.seek(0) + return output.getvalue() + + +def _create_sheet(wb: Workbook, title: str, data: list[FeedbackResponse]): + sheet = wb.create_sheet(title=title) + + if len(data) == 0: + return sheet + + # we instruct the users not to mix exports of different courses, so we can assume the questions are the same and of the first type + question_data = ( + UK_FEEDBACK_QUESTIONS + if data[0].data["feedback_type"] == "uk" + else VV_FEEDBACK_QUESTIONS + ) + + # add header + sheet.cell(row=1, column=1, value="Durchführung") + sheet.cell(row=1, column=2, value="Datum") + questions = [q[1] for q in question_data] + for col_idx, title in enumerate(questions, start=3): + sheet.cell(row=1, column=col_idx, value=title) + + _add_rows(sheet, data, question_data) + return sheet + + +def _add_rows(sheet, data, question_data): + for row_idx, feedback in enumerate(data, start=2): + sheet.cell(row=row_idx, column=1, value=feedback.course_session.title) + sheet.cell( + row=row_idx, column=2, value=feedback.updated_at.date().strftime("%d.%m.%Y") + ) + for col_idx, question in enumerate(question_data, start=3): + response = feedback.data.get(question[0], "") + sheet.cell(row=row_idx, column=col_idx, value=response) + + +def make_export_filename(name: str = "feedback_export"): + today_date = datetime.today().strftime("%Y-%m-%d") + return f"{name}_{today_date}.xlsx" + + +# used as admin action, that's why it's not in the views.py +def get_feedbacks_for_course_sessions(_modeladmin, _request, queryset): + file_name = "feedback_export_durchfuehrungen" + return _handle_feedback_export_action(queryset, file_name) + + +def get_feedbacks_for_courses(_modeladmin, _request, queryset): + course_sessions = CourseSession.objects.filter(course__in=queryset) + file_name = "feedback_export_lehrgaenge" + return _handle_feedback_export_action(course_sessions, file_name) + + +def _handle_feedback_export_action(course_seesions, file_name): + excel_bytes = export_feedback(course_seesions, False) + + response = HttpResponse( + content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + ) + response[ + "Content-Disposition" + ] = f"attachment; filename={make_export_filename(file_name)}" + response.write(excel_bytes) + + return response diff --git a/server/vbv_lernwelt/learnpath/create_vv_new_learning_path.py b/server/vbv_lernwelt/learnpath/create_vv_new_learning_path.py index a288a5fa..169fcc35 100644 --- a/server/vbv_lernwelt/learnpath/create_vv_new_learning_path.py +++ b/server/vbv_lernwelt/learnpath/create_vv_new_learning_path.py @@ -12,7 +12,11 @@ from vbv_lernwelt.competence.factories import ( ) from vbv_lernwelt.competence.models import ActionCompetence from vbv_lernwelt.core.admin import User -from vbv_lernwelt.course.consts import COURSE_VERSICHERUNGSVERMITTLERIN_ID +from vbv_lernwelt.course.consts import ( + COURSE_MOTORFAHRZEUG_PRUEFUNG_ID, + COURSE_VERSICHERUNGSVERMITTLERIN_ID, + COURSE_VERSICHERUNGSVERMITTLERIN_PRUEFUNG_ID, +) from vbv_lernwelt.course.models import CourseCategory, CoursePage from vbv_lernwelt.learnpath.models import LearningUnitPerformanceFeedbackType from vbv_lernwelt.learnpath.tests.learning_path_factories import ( @@ -89,7 +93,7 @@ def create_vv_new_learning_path( def create_vv_pruefung_learning_path( - course_id=COURSE_VERSICHERUNGSVERMITTLERIN_ID, user=None + course_id=COURSE_VERSICHERUNGSVERMITTLERIN_PRUEFUNG_ID, user=None ): if user is None: user = User.objects.get(username="info@iterativ.ch") @@ -108,6 +112,25 @@ def create_vv_pruefung_learning_path( Page.objects.update(owner=user) +def create_vv_motorfahrzeug_pruefung_learning_path( + course_id=COURSE_MOTORFAHRZEUG_PRUEFUNG_ID, user=None +): + if user is None: + user = User.objects.get(username="info@iterativ.ch") + + course_page = CoursePage.objects.get(course_id=course_id) + lp = LearningPathFactory( + title="Lernpfad", + parent=course_page, + ) + + TopicFactory(title="Fahrzeug", parent=lp) + create_circle_fahrzeug(lp, course_page=course_page) + + # all pages belong to 'admin' by default + Page.objects.update(owner=user) + + def create_circle_basis(lp, title="Basis", course_page=None): circle = CircleFactory( title=title,