diff --git a/.gitignore b/.gitignore
index a511dff6..0daeaac9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -61,8 +61,6 @@ target/
# pyenv
.python-version
-
-
# Environments
.venv
venv/
@@ -76,7 +74,6 @@ venv/
# mypy
.mypy_cache/
-
### Node template
# Logs
logs
@@ -159,10 +156,6 @@ typings/
# Local History for Visual Studio Code
.history/
-
-
-
-
### Windows template
# Windows thumbnail cache files
Thumbs.db
@@ -272,6 +265,7 @@ tags
### Project template
+.~lock.*
.pytest_cache/
.ipython/
vendors.js
diff --git a/caprover_deploy.sh b/caprover_deploy.sh
index 2cb0c455..e6bda1e6 100755
--- a/caprover_deploy.sh
+++ b/caprover_deploy.sh
@@ -26,14 +26,14 @@ function generate_default_app_name() {
APP_NAME=${1:-$(generate_default_app_name)}
# VITE_* variables need to be present at build time
-VITE_APP_ENVIRONMENT="dev-$APP_NAME"
+export VITE_APP_ENVIRONMENT="dev-$APP_NAME"
if [[ "$APP_NAME" == "myvbv-stage" ]]; then
- VITE_OAUTH_API_BASE_URL="https://vbvtst.b2clogin.com/vbvtst.onmicrosoft.com/b2c_1_signupandsignin/oauth2/v2.0/"
- VITE_APP_ENVIRONMENT="stage-caprover"
+ export VITE_OAUTH_API_BASE_URL="https://vbvtst.b2clogin.com/vbvtst.onmicrosoft.com/b2c_1_signupandsignin/oauth2/v2.0/"
+ export VITE_APP_ENVIRONMENT="stage-caprover"
elif [[ "$APP_NAME" == prod* ]]; then
- VITE_OAUTH_API_BASE_URL="https://edumgr.b2clogin.com/edumgr.onmicrosoft.com/b2c_1_signupandsignin/oauth2/v2.0/"
- VITE_APP_ENVIRONMENT=$APP_NAME
+ export VITE_OAUTH_API_BASE_URL="https://edumgr.b2clogin.com/edumgr.onmicrosoft.com/b2c_1_signupandsignin/oauth2/v2.0/"
+ export VITE_APP_ENVIRONMENT=$APP_NAME
fi
echo "Deploy to $APP_NAME"
diff --git a/client/src/main.ts b/client/src/main.ts
index bb082423..50a87bb8 100644
--- a/client/src/main.ts
+++ b/client/src/main.ts
@@ -21,6 +21,7 @@ if (appEnv.startsWith("prod")) {
} else {
log.setLevel("trace");
}
+log.warn(`application started appEnv=${appEnv}`);
const i18n = setupI18n();
const app = createApp(App);
diff --git a/client/src/pages/learningPath/learningContentPage/attendanceCourse/AttendanceCourse.vue b/client/src/pages/learningPath/learningContentPage/attendanceCourse/AttendanceCourse.vue
index 2373d00e..aec81364 100644
--- a/client/src/pages/learningPath/learningContentPage/attendanceCourse/AttendanceCourse.vue
+++ b/client/src/pages/learningPath/learningContentPage/attendanceCourse/AttendanceCourse.vue
@@ -7,7 +7,10 @@
diff --git a/server/config/settings/base.py b/server/config/settings/base.py
index 8330a516..61c931e6 100644
--- a/server/config/settings/base.py
+++ b/server/config/settings/base.py
@@ -140,9 +140,7 @@ AUTH_USER_MODEL = "core.User"
# https://docs.djangoproject.com/en/dev/ref/settings/#login-redirect-url
# https://docs.djangoproject.com/en/dev/ref/settings/#login-url
-# FIXME make configurable!?
-# LOGIN_URL = "/sso/login/"
-LOGIN_URL = "/login"
+LOGIN_URL = "/login-local"
LOGIN_REDIRECT_URL = "/"
ALLOW_LOCAL_LOGIN = env.bool("IT_ALLOW_LOCAL_LOGIN", default=DEBUG)
diff --git a/server/requirements/requirements-dev.txt b/server/requirements/requirements-dev.txt
index 62594c31..79b0692c 100644
--- a/server/requirements/requirements-dev.txt
+++ b/server/requirements/requirements-dev.txt
@@ -319,7 +319,9 @@ mypy-extensions==0.4.3
nodeenv==1.6.0
# via pre-commit
openpyxl==3.1.2
- # via wagtail
+ # via
+ # -r requirements.in
+ # wagtail
packaging==21.3
# via
# build
@@ -405,6 +407,7 @@ pytest-sugar==0.9.4
# via -r requirements-dev.in
python-dateutil==2.8.2
# via
+ # -r requirements.in
# botocore
# faker
python-dotenv==0.20.0
diff --git a/server/requirements/requirements.in b/server/requirements/requirements.in
index 7910d91e..6364d87b 100644
--- a/server/requirements/requirements.in
+++ b/server/requirements/requirements.in
@@ -37,6 +37,7 @@ sendgrid
structlog
python-json-logger
concurrent-log-handler
+python-dateutil
wagtail>=4
wagtail-factories>=4
@@ -47,3 +48,4 @@ azure-storage-blob
azure-identity
boto3
+openpyxl
diff --git a/server/requirements/requirements.txt b/server/requirements/requirements.txt
index b3015ddd..8e3e7d35 100644
--- a/server/requirements/requirements.txt
+++ b/server/requirements/requirements.txt
@@ -190,7 +190,9 @@ msal==1.22.0
msal-extensions==1.0.0
# via azure-identity
openpyxl==3.1.2
- # via wagtail
+ # via
+ # -r requirements.in
+ # wagtail
packaging==21.3
# via
# marshmallow
@@ -219,6 +221,7 @@ pyrsistent==0.18.1
# via jsonschema
python-dateutil==2.8.2
# via
+ # -r requirements.in
# botocore
# faker
python-dotenv==0.20.0
diff --git a/server/vbv_lernwelt/core/managers.py b/server/vbv_lernwelt/core/managers.py
deleted file mode 100644
index eead0bf9..00000000
--- a/server/vbv_lernwelt/core/managers.py
+++ /dev/null
@@ -1,25 +0,0 @@
-from django.contrib.auth.base_user import BaseUserManager
-from django.contrib.auth.models import AbstractUser
-
-
-class UserManager(BaseUserManager):
- def create_or_update_by_email(self, user_dict: dict) -> tuple[AbstractUser, bool]:
- # create or sync user with OpenID Data
- user, created = self.model.objects.get_or_create(
- sso_id=user_dict["oid"],
- defaults={
- "email": user_dict["email"],
- "username": user_dict["email"],
- "first_name": user_dict["first_name"],
- "last_name": user_dict["last_name"],
- },
- )
-
- if not created:
- user.email = user_dict["email"]
- user.username = user_dict["email"]
- user.first_name = user_dict["first_name"]
- user.last_name = user_dict["last_name"]
- user.save()
-
- return user, created
diff --git a/server/vbv_lernwelt/core/migrations/0002_alter_user_managers.py b/server/vbv_lernwelt/core/migrations/0002_alter_user_managers.py
new file mode 100644
index 00000000..3f6b16fe
--- /dev/null
+++ b/server/vbv_lernwelt/core/migrations/0002_alter_user_managers.py
@@ -0,0 +1,20 @@
+# Generated by Django 3.2.13 on 2023-05-31 14:34
+
+import django.contrib.auth.models
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("core", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.AlterModelManagers(
+ name="user",
+ managers=[
+ ("objects", django.contrib.auth.models.UserManager()),
+ ],
+ ),
+ ]
diff --git a/server/vbv_lernwelt/core/migrations/0003_alter_user_avatar_url.py b/server/vbv_lernwelt/core/migrations/0003_alter_user_avatar_url.py
new file mode 100644
index 00000000..d1e22e74
--- /dev/null
+++ b/server/vbv_lernwelt/core/migrations/0003_alter_user_avatar_url.py
@@ -0,0 +1,22 @@
+# Generated by Django 3.2.13 on 2023-06-02 12:32
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("core", "0002_alter_user_managers"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="user",
+ name="avatar_url",
+ field=models.CharField(
+ blank=True,
+ default="/static/avatars/myvbv-default-avatar.png",
+ max_length=254,
+ ),
+ ),
+ ]
diff --git a/server/vbv_lernwelt/core/models.py b/server/vbv_lernwelt/core/models.py
index 73e498f1..c1a09d96 100644
--- a/server/vbv_lernwelt/core/models.py
+++ b/server/vbv_lernwelt/core/models.py
@@ -2,8 +2,6 @@ from django.contrib.auth.models import AbstractUser
from django.db import models
from django.db.models import JSONField
-from vbv_lernwelt.core.managers import UserManager
-
class User(AbstractUser):
"""
@@ -19,7 +17,9 @@ class User(AbstractUser):
# FIXME: look into it...
# objects = UserManager()
- avatar_url = models.CharField(max_length=254, blank=True, default="")
+ avatar_url = models.CharField(
+ max_length=254, blank=True, default="/static/avatars/myvbv-default-avatar.png"
+ )
email = models.EmailField("email address", unique=True)
sso_id = models.UUIDField(
"SSO subscriber ID", unique=True, null=True, blank=True, default=None
@@ -27,8 +27,6 @@ class User(AbstractUser):
additional_json_data = JSONField(default=dict, blank=True)
language = models.CharField(max_length=2, choices=LANGUAGE_CHOICES, default="de")
- objects = UserManager()
-
class SecurityRequestResponseLog(models.Model):
label = models.CharField(max_length=255, blank=True, default="")
diff --git a/server/vbv_lernwelt/course/admin.py b/server/vbv_lernwelt/course/admin.py
index 450d187b..ab5d2bbe 100644
--- a/server/vbv_lernwelt/course/admin.py
+++ b/server/vbv_lernwelt/course/admin.py
@@ -20,6 +20,7 @@ class CourseSessionAdmin(admin.ModelAdmin):
list_display = [
"title",
"course",
+ "import_id",
"start_date",
"end_date",
"created_at",
@@ -31,8 +32,9 @@ class CourseSessionAdmin(admin.ModelAdmin):
class CourseSessionUserAdmin(admin.ModelAdmin):
date_hierarchy = "created_at"
list_display = [
- "course_session",
"user",
+ "course_session",
+ "role",
"created_at",
"updated_at",
]
@@ -43,12 +45,12 @@ class CourseSessionUserAdmin(admin.ModelAdmin):
"course_session__title",
]
list_filter = [
- "course_session__course",
"course_session",
+ "role",
]
fieldsets = [
- (None, {"fields": ("user", "course_session")}),
+ (None, {"fields": ("user", "course_session", "role")}),
(
"Expert/Trainer",
{
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 f681bc5b..e25bb0fc 100644
--- a/server/vbv_lernwelt/course/management/commands/create_default_courses.py
+++ b/server/vbv_lernwelt/course/management/commands/create_default_courses.py
@@ -1,3 +1,4 @@
+import os
import random
import djclick as click
@@ -43,9 +44,19 @@ from vbv_lernwelt.course.creators.uk_training_course import (
from vbv_lernwelt.course.creators.versicherungsvermittlerin import (
create_versicherungsvermittlerin_with_categories,
)
-from vbv_lernwelt.course.models import CoursePage, CourseSession, CourseSessionUser
+from vbv_lernwelt.course.models import (
+ Course,
+ CoursePage,
+ CourseSession,
+ CourseSessionUser,
+)
from vbv_lernwelt.course.services import mark_course_completion
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,
+)
from vbv_lernwelt.learnpath.create_vv_new_learning_path import (
create_vv_new_learning_path,
)
@@ -439,21 +450,24 @@ def create_course_training_de():
create_uk_training_competence_profile(course_id=COURSE_UK_TRAINING)
create_default_media_library(course_id=COURSE_UK_TRAINING)
- cs = CourseSession.objects.create(
- course_id=COURSE_UK_TRAINING,
- title="Demo-Tag",
- attendance_courses=[
- {
- "learningContentId": LearningContentAttendanceCourse.objects.get(
- slug=f"{course.slug}-lp-circle-fahrzeug-lc-präsenzkurs-fahrzeug"
- ).id,
- "start": "2023-05-23T08:30:00+0200",
- "end": "2023-05-23T17:00:00+0200",
- "location": "Handelsschule KV Bern, Zimmer 123, Eigerstrasse 16, 3012 Bern",
- "trainer": "Roland Grossenbacher, roland.grossenbacher@helvetia.ch",
- }
- ],
- assignment_details_list=[
+ current_dir = os.path.dirname(os.path.realpath(__file__))
+ 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",
+ )
+ import_trainers_from_excel(
+ course,
+ f"{current_dir}/../../../importer/tests/Schulungen_Durchfuehrung_Trainer.xlsx",
+ )
+ import_students_from_excel(
+ course,
+ f"{current_dir}/../../../importer/tests/Schulungen_Teilnehmende.xlsx",
+ )
+
+ for cs in CourseSession.objects.filter(course_id=COURSE_UK_TRAINING):
+ cs.assignment_details_list = [
{
"learningContentId": LearningContentAssignment.objects.get(
slug=f"{course.slug}-lp-circle-fahrzeug-lc-überprüfen-einer-motorfahrzeug-versicherungspolice"
@@ -468,5 +482,34 @@ def create_course_training_de():
"submissionDeadlineDateTimeUtc": "2023-06-13T19:00:00Z",
"evaluationDeadlineDateTimeUtc": "2023-06-27T19:00:00Z",
},
- ],
- )
+ ]
+ cs.save()
+
+ # attach users as trainers to ÜK course
+ course_uk = Course.objects.filter(id=COURSE_UK).first()
+ if course_uk:
+ users = [
+ csu.user
+ for csu in CourseSessionUser.objects.filter(
+ course_session__course_id=COURSE_UK_TRAINING
+ )
+ ]
+
+ cs = CourseSession.objects.get(course_id=COURSE_UK, title="Bern 2023 a")
+
+ for user in users:
+ csu, _created = CourseSessionUser.objects.get_or_create(
+ course_session_id=cs.id, user_id=user.id
+ )
+ csu.role = CourseSessionUser.Role.EXPERT
+ csu.expert.add(
+ Circle.objects.get(slug="überbetriebliche-kurse-lp-circle-kickoff")
+ )
+ csu.expert.add(
+ Circle.objects.get(slug="überbetriebliche-kurse-lp-circle-basis")
+ )
+ csu.expert.add(
+ Circle.objects.get(slug="überbetriebliche-kurse-lp-circle-fahrzeug")
+ )
+
+ csu.save()
diff --git a/server/vbv_lernwelt/course/migrations/0004_import_fields.py b/server/vbv_lernwelt/course/migrations/0004_import_fields.py
new file mode 100644
index 00000000..cc7f16c7
--- /dev/null
+++ b/server/vbv_lernwelt/course/migrations/0004_import_fields.py
@@ -0,0 +1,38 @@
+# Generated by Django 3.2.13 on 2023-05-31 15:59
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("course", "0003_rename_attendance_days_coursesession_attendance_courses"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="coursesession",
+ name="generation",
+ field=models.TextField(blank=True, default=""),
+ ),
+ migrations.AddField(
+ model_name="coursesession",
+ name="group",
+ field=models.TextField(blank=True, default=""),
+ ),
+ migrations.AddField(
+ model_name="coursesession",
+ name="import_id",
+ field=models.TextField(blank=True, default=""),
+ ),
+ migrations.AddField(
+ model_name="coursesession",
+ name="region",
+ field=models.TextField(blank=True, default=""),
+ ),
+ migrations.AlterField(
+ model_name="coursesession",
+ name="title",
+ field=models.TextField(unique=True),
+ ),
+ ]
diff --git a/server/vbv_lernwelt/course/models.py b/server/vbv_lernwelt/course/models.py
index f0bd86b2..2a49e8d9 100644
--- a/server/vbv_lernwelt/course/models.py
+++ b/server/vbv_lernwelt/course/models.py
@@ -215,7 +215,13 @@ class CourseSession(models.Model):
updated_at = models.DateTimeField(auto_now=True)
course = models.ForeignKey("course.Course", on_delete=models.CASCADE)
- title = models.TextField()
+ title = models.TextField(unique=True)
+
+ import_id = models.TextField(blank=True, default="")
+
+ generation = models.TextField(blank=True, default="")
+ region = models.TextField(blank=True, default="")
+ group = models.TextField(blank=True, default="")
start_date = models.DateField(null=True, blank=True)
end_date = models.DateField(null=True, blank=True)
diff --git a/server/vbv_lernwelt/importer/__init__.py b/server/vbv_lernwelt/importer/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/server/vbv_lernwelt/importer/admin.py b/server/vbv_lernwelt/importer/admin.py
new file mode 100644
index 00000000..8c38f3f3
--- /dev/null
+++ b/server/vbv_lernwelt/importer/admin.py
@@ -0,0 +1,3 @@
+from django.contrib import admin
+
+# Register your models here.
diff --git a/server/vbv_lernwelt/importer/apps.py b/server/vbv_lernwelt/importer/apps.py
new file mode 100644
index 00000000..a0f916a8
--- /dev/null
+++ b/server/vbv_lernwelt/importer/apps.py
@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class SsoConfig(AppConfig):
+ default_auto_field = "django.db.models.BigAutoField"
+ name = "vbv_lernwelt.importer"
diff --git a/server/vbv_lernwelt/importer/migrations/__init__.py b/server/vbv_lernwelt/importer/migrations/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/server/vbv_lernwelt/importer/models.py b/server/vbv_lernwelt/importer/models.py
new file mode 100644
index 00000000..e69de29b
diff --git a/server/vbv_lernwelt/importer/services.py b/server/vbv_lernwelt/importer/services.py
new file mode 100644
index 00000000..d75fe9a0
--- /dev/null
+++ b/server/vbv_lernwelt/importer/services.py
@@ -0,0 +1,223 @@
+from typing import Any, Dict
+
+import structlog
+from openpyxl.reader.excel import load_workbook
+
+from vbv_lernwelt.core.models import User
+from vbv_lernwelt.course.models import Course, CourseSession, CourseSessionUser
+from vbv_lernwelt.importer.utils import (
+ calc_header_tuple_list_from_pyxl_sheet,
+ parse_circle_group_string,
+ try_parse_datetime,
+)
+from vbv_lernwelt.learnpath.models import Circle, LearningContentAttendanceCourse
+
+logger = structlog.get_logger(__name__)
+
+
+def create_or_update_user(
+ email: str, first_name: str = "", last_name: str = "", sso_id: str = None
+):
+ logger.debug(
+ "create_or_update_user",
+ email=email,
+ first_name=first_name,
+ last_name=last_name,
+ sso_id=sso_id,
+ label="import",
+ )
+ user = None
+ if sso_id:
+ user_qs = User.objects.filter(sso_id=sso_id)
+ if user_qs.exists():
+ user = user_qs.first()
+
+ if not user:
+ user_qs = User.objects.filter(email=email)
+ if user_qs.exists():
+ user = user_qs.first()
+
+ if not user:
+ # create user
+ user = User(sso_id=sso_id, email=email, username=email)
+
+ user.email = email
+ user.sso_id = user.sso_id or sso_id
+ user.first_name = first_name or user.first_name
+ user.last_name = last_name or user.last_name
+ user.username = email
+ user.set_unusable_password()
+ user.save()
+
+ return user
+
+
+def import_course_sessions_from_excel(course: Course, filename: str):
+ 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:
+ create_or_update_course_session(course, dict(row), circles=["Fahrzeug"])
+
+
+def create_or_update_course_session(course: Course, data: Dict[str, Any], circles=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,
+ data=data,
+ label="import",
+ )
+
+ if circles is None:
+ circles = []
+
+ # TODO: validation
+ group = data["Klasse"].strip()
+ import_id = data["ID"].strip()
+
+ generation = str(data["Generation"]).strip()
+ region = data["Region"].strip()
+
+ title = f"{region} {generation} {group}"
+
+ cs, _created = CourseSession.objects.get_or_create(
+ import_id=import_id, group=group, course=course
+ )
+
+ cs.additional_json_data["import_data"] = data
+ cs.save()
+
+ cs.title = title
+ cs.generation = generation
+ cs.region = region
+ cs.group = group
+ cs.import_id = import_id
+
+ cs.save()
+
+ for circle in circles:
+ attendance_course_lp_qs = LearningContentAttendanceCourse.objects.filter(
+ slug=f"{course.slug}-lp-circle-{circle.lower()}-lc-präsenzkurs-{circle.lower()}"
+ )
+ if attendance_course_lp_qs.exists():
+ cs.attendance_courses.append(
+ {
+ "learningContentId": attendance_course_lp_qs.first().id,
+ "start": try_parse_datetime(data[f"{circle} Start"])[1].isoformat(),
+ "end": try_parse_datetime(data[f"{circle} Ende"])[1].isoformat(),
+ "location": data[f"{circle} Raum"],
+ "trainer": "",
+ }
+ )
+ cs.save()
+
+ return cs
+
+
+def import_trainers_from_excel(course: Course, filename: str):
+ 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))
+
+
+def create_or_update_trainer(course: Course, data: Dict[str, Any]):
+ logger.debug(
+ "create_or_update_trainer",
+ course=course.title,
+ data=data,
+ label="import",
+ )
+
+ user = create_or_update_user(
+ email=data["Email"],
+ first_name=data["Vorname"],
+ last_name=data["Name"],
+ )
+
+ # TODO: handle language
+
+ groups = [g.strip() for g in data["Klasse"].strip().split(",")]
+
+ # general expert handling
+ for group in groups:
+ import_id = f"{data['Generation'].strip()} {group}"
+ course_session = CourseSession.objects.filter(
+ import_id=import_id, group=group, course=course
+ ).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()
+
+ # 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(",")]
+
+ # print(circle_name, groups)
+ for group in groups:
+ course_session = CourseSession.objects.filter(
+ import_id=import_id, group=group, course=course
+ ).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()
+
+
+def import_students_from_excel(course: Course, 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:
+ create_or_update_student(course, dict(row))
+
+
+def create_or_update_student(course: Course, data: Dict[str, Any]):
+ logger.debug(
+ "create_or_update_student",
+ course=course.title,
+ data=data,
+ label="import",
+ )
+
+ user = create_or_update_user(
+ email=data["Email"],
+ first_name=data["Vorname"],
+ last_name=data["Name"],
+ )
+
+ # TODO: handle language
+
+ # 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, course=course
+ ).first()
+ if course_session:
+ csu, _created = CourseSessionUser.objects.get_or_create(
+ course_session_id=course_session.id, user_id=user.id
+ )
+ csu.save()
diff --git a/server/vbv_lernwelt/importer/tests/Schulungen_Durchfuehrung_Trainer.xlsx b/server/vbv_lernwelt/importer/tests/Schulungen_Durchfuehrung_Trainer.xlsx
new file mode 100644
index 00000000..05311f3e
Binary files /dev/null and b/server/vbv_lernwelt/importer/tests/Schulungen_Durchfuehrung_Trainer.xlsx differ
diff --git a/server/vbv_lernwelt/importer/tests/Schulungen_Teilnehmende.xlsx b/server/vbv_lernwelt/importer/tests/Schulungen_Teilnehmende.xlsx
new file mode 100644
index 00000000..f2f57358
Binary files /dev/null and b/server/vbv_lernwelt/importer/tests/Schulungen_Teilnehmende.xlsx differ
diff --git a/server/vbv_lernwelt/importer/tests/test_import_course_sessions.py b/server/vbv_lernwelt/importer/tests/test_import_course_sessions.py
new file mode 100644
index 00000000..adf1c715
--- /dev/null
+++ b/server/vbv_lernwelt/importer/tests/test_import_course_sessions.py
@@ -0,0 +1,129 @@
+import os
+
+from django.test import TestCase
+from openpyxl.reader.excel import load_workbook
+
+from vbv_lernwelt.course.creators.test_course import create_test_course
+from vbv_lernwelt.course.models import CourseSession
+from vbv_lernwelt.importer.services import create_or_update_course_session
+from vbv_lernwelt.importer.utils import calc_header_tuple_list_from_pyxl_sheet
+
+test_dir = os.path.dirname(os.path.abspath(__file__))
+
+
+class ImportCourseSessionTestCase(TestCase):
+ def setUp(self):
+ self.course = create_test_course(include_vv=False)
+
+ def test_import_excel_file(self):
+ workbook = load_workbook(
+ filename=f"{test_dir}/Schulungen_Durchfuehrung_Trainer.xlsx"
+ )
+ sheet = workbook["Schulungen Durchführung"]
+
+ tuple_list = calc_header_tuple_list_from_pyxl_sheet(sheet)
+ for row in tuple_list:
+ print(row)
+ create_or_update_course_session(
+ self.course, dict(row), circles=["Fahrzeug"]
+ )
+
+ self.assertEqual(CourseSession.objects.count(), 6)
+
+
+class CreateOrUpdateCourseSessionTestCase(TestCase):
+ def setUp(self):
+ self.course = create_test_course(include_vv=False)
+
+ def test_create_course_session(self):
+ row = [
+ ("ID", "DE 2023 A"),
+ ("Generation", 2023),
+ ("Region", "Deutschschweiz"),
+ ("Klasse", "A"),
+ ("Fahrzeug Start", "06.06.2023, 13:30"),
+ ("Fahrzeug Ende", "06.06.2023, 15:00"),
+ (
+ "Fahrzeug Raum",
+ "https://teams.microsoft.com/l/meetup-join/19%3ameeting_N2I5YzViZTQtYTM2Ny00OTYwLTgzNzAtYWI4OTQzODcxNTlj%40thread.v2/0?context=%7b%22Tid%22%3a%22fedd03c8-a756-4803-8f27-0db8f7c488f2%22%2c%22Oid%22%3a%22f92e6382-3884-4e71-a2fd-b305a75d9812%22%7d",
+ ),
+ ("Fahrzeug Standort", None),
+ ("Fahrzeug Adresse", None),
+ ]
+
+ data = dict(row)
+
+ cs = create_or_update_course_session(self.course, data, circles=["Fahrzeug"])
+
+ self.assertEqual(cs.import_id, "DE 2023 A")
+ self.assertEqual(cs.title, "Deutschschweiz 2023 A")
+ self.assertEqual(cs.generation, "2023")
+ self.assertEqual(cs.region, "Deutschschweiz")
+ self.assertEqual(cs.group, "A")
+
+ attendance_course = cs.attendance_courses[0]
+ attendance_course = {
+ k: v
+ for k, v in attendance_course.items()
+ if k not in ["learningContentId", "location"]
+ }
+
+ self.assertDictEqual(
+ attendance_course,
+ {
+ "start": "2023-06-06T13:30:00",
+ "end": "2023-06-06T15:00:00",
+ "trainer": "",
+ },
+ )
+
+ def test_update_course_session(self):
+ cs = CourseSession.objects.create(
+ course_id=self.course.id,
+ title="Deutschschweiz 2023 A",
+ import_id="DE 2023",
+ group="A",
+ )
+
+ row = [
+ ("ID", "DE 2023"),
+ ("Generation", 2023),
+ ("Region", "Deutschschweiz"),
+ ("Klasse", "A"),
+ ("Fahrzeug Start", "06.06.2023, 13:30"),
+ ("Fahrzeug Ende", "06.06.2023, 15:00"),
+ (
+ "Fahrzeug Raum",
+ "https://teams.microsoft.com/l/meetup-join/19%3ameeting_N2I5YzViZTQtYTM2Ny00OTYwLTgzNzAtYWI4OTQzODcxNTlj%40thread.v2/0?context=%7b%22Tid%22%3a%22fedd03c8-a756-4803-8f27-0db8f7c488f2%22%2c%22Oid%22%3a%22f92e6382-3884-4e71-a2fd-b305a75d9812%22%7d",
+ ),
+ ("Fahrzeug Standort", None),
+ ("Fahrzeug Adresse", None),
+ ]
+
+ data = dict(row)
+
+ cs = create_or_update_course_session(self.course, data, circles=["Fahrzeug"])
+
+ self.assertEqual(1, CourseSession.objects.count())
+
+ self.assertEqual(cs.import_id, "DE 2023")
+ self.assertEqual(cs.title, "Deutschschweiz 2023 A")
+ self.assertEqual(cs.generation, "2023")
+ self.assertEqual(cs.region, "Deutschschweiz")
+ self.assertEqual(cs.group, "A")
+
+ attendance_course = cs.attendance_courses[0]
+ attendance_course = {
+ k: v
+ for k, v in attendance_course.items()
+ if k not in ["learningContentId", "location"]
+ }
+
+ self.assertDictEqual(
+ attendance_course,
+ {
+ "start": "2023-06-06T13:30:00",
+ "end": "2023-06-06T15:00:00",
+ "trainer": "",
+ },
+ )
diff --git a/server/vbv_lernwelt/importer/tests/test_import_students.py b/server/vbv_lernwelt/importer/tests/test_import_students.py
new file mode 100644
index 00000000..500299b0
--- /dev/null
+++ b/server/vbv_lernwelt/importer/tests/test_import_students.py
@@ -0,0 +1,76 @@
+import os
+from datetime import datetime
+
+from django.test import TestCase
+from openpyxl.reader.excel import load_workbook
+
+from vbv_lernwelt.course.creators.test_course import create_test_course
+from vbv_lernwelt.course.models import CourseSession, CourseSessionUser
+from vbv_lernwelt.importer.services import create_or_update_student
+from vbv_lernwelt.importer.utils import calc_header_tuple_list_from_pyxl_sheet
+
+test_dir = os.path.dirname(os.path.abspath(__file__))
+
+
+class ImportStudentsTestCase(TestCase):
+ def setUp(self):
+ self.course = create_test_course(include_vv=False)
+ self.course_session_a = CourseSession.objects.create(
+ course=self.course,
+ title="Deutschschweiz 2023 A",
+ import_id="DE 2023 A",
+ group="A",
+ )
+
+ def test_import_excel_file(self):
+ workbook = load_workbook(filename=f"{test_dir}/Schulungen_Teilnehmende.xlsx")
+ sheet = workbook.active
+
+ tuple_list = calc_header_tuple_list_from_pyxl_sheet(sheet)
+ for row in tuple_list:
+ print(row)
+ create_or_update_student(self.course, dict(row))
+
+ self.assertEqual(CourseSessionUser.objects.count(), 28)
+
+
+class CreateOrUpdateStudentTestCase(TestCase):
+ def setUp(self):
+ self.course = create_test_course(include_vv=False)
+ self.course_session_a = CourseSession.objects.create(
+ course=self.course,
+ title="Deutschschweiz 2023 A",
+ import_id="DE 2023 A",
+ group="A",
+ )
+
+ def test_create_student(self):
+ row = [
+ ("Name", "Rascher"),
+ ("Vorname", "Barbara"),
+ ("Email", "barbara.rascher@vbv-afa.ch"),
+ ("Sprache", "de"),
+ ("Durchführungen", "DE 2023 A"),
+ ("Datum", datetime(2023, 9, 6, 0, 0)),
+ (None, "VBV"),
+ (None, None),
+ (None, None),
+ (None, None),
+ (None, None),
+ ]
+
+ create_or_update_student(self.course, dict(row))
+
+ self.assertEqual(
+ CourseSessionUser.objects.filter(
+ user__email="barbara.rascher@vbv-afa.ch"
+ ).count(),
+ 1,
+ )
+
+ csu = CourseSessionUser.objects.get(
+ course_session=self.course_session_a,
+ )
+
+ self.assertEqual(csu.role, CourseSessionUser.Role.MEMBER)
+ self.assertEqual(csu.user.email, "barbara.rascher@vbv-afa.ch")
diff --git a/server/vbv_lernwelt/importer/tests/test_import_trainers.py b/server/vbv_lernwelt/importer/tests/test_import_trainers.py
new file mode 100644
index 00000000..383319fa
--- /dev/null
+++ b/server/vbv_lernwelt/importer/tests/test_import_trainers.py
@@ -0,0 +1,88 @@
+import os
+
+from django.test import TestCase
+from openpyxl.reader.excel import load_workbook
+
+from vbv_lernwelt.course.creators.test_course import create_test_course
+from vbv_lernwelt.course.models import CourseSession, CourseSessionUser
+from vbv_lernwelt.importer.services import create_or_update_trainer
+from vbv_lernwelt.importer.utils import calc_header_tuple_list_from_pyxl_sheet
+
+test_dir = os.path.dirname(os.path.abspath(__file__))
+
+
+class ImportTrainerTestCase(TestCase):
+ def setUp(self):
+ self.course = create_test_course(include_vv=False)
+ self.course_session_a = CourseSession.objects.create(
+ course=self.course,
+ title="Deutschschweiz 2023 A",
+ import_id="DE 2023 A",
+ group="A",
+ )
+ self.course_session_a = CourseSession.objects.create(
+ course=self.course,
+ title="Deutschschweiz 2023 B",
+ import_id="DE 2023 B",
+ group="B",
+ )
+
+ def test_import_excel_file(self):
+ workbook = load_workbook(
+ filename=f"{test_dir}/Schulungen_Durchfuehrung_Trainer.xlsx"
+ )
+ sheet = workbook["Schulungen Trainer"]
+
+ tuple_list = calc_header_tuple_list_from_pyxl_sheet(sheet)
+ for row in tuple_list:
+ print(row)
+ create_or_update_trainer(self.course, dict(row))
+
+ self.assertEqual(CourseSessionUser.objects.count(), 4)
+
+
+class CreateOrUpdateTrainerTestCase(TestCase):
+ def setUp(self):
+ self.course = create_test_course(include_vv=False)
+ self.course_session_a = CourseSession.objects.create(
+ course=self.course,
+ title="Deutschschweiz 2023 A",
+ import_id="DE 2023 A",
+ group="A",
+ )
+ self.course_session_a = CourseSession.objects.create(
+ course=self.course,
+ title="Deutschschweiz 2023 B",
+ import_id="DE 2023 B",
+ group="B",
+ )
+
+ def test_create_trainer(self):
+ row = [
+ ("Name", "Hänni"),
+ ("Vorname", "Fabienne"),
+ ("Email", "fabienne.haenni@vbv-afa.ch"),
+ ("Sprache", "de"),
+ ("Generation", "DE 2023"),
+ ("Klasse", "A, B"),
+ ("Circles", "Fahrzeug (A, B), Reisen (A), KMU (B)"),
+ ("Status Referenten", "ok"),
+ (None, "Schulung D"),
+ ]
+
+ create_or_update_trainer(self.course, dict(row))
+
+ self.assertEqual(
+ CourseSessionUser.objects.filter(
+ user__email="fabienne.haenni@vbv-afa.ch"
+ ).count(),
+ 2,
+ )
+
+ csu = CourseSessionUser.objects.get(
+ course_session=self.course_session_a,
+ )
+
+ self.assertEqual(csu.role, CourseSessionUser.Role.EXPERT)
+ self.assertEqual(csu.user.email, "fabienne.haenni@vbv-afa.ch")
+ self.assertEqual(csu.expert.all().first().title, "Fahrzeug")
diff --git a/server/vbv_lernwelt/importer/tests/test_services.py b/server/vbv_lernwelt/importer/tests/test_services.py
new file mode 100644
index 00000000..c39aded5
--- /dev/null
+++ b/server/vbv_lernwelt/importer/tests/test_services.py
@@ -0,0 +1,94 @@
+from django.test import TestCase
+
+from vbv_lernwelt.core.models import User
+from vbv_lernwelt.importer.services import create_or_update_user
+
+
+class CreateOrUpdateUserTestCase(TestCase):
+ def test_create_user(self):
+ u = create_or_update_user(
+ email="daniel@example.com",
+ first_name="Daniel",
+ last_name="Egger",
+ sso_id="12229620-81ea-483d-8d96-6ba8be5f9eb7",
+ )
+
+ saved_user = User.objects.get(id=u.id)
+
+ self.assertEqual(saved_user.email, "daniel@example.com")
+ self.assertEqual(saved_user.username, "daniel@example.com")
+ self.assertEqual(saved_user.first_name, "Daniel")
+ self.assertEqual(saved_user.last_name, "Egger")
+ self.assertEqual(str(saved_user.sso_id), "12229620-81ea-483d-8d96-6ba8be5f9eb7")
+
+ def test_update_existing_user_with_oid(self):
+ User.objects.create(
+ email="daniel@example.com",
+ username="daniel@example.com",
+ first_name="Daniel",
+ last_name="Egger",
+ )
+
+ create_or_update_user(
+ email="daniel@example.com",
+ last_name="Egger",
+ sso_id="12229620-81ea-483d-8d96-6ba8be5f9eb7",
+ )
+
+ self.assertEqual(1, User.objects.count())
+ user = User.objects.first()
+
+ self.assertEqual(user.email, "daniel@example.com")
+ self.assertEqual(user.username, "daniel@example.com")
+ self.assertEqual(user.first_name, "Daniel")
+ self.assertEqual(user.last_name, "Egger")
+ self.assertEqual(str(user.sso_id), "12229620-81ea-483d-8d96-6ba8be5f9eb7")
+
+ def test_update_existing_user_with_new_last_name(self):
+ User.objects.create(
+ email="daniel@example.com",
+ username="daniel@example.com",
+ first_name="Daniel",
+ last_name="Egger",
+ sso_id="12229620-81ea-483d-8d96-6ba8be5f9eb7",
+ )
+
+ create_or_update_user(
+ email="daniel@example.com",
+ first_name="Daniel",
+ last_name="Marro",
+ )
+
+ self.assertEqual(1, User.objects.count())
+ user = User.objects.first()
+
+ self.assertEqual(user.email, "daniel@example.com")
+ self.assertEqual(user.username, "daniel@example.com")
+ self.assertEqual(user.first_name, "Daniel")
+ self.assertEqual(user.last_name, "Marro")
+ self.assertEqual(str(user.sso_id), "12229620-81ea-483d-8d96-6ba8be5f9eb7")
+
+ def test_update_existing_user_with_new_email(self):
+ User.objects.create(
+ email="daniel@example.com",
+ username="daniel@example.com",
+ first_name="Daniel",
+ last_name="Egger",
+ sso_id="12229620-81ea-483d-8d96-6ba8be5f9eb7",
+ )
+
+ create_or_update_user(
+ email="danu@example.com",
+ first_name="Daniel",
+ last_name="Egger",
+ sso_id="12229620-81ea-483d-8d96-6ba8be5f9eb7",
+ )
+
+ self.assertEqual(1, User.objects.count())
+ user = User.objects.first()
+
+ self.assertEqual(user.email, "danu@example.com")
+ self.assertEqual(user.username, "danu@example.com")
+ self.assertEqual(user.first_name, "Daniel")
+ self.assertEqual(user.last_name, "Egger")
+ self.assertEqual(str(user.sso_id), "12229620-81ea-483d-8d96-6ba8be5f9eb7")
diff --git a/server/vbv_lernwelt/importer/tests/test_utils.py b/server/vbv_lernwelt/importer/tests/test_utils.py
new file mode 100644
index 00000000..69be4b32
--- /dev/null
+++ b/server/vbv_lernwelt/importer/tests/test_utils.py
@@ -0,0 +1,192 @@
+from datetime import date, datetime
+from unittest import TestCase
+
+from vbv_lernwelt.importer.utils import (
+ parse_circle_group_string,
+ try_parse_date,
+ try_parse_datetime,
+ try_parse_int,
+)
+
+
+class TryParseDateTestCase(TestCase):
+ def test_wrongData_returnsFalseAndValue(self):
+ flag, value = try_parse_date("nonsense")
+
+ self.assertFalse(flag)
+ self.assertEqual("nonsense", value)
+
+ def test_isoDate_returnsCorrectDate(self):
+ flag, value = try_parse_date("2015-01-20")
+
+ self.assertTrue(flag)
+ self.assertEqual(date(2015, 1, 20), value)
+
+ def test_isoDateWithTime_returnsCorrectDate(self):
+ flag, value = try_parse_date("2015-01-20T15:21")
+
+ self.assertTrue(flag)
+ self.assertEqual(date(2015, 1, 20), value)
+
+ def test_isoDateWithTime2_returnsCorrectDate(self):
+ flag, value = try_parse_date("2018-05-03T00:00:00")
+
+ self.assertTrue(flag)
+ self.assertEqual(date(2018, 5, 3), value)
+
+ def test_swissDate_returnsCorrectDate(self):
+ flag, value = try_parse_date("01.05.2018")
+
+ self.assertTrue(flag)
+ self.assertEqual(date(2018, 5, 1), value)
+
+ def test_wrongIsoDate_returnsFalseAndValue(self):
+ flag, value = try_parse_date("2015-14-40")
+
+ self.assertFalse(flag)
+ self.assertEqual("2015-14-40", value)
+
+ def test_inputIsDate_returnsDate(self):
+ flag, value = try_parse_date(date(2016, 5, 1))
+
+ self.assertTrue(flag)
+ self.assertEqual(date(2016, 5, 1), value)
+
+ def test_inputIsNumber_returnsFalseAndNumber(self):
+ flag, value = try_parse_date(123)
+
+ self.assertFalse(flag)
+ self.assertEqual(123, value)
+
+ def test_inputIsNumberString_returnsFalseAndString(self):
+ flag, value = try_parse_date("56")
+
+ self.assertFalse(flag)
+ self.assertEqual("56", value)
+
+ def test_inputIsFloatString_returnsFalseAndString(self):
+ flag, value = try_parse_date("3.14")
+
+ self.assertFalse(flag)
+ self.assertEqual("3.14", value)
+
+ def test_inputIsShortDateWithoutYear_returnsFalseAndString(self):
+ flag, value = try_parse_date("11-01")
+
+ self.assertFalse(flag)
+ self.assertEqual("11-01", value)
+
+
+class TryParseInt(TestCase):
+ def test_int_works(self):
+ flag, value = try_parse_int(123)
+
+ self.assertTrue(flag)
+ self.assertEqual(123, value)
+
+ def test_valid_string_works(self):
+ flag, value = try_parse_int("123")
+
+ self.assertTrue(flag)
+ self.assertEqual(123, value)
+
+ def test_invalid_string_breaks(self):
+ flag, value = try_parse_int("123qwer")
+
+ self.assertFalse(flag)
+ self.assertEqual("123qwer", value)
+
+ def test_invalid_string_returns_default(self):
+ flag, value = try_parse_int("123qwer", 0)
+
+ self.assertFalse(flag)
+ self.assertEqual(0, value)
+
+
+class TryParseDateTimeTestCase(TestCase):
+ def test_isoDateTime_returnsCorrectDateTime(self):
+ flag, value = try_parse_datetime("2016-05-31T10:00:00.000000")
+
+ self.assertTrue(flag)
+ self.assertEqual(datetime(2016, 5, 31, 10, 0, 0), value)
+
+ def test_isoDateTimeWithoutSeconds_returnsCorrectDateTime(self):
+ flag, value = try_parse_datetime("2016-05-02T10:00")
+
+ self.assertTrue(flag)
+ self.assertEqual(datetime(2016, 5, 2, 10, 0, 0), value)
+
+ def test_isoDateWithoutTime_returnTrueAndDatetimeWithZeroHour(self):
+ flag, value = try_parse_datetime("2016-05-31")
+
+ self.assertTrue(flag)
+ self.assertEqual(datetime(2016, 5, 31), value)
+
+ def test_isoDateWithSpaceBeforeTime_returnTrueAndDatetimeWithZeroHour(self):
+ flag, value = try_parse_datetime("2016-05-03 14:12")
+
+ self.assertTrue(flag)
+ self.assertEqual(datetime(2016, 5, 3, 14, 12), value)
+
+ def test_swissDateWithTime_returnTrueAndDatetime(self):
+ flag, value = try_parse_datetime("01.05.2018 15:20")
+
+ self.assertTrue(flag)
+ self.assertEqual(datetime(2018, 5, 1, 15, 20), value)
+
+ def test_swissDateWithTimeWithMultipleSpaces_returnTrueAndDatetime(self):
+ flag, value = try_parse_datetime("01 .05. 2018 15:20")
+
+ self.assertTrue(flag)
+ self.assertEqual(datetime(2018, 5, 1, 15, 20), value)
+
+ def test_withDateTimeInput_returnsTrueAndDateTime(self):
+ flag, value = try_parse_datetime(datetime(2016, 5, 31, 10, 0))
+
+ self.assertTrue(flag)
+ self.assertEqual(datetime(2016, 5, 31, 10, 0, 0), value)
+
+ def test_inputIsNumber_returnsFalseAndNumber(self):
+ flag, value = try_parse_datetime(123)
+
+ self.assertFalse(flag)
+ self.assertEqual(123, value)
+
+ def test_inputIsNumberString_returnsFalseAndString(self):
+ flag, value = try_parse_datetime("56")
+
+ self.assertFalse(flag)
+ self.assertEqual("56", value)
+
+ def test_inputIsFloatString_returnsFalseAndString(self):
+ flag, value = try_parse_datetime("3.14")
+
+ self.assertFalse(flag)
+ self.assertEqual("3.14", value)
+
+ def test_inputIsShortDateWithoutYear_returnsFalseAndString(self):
+ flag, value = try_parse_date("11-01")
+
+ self.assertFalse(flag)
+ self.assertEqual("11-01", value)
+
+ def test_inputIsTimeString_returnsCurrentDateWithGivenTime(self):
+ flag, value = try_parse_datetime(" 15:00")
+
+ self.assertFalse(flag)
+ self.assertEqual(" 15:00", value)
+
+ def test_inputFromVbvExcel_returnsCurrentDateWithGivenTime(self):
+ flag, value = try_parse_datetime("09.06.2023, 13:30")
+
+ self.assertTrue(flag)
+ self.assertEqual(datetime(2023, 6, 9, 13, 30, 0), value)
+
+
+class ParseCircleGroupStringTestCase(TestCase):
+ def test_withMultipleCircles(self):
+ value = "Fahrzeug (A, B), Reisen (A), KMU (B)"
+ self.assertEqual(
+ ["Fahrzeug (A, B)", "Reisen (A)", "KMU (B)"],
+ parse_circle_group_string(value),
+ )
diff --git a/server/vbv_lernwelt/importer/urls.py b/server/vbv_lernwelt/importer/urls.py
new file mode 100644
index 00000000..e69de29b
diff --git a/server/vbv_lernwelt/importer/utils.py b/server/vbv_lernwelt/importer/utils.py
new file mode 100644
index 00000000..d61405d2
--- /dev/null
+++ b/server/vbv_lernwelt/importer/utils.py
@@ -0,0 +1,110 @@
+import datetime
+import re
+from typing import Any, List, Optional, Tuple, Union
+
+from dateutil.parser import parse
+from six import string_types
+
+
+def parse_formats(dt_str, fmt_strs, **parser_kwargs):
+ for fmt in fmt_strs:
+ try:
+ return datetime.datetime.strptime(dt_str, fmt)
+ except ValueError:
+ pass
+
+ return parse(dt_str, **parser_kwargs)
+
+
+def try_parse_int(x: Any, default: Optional[Any] = None) -> Tuple[bool, Any]:
+ try:
+ return True, int(x)
+ # pylint: disable=broad-except
+ except Exception:
+ if default is None:
+ return False, x
+ return False, default
+
+
+def try_parse_date(
+ value: Union[str, datetime.date]
+) -> Tuple[bool, Union[str, datetime.date]]:
+ if isinstance(value, datetime.date):
+ return True, value
+ elif isinstance(value, datetime.datetime):
+ return True, value.date()
+ elif isinstance(value, string_types):
+ if value.strip().replace(".", "", 1).isdigit():
+ return False, value
+
+ # date needs at least 3 parts
+ if len(re.split(r"[.-]", value)) < 3:
+ return False, value
+
+ try:
+ date_with_time = parse_formats(
+ value,
+ [
+ "%Y-%m-%dT%H:%M:%S",
+ "%Y-%m-%d",
+ "%d.%m.%Y",
+ ],
+ dayfirst=True,
+ )
+ return True, date_with_time.date()
+ except ValueError:
+ return False, value
+ else:
+ return False, value
+
+
+def try_parse_datetime(
+ value: Union[str, datetime.datetime]
+) -> Tuple[bool, Union[str, datetime.datetime]]:
+ if isinstance(value, datetime.datetime):
+ return True, value
+ elif isinstance(value, string_types):
+ if value.strip().replace(".", "", 1).isdigit():
+ return False, value
+
+ # date needs at least 3 parts
+ if len(re.split(r"[.-]", value)) < 3:
+ return False, value
+
+ try:
+ date_with_time = parse_formats(
+ value,
+ [
+ "%Y-%m-%dT%H:%M:%S",
+ "%Y-%m-%dT%H:%M",
+ "%Y-%m-%d %H:%M:%S",
+ "%Y-%m-%d %H:%M",
+ "%d.%m.%Y, %H:%M",
+ ],
+ dayfirst=True,
+ )
+ return True, date_with_time
+ except ValueError:
+ return False, value
+ else:
+ return False, value
+
+
+def parse_circle_group_string(value: str) -> List[str]:
+ # This regex pattern matches any comma that is not inside parentheses
+ pattern = r",(?![^()]*\))"
+
+ # re.split() splits the string based on the pattern
+ return [x.strip() for x in re.split(pattern, value)]
+
+
+def calc_header_tuple_list_from_pyxl_sheet(sheet):
+ header = [cell.value for cell in sheet[1]]
+
+ result = []
+ for row in sheet.iter_rows(min_row=2, values_only=True):
+ if all(cell_value is None for cell_value in row):
+ continue
+ result.append(list(zip(header, row)))
+
+ return result
diff --git a/server/vbv_lernwelt/importer/views.py b/server/vbv_lernwelt/importer/views.py
new file mode 100644
index 00000000..e69de29b
diff --git a/server/vbv_lernwelt/notify/service.py b/server/vbv_lernwelt/notify/service.py
index e14decd9..ec384814 100644
--- a/server/vbv_lernwelt/notify/service.py
+++ b/server/vbv_lernwelt/notify/service.py
@@ -1,6 +1,6 @@
-import logging
from typing import Optional
+import structlog
from notifications.signals import notify
from sendgrid import Mail, SendGridAPIClient
from storages.utils import setting
@@ -8,7 +8,7 @@ from storages.utils import setting
from vbv_lernwelt.core.models import User
from vbv_lernwelt.notify.models import Notification, NotificationType
-logger = logging.getLogger(__name__)
+logger = structlog.get_logger(__name__)
class EmailService:
@@ -25,10 +25,14 @@ class EmailService:
)
try:
cls._sendgrid_client.send(message)
- logger.info(f"Successfully sent email to {recipient}")
+ logger.info(f"Successfully sent email to {recipient}", label="email")
return True
except Exception as e:
- logger.error(f"Failed to send email to {recipient}: {e}")
+ logger.error(
+ f"Failed to send email to {recipient}: {e}",
+ exc_info=True,
+ label="email",
+ )
return False
diff --git a/server/vbv_lernwelt/sso/jwt.py b/server/vbv_lernwelt/sso/jwt.py
index c31462a6..dee3c02a 100644
--- a/server/vbv_lernwelt/sso/jwt.py
+++ b/server/vbv_lernwelt/sso/jwt.py
@@ -1,8 +1,9 @@
import base64
import json
-import logging
-logger = logging.getLogger(__name__)
+import structlog
+
+logger = structlog.get_logger(__name__)
def decode_jwt(jwt: str):
@@ -11,7 +12,9 @@ def decode_jwt(jwt: str):
payload_bytes = base64.urlsafe_b64decode(_correct_padding(jwt_parts[1]))
payload = json.loads(payload_bytes.decode("UTF-8"))
except Exception as e:
- logger.warning(f"OAuthToken error: Could not decode jwt: {e}")
+ logger.warning(
+ f"OAuthToken error: Could not decode jwt: {e}", exc_info=True, label="sso"
+ )
return None
return payload
diff --git a/server/vbv_lernwelt/sso/tests.py b/server/vbv_lernwelt/sso/tests.py
deleted file mode 100644
index 7ce503c2..00000000
--- a/server/vbv_lernwelt/sso/tests.py
+++ /dev/null
@@ -1,3 +0,0 @@
-from django.test import TestCase
-
-# Create your tests here.
diff --git a/server/vbv_lernwelt/sso/tests/sso_token.json b/server/vbv_lernwelt/sso/tests/sso_token.json
new file mode 100644
index 00000000..e9995eee
--- /dev/null
+++ b/server/vbv_lernwelt/sso/tests/sso_token.json
@@ -0,0 +1,17 @@
+{
+ "ver": "1.0",
+ "iss": "https://vbvtst.b2clogin.com/6967b19e-ec5c-4a46-bb16-01b0983da41b/v2.0/",
+ "sub": "f8c8e526-9fb1-4983-a5b7-4c069a83e317",
+ "aud": "8d32c131-0d60-4588-b01a-ae3435d44c23",
+ "exp": 1685538794,
+ "nonce": "mABq9hjYOMF34fCEi3VL",
+ "iat": 1685535194,
+ "auth_time": 1685535194,
+ "oid": "f8c8e526-9fb1-4983-a5b7-4c069a83e317",
+ "given_name": "Daniel",
+ "family_name": "Egger",
+ "name": "unknown",
+ "emails": ["daniel.egger+vbv-stage@gmail.com"],
+ "tfp": "B2C_1_SignUpAndSignIn_v3",
+ "nbf": 1685535194
+}
diff --git a/server/vbv_lernwelt/sso/views.py b/server/vbv_lernwelt/sso/views.py
index d5a4698d..98aa587e 100644
--- a/server/vbv_lernwelt/sso/views.py
+++ b/server/vbv_lernwelt/sso/views.py
@@ -1,10 +1,11 @@
import structlog as structlog
from authlib.integrations.base_client import OAuthError
from django.conf import settings
-from django.contrib.auth import get_user_model, login as dj_login
+from django.contrib.auth import login as dj_login
from django.shortcuts import redirect
from sentry_sdk import capture_exception
+from vbv_lernwelt.importer.services import create_or_update_user
from vbv_lernwelt.sso.client import oauth
from vbv_lernwelt.sso.jwt import decode_jwt
@@ -22,19 +23,25 @@ def login(request):
def authorize(request):
try:
- logger.debug(request)
+ logger.debug(request, label="sso")
token = getattr(oauth, settings.OAUTH["client_name"]).authorize_access_token(
request
)
- deocded_token = decode_jwt(token["id_token"])
+ decoded_token = decode_jwt(token["id_token"])
+ # logger.debug(label="sso", decoded_token=decoded_token)
except OAuthError as e:
- logger.error(f"OAuth error: {e}")
+ logger.error(e, exc_info=True, label="sso")
if not settings.DEBUG:
capture_exception(e)
return redirect(f"/{OAUTH_FAIL_REDIRECT}?state=someerror") # to be defined
- user_data = _user_data_from_token_data(deocded_token)
- user, created = get_user_model().objects.create_or_update_by_email(user_data)
+ user_data = _user_data_from_token_data(decoded_token)
+ user = create_or_update_user(
+ email=user_data.get("email"),
+ sso_id=user_data.get("sso_id"),
+ first_name=user_data.get("first_name", ""),
+ last_name=user_data.get("last_name", ""),
+ )
dj_login(request, user)
return redirect(f"/")
@@ -45,7 +52,6 @@ def _user_data_from_token_data(token: dict) -> dict:
return {
"first_name": token.get("given_name", ""),
"last_name": token.get("family_name", ""),
- "username": token.get("preferred_username", first_email),
"email": first_email,
- "oid": token.get("oid"),
+ "sso_id": token.get("oid"),
}
diff --git a/server/vbv_lernwelt/static/avatars/myvbv-default-avatar.png b/server/vbv_lernwelt/static/avatars/myvbv-default-avatar.png
new file mode 100644
index 00000000..684656de
Binary files /dev/null and b/server/vbv_lernwelt/static/avatars/myvbv-default-avatar.png differ