Die Daten konnten nicht importiert werden
+Folgender Fehler ist aufgetreten:
+{{ error }}
+ diff --git a/server/config/settings/base.py b/server/config/settings/base.py index 7e92f087..4cafd708 100644 --- a/server/config/settings/base.py +++ b/server/config/settings/base.py @@ -122,6 +122,7 @@ LOCAL_APPS = [ "vbv_lernwelt.notify", "vbv_lernwelt.assignment", "vbv_lernwelt.duedate", + "vbv_lernwelt.importer", ] # https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS diff --git a/server/config/urls.py b/server/config/urls.py index 71f35177..0910be58 100644 --- a/server/config/urls.py +++ b/server/config/urls.py @@ -42,6 +42,11 @@ from vbv_lernwelt.feedback.views import ( get_expert_feedbacks_for_course, get_feedback_for_circle, ) +from vbv_lernwelt.importer.views import ( + coursesessions_students_import, + coursesessions_trainers_import, + t2l_sync, +) from vbv_lernwelt.notify.views import email_notification_settings from wagtail import urls as wagtail_urls from wagtail.admin import urls as wagtailadmin_urls @@ -143,6 +148,23 @@ urlpatterns = [ # edoniq test path(r'api/core/edoniq-test/export-users/', export_students, name='edoniq_export_students'), + # importer + path( + r"server/importer/coursesession-trainer-import/", + coursesessions_trainers_import, + name="coursesessions_trainers_import", + ), + path( + r"server/importer/coursesession-students-import/", + coursesessions_students_import, + name="coursesessions_students_import", + ), + path( + r"server/importer/t2l-sync/", + t2l_sync, + name="t2l_sync", + ), + # testing and debug path('server/raise_error/', user_passes_test(lambda u: u.is_superuser, login_url='/login/')( diff --git a/server/vbv_lernwelt/core/admin.py b/server/vbv_lernwelt/core/admin.py index e22d7124..4fdb1a9b 100644 --- a/server/vbv_lernwelt/core/admin.py +++ b/server/vbv_lernwelt/core/admin.py @@ -23,6 +23,7 @@ class UserAdmin(auth_admin.UserAdmin): }, ), (_("Important dates"), {"fields": ("last_login", "date_joined")}), + (_("Additional data"), {"fields": ("additional_json_data",)}), ) list_display = [ "username", 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 ac34fc3d..7b836e78 100644 --- a/server/vbv_lernwelt/course/management/commands/create_default_courses.py +++ b/server/vbv_lernwelt/course/management/commands/create_default_courses.py @@ -79,7 +79,7 @@ from vbv_lernwelt.feedback.creators.create_demo_feedback import create_feedback from vbv_lernwelt.importer.services import ( import_course_sessions_from_excel, import_students_from_excel, - import_trainers_from_excel, + import_trainers_from_excel_for_training, ) from vbv_lernwelt.learnpath.create_vv_new_learning_path import ( create_vv_new_learning_path, @@ -561,12 +561,13 @@ def create_course_training_de(): print(current_dir) course = Course.objects.get(id=COURSE_UK_TRAINING) import_course_sessions_from_excel( - course, f"{current_dir}/../../../importer/tests/Schulungen_Durchfuehrung_Trainer.xlsx", + course=course, + restrict_language="de", ) - import_trainers_from_excel( - course, + import_trainers_from_excel_for_training( f"{current_dir}/../../../importer/tests/Schulungen_Durchfuehrung_Trainer.xlsx", + course=course, ) import_students_from_excel( f"{current_dir}/../../../importer/tests/Schulungen_Teilnehmende.xlsx", @@ -650,13 +651,13 @@ def create_course_training_fr(): print(current_dir) course = Course.objects.get(id=COURSE_UK_TRAINING_FR) import_course_sessions_from_excel( - course, f"{current_dir}/../../../importer/tests/Schulungen_Durchfuehrung_Trainer.xlsx", - language="fr", + restrict_language="fr", + course=course, ) - import_trainers_from_excel( - course, + import_trainers_from_excel_for_training( f"{current_dir}/../../../importer/tests/Schulungen_Durchfuehrung_Trainer.xlsx", + course=course, language="fr", ) import_students_from_excel( @@ -737,13 +738,13 @@ def create_course_training_it(): print(current_dir) course = Course.objects.get(id=COURSE_UK_TRAINING_IT) import_course_sessions_from_excel( - course, f"{current_dir}/../../../importer/tests/Schulungen_Durchfuehrung_Trainer.xlsx", - language="it", + restrict_language="it", + course=course, ) - import_trainers_from_excel( - course, + import_trainers_from_excel_for_training( f"{current_dir}/../../../importer/tests/Schulungen_Durchfuehrung_Trainer.xlsx", + course=course, language="it", ) import_students_from_excel( diff --git a/server/vbv_lernwelt/importer/services.py b/server/vbv_lernwelt/importer/services.py index dffe8acf..b773b221 100644 --- a/server/vbv_lernwelt/importer/services.py +++ b/server/vbv_lernwelt/importer/services.py @@ -1,9 +1,11 @@ -from typing import Any, Dict +from datetime import date, datetime, time +from typing import Any, Dict, List import structlog from openpyxl.reader.excel import load_workbook from vbv_lernwelt.core.models import User +from vbv_lernwelt.course.consts import COURSE_UK, COURSE_UK_FR, COURSE_UK_IT from vbv_lernwelt.course.models import Course, CourseSession, CourseSessionUser from vbv_lernwelt.course_session.models import CourseSessionAttendanceCourse from vbv_lernwelt.importer.utils import ( @@ -15,9 +17,27 @@ from vbv_lernwelt.learnpath.models import Circle, LearningContentAttendanceCours logger = structlog.get_logger(__name__) +CIRCLE_NAMES = { + "Kickoff": {"de": "Kickoff", "fr": "Lancement", "it": "Introduzione"}, + "Basis": {"de": "Basis", "fr": "Base", "it": "Base"}, + "Fahrzeug": {"de": "Fahrzeug", "fr": "Véhicule", "it": "Veicolo"}, + "Haushalt Teil 1": {"de": "Haushalt Teil 1", "fr": "Habitat partie 1", "it": ""}, + "Haushalt Teil 2": {"de": "Haushalt Teil 2", "fr": "Haushalt Teil 2", "it": ""}, +} + +T2L_IGNORE_FIELDS = ["Vorname", "Name", "Email", "Sprache", "Durchführungen"] + + +class DataImportError(Exception): + pass + def create_or_update_user( - email: str, first_name: str = "", last_name: str = "", sso_id: str = None + email: str, + first_name: str = "", + last_name: str = "", + sso_id: str = None, + contract_number: str = "", ): logger.debug( "create_or_update_user", @@ -33,6 +53,13 @@ def create_or_update_user( if user_qs.exists(): user = user_qs.first() + if not user and contract_number: + user_qs = User.objects.filter( + additional_json_data__Lehrvertragsnummer=contract_number + ) + if user_qs.exists(): + user = user_qs.first() + if not user: user_qs = User.objects.filter(email=email) if user_qs.exists(): @@ -53,25 +80,41 @@ def create_or_update_user( return user -def import_course_sessions_from_excel(course: Course, filename: str, language="de"): +def import_course_sessions_from_excel( + filename: str, course: Course = None, restrict_language=None +): workbook = load_workbook(filename=filename) sheet = workbook["Schulungen Durchführung"] tuple_list = calc_header_tuple_list_from_pyxl_sheet(sheet) + for row in tuple_list: + data = dict(row) + validate_row_data(data, ["Klasse", "ID", "Generation", "Region", "Sprache"]) + language = data["Sprache"].strip() + + # this can be removed when the training import (create_course_training_xx) is no longer used + if restrict_language and language != restrict_language: + continue + + if not course: + course = get_uk_course(language) + create_or_update_course_session( - course, dict(row), language=language, circles=["Fahrzeug"] + course, data, language, circle_keys=["Kickoff", "Basis", "Fahrzeug"] ) def create_or_update_course_session( - course: Course, data: Dict[str, Any], language="de", circles=None + course: Course, + data: Dict[str, Any], + language: str, + circle_keys=None, ): """ :param data: the following keys are required to process the data: Generation, Region, Klasse :return: """ - logger.debug( "create_or_update_course_session", course=course.title, @@ -79,10 +122,9 @@ def create_or_update_course_session( label="import", ) - if circles is None: - circles = [] + if not circle_keys: + circle_keys = [] - # TODO: validation group = data["Klasse"].strip() import_id = data["ID"].strip() @@ -91,12 +133,8 @@ def create_or_update_course_session( title = f"{region} {generation} {group}" - if not import_id.lower().startswith(language.lower()): - # FIXME: language check depends on import_id format for now... - return None - cs, _created = CourseSession.objects.get_or_create( - import_id=import_id, group=group, course=course + title=title, course=course, import_id=import_id ) cs.additional_json_data["import_data"] = data @@ -109,31 +147,23 @@ def create_or_update_course_session( cs.import_id = import_id cs.save() - for circle in circles: - attendance_course_lp_qs = None - if language == "de": - attendance_course_lp_qs = LearningContentAttendanceCourse.objects.filter( - slug=f"{course.slug}-lp-circle-{circle.lower()}-lc-präsenzkurs-{circle.lower()}" + for circle in circle_keys: + circle_name = CIRCLE_NAMES[circle][language] + + attendance_course_lc = LearningContentAttendanceCourse.objects.filter( + slug=f"{course.slug}-lp-circle-{circle_name.lower()}-lc-präsenzkurs-{circle_name.lower()}" + ).first() + + if attendance_course_lc: + # update existing data + csa, _created = CourseSessionAttendanceCourse.objects.get_or_create( + course_session=cs, learning_content=attendance_course_lc ) - elif language == "fr": - # todo: this is a hack remove me - attendance_course_lp_qs = LearningContentAttendanceCourse.objects.filter( - slug=f"{course.slug}-lp-circle-véhicule-lc-cours-de-présence-véhicule-à-moteur" - ) - elif language == "it": - # todo: this is a hack remove me - attendance_course_lp_qs = LearningContentAttendanceCourse.objects.filter( - slug=f"{course.slug}-lp-circle-veicolo-lc-corso-di-presenza-veicolo" - ) + location = f"{data[f'{circle} Raum']}, {data[f'{circle} Standort']}, {data[f'{circle} Adresse']}" + csa.location = location - if attendance_course_lp_qs and attendance_course_lp_qs.exists(): - csa = CourseSessionAttendanceCourse.objects.create( - course_session=cs, - learning_content=attendance_course_lp_qs.first(), - location=data[f"{circle} Raum"], - trainer="", - ) + csa.trainer = "" csa.due_date.start = try_parse_datetime(data[f"{circle} Start"])[1] csa.due_date.end = try_parse_datetime(data[f"{circle} Ende"])[1] csa.due_date.save() @@ -141,19 +171,50 @@ def create_or_update_course_session( return cs -def import_trainers_from_excel(course: Course, filename: str, language="de"): +def validate_row_data(data: Dict[str, any], required_headers: List[str]): + for header in required_headers: + some = str(data.get(header, "")).strip() + if str(data.get(header, "")).strip() in ["", "None"]: + logger.debug( + "validate_row_data_missing_header", + data={"data": data, "header": header}, + label="import", + ) + raise DataImportError(f"Missing or empty value for header {header}") + + +def get_uk_course(language: str) -> Course: + if language == "fr": + course_id = COURSE_UK_FR + elif language == "it": + course_id = COURSE_UK_IT + else: + course_id = COURSE_UK + + return Course.objects.get(id=course_id) + + +def import_trainers_from_excel_for_training( + filename: str, language="de", course: Course = None +): workbook = load_workbook(filename=filename) sheet = workbook["Schulungen Trainer"] tuple_list = calc_header_tuple_list_from_pyxl_sheet(sheet) for row in tuple_list: - create_or_update_trainer(course, dict(row), language=language) + data = dict(row) + validate_row_data( + data, ["Email", "Vorname", "Name", "Sprache", "Klasse", "Generation"] + ) + create_or_update_trainer(course, data, language=language) def create_or_update_trainer(course: Course, data: Dict[str, Any], language="de"): + course_title = course.title if course else "None" + logger.debug( "create_or_update_trainer", - course=course.title, + course=course_title, data=data, label="import", ) @@ -163,62 +224,64 @@ def create_or_update_trainer(course: Course, data: Dict[str, Any], language="de" first_name=data["Vorname"], last_name=data["Name"], ) + user.language = data["Sprache"] + user.save() - groups = [g.strip() for g in data["Klasse"].strip().split(",")] + group = data["Klasse"].strip() # general expert handling - for group in groups: - import_id = f"{data['Generation'].strip()} {group}" - course_session = CourseSession.objects.filter( + import_id = f"{data['Generation'].strip()} {group}" + course_session = CourseSession.objects.filter( + import_id=import_id, + group=group, + ).first() + if course_session: + csu, _created = CourseSessionUser.objects.get_or_create( + course_session_id=course_session.id, user_id=user.id + ) + csu.role = CourseSessionUser.Role.EXPERT + csu.save() + else: + logger.warning( + "create_or_update_trainer: course_session not found", import_id=import_id, group=group, - ).first() - if course_session: - csu, _created = CourseSessionUser.objects.get_or_create( - course_session_id=course_session.id, user_id=user.id - ) - csu.role = CourseSessionUser.Role.EXPERT - csu.save() - else: - logger.warning( - "create_or_update_trainer: course_session not found", - import_id=import_id, - group=group, - label="import", - ) + label="import", + ) + + if not course and not course_session: + logger.warning( + "create_or_update_trainer: course_session and course are None", + import_id=import_id, + group=group, + label="import", + ) + return if not course: - return + course = course_session.course # circle expert handling circle_data = parse_circle_group_string(data["Circles"]) - for circle_string in circle_data: - parts = circle_string.split("(", 1) - circle_name = parts[0].strip() - - groups = [g.strip() for g in parts[1].rstrip(")").strip().split(",")] - - # FIXME: hardcoded translation - if language == "fr" and circle_name == "Fahrzeug": - circle_name = "Véhicule" + for circle_key in circle_data: + circle_name = CIRCLE_NAMES[circle_key][language] # print(circle_name, groups) - for group in groups: - import_id = f"{data['Generation'].strip()} {group}" - course_session = CourseSession.objects.filter( - import_id=import_id, group=group - ).first() - circle = Circle.objects.filter( - slug=f"{course.slug}-lp-circle-{circle_name.lower()}" - ).first() + import_id = f"{data['Generation'].strip()} {group}" + course_session = CourseSession.objects.filter( + import_id=import_id, group=group + ).first() + circle = Circle.objects.filter( + slug=f"{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() + 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 import_students_from_excel(filename: str): @@ -227,7 +290,18 @@ def import_students_from_excel(filename: str): tuple_list = calc_header_tuple_list_from_pyxl_sheet(sheet) for row in tuple_list: - create_or_update_student(dict(row)) + data = dict(row) + validate_row_data( + data, + [ + "Email", + "Vorname", + "Name", + "Sprache", + "Durchführungen", + ], + ) + create_or_update_student(data) def create_or_update_student(data: Dict[str, Any]): @@ -241,16 +315,71 @@ def create_or_update_student(data: Dict[str, Any]): email=data["Email"].lower(), first_name=data["Vorname"], last_name=data["Name"], + contract_number=data.get("Lehrvertragsnummer", ""), ) - # TODO: handle language + user.language = data["Sprache"] + update_user_json_data(user, data) + user.save() # general expert handling - import_ids = [i.strip() for i in data["Durchführungen"].split(",")] - for import_id in import_ids: - course_session = CourseSession.objects.filter(import_id=import_id).first() - if course_session: - csu, _created = CourseSessionUser.objects.get_or_create( - course_session_id=course_session.id, user_id=user.id - ) - csu.save() + import_id = data["Durchführungen"] + course_session = CourseSession.objects.filter(import_id=import_id).first() + if course_session: + csu, _created = CourseSessionUser.objects.get_or_create( + course_session_id=course_session.id, user_id=user.id + ) + csu.save() + + +def sync_students_from_t2l_excel(filename: str): + workbook = load_workbook(filename=filename) + sheet = workbook.active + + tuple_list = calc_header_tuple_list_from_pyxl_sheet(sheet) + for row in tuple_list: + data = dict(row) + sync_students_from_t2l(data | {}) + + +def sync_students_from_t2l(data): + # ignore errors + try: + user = User.objects.get( + additional_json_data__Lehrvertragsnummer=data["Lehrvertragsnummer"] + ) + except User.DoesNotExist: + return + + # only sync data that is not in our user model + for field in T2L_IGNORE_FIELDS: + try: + del data[field] + except KeyError: + pass + + update_user_json_data(user, data) + user.save() + + +def update_user_json_data(user: User, data: Dict[str, Any]): + user.additional_json_data = user.additional_json_data | sanitize_json_data_input( + data + ) + + +def sanitize_json_data_input(data: Dict[str, Any]) -> Dict[str, Any]: + """ + Saving additional_json_data fails if the data contains datetime objects. + This is a quick and dirty fix to convert datetime objects to iso strings. + """ + for key, value in data.items(): + if isinstance(value, datetime): + data[key] = value.isoformat() + elif isinstance(value, date): + data[key] = value.isoformat() + elif isinstance(value, time): + data[key] = value.isoformat() + else: + data[key] = value + return data diff --git a/server/vbv_lernwelt/importer/templates/admin/importer/error.html b/server/vbv_lernwelt/importer/templates/admin/importer/error.html new file mode 100644 index 00000000..14a490e6 --- /dev/null +++ b/server/vbv_lernwelt/importer/templates/admin/importer/error.html @@ -0,0 +1,12 @@ +{% extends "admin/index.html" %} + +{% block content %} +
Folgender Fehler ist aufgetreten:
+{{ error }}
+