From 833dc0e7c7d44e2617dbf36c64dd0fa1a93bfa6a Mon Sep 17 00:00:00 2001 From: Christian Cueni Date: Tue, 20 Feb 2024 07:05:10 +0100 Subject: [PATCH 01/11] Add tracking code --- server/config/settings/base.py | 1 + server/vbv_lernwelt/core/views.py | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/server/config/settings/base.py b/server/config/settings/base.py index b073746e..f4721dde 100644 --- a/server/config/settings/base.py +++ b/server/config/settings/base.py @@ -736,6 +736,7 @@ CONSTANCE_CONFIG = { "Default value is empty and will not send any emails. (No regex support!)", ), } +TRACKING_TAG = env("IT_TRACKING_TAG", default="") if APP_ENVIRONMENT == "local": # http://whitenoise.evans.io/en/latest/django.html#using-whitenoise-in-development diff --git a/server/vbv_lernwelt/core/views.py b/server/vbv_lernwelt/core/views.py index aeb6825e..c6eb2744 100644 --- a/server/vbv_lernwelt/core/views.py +++ b/server/vbv_lernwelt/core/views.py @@ -58,6 +58,12 @@ def vue_home(request, *args): # render index.html from `npm run build` content = loader.render_to_string("vue/index.html", context={}, request=request) + # inject Plausible tracking tag + if settings.TRACKING_TAG: + content = content.replace( + "", + f"\n{settings.TRACKING_TAG}\n", + ) return HttpResponse(content) From ae97931ca5578e11328af7161f6177cdbd68ba8d Mon Sep 17 00:00:00 2001 From: Christian Cueni Date: Thu, 22 Feb 2024 15:44:27 +0100 Subject: [PATCH 02/11] WIP: Create uk course session --- .../management/commands/reset_test_courses.py | 267 ++++++++++++++++++ 1 file changed, 267 insertions(+) create mode 100644 server/vbv_lernwelt/core/management/commands/reset_test_courses.py diff --git a/server/vbv_lernwelt/core/management/commands/reset_test_courses.py b/server/vbv_lernwelt/core/management/commands/reset_test_courses.py new file mode 100644 index 00000000..5075808f --- /dev/null +++ b/server/vbv_lernwelt/core/management/commands/reset_test_courses.py @@ -0,0 +1,267 @@ +from datetime import datetime, time, timedelta + +import djclick as click +import structlog +from django.utils import timezone + +from vbv_lernwelt.assignment.models import AssignmentCompletion +from vbv_lernwelt.core.admin import User +from vbv_lernwelt.course.consts import COURSE_UK +from vbv_lernwelt.course.models import ( + Course, + CourseCompletion, + CourseSession, + CourseSessionUser, +) +from vbv_lernwelt.course_session.models import CourseSessionAttendanceCourse +from vbv_lernwelt.course_session_group.models import CourseSessionGroup +from vbv_lernwelt.feedback.models import FeedbackResponse +from vbv_lernwelt.learning_mentor.models import LearningMentor +from vbv_lernwelt.learnpath.models import Circle +from vbv_lernwelt.notify.models import Notification + +logger = structlog.get_logger(__name__) +from vbv_lernwelt.importer.services import ( + create_or_update_course_session, + LP_DATA, + TRANSLATIONS, +) + +# create durchführung +# create users +# create / reset data coursesession-data +# reset connections + +IT_VV_TEST_COURSE = "Iterativ VV Testkurs" +IT_UK_TEST_COURSE = "Iterativ üK Testkurs" +IT_UK_TEST_REGION = "Iterativ Region" +TIME_FORMAT = "%d.%m.%Y, %H:%M" +PASSWORD = "KqaDm3-x8zhCKHLWDV_oiqFrYWHg" + +logger = structlog.get_logger(__name__) + + +@click.command() +def command(): + create_or_update_uk() + + +def create_or_update_uk(language="de"): + uk_course = Course.objects.get(id=COURSE_UK) + uk_circle_keys = [ + "Kickoff", + "Basis", + "Fahrzeug", + "Haushalt Teil 1", + "Haushalt Teil 2", + ] + + data = create_uk_data(language) + create_or_update_course_session( + uk_course, + data, + language, + circle_keys=uk_circle_keys, + ) + cs = CourseSession.objects.get(import_id=data["ID"]) + + members, member_with_mentor, trainer, regionenleiter, mentor = reset_uk_users() + delete_cs_data(cs, members + [member_with_mentor, trainer, regionenleiter, mentor]) + + add_to_course_session(cs, members + [member_with_mentor]) + add_trainers_to_course_session(cs, [trainer], uk_circle_keys, language) + add_mentor_to_course_session(cs, [(mentor, member_with_mentor)]) + create_and_add_to_cs_group(cs.course, IT_UK_TEST_REGION, [cs], regionenleiter) + + # create group for regionenleiter + + +def delete_cs_data(cs: CourseSession, users: list[User]): + try: + CourseCompletion.objects.filter(course_session=cs).delete() + Notification.objects.filter(course_session=cs).delete() + AssignmentCompletion.objects.filter(course_session=cs).delete() + CourseSessionAttendanceCourse.objects.filter(course_session=cs).update( + attendance_user_list=[] + ) + CourseSessionUser.objects.filter(course_session=cs).delete() + LearningMentor.objects.filter(course=cs.course).delete() + except CourseSession.DoesNotExist: + logger.info("no_course_session_found", import_id=cs.import_id) + + FeedbackResponse.objects.filter(feedback_user__in=users).delete() + + +def add_to_course_session( + course_session: CourseSession, + members: list[User], + role=CourseSessionUser.Role.MEMBER, +): + if course_session: + for user in members: + csu, _created = CourseSessionUser.objects.get_or_create( + course_session_id=course_session.id, user_id=user.id, role=role + ) + csu.save() + + +def add_mentor_to_course_session( + course_session: CourseSession, mentor_mentee_pairs: list[tuple[User, User]] +): + for mentor, mentee in mentor_mentee_pairs: + uk_mentor = LearningMentor.objects.create( + course=course_session.course, + mentor=mentor, + ) + uk_mentor.participants.add( + CourseSessionUser.objects.get( + user__id=mentee.id, + course_session=course_session, + ) + ) + + +def add_trainers_to_course_session( + course_session: CourseSession, + trainers: list[User], + circle_keys: list[str], + language, +): + add_to_course_session(course_session, trainers, CourseSessionUser.Role.EXPERT) + for user in trainers: + for circle_key in circle_keys: + circle_name = LP_DATA[circle_key][language]["title"] + circle = Circle.objects.filter( + slug=f"{course_session.course.slug}-lp-circle-{circle_name.lower()}" + ).first() + + if course_session and circle: + csu = CourseSessionUser.objects.filter( + course_session_id=course_session.id, user_id=user.id + ).first() + if csu: + csu.expert.add(circle) + csu.save() + + +def reset_uk_users(): + # todo create more users + members = [ + _create_or_update_user( + f"teilnehmer{n}.uk@iterativ.ch", "Teilnehmer üK", "Iterativ", PASSWORD, "de" + ) + for n in range(1, 10) + ] + member_with_mentor = _create_or_update_user( + "teilnehmer1.uk.lb@iterativ.ch", + "Teilnehmer üK mit LB", + "Iterativ", + PASSWORD, + "de", + ) + trainer = _create_or_update_user( + "trainer1.uk@iterativ.ch", "Trainer üK", "Iterativ", PASSWORD, "de" + ) + regionenleiter = _create_or_update_user( + "regionenleiter1.uk@iterativ.ch", + "Regionenleiter üK", + "Iterativ", + PASSWORD, + "de", + ) + mentor = _create_or_update_user( + "lernbegleitung1.uk@iterativ.ch", + "Lernbegleitung üK", + "Iterativ", + PASSWORD, + "de", + ) + return members, member_with_mentor, trainer, regionenleiter, mentor + + +def _create_or_update_user(email, first_name, last_name, password, language): + try: + user = User.objects.get(email=email) + except User.DoesNotExist: + user = User( + email=email, + username=email, + ) + + user.email = email + user.first_name = first_name or user.first_name + user.last_name = last_name or user.last_name + user.username = email + user.language = language + user.set_password(password) + user.save() + return user + + +def create_uk_data(language): + return { + "Klasse": "Iterativ Test üK", + "ID": "Iterativ Test üK", + "Generation": 2024, + "Region": "Bern", + "Sprache": language, + f"Kickoff {TRANSLATIONS[language]['start']}": timezone.make_aware( + datetime.combine((timezone.now() + timedelta(weeks=2)).date(), time(9, 0)) + ).strftime("%d.%m.%Y, %H:%M"), + f"Kickoff {TRANSLATIONS[language]['ende']}": timezone.make_aware( + datetime.combine((timezone.now() + timedelta(weeks=2)).date(), time(16, 0)) + ).strftime("%d.%m.%Y, %H:%M"), + f"Kickoff {TRANSLATIONS[language]['raum']}": "Raum 1", + f"Kickoff {TRANSLATIONS[language]['standort']}": "Bern", + f"Kickoff {TRANSLATIONS[language]['adresse']}": "Musterstrasse 1", + f"Basis {TRANSLATIONS[language]['start']}": timezone.make_aware( + datetime.combine((timezone.now() + timedelta(weeks=4)).date(), time(9, 0)) + ).strftime("%d.%m.%Y, %H:%M"), + f"Basis {TRANSLATIONS[language]['ende']}": timezone.make_aware( + datetime.combine((timezone.now() + timedelta(weeks=4)).date(), time(16, 0)) + ).strftime("%d.%m.%Y, %H:%M"), + f"Basis {TRANSLATIONS[language]['raum']}": "Raum 1", + f"Basis {TRANSLATIONS[language]['standort']}": "Bern", + f"Basis {TRANSLATIONS[language]['adresse']}": "Musterstrasse 1", + f"Fahrzeug {TRANSLATIONS[language]['start']}": timezone.make_aware( + datetime.combine((timezone.now() + timedelta(weeks=6)).date(), time(9, 0)) + ).strftime("%d.%m.%Y, %H:%M"), + f"Fahrzeug {TRANSLATIONS[language]['ende']}": timezone.make_aware( + datetime.combine((timezone.now() + timedelta(weeks=6)).date(), time(16, 0)) + ).strftime("%d.%m.%Y, %H:%M"), + f"Fahrzeug {TRANSLATIONS[language]['raum']}": "Raum 1", + f"Fahrzeug {TRANSLATIONS[language]['standort']}": "Bern", + f"Fahrzeug {TRANSLATIONS[language]['adresse']}": "Musterstrasse 1", + f"Haushalt Teil 1 {TRANSLATIONS[language]['start']}": timezone.make_aware( + datetime.combine((timezone.now() + timedelta(weeks=8)).date(), time(9, 0)) + ).strftime("%d.%m.%Y, %H:%M"), + f"Haushalt Teil 1 {TRANSLATIONS[language]['ende']}": timezone.make_aware( + datetime.combine((timezone.now() + timedelta(weeks=8)).date(), time(16, 0)) + ).strftime("%d.%m.%Y, %H:%M"), + f"Haushalt Teil 1 {TRANSLATIONS[language]['raum']}": "Raum 1", + f"Haushalt Teil 1 {TRANSLATIONS[language]['standort']}": "Bern", + f"Haushalt Teil 1 {TRANSLATIONS[language]['adresse']}": "Musterstrasse 1", + f"Haushalt Teil 2 {TRANSLATIONS[language]['start']}": timezone.make_aware( + datetime.combine((timezone.now() + timedelta(weeks=10)).date(), time(9, 0)) + ).strftime("%d.%m.%Y, %H:%M"), + f"Haushalt Teil 2 {TRANSLATIONS[language]['ende']}": timezone.make_aware( + datetime.combine((timezone.now() + timedelta(weeks=10)).date(), time(16, 0)) + ).strftime("%d.%m.%Y, %H:%M"), + f"Haushalt Teil 2 {TRANSLATIONS[language]['raum']}": "Raum 1", + f"Haushalt Teil 2 {TRANSLATIONS[language]['standort']}": "Bern", + f"Haushalt Teil 2 {TRANSLATIONS[language]['adresse']}": "Musterstrasse 1", + } + + +def create_and_add_to_cs_group( + course: Course, name: str, course_sessions: list[CourseSession], supervisor: User +): + region, _ = CourseSessionGroup.objects.get_or_create( + name=name, + course=course, + ) + + for cs in course_sessions: + region.course_session.add(cs) + + region.supervisor.add(supervisor) From abb9b4b7db693d622b4ba759f76b8f1852fc00fe Mon Sep 17 00:00:00 2001 From: Christian Cueni Date: Mon, 26 Feb 2024 09:33:01 +0100 Subject: [PATCH 03/11] Add VV reset --- .../management/commands/reset_test_courses.py | 94 ++++++++++++++++--- 1 file changed, 81 insertions(+), 13 deletions(-) diff --git a/server/vbv_lernwelt/core/management/commands/reset_test_courses.py b/server/vbv_lernwelt/core/management/commands/reset_test_courses.py index 5075808f..73be4444 100644 --- a/server/vbv_lernwelt/core/management/commands/reset_test_courses.py +++ b/server/vbv_lernwelt/core/management/commands/reset_test_courses.py @@ -4,16 +4,23 @@ import djclick as click import structlog from django.utils import timezone -from vbv_lernwelt.assignment.models import AssignmentCompletion +from vbv_lernwelt.assignment.models import Assignment, AssignmentCompletion from vbv_lernwelt.core.admin import User -from vbv_lernwelt.course.consts import COURSE_UK +from vbv_lernwelt.course.consts import ( + COURSE_VERSICHERUNGSVERMITTLERIN_FR_ID, + COURSE_VERSICHERUNGSVERMITTLERIN_ID, + COURSE_VERSICHERUNGSVERMITTLERIN_IT_ID, +) from vbv_lernwelt.course.models import ( Course, CourseCompletion, CourseSession, CourseSessionUser, ) -from vbv_lernwelt.course_session.models import CourseSessionAttendanceCourse +from vbv_lernwelt.course_session.models import ( + CourseSessionAssignment, + CourseSessionAttendanceCourse, +) from vbv_lernwelt.course_session_group.models import CourseSessionGroup from vbv_lernwelt.feedback.models import FeedbackResponse from vbv_lernwelt.learning_mentor.models import LearningMentor @@ -23,15 +30,11 @@ from vbv_lernwelt.notify.models import Notification logger = structlog.get_logger(__name__) from vbv_lernwelt.importer.services import ( create_or_update_course_session, + get_uk_course, LP_DATA, TRANSLATIONS, ) -# create durchführung -# create users -# create / reset data coursesession-data -# reset connections - IT_VV_TEST_COURSE = "Iterativ VV Testkurs" IT_UK_TEST_COURSE = "Iterativ üK Testkurs" IT_UK_TEST_REGION = "Iterativ Region" @@ -44,10 +47,11 @@ logger = structlog.get_logger(__name__) @click.command() def command(): create_or_update_uk() + create_or_update_vv() def create_or_update_uk(language="de"): - uk_course = Course.objects.get(id=COURSE_UK) + uk_course = get_uk_course(language) uk_circle_keys = [ "Kickoff", "Basis", @@ -73,7 +77,21 @@ def create_or_update_uk(language="de"): add_mentor_to_course_session(cs, [(mentor, member_with_mentor)]) create_and_add_to_cs_group(cs.course, IT_UK_TEST_REGION, [cs], regionenleiter) - # create group for regionenleiter + +def create_or_update_vv(language="de"): + vv_course = get_vv_course(language) + + cs, _created = CourseSession.objects.get_or_create( + course=vv_course, import_id=IT_VV_TEST_COURSE + ) + cs.title = IT_VV_TEST_COURSE + cs.save() + create_or_update_assignment_course_session(cs) + members, member_with_mentor, mentor = reset_vv_users() + delete_cs_data(cs, members + [member_with_mentor, mentor]) + + add_to_course_session(cs, members + [member_with_mentor]) + add_mentor_to_course_session(cs, [(mentor, member_with_mentor)]) def delete_cs_data(cs: CourseSession, users: list[User]): @@ -145,7 +163,6 @@ def add_trainers_to_course_session( def reset_uk_users(): - # todo create more users members = [ _create_or_update_user( f"teilnehmer{n}.uk@iterativ.ch", "Teilnehmer üK", "Iterativ", PASSWORD, "de" @@ -179,6 +196,30 @@ def reset_uk_users(): return members, member_with_mentor, trainer, regionenleiter, mentor +def reset_vv_users(): + members = [ + _create_or_update_user( + f"teilnehmer{n}.vv@iterativ.ch", "Teilnehmer VV", "Iterativ", PASSWORD, "de" + ) + for n in range(1, 10) + ] + member_with_mentor = _create_or_update_user( + "teilnehmer1.vv.lb@iterativ.ch", + "Teilnehmer VV mit LB", + "Iterativ", + PASSWORD, + "de", + ) + mentor = _create_or_update_user( + "lernbegleitung1.vv@iterativ.ch", + "Lernbegleitung VV", + "Iterativ", + PASSWORD, + "de", + ) + return members, member_with_mentor, mentor + + def _create_or_update_user(email, first_name, last_name, password, language): try: user = User.objects.get(email=email) @@ -200,8 +241,8 @@ def _create_or_update_user(email, first_name, last_name, password, language): def create_uk_data(language): return { - "Klasse": "Iterativ Test üK", - "ID": "Iterativ Test üK", + "Klasse": IT_UK_TEST_COURSE, + "ID": IT_UK_TEST_COURSE, "Generation": 2024, "Region": "Bern", "Sprache": language, @@ -265,3 +306,30 @@ def create_and_add_to_cs_group( region.course_session.add(cs) region.supervisor.add(supervisor) + + +def get_vv_course(language: str) -> Course: + if language == "fr": + course_id = COURSE_VERSICHERUNGSVERMITTLERIN_FR_ID + elif language == "it": + course_id = COURSE_VERSICHERUNGSVERMITTLERIN_IT_ID + else: + course_id = COURSE_VERSICHERUNGSVERMITTLERIN_ID + + return Course.objects.get(id=course_id) + + +def create_or_update_assignment_course_session(cs: CourseSession): + # not nice but works for now + for assignment in Assignment.objects.all(): + if assignment.get_course().id == cs.course.id: + logger.debug( + "create_course_session_assigments", + assignment=assignment, + label="reset_test_courses", + ) + for lca in assignment.learningcontentassignment_set.all(): + _csa, _created = CourseSessionAssignment.objects.get_or_create( + course_session=cs, + learning_content=lca, + ) From a407b76038a1af4e23ceb731003195087a83d3e5 Mon Sep 17 00:00:00 2001 From: Christian Cueni Date: Mon, 26 Feb 2024 09:53:24 +0100 Subject: [PATCH 04/11] Add endpoint and button in admin ui --- server/config/urls.py | 8 +++ ...ses.py => reset_iterativ_test_sessions.py} | 53 ++++++++++--------- server/vbv_lernwelt/core/views.py | 11 ++++ .../vbv_lernwelt/templates/admin/index.html | 8 +++ 4 files changed, 54 insertions(+), 26 deletions(-) rename server/vbv_lernwelt/core/management/commands/{reset_test_courses.py => reset_iterativ_test_sessions.py} (91%) diff --git a/server/config/urls.py b/server/config/urls.py index 1f2b36fa..42917ad9 100644 --- a/server/config/urls.py +++ b/server/config/urls.py @@ -25,6 +25,7 @@ from vbv_lernwelt.core.views import ( check_rate_limit, cypress_reset_view, generate_web_component_icons, + iterativ_test_coursesessions_reset_view, permission_denied_view, rate_limit_exceeded_view, vue_home, @@ -209,6 +210,13 @@ urlpatterns = [ name="t2l_sync", ), + # iterativ Test course sessions + path( + r"api/core/resetiterativsessions/", + iterativ_test_coursesessions_reset_view, + name="iterativ_test_coursesessions_reset_view", + ), + path("server/graphql/", csrf_exempt(GraphQLView.as_view(graphiql=True, schema=schema))), # testing and debug diff --git a/server/vbv_lernwelt/core/management/commands/reset_test_courses.py b/server/vbv_lernwelt/core/management/commands/reset_iterativ_test_sessions.py similarity index 91% rename from server/vbv_lernwelt/core/management/commands/reset_test_courses.py rename to server/vbv_lernwelt/core/management/commands/reset_iterativ_test_sessions.py index 73be4444..ed690011 100644 --- a/server/vbv_lernwelt/core/management/commands/reset_test_courses.py +++ b/server/vbv_lernwelt/core/management/commands/reset_iterativ_test_sessions.py @@ -20,6 +20,7 @@ from vbv_lernwelt.course.models import ( from vbv_lernwelt.course_session.models import ( CourseSessionAssignment, CourseSessionAttendanceCourse, + CourseSessionEdoniqTest, ) from vbv_lernwelt.course_session_group.models import CourseSessionGroup from vbv_lernwelt.feedback.models import FeedbackResponse @@ -69,12 +70,11 @@ def create_or_update_uk(language="de"): ) cs = CourseSession.objects.get(import_id=data["ID"]) - members, member_with_mentor, trainer, regionenleiter, mentor = reset_uk_users() - delete_cs_data(cs, members + [member_with_mentor, trainer, regionenleiter, mentor]) + members, trainer, regionenleiter = get_or_create_users_uk() + delete_cs_data(cs, members + [trainer, regionenleiter]) - add_to_course_session(cs, members + [member_with_mentor]) + add_to_course_session(cs, members) add_trainers_to_course_session(cs, [trainer], uk_circle_keys, language) - add_mentor_to_course_session(cs, [(mentor, member_with_mentor)]) create_and_add_to_cs_group(cs.course, IT_UK_TEST_REGION, [cs], regionenleiter) @@ -86,8 +86,9 @@ def create_or_update_vv(language="de"): ) cs.title = IT_VV_TEST_COURSE cs.save() + create_or_update_assignment_course_session(cs) - members, member_with_mentor, mentor = reset_vv_users() + members, member_with_mentor, mentor = get_or_create_users_vv() delete_cs_data(cs, members + [member_with_mentor, mentor]) add_to_course_session(cs, members + [member_with_mentor]) @@ -95,16 +96,26 @@ def create_or_update_vv(language="de"): def delete_cs_data(cs: CourseSession, users: list[User]): - try: + if cs: CourseCompletion.objects.filter(course_session=cs).delete() Notification.objects.filter(course_session=cs).delete() AssignmentCompletion.objects.filter(course_session=cs).delete() CourseSessionAttendanceCourse.objects.filter(course_session=cs).update( attendance_user_list=[] ) + CourseSessionEdoniqTest.objects.filter(course_session=cs).delete() CourseSessionUser.objects.filter(course_session=cs).delete() - LearningMentor.objects.filter(course=cs.course).delete() - except CourseSession.DoesNotExist: + learning_mentor_ids = ( + LearningMentor.objects.filter(participants__course_session=cs) + .values_list("id", flat=True) + .distinct() + | LearningMentor.objects.filter(mentor__in=users) + .values_list("id", flat=True) + .distinct() + ) + # cannot call delete on distinct objects + LearningMentor.objects.filter(id__in=list(learning_mentor_ids)).delete() + else: logger.info("no_course_session_found", import_id=cs.import_id) FeedbackResponse.objects.filter(feedback_user__in=users).delete() @@ -127,11 +138,11 @@ def add_mentor_to_course_session( course_session: CourseSession, mentor_mentee_pairs: list[tuple[User, User]] ): for mentor, mentee in mentor_mentee_pairs: - uk_mentor = LearningMentor.objects.create( + lm = LearningMentor.objects.create( course=course_session.course, mentor=mentor, ) - uk_mentor.participants.add( + lm.participants.add( CourseSessionUser.objects.get( user__id=mentee.id, course_session=course_session, @@ -162,20 +173,13 @@ def add_trainers_to_course_session( csu.save() -def reset_uk_users(): +def get_or_create_users_uk(): members = [ _create_or_update_user( f"teilnehmer{n}.uk@iterativ.ch", "Teilnehmer üK", "Iterativ", PASSWORD, "de" ) for n in range(1, 10) ] - member_with_mentor = _create_or_update_user( - "teilnehmer1.uk.lb@iterativ.ch", - "Teilnehmer üK mit LB", - "Iterativ", - PASSWORD, - "de", - ) trainer = _create_or_update_user( "trainer1.uk@iterativ.ch", "Trainer üK", "Iterativ", PASSWORD, "de" ) @@ -186,17 +190,14 @@ def reset_uk_users(): PASSWORD, "de", ) - mentor = _create_or_update_user( - "lernbegleitung1.uk@iterativ.ch", - "Lernbegleitung üK", - "Iterativ", - PASSWORD, - "de", + return ( + members, + trainer, + regionenleiter, ) - return members, member_with_mentor, trainer, regionenleiter, mentor -def reset_vv_users(): +def get_or_create_users_vv(): members = [ _create_or_update_user( f"teilnehmer{n}.vv@iterativ.ch", "Teilnehmer VV", "Iterativ", PASSWORD, "de" diff --git a/server/vbv_lernwelt/core/views.py b/server/vbv_lernwelt/core/views.py index 854530a1..1e984b4d 100644 --- a/server/vbv_lernwelt/core/views.py +++ b/server/vbv_lernwelt/core/views.py @@ -179,6 +179,17 @@ def cypress_reset_view(request): return HttpResponseRedirect("/server/admin/") +@api_view(["POST"]) +@authentication_classes((authentication.SessionAuthentication,)) +@permission_classes((IsAdminUser,)) +def iterativ_test_coursesessions_reset_view(request): + call_command( + "reset_iterativ_test_sessions", + ) + + return HttpResponseRedirect("/server/admin/") + + @django_view_authentication_exempt def generate_web_component_icons(request): svg_files = [] diff --git a/server/vbv_lernwelt/templates/admin/index.html b/server/vbv_lernwelt/templates/admin/index.html index d6cabe3f..55e83b58 100644 --- a/server/vbv_lernwelt/templates/admin/index.html +++ b/server/vbv_lernwelt/templates/admin/index.html @@ -43,6 +43,14 @@ Teilnehmer und Trainer exportieren +
+ +
+ {% csrf_token %} +

Zurücksetzen der Iterativ Testdurchführungen (üK: "Iterativ üK Testkurs", VV: "Iterativ VV Testkurs")

+ +
+
From a3d65eb78d2f8b63155977a2bfaf89e0317d1fab Mon Sep 17 00:00:00 2001 From: Christian Cueni Date: Wed, 28 Feb 2024 07:50:31 +0100 Subject: [PATCH 05/11] WIP: Add feedback export to file --- .../feedback/management/__init__.py | 0 .../feedback/management/commands/__init__.py | 0 .../management/commands/export_feedback.py | 74 +++++++++++++++++++ 3 files changed, 74 insertions(+) create mode 100644 server/vbv_lernwelt/feedback/management/__init__.py create mode 100644 server/vbv_lernwelt/feedback/management/commands/__init__.py create mode 100644 server/vbv_lernwelt/feedback/management/commands/export_feedback.py 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..011275f3 --- /dev/null +++ b/server/vbv_lernwelt/feedback/management/commands/export_feedback.py @@ -0,0 +1,74 @@ +from datetime import datetime +from itertools import groupby +from operator import attrgetter + +import djclick as click +import structlog +from openpyxl import Workbook + +from vbv_lernwelt.feedback.models import FeedbackResponse + +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?"), +] + +logger = structlog.get_logger(__name__) + + +@click.command() +@click.argument("course_session_id") +def command(course_session_id): + wb = Workbook() + wb.remove_sheet(wb.active) + # Create another sheet + feedbacks = FeedbackResponse.objects.filter( + course_session_id=course_session_id + ).order_by("circle") + grouped_feedbacks = groupby(feedbacks, key=attrgetter("circle")) + + for circle, group_feedbacks in grouped_feedbacks: + logger.debug( + "export_feedback_for_circle", + data={ + "circle": circle.title, + "course_session_id": course_session_id, + "count": sum(1 for _ in group_feedbacks), + }, + label="feedback_export", + ) + create_sheet(wb, circle.title, group_feedbacks) + + today_date = datetime.today().strftime("%Y-%m-%d") + + # Set the filename with today's date + filename = f"feedback_export_{today_date}.xlsx" + wb.save("example.xlsx") + + +def create_sheet(wb: Workbook, title: str, data: list[FeedbackResponse]): + sheet = wb.create_sheet(title=title) + # add header + questions = [q[1] for q in VV_FEEDBACK_QUESTIONS] + for col_idx, title in enumerate(questions, start=2): + sheet.cell(row=1, column=col_idx, value=title) + + add_rows(sheet, data) + return sheet + + +def add_rows(sheet, data): + for row_idx, feedback in enumerate(data, start=2): + for col_idx, question in enumerate(VV_FEEDBACK_QUESTIONS, start=2): + response = feedback.data.get(question[0], "") + sheet.cell(row=row_idx, column=col_idx, value=response) From 603c0544c2cadec22ca6ab6430d857c997d936b1 Mon Sep 17 00:00:00 2001 From: Christian Cueni Date: Wed, 28 Feb 2024 10:50:03 +0100 Subject: [PATCH 06/11] WIP: Add view --- server/config/urls.py | 10 ++++-- .../management/commands/export_feedback.py | 36 +++++++++++++++---- server/vbv_lernwelt/feedback/views.py | 19 +++++++++- 3 files changed, 54 insertions(+), 11 deletions(-) diff --git a/server/config/urls.py b/server/config/urls.py index 1f2b36fa..2d96e5cb 100644 --- a/server/config/urls.py +++ b/server/config/urls.py @@ -10,6 +10,9 @@ from django.views import defaults as default_views from django.views.decorators.csrf import csrf_exempt from django_ratelimit.exceptions import Ratelimited from graphene_django.views import GraphQLView +from wagtail import urls as wagtail_urls +from wagtail.admin import urls as wagtailadmin_urls +from wagtail.documents import urls as media_library_urls from vbv_lernwelt.api.directory import list_entities from vbv_lernwelt.api.user import ( @@ -52,6 +55,7 @@ from vbv_lernwelt.edoniq_test.views import ( from vbv_lernwelt.feedback.views import ( get_expert_feedbacks_for_course, get_feedback_for_circle, + export_feedback_for_course_session, ) from vbv_lernwelt.files.views import presign from vbv_lernwelt.importer.views import ( @@ -61,9 +65,6 @@ from vbv_lernwelt.importer.views import ( ) from vbv_lernwelt.media_files.views import user_image from vbv_lernwelt.notify.views import email_notification_settings -from wagtail import urls as wagtail_urls -from wagtail.admin import urls as wagtailadmin_urls -from wagtail.documents import urls as media_library_urls class SignedIntConverter(IntConverter): @@ -171,6 +172,9 @@ urlpatterns = [ name='storage_presign'), # feedback + path(r'api/core/feedback/export//', + export_feedback_for_course_session, + name='export_feedback_for_course_session'), path(r'api/core/feedback//summary/', get_expert_feedbacks_for_course, name='feedback_summary'), diff --git a/server/vbv_lernwelt/feedback/management/commands/export_feedback.py b/server/vbv_lernwelt/feedback/management/commands/export_feedback.py index 011275f3..8864f956 100644 --- a/server/vbv_lernwelt/feedback/management/commands/export_feedback.py +++ b/server/vbv_lernwelt/feedback/management/commands/export_feedback.py @@ -1,4 +1,5 @@ from datetime import datetime +from io import BytesIO from itertools import groupby from operator import attrgetter @@ -28,32 +29,48 @@ logger = structlog.get_logger(__name__) @click.command() @click.argument("course_session_id") -def command(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) + + +def export_feedback(course_session_id: 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) - # Create another sheet + feedbacks = FeedbackResponse.objects.filter( course_session_id=course_session_id ).order_by("circle") 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.title, "course_session_id": course_session_id, - "count": sum(1 for _ in group_feedbacks), + "count": len(group_feedbacks), }, label="feedback_export", ) create_sheet(wb, circle.title, group_feedbacks) - today_date = datetime.today().strftime("%Y-%m-%d") + if save_as_file: + wb.save(make_export_filename()) + else: + output = BytesIO() + wb.save(output) - # Set the filename with today's date - filename = f"feedback_export_{today_date}.xlsx" - wb.save("example.xlsx") + output.seek(0) + return output.getvalue() def create_sheet(wb: Workbook, title: str, data: list[FeedbackResponse]): @@ -72,3 +89,8 @@ def add_rows(sheet, data): for col_idx, question in enumerate(VV_FEEDBACK_QUESTIONS, start=2): response = feedback.data.get(question[0], "") sheet.cell(row=row_idx, column=col_idx, value=response) + + +def make_export_filename(): + today_date = datetime.today().strftime("%Y-%m-%d") + return f"feedback_export_{today_date}.xlsx" diff --git a/server/vbv_lernwelt/feedback/views.py b/server/vbv_lernwelt/feedback/views.py index ecd11483..13dbe37c 100644 --- a/server/vbv_lernwelt/feedback/views.py +++ b/server/vbv_lernwelt/feedback/views.py @@ -1,10 +1,14 @@ import itertools import structlog -from rest_framework.decorators import api_view +from django.http import HttpResponse +from rest_framework import authentication +from rest_framework.decorators import api_view, authentication_classes, permission_classes from rest_framework.exceptions import PermissionDenied +from rest_framework.permissions import IsAdminUser from rest_framework.response import Response +from vbv_lernwelt.feedback.management.commands.export_feedback import export_feedback, make_export_filename from vbv_lernwelt.feedback.models import FeedbackResponse from vbv_lernwelt.feedback.utils import feedback_users from vbv_lernwelt.iam.permissions import is_course_session_expert @@ -78,3 +82,16 @@ def get_feedback_for_circle(request, course_session_id, circle_id): feedback_data["questions"][field].append(data) return Response(status=200, data=feedback_data) + + +@api_view(["GET"]) +@authentication_classes((authentication.SessionAuthentication,)) +@permission_classes((IsAdminUser,)) +def export_feedback_for_course_session(request, course_session_id): + excel_bytes = export_feedback(course_session_id, False) + + response = HttpResponse(content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + response['Content-Disposition'] = f"attachment; filename={make_export_filename()}" + response.write(excel_bytes) + + return response From 798c159ee75050974602cc5489a6252d047b38b8 Mon Sep 17 00:00:00 2001 From: Christian Cueni Date: Wed, 28 Feb 2024 11:40:10 +0100 Subject: [PATCH 07/11] Move code to service, add admin action --- server/config/urls.py | 10 +- server/vbv_lernwelt/course/admin.py | 4 + .../management/commands/export_feedback.py | 80 +--------------- server/vbv_lernwelt/feedback/services.py | 94 +++++++++++++++++++ server/vbv_lernwelt/feedback/views.py | 19 +--- 5 files changed, 103 insertions(+), 104 deletions(-) diff --git a/server/config/urls.py b/server/config/urls.py index 2d96e5cb..1f2b36fa 100644 --- a/server/config/urls.py +++ b/server/config/urls.py @@ -10,9 +10,6 @@ from django.views import defaults as default_views from django.views.decorators.csrf import csrf_exempt from django_ratelimit.exceptions import Ratelimited from graphene_django.views import GraphQLView -from wagtail import urls as wagtail_urls -from wagtail.admin import urls as wagtailadmin_urls -from wagtail.documents import urls as media_library_urls from vbv_lernwelt.api.directory import list_entities from vbv_lernwelt.api.user import ( @@ -55,7 +52,6 @@ from vbv_lernwelt.edoniq_test.views import ( from vbv_lernwelt.feedback.views import ( get_expert_feedbacks_for_course, get_feedback_for_circle, - export_feedback_for_course_session, ) from vbv_lernwelt.files.views import presign from vbv_lernwelt.importer.views import ( @@ -65,6 +61,9 @@ from vbv_lernwelt.importer.views import ( ) from vbv_lernwelt.media_files.views import user_image from vbv_lernwelt.notify.views import email_notification_settings +from wagtail import urls as wagtail_urls +from wagtail.admin import urls as wagtailadmin_urls +from wagtail.documents import urls as media_library_urls class SignedIntConverter(IntConverter): @@ -172,9 +171,6 @@ urlpatterns = [ name='storage_presign'), # feedback - path(r'api/core/feedback/export//', - export_feedback_for_course_session, - name='export_feedback_for_course_session'), path(r'api/core/feedback//summary/', get_expert_feedbacks_for_course, name='feedback_summary'), diff --git a/server/vbv_lernwelt/course/admin.py b/server/vbv_lernwelt/course/admin.py index 581266a9..1285f3e8 100644 --- a/server/vbv_lernwelt/course/admin.py +++ b/server/vbv_lernwelt/course/admin.py @@ -1,8 +1,11 @@ from django.contrib import admin from vbv_lernwelt.course.models import Course, CourseSession, CourseSessionUser +from vbv_lernwelt.feedback.services import get_feedbacks_for_course from vbv_lernwelt.learnpath.models import Circle +get_feedbacks_for_course.short_description = "Feedback export" + @admin.register(Course) class CourseAdmin(admin.ModelAdmin): @@ -26,6 +29,7 @@ class CourseSessionAdmin(admin.ModelAdmin): "created_at", "updated_at", ] + actions = [get_feedbacks_for_course] @admin.register(CourseSessionUser) diff --git a/server/vbv_lernwelt/feedback/management/commands/export_feedback.py b/server/vbv_lernwelt/feedback/management/commands/export_feedback.py index 8864f956..f0cae06b 100644 --- a/server/vbv_lernwelt/feedback/management/commands/export_feedback.py +++ b/server/vbv_lernwelt/feedback/management/commands/export_feedback.py @@ -1,28 +1,7 @@ -from datetime import datetime -from io import BytesIO -from itertools import groupby -from operator import attrgetter - import djclick as click import structlog -from openpyxl import Workbook -from vbv_lernwelt.feedback.models import FeedbackResponse - -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?"), -] +from vbv_lernwelt.feedback.services import export_feedback logger = structlog.get_logger(__name__) @@ -37,60 +16,3 @@ logger = structlog.get_logger(__name__) 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) - - -def export_feedback(course_session_id: 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=course_session_id - ).order_by("circle") - 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.title, - "course_session_id": course_session_id, - "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) - # add header - questions = [q[1] for q in VV_FEEDBACK_QUESTIONS] - for col_idx, title in enumerate(questions, start=2): - sheet.cell(row=1, column=col_idx, value=title) - - add_rows(sheet, data) - return sheet - - -def add_rows(sheet, data): - for row_idx, feedback in enumerate(data, start=2): - for col_idx, question in enumerate(VV_FEEDBACK_QUESTIONS, start=2): - response = feedback.data.get(question[0], "") - sheet.cell(row=row_idx, column=col_idx, value=response) - - -def make_export_filename(): - today_date = datetime.today().strftime("%Y-%m-%d") - return f"feedback_export_{today_date}.xlsx" diff --git a/server/vbv_lernwelt/feedback/services.py b/server/vbv_lernwelt/feedback/services.py index 6d6979f7..265139d1 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,19 @@ 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?"), +] + def update_feedback_response( feedback_user: User, @@ -100,3 +119,78 @@ def initial_data_for_feedback_page( "feedback_type": "vv", } return {} + + +def export_feedback(course_session_id: 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=course_session_id, + submitted=True, + ).order_by("circle", "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_id": course_session_id, + "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) + # add header + sheet.cell(row=1, column=1, value="Datum") + questions = [q[1] for q in VV_FEEDBACK_QUESTIONS] + for col_idx, title in enumerate(questions, start=2): + sheet.cell(row=1, column=col_idx, value=title) + + _add_rows(sheet, data) + return sheet + + +def _add_rows(sheet, data): + for row_idx, feedback in enumerate(data, start=2): + sheet.cell( + row=row_idx, column=1, value=feedback.updated_at.date().strftime("%d.%m.%Y") + ) + for col_idx, question in enumerate(VV_FEEDBACK_QUESTIONS, start=2): + response = feedback.data.get(question[0], "") + sheet.cell(row=row_idx, column=col_idx, value=response) + + +def make_export_filename(): + today_date = datetime.today().strftime("%Y-%m-%d") + return f"feedback_export_{today_date}.xlsx" + + +# used as admin action, that's why it's not in the views.py +def get_feedbacks_for_course(_modeladmin, _request, queryset): + excel_bytes = export_feedback(queryset.first(), False) + + response = HttpResponse( + content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + ) + response["Content-Disposition"] = f"attachment; filename={make_export_filename()}" + response.write(excel_bytes) + + return response diff --git a/server/vbv_lernwelt/feedback/views.py b/server/vbv_lernwelt/feedback/views.py index 13dbe37c..ecd11483 100644 --- a/server/vbv_lernwelt/feedback/views.py +++ b/server/vbv_lernwelt/feedback/views.py @@ -1,14 +1,10 @@ import itertools import structlog -from django.http import HttpResponse -from rest_framework import authentication -from rest_framework.decorators import api_view, authentication_classes, permission_classes +from rest_framework.decorators import api_view from rest_framework.exceptions import PermissionDenied -from rest_framework.permissions import IsAdminUser from rest_framework.response import Response -from vbv_lernwelt.feedback.management.commands.export_feedback import export_feedback, make_export_filename from vbv_lernwelt.feedback.models import FeedbackResponse from vbv_lernwelt.feedback.utils import feedback_users from vbv_lernwelt.iam.permissions import is_course_session_expert @@ -82,16 +78,3 @@ def get_feedback_for_circle(request, course_session_id, circle_id): feedback_data["questions"][field].append(data) return Response(status=200, data=feedback_data) - - -@api_view(["GET"]) -@authentication_classes((authentication.SessionAuthentication,)) -@permission_classes((IsAdminUser,)) -def export_feedback_for_course_session(request, course_session_id): - excel_bytes = export_feedback(course_session_id, False) - - response = HttpResponse(content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") - response['Content-Disposition'] = f"attachment; filename={make_export_filename()}" - response.write(excel_bytes) - - return response From 93efd7333ecda943d75c7653224927eb8aba49e0 Mon Sep 17 00:00:00 2001 From: Christian Cueni Date: Wed, 28 Feb 2024 14:57:19 +0100 Subject: [PATCH 08/11] Handle export for multiple course sessions --- server/vbv_lernwelt/course/admin.py | 8 +++--- .../management/commands/export_feedback.py | 2 +- server/vbv_lernwelt/feedback/services.py | 27 +++++++++++++------ 3 files changed, 25 insertions(+), 12 deletions(-) diff --git a/server/vbv_lernwelt/course/admin.py b/server/vbv_lernwelt/course/admin.py index 1285f3e8..afaa928f 100644 --- a/server/vbv_lernwelt/course/admin.py +++ b/server/vbv_lernwelt/course/admin.py @@ -1,10 +1,11 @@ from django.contrib import admin from vbv_lernwelt.course.models import Course, CourseSession, CourseSessionUser -from vbv_lernwelt.feedback.services import get_feedbacks_for_course +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.short_description = "Feedback export" +get_feedbacks_for_course_sessions.short_description = "Feedback export" +get_feedbacks_for_courses.short_description = "Feedback export" @admin.register(Course) @@ -15,6 +16,7 @@ class CourseAdmin(admin.ModelAdmin): "category_name", "slug", ] + actions = [get_feedbacks_for_courses] @admin.register(CourseSession) @@ -29,7 +31,7 @@ class CourseSessionAdmin(admin.ModelAdmin): "created_at", "updated_at", ] - actions = [get_feedbacks_for_course] + actions = [get_feedbacks_for_course_sessions] @admin.register(CourseSessionUser) diff --git a/server/vbv_lernwelt/feedback/management/commands/export_feedback.py b/server/vbv_lernwelt/feedback/management/commands/export_feedback.py index f0cae06b..790661d9 100644 --- a/server/vbv_lernwelt/feedback/management/commands/export_feedback.py +++ b/server/vbv_lernwelt/feedback/management/commands/export_feedback.py @@ -15,4 +15,4 @@ logger = structlog.get_logger(__name__) ) 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) + 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 265139d1..7e84915b 100644 --- a/server/vbv_lernwelt/feedback/services.py +++ b/server/vbv_lernwelt/feedback/services.py @@ -121,14 +121,14 @@ def initial_data_for_feedback_page( return {} -def export_feedback(course_session_id: str, save_as_file: bool): +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=course_session_id, + course_session_id__in=course_session_ids, submitted=True, ).order_by("circle", "updated_at") grouped_feedbacks = groupby(feedbacks, key=attrgetter("circle")) @@ -139,7 +139,7 @@ def export_feedback(course_session_id: str, save_as_file: bool): "export_feedback_for_circle", data={ "circle": circle.id, - "course_session_id": course_session_id, + "course_session_ids": course_session_ids, "count": len(group_feedbacks), }, label="feedback_export", @@ -178,19 +178,30 @@ def _add_rows(sheet, data): sheet.cell(row=row_idx, column=col_idx, value=response) -def make_export_filename(): +def make_export_filename(name: str = "feedback_export"): today_date = datetime.today().strftime("%Y-%m-%d") - return f"feedback_export_{today_date}.xlsx" + 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(_modeladmin, _request, queryset): - excel_bytes = export_feedback(queryset.first(), False) +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()}" + response["Content-Disposition"] = f"attachment; filename={make_export_filename(file_name)}" response.write(excel_bytes) return response From f6643fc15e20d7e2967286a6dad2f0c92c78a787 Mon Sep 17 00:00:00 2001 From: Livio Bieri Date: Wed, 28 Feb 2024 15:13:54 +0100 Subject: [PATCH 09/11] fix: sensibly round evaluation points --- client/src/gql/graphql.ts | 4 ++-- client/src/gql/schema.graphql | 6 +++--- server/vbv_lernwelt/assignment/graphql/types.py | 16 ++++++++++++++-- 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/client/src/gql/graphql.ts b/client/src/gql/graphql.ts index 6899be04..d98966b7 100644 --- a/client/src/gql/graphql.ts +++ b/client/src/gql/graphql.ts @@ -1042,7 +1042,7 @@ export type TopicObjectType = CoursePageInterface & { export type UserObjectType = { __typename?: 'UserObjectType'; - avatar_url: Scalars['String']['output']; + avatar_url?: Maybe; email: Scalars['String']['output']; first_name: Scalars['String']['output']; id: Scalars['UUID']['output']; @@ -1139,7 +1139,7 @@ export type AssignmentCompletionQueryQueryVariables = Exact<{ export type AssignmentCompletionQueryQuery = { __typename?: 'Query', assignment?: { __typename?: 'AssignmentObjectType', assignment_type: AssignmentAssignmentAssignmentTypeChoices, needs_expert_evaluation: boolean, max_points?: number | null, content_type: string, effort_required: string, evaluation_description: string, evaluation_document_url: string, evaluation_tasks?: any | null, id: string, intro_text: string, performance_objectives?: any | null, slug: string, tasks?: any | null, title: string, translation_key: string, solution_sample?: { __typename?: 'ContentDocumentObjectType', id: string, url?: string | null } | null, competence_certificate?: ( { __typename?: 'CompetenceCertificateObjectType' } & { ' $fragmentRefs'?: { 'CoursePageFieldsCompetenceCertificateObjectTypeFragment': CoursePageFieldsCompetenceCertificateObjectTypeFragment } } - ) | null } | null, assignment_completion?: { __typename?: 'AssignmentCompletionObjectType', id: string, completion_status: AssignmentAssignmentCompletionCompletionStatusChoices, submitted_at?: string | null, evaluation_submitted_at?: string | null, evaluation_points?: number | null, evaluation_max_points?: number | null, evaluation_passed?: boolean | null, edoniq_extended_time_flag: boolean, completion_data?: any | null, task_completion_data?: any | null, evaluation_user?: { __typename?: 'UserObjectType', id: string, first_name: string, last_name: string } | null, assignment_user: { __typename?: 'UserObjectType', avatar_url: string, first_name: string, last_name: string, id: string } } | null }; + ) | null } | null, assignment_completion?: { __typename?: 'AssignmentCompletionObjectType', id: string, completion_status: AssignmentAssignmentCompletionCompletionStatusChoices, submitted_at?: string | null, evaluation_submitted_at?: string | null, evaluation_points?: number | null, evaluation_max_points?: number | null, evaluation_passed?: boolean | null, edoniq_extended_time_flag: boolean, completion_data?: any | null, task_completion_data?: any | null, evaluation_user?: { __typename?: 'UserObjectType', id: string, first_name: string, last_name: string } | null, assignment_user: { __typename?: 'UserObjectType', avatar_url?: string | null, first_name: string, last_name: string, id: string } } | null }; export type CompetenceCertificateQueryQueryVariables = Exact<{ courseSlug: Scalars['String']['input']; diff --git a/client/src/gql/schema.graphql b/client/src/gql/schema.graphql index 989ed785..418e18ba 100644 --- a/client/src/gql/schema.graphql +++ b/client/src/gql/schema.graphql @@ -552,8 +552,6 @@ type AssignmentCompletionObjectType { submitted_at: DateTime evaluation_submitted_at: DateTime evaluation_user: UserObjectType - evaluation_points: Float - evaluation_max_points: Float evaluation_passed: Boolean edoniq_extended_time_flag: Boolean! assignment_user: UserObjectType! @@ -564,6 +562,8 @@ type AssignmentCompletionObjectType { additional_json_data: JSONString! task_completion_data: GenericScalar learning_content_page_id: ID + evaluation_points: Float + evaluation_max_points: Float } """ @@ -580,9 +580,9 @@ type UserObjectType { first_name: String! last_name: String! id: UUID! - avatar_url: String! email: String! language: CoreUserLanguageChoices! + avatar_url: String } """An enumeration.""" diff --git a/server/vbv_lernwelt/assignment/graphql/types.py b/server/vbv_lernwelt/assignment/graphql/types.py index cf0267ca..b8a23d3d 100644 --- a/server/vbv_lernwelt/assignment/graphql/types.py +++ b/server/vbv_lernwelt/assignment/graphql/types.py @@ -17,6 +17,10 @@ class AssignmentCompletionObjectType(DjangoObjectType): task_completion_data = GenericScalar() learning_content_page_id = graphene.ID(source="learning_content_page_id") + # rounded to sensible representation + evaluation_points = graphene.Float() + evaluation_max_points = graphene.Float() + class Meta: model = AssignmentCompletion fields = ( @@ -34,12 +38,20 @@ class AssignmentCompletionObjectType(DjangoObjectType): "evaluation_user", "additional_json_data", "edoniq_extended_time_flag", - "evaluation_points", "evaluation_passed", - "evaluation_max_points", "task_completion_data", ) + def resolve_evaluation_points(self, info): + if self.evaluation_points: + return round(self.evaluation_points, 1) # noqa + return None + + def resolve_evaluation_max_points(self, info): + if self.evaluation_max_points: + return round(self.evaluation_max_points, 1) # noqa + return None + class AssignmentObjectType(DjangoObjectType): tasks = JSONStreamField() From 36c7a1a5b3d5d2ce91e01d97e59790706b685c1f Mon Sep 17 00:00:00 2001 From: Christian Cueni Date: Wed, 28 Feb 2024 15:35:20 +0100 Subject: [PATCH 10/11] Handle uk feedback as well --- server/vbv_lernwelt/course/admin.py | 5 +- server/vbv_lernwelt/feedback/services.py | 61 ++++++++++++++++++++---- 2 files changed, 56 insertions(+), 10 deletions(-) diff --git a/server/vbv_lernwelt/course/admin.py b/server/vbv_lernwelt/course/admin.py index afaa928f..5777b9be 100644 --- a/server/vbv_lernwelt/course/admin.py +++ b/server/vbv_lernwelt/course/admin.py @@ -1,7 +1,10 @@ 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.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" diff --git a/server/vbv_lernwelt/feedback/services.py b/server/vbv_lernwelt/feedback/services.py index 7e84915b..cd597e4e 100644 --- a/server/vbv_lernwelt/feedback/services.py +++ b/server/vbv_lernwelt/feedback/services.py @@ -32,6 +32,34 @@ VV_FEEDBACK_QUESTIONS = [ ("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, @@ -130,7 +158,7 @@ def export_feedback(course_session_ids: list[str], save_as_file: bool): feedbacks = FeedbackResponse.objects.filter( course_session_id__in=course_session_ids, submitted=True, - ).order_by("circle", "updated_at") + ).order_by("circle", "course_session", "updated_at") grouped_feedbacks = groupby(feedbacks, key=attrgetter("circle")) for circle, group_feedbacks in grouped_feedbacks: @@ -158,22 +186,35 @@ def export_feedback(course_session_ids: list[str], save_as_file: bool): 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="Datum") - questions = [q[1] for q in VV_FEEDBACK_QUESTIONS] - for col_idx, title in enumerate(questions, start=2): + 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) + _add_rows(sheet, data, question_data) return sheet -def _add_rows(sheet, data): +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=1, value=feedback.updated_at.date().strftime("%d.%m.%Y") + row=row_idx, column=2, value=feedback.updated_at.date().strftime("%d.%m.%Y") ) - for col_idx, question in enumerate(VV_FEEDBACK_QUESTIONS, start=2): + 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) @@ -201,7 +242,9 @@ def _handle_feedback_export_action(course_seesions, file_name): response = HttpResponse( content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" ) - response["Content-Disposition"] = f"attachment; filename={make_export_filename(file_name)}" + response[ + "Content-Disposition" + ] = f"attachment; filename={make_export_filename(file_name)}" response.write(excel_bytes) return response From 8e338f4773531ed6bfbe9d119ff5dab9b2e6f64b Mon Sep 17 00:00:00 2001 From: Christian Cueni Date: Wed, 28 Feb 2024 17:16:18 +0100 Subject: [PATCH 11/11] =?UTF-8?q?Add=20Motorfahrzeug=20Pr=C3=BCfungs=20cir?= =?UTF-8?q?cle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- compose/django/docker_start.sh | 3 ++ server/vbv_lernwelt/course/consts.py | 1 + .../commands/create_default_courses.py | 30 +++++++++++++++++++ .../commands/create_motorfahrzeug_pruefung.py | 23 ++++++++++++++ .../learnpath/create_vv_new_learning_path.py | 27 +++++++++++++++-- 5 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 server/vbv_lernwelt/course/management/commands/create_motorfahrzeug_pruefung.py 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/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/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,