From aa3f2221129c403d05a9506843fa80f14f333937 Mon Sep 17 00:00:00 2001 From: Christian Cueni Date: Wed, 19 Jun 2024 13:26:50 +0200 Subject: [PATCH 01/23] wip: Add KC-client and basic methods, signal handler --- server/config/settings/base.py | 6 + server/requirements/requirements-dev.txt | 386 ++++++++++-------- server/requirements/requirements.in | 2 + server/requirements/requirements.txt | 226 +++++----- server/vbv_lernwelt/course/models.py | 25 ++ server/vbv_lernwelt/course/signals.py | 9 +- .../course_session_group/admin.py | 3 +- server/vbv_lernwelt/edoniq_test/views.py | 6 +- server/vbv_lernwelt/feedback/services.py | 6 +- server/vbv_lernwelt/importer/services.py | 4 + .../vbv_lernwelt/notify/tests/test_service.py | 18 +- .../self_evaluation_feedback/serializers.py | 6 +- server/vbv_lernwelt/sso/role_sync/__init__.py | 0 server/vbv_lernwelt/sso/role_sync/client.py | 92 +++++ server/vbv_lernwelt/sso/role_sync/roles.py | 54 +++ server/vbv_lernwelt/sso/views.py | 1 + 16 files changed, 562 insertions(+), 282 deletions(-) create mode 100644 server/vbv_lernwelt/sso/role_sync/__init__.py create mode 100644 server/vbv_lernwelt/sso/role_sync/client.py create mode 100644 server/vbv_lernwelt/sso/role_sync/roles.py diff --git a/server/config/settings/base.py b/server/config/settings/base.py index ee33a0b2..af6b2e95 100644 --- a/server/config/settings/base.py +++ b/server/config/settings/base.py @@ -631,6 +631,12 @@ OAUTH_SIGNIN_REDIRECT_URI = env( "OAUTH_SIGNIN_REDIRECT_URI", default="http://localhost:8000/sso/callback" ) +OAUTH_SIGNIN_URL = env("OAUTH_SIGNIN_URL", default="") +OAUTH_SIGNIN_REALM = env("OAUTH_SIGNIN_REALM", default="vbv") +OAUTH_SIGNIN_ADMIN_CLIENT_ID = env("OAUTH_SIGNIN_ADMIN_CLIENT_ID", default="") +OAUTH_SIGNIN_ADMIN_CLIENT_SECRET = env("OAUTH_SIGNIN_ADMIN_CLIENT_SECRET", default="") +OAUTH_SYNC_ROLES = env.bool("OAUTH_SYNC_ROLES", default=False) + GRAPHENE = { "SCHEMA": "vbv_lernwelt.core.schema.schema", "SCHEMA_OUTPUT": "../client/src/gql/schema.graphql", diff --git a/server/requirements/requirements-dev.txt b/server/requirements/requirements-dev.txt index 7312ba40..2e726be0 100644 --- a/server/requirements/requirements-dev.txt +++ b/server/requirements/requirements-dev.txt @@ -2,79 +2,84 @@ # This file is autogenerated by pip-compile with Python 3.10 # by the following command: # -# pip-compile --output-file=requirements-dev.txt requirements-dev.in +# pip-compile requirements-dev.in # aniso8601==9.0.1 # via graphene anyascii==0.3.2 # via wagtail -anyio==3.7.1 - # via watchfiles -appnope==0.1.3 - # via ipython -argon2-cffi==21.3.0 +anyio==4.4.0 + # via + # httpx + # watchfiles +argon2-cffi==23.1.0 # via -r requirements.in argon2-cffi-bindings==21.2.0 # via argon2-cffi -asgiref==3.7.2 - # via django -astroid==2.15.6 +asgiref==3.8.1 + # via + # django + # django-cors-headers + # django-stubs +astroid==3.2.2 # via pylint -asttokens==2.2.1 +asttokens==2.4.1 # via stack-data -async-timeout==4.0.2 +async-property==0.2.2 + # via python-keycloak +async-timeout==4.0.3 # via redis -attrs==23.1.0 +attrs==23.2.0 # via # jsonschema # referencing # usort -authlib==1.2.1 +authlib==1.3.1 # via -r requirements.in -azure-core==1.29.1 +azure-core==1.30.2 # via # azure-identity # azure-storage-blob -azure-identity==1.14.0 +azure-identity==1.17.0 # via -r requirements.in -azure-storage-blob==12.17.0 +azure-storage-blob==12.20.0 # via -r requirements.in -backcall==0.2.0 - # via ipython -bcrypt==4.0.1 +bcrypt==4.1.3 # via paramiko beautifulsoup4==4.11.2 # via wagtail -black==23.7.0 +black==24.4.2 # via # -r requirements-dev.in # ufmt -boto3==1.28.23 +boto3==1.34.129 # via -r requirements.in -botocore==1.31.23 +botocore==1.34.129 # via # boto3 # s3transfer -brotli==1.0.9 +brotli==1.1.0 # via whitenoise -build==0.10.0 +build==1.2.1 # via pip-tools caprover-api @ git+https://github.com/iterativ/Caprover-API.git@5013f8fc929e8e3281b9d609e968a782e8e99530 # via -r requirements-dev.in -certifi==2023.7.22 +certifi==2024.6.2 # via + # httpcore + # httpx # requests # sentry-sdk -cffi==1.15.1 +cffi==1.16.0 # via # argon2-cffi-bindings # cryptography # pynacl -cfgv==3.3.1 +cfgv==3.4.0 # via pre-commit -charset-normalizer==3.2.0 +charset-normalizer==3.3.2 # via requests -click==8.1.6 +click==8.1.7 # via # -r requirements.in # black @@ -84,17 +89,18 @@ click==8.1.6 # ufmt # usort # uvicorn -concurrent-log-handler==0.9.24 +concurrent-log-handler==0.9.25 # via -r requirements.in -coverage==7.2.7 +coverage==7.5.3 # via # -r requirements-dev.in # django-coverage-plugin -cryptography==41.0.3 +cryptography==42.0.8 # via # authlib # azure-identity # azure-storage-blob + # jwcrypto # msal # paramiko # pyjwt @@ -104,13 +110,15 @@ decorator==5.1.1 # ipython defusedxml==0.7.1 # via willow -dill==0.3.7 +deprecation==2.1.0 + # via python-keycloak +dill==0.3.8 # via pylint -distlib==0.3.7 +distlib==0.3.8 # via virtualenv -dj-database-url==2.0.0 +dj-database-url==2.2.0 # via -r requirements.in -django==3.2.20 +django==3.2.25 # via # -r requirements.in # dj-database-url @@ -137,96 +145,99 @@ django==3.2.20 # jsonfield # wagtail # wagtail-localize -django-click==2.3.0 +django-click==2.4.0 # via -r requirements.in django-constance==3.1.0 # via -r requirements.in -django-cors-headers==4.2.0 +django-cors-headers==4.3.1 # via -r requirements.in django-coverage-plugin==3.1.0 # via -r requirements-dev.in -django-csp==3.7 +django-csp==3.8 # via -r requirements.in -django-debug-toolbar==4.1.0 +django-debug-toolbar==4.3.0 # via -r requirements-dev.in django-extensions==3.2.3 # via -r requirements-dev.in -django-filter==23.2 +django-filter==23.5 # via wagtail -django-ipware==5.0.0 +django-ipware==7.0.1 # via -r requirements.in -django-jsonform==2.18.0 +django-jsonform==2.22.0 # via -r requirements.in -django-model-utils==4.3.1 +django-model-utils==4.5.1 # via # -r requirements.in # django-notifications-hq -django-modelcluster==6.0 +django-modelcluster==6.3 # via wagtail -django-notifications-hq==1.8.2 +django-notifications-hq==1.8.3 # via -r requirements.in django-permissionedforms==0.1 # via wagtail -django-picklefield==3.1 +django-picklefield==3.2 # via django-constance django-ratelimit==4.1.0 # via -r requirements.in -django-redis==5.3.0 +django-redis==5.4.0 # via -r requirements.in -django-storages==1.13.2 +django-storages==1.14.3 # via -r requirements.in -django-stubs==4.2.3 +django-stubs==5.0.2 # via # -r requirements-dev.in # djangorestframework-stubs -django-stubs-ext==4.2.2 +django-stubs-ext==5.0.2 # via django-stubs django-taggit==4.0.0 # via wagtail -django-treebeard==4.7 +django-treebeard==4.7.1 # via wagtail -djangorestframework==3.14.0 +django-watchfiles @ https://github.com/q0w/django-watchfiles/archive/issue-1.zip + # via -r requirements-dev.in +djangorestframework==3.15.1 # via # -r requirements.in # drf-spectacular # wagtail -djangorestframework-stubs==3.14.2 +djangorestframework-stubs==3.15.0 # via -r requirements-dev.in draftjs-exporter==2.1.7 # via wagtail -drf-spectacular==0.26.4 +drf-spectacular==0.27.2 # via -r requirements.in -environs==9.5.0 +environs==11.0.0 # via -r requirements.in et-xmlfile==1.1.0 # via openpyxl -exceptiongroup==1.1.2 +exceptiongroup==1.2.1 # via # anyio + # ipython # pytest -execnet==2.0.2 +execnet==2.1.1 # via pytest-xdist -executing==1.2.0 +executing==2.0.1 # via stack-data factory-boy==3.3.0 # via # -r requirements-dev.in # wagtail-factories -faker==19.3.0 +faker==25.8.0 # via factory-boy -filelock==3.12.2 +filelock==3.15.1 # via virtualenv filetype==1.2.0 # via willow -flake8==6.1.0 +flake8==7.1.0 # via # -r requirements-dev.in # flake8-isort -flake8-isort==6.0.0 +flake8-isort==6.1.1 # via -r requirements-dev.in -freezegun==1.2.2 +freezegun==1.5.1 # via -r requirements-dev.in -gitdb==4.0.10 +gitdb==4.0.11 # via gitdb2 gitdb2==4.0.2 # via gitpython @@ -234,7 +245,7 @@ gitpython==3.0.6 # via trufflehog graphene==3.3 # via graphene-django -graphene-django==3.1.5 +graphene-django==3.2.2 # via wagtail-grapple graphql-core==3.2.3 # via @@ -245,19 +256,26 @@ graphql-relay==3.2.0 # via # graphene # graphene-django -gunicorn==21.2.0 +gunicorn==22.0.0 # via -r requirements.in h11==0.14.0 - # via uvicorn + # via + # httpcore + # uvicorn html5lib==1.1 # via wagtail -httptools==0.6.0 +httpcore==1.0.5 + # via httpx +httptools==0.6.1 # via uvicorn -identify==2.5.26 +httpx==0.27.0 + # via python-keycloak +identify==2.5.36 # via pre-commit -idna==3.4 +idna==3.7 # via # anyio + # httpx # requests inflection==0.5.1 # via drf-spectacular @@ -265,15 +283,15 @@ iniconfig==2.0.0 # via pytest ipdb==0.13.13 # via -r requirements-dev.in -ipython==8.14.0 +ipython==8.25.0 # via ipdb isodate==0.6.1 # via azure-storage-blob -isort==5.12.0 +isort==5.13.2 # via # flake8-isort # pylint -jedi==0.19.0 +jedi==0.19.1 # via ipython jmespath==1.0.1 # via @@ -281,21 +299,21 @@ jmespath==1.0.1 # botocore jsonfield==3.1.0 # via django-notifications-hq -jsonschema==4.19.0 +jsonschema==4.22.0 # via drf-spectacular -jsonschema-specifications==2023.7.1 +jsonschema-specifications==2023.12.1 # via jsonschema +jwcrypto==1.5.6 + # via python-keycloak l18n==2021.3 # via wagtail -lazy-object-proxy==1.9.0 - # via astroid -libcst==1.0.1 +libcst==1.4.0 # via # ufmt # usort -marshmallow==3.20.1 +marshmallow==3.21.3 # via environs -matplotlib-inline==0.1.6 +matplotlib-inline==0.1.7 # via ipython mccabe==0.7.0 # via @@ -305,148 +323,166 @@ moreorless==0.4.0 # via # ufmt # usort -msal==1.23.0 +msal==1.28.1 # via # azure-identity # msal-extensions -msal-extensions==1.0.0 +msal-extensions==1.1.0 # via azure-identity -mypy==1.4.1 - # via - # -r requirements-dev.in - # django-stubs - # djangorestframework-stubs +mypy==1.10.0 + # via -r requirements-dev.in mypy-extensions==1.0.0 # via # black # mypy - # typing-inspect -newrelic==8.11.0 +newrelic==9.11.0 # via -r requirements.in -nodeenv==1.8.0 +nodeenv==1.9.1 # via pre-commit -openpyxl==3.1.2 +openpyxl==3.1.4 # via # -r requirements.in # wagtail -packaging==23.1 +packaging==24.1 # via # black # build + # deprecation # gunicorn # marshmallow + # msal-extensions # pytest # pytest-sugar +<<<<<<< HEAD paramiko==3.3.1 # via # -r requirements.in # sftpserver parso==0.8.3 +======= +paramiko==3.4.0 + # via -r requirements.in +parso==0.8.4 +>>>>>>> 9e6b9a1e (wip: Add KC-client and basic methods, signal handler) # via jedi -pathspec==0.11.2 +pathspec==0.12.1 # via # black # trailrunner -pexpect==4.8.0 +pexpect==4.9.0 # via ipython -pickleshare==0.7.5 - # via ipython -pillow==10.0.0 +pillow==10.3.0 # via # -r requirements.in # pillow-heif # wagtail -pillow-heif==0.13.0 +pillow-heif==0.16.0 # via willow -pip-tools==7.3.0 +pip-tools==7.4.1 # via -r requirements-dev.in -platformdirs==3.10.0 +platformdirs==4.2.2 # via # black # pylint # virtualenv -pluggy==1.2.0 +pluggy==1.5.0 # via pytest polib==1.2.0 # via wagtail-localize -portalocker==2.7.0 +portalocker==2.8.2 # via # concurrent-log-handler # msal-extensions -pre-commit==3.3.3 +pre-commit==3.7.1 # via -r requirements-dev.in promise==2.3 # via graphene-django -prompt-toolkit==3.0.39 +prompt-toolkit==3.0.47 # via ipython -psycopg2-binary==2.9.7 +psycopg2-binary==2.9.9 # via -r requirements.in ptyprocess==0.7.0 # via pexpect pure-eval==0.2.2 # via stack-data -pycodestyle==2.11.0 +pycodestyle==2.12.0 # via flake8 -pycparser==2.21 +pycparser==2.22 # via cffi -pycryptodome==3.18.0 +pycryptodome==3.20.0 # via -r requirements.in -pyflakes==3.1.0 +pyflakes==3.2.0 # via flake8 -pygments==2.16.1 +pygments==2.18.0 # via ipython pyjwt[crypto]==2.8.0 +<<<<<<< HEAD # via msal pylint==2.17.5 +======= + # via + # msal + # pyjwt +pylint==3.2.3 +>>>>>>> 9e6b9a1e (wip: Add KC-client and basic methods, signal handler) # via # pylint-django # pylint-plugin-utils -pylint-django==2.5.3 +pylint-django==2.5.5 # via -r requirements-dev.in pylint-plugin-utils==0.8.2 # via pylint-django pynacl==1.5.0 # via paramiko -pyproject-hooks==1.0.0 - # via build -pytest==7.4.0 +pyproject-hooks==1.1.0 + # via + # build + # pip-tools +pytest==8.2.2 # via # -r requirements-dev.in # pytest-django # pytest-order # pytest-sugar # pytest-xdist -pytest-django==4.5.2 +pytest-django==4.8.0 # via -r requirements-dev.in +<<<<<<< HEAD pytest-order==1.2.1 # via -r requirements-dev.in pytest-sugar==0.9.7 +======= +pytest-sugar==1.0.0 +>>>>>>> 9e6b9a1e (wip: Add KC-client and basic methods, signal handler) # via -r requirements-dev.in -pytest-xdist==3.5.0 +pytest-xdist==3.6.1 # via -r requirements-dev.in -python-dateutil==2.8.2 +python-dateutil==2.9.0.post0 # via # -r requirements.in # botocore # faker # freezegun -python-dotenv==1.0.0 +python-dotenv==1.0.1 # via # environs # uvicorn python-http-client==3.3.7 # via sendgrid +python-ipware==3.0.0 + # via django-ipware python-json-logger==2.0.7 # via -r requirements.in -python-slugify==8.0.1 +python-keycloak==4.1.0 # via -r requirements.in -pytz==2023.3 +python-slugify==8.0.4 + # via -r requirements.in +pytz==2024.1 # via # -r requirements.in # django # django-modelcluster # django-notifications-hq - # djangorestframework # l18n pyyaml==6.0.1 # via @@ -455,30 +491,34 @@ pyyaml==6.0.1 # libcst # pre-commit # uvicorn -redis==4.6.0 +redis==5.0.6 # via # -r requirements.in # django-redis -referencing==0.30.2 +referencing==0.35.1 # via # jsonschema # jsonschema-specifications -requests==2.31.0 +requests==2.32.3 # via # azure-core # caprover-api # djangorestframework-stubs # msal + # python-keycloak + # requests-toolbelt # wagtail -rpds-py==0.9.2 +requests-toolbelt==1.0.0 + # via python-keycloak +rpds-py==0.18.1 # via # jsonschema # referencing -s3transfer==0.6.1 +s3transfer==0.10.1 # via boto3 -sendgrid==6.10.0 +sendgrid==6.11.0 # via -r requirements.in -sentry-sdk==1.29.2 +sentry-sdk==2.5.1 # via -r requirements.in sftpserver @ git+https://github.com/lonetwin/sftpserver.git@1d16896d3f0f90d63d1caaf4e199f2a9dde6456f # via -r requirements-dev.in @@ -491,29 +531,31 @@ six==1.16.0 # l18n # promise # python-dateutil -smmap==5.0.0 +smmap==5.0.1 # via gitdb -sniffio==1.3.0 - # via anyio -soupsieve==2.4.1 +sniffio==1.3.1 + # via + # anyio + # httpx +soupsieve==2.5 # via beautifulsoup4 -sqlparse==0.4.4 +sqlparse==0.5.0 # via # django # django-debug-toolbar -stack-data==0.6.2 +stack-data==0.6.3 # via ipython starkbank-ecdsa==2.2.0 # via sendgrid -stdlibs==2022.10.9 +stdlibs==2024.5.15 # via usort -structlog==23.1.0 +structlog==24.2.0 # via -r requirements.in swapper==1.3.0 # via django-notifications-hq telepath==0.3.1 # via wagtail -termcolor==2.3.0 +termcolor==2.4.0 # via pytest-sugar text-unidecode==1.3 # via @@ -530,9 +572,8 @@ tomli==2.0.1 # mypy # pip-tools # pylint - # pyproject-hooks # pytest -tomlkit==0.12.1 +tomlkit==0.12.5 # via # pylint # ufmt @@ -540,7 +581,7 @@ trailrunner==1.4.0 # via # ufmt # usort -traitlets==5.9.0 +traitlets==5.14.3 # via # ipython # matplotlib-inline @@ -548,82 +589,95 @@ trufflehog==2.2.1 # via -r requirements-dev.in trufflehogregexes==0.0.7 # via trufflehog -types-pytz==2023.3.0.0 - # via django-stubs -types-pyyaml==6.0.12.11 +types-pyyaml==6.0.12.20240311 # via # django-stubs # djangorestframework-stubs -types-requests==2.31.0.2 +types-requests==2.32.0.20240602 # via djangorestframework-stubs -types-urllib3==1.26.25.14 - # via types-requests -typing-extensions==4.7.1 +typing-extensions==4.12.2 # via + # anyio # asgiref # astroid # azure-core + # azure-identity # azure-storage-blob + # black # dj-database-url # django-stubs # django-stubs-ext # djangorestframework-stubs - # libcst + # ipython + # jwcrypto # mypy - # typing-inspect # ufmt # uvicorn # wagtail-localize -typing-inspect==0.9.0 - # via libcst -ufmt==2.2.0 +ufmt==2.7.0 # via -r requirements-dev.in uritemplate==4.1.1 # via drf-spectacular -urllib3==1.26.16 +urllib3==2.2.2 # via # botocore # requests # sentry-sdk -usort==1.0.7 + # types-requests +usort==1.0.8.post1 # via ufmt -uvicorn[standard]==0.23.2 +uvicorn[standard]==0.30.1 # via -r requirements.in -uvloop==0.17.0 +uvloop==0.19.0 # via uvicorn -virtualenv==20.24.2 +virtualenv==20.26.2 # via pre-commit -wagtail==5.1 +wagtail==5.2.5 # via # -r requirements.in # wagtail-factories # wagtail-grapple # wagtail-headless-preview # wagtail-localize -wagtail-factories==4.1.0 +wagtail-factories==4.2.1 # via -r requirements.in -wagtail-grapple==0.20.0 +wagtail-grapple==0.25.1 # via -r requirements.in -wagtail-headless-preview==0.6.0 +wagtail-headless-preview==0.8.0 # via wagtail-grapple -wagtail-localize==1.5.1 +wagtail-localize==1.9 # via -r requirements.in +<<<<<<< HEAD watchfiles==0.19.0 # via uvicorn wcwidth==0.2.6 +======= +watchfiles==0.22.0 + # via + # django-watchfiles + # uvicorn +wcwidth==0.2.13 +>>>>>>> 9e6b9a1e (wip: Add KC-client and basic methods, signal handler) # via prompt-toolkit webencodings==0.5.1 # via html5lib -websockets==11.0.3 +websockets==12.0 # via uvicorn -wheel==0.41.1 +wheel==0.43.0 # via pip-tools -whitenoise[brotli]==6.5.0 +whitenoise[brotli]==6.6.0 # via -r requirements.in +<<<<<<< HEAD willow[heif]==1.6.1 # via wagtail wrapt==1.15.0 # via astroid +======= +willow[heif]==1.6.3 + # via + # wagtail + # willow +>>>>>>> 9e6b9a1e (wip: Add KC-client and basic methods, signal handler) # The following packages are considered to be unsafe in a requirements file: # pip diff --git a/server/requirements/requirements.in b/server/requirements/requirements.in index 3b5ae91e..56427d54 100644 --- a/server/requirements/requirements.in +++ b/server/requirements/requirements.in @@ -52,3 +52,5 @@ azure-identity boto3 openpyxl newrelic +python-keycloak + diff --git a/server/requirements/requirements.txt b/server/requirements/requirements.txt index 1987798b..12595315 100644 --- a/server/requirements/requirements.txt +++ b/server/requirements/requirements.txt @@ -2,79 +2,90 @@ # This file is autogenerated by pip-compile with Python 3.10 # by the following command: # -# pip-compile --output-file=requirements.txt requirements.in +# pip-compile requirements.in # aniso8601==9.0.1 # via graphene anyascii==0.3.2 # via wagtail -anyio==3.7.1 - # via watchfiles -argon2-cffi==21.3.0 +anyio==4.4.0 + # via + # httpx + # watchfiles +argon2-cffi==23.1.0 # via -r requirements.in argon2-cffi-bindings==21.2.0 # via argon2-cffi -asgiref==3.7.2 - # via django -async-timeout==4.0.2 +asgiref==3.8.1 + # via + # django + # django-cors-headers +async-property==0.2.2 + # via python-keycloak +async-timeout==4.0.3 # via redis -attrs==23.1.0 +attrs==23.2.0 # via # jsonschema # referencing -authlib==1.2.1 +authlib==1.3.1 # via -r requirements.in -azure-core==1.29.1 +azure-core==1.30.2 # via # azure-identity # azure-storage-blob -azure-identity==1.14.0 +azure-identity==1.17.0 # via -r requirements.in -azure-storage-blob==12.17.0 +azure-storage-blob==12.20.0 # via -r requirements.in -bcrypt==4.0.1 +bcrypt==4.1.3 # via paramiko beautifulsoup4==4.11.2 # via wagtail -boto3==1.28.23 +boto3==1.34.129 # via -r requirements.in -botocore==1.31.23 +botocore==1.34.129 # via # boto3 # s3transfer -brotli==1.0.9 +brotli==1.1.0 # via whitenoise -certifi==2023.7.22 +certifi==2024.6.2 # via + # httpcore + # httpx # requests # sentry-sdk -cffi==1.15.1 +cffi==1.16.0 # via # argon2-cffi-bindings # cryptography # pynacl -charset-normalizer==3.2.0 +charset-normalizer==3.3.2 # via requests -click==8.1.6 +click==8.1.7 # via # -r requirements.in # django-click # uvicorn -concurrent-log-handler==0.9.24 +concurrent-log-handler==0.9.25 # via -r requirements.in -cryptography==41.0.3 +cryptography==42.0.8 # via # authlib # azure-identity # azure-storage-blob + # jwcrypto # msal # paramiko # pyjwt defusedxml==0.7.1 # via willow -dj-database-url==2.0.0 +deprecation==2.1.0 + # via python-keycloak +dj-database-url==2.2.0 # via -r requirements.in -django==3.2.20 +django==3.2.25 # via # -r requirements.in # dj-database-url @@ -97,66 +108,66 @@ django==3.2.20 # jsonfield # wagtail # wagtail-localize -django-click==2.3.0 +django-click==2.4.0 # via -r requirements.in django-constance==3.1.0 # via -r requirements.in -django-cors-headers==4.2.0 +django-cors-headers==4.3.1 # via -r requirements.in -django-csp==3.7 +django-csp==3.8 # via -r requirements.in -django-filter==23.2 +django-filter==23.5 # via wagtail -django-ipware==5.0.0 +django-ipware==7.0.1 # via -r requirements.in -django-jsonform==2.18.0 +django-jsonform==2.22.0 # via -r requirements.in -django-model-utils==4.3.1 +django-model-utils==4.5.1 # via # -r requirements.in # django-notifications-hq -django-modelcluster==6.0 +django-modelcluster==6.3 # via wagtail -django-notifications-hq==1.8.2 +django-notifications-hq==1.8.3 # via -r requirements.in django-permissionedforms==0.1 # via wagtail -django-picklefield==3.1 +django-picklefield==3.2 # via django-constance django-ratelimit==4.1.0 # via -r requirements.in -django-redis==5.3.0 +django-redis==5.4.0 # via -r requirements.in -django-storages==1.13.2 +django-storages==1.14.3 # via -r requirements.in django-taggit==4.0.0 # via wagtail -django-treebeard==4.7 +django-treebeard==4.7.1 # via wagtail -djangorestframework==3.14.0 +djangorestframework==3.15.1 # via # -r requirements.in # drf-spectacular # wagtail draftjs-exporter==2.1.7 # via wagtail -drf-spectacular==0.26.4 +drf-spectacular==0.27.2 # via -r requirements.in -environs==9.5.0 +environs==11.0.0 # via -r requirements.in et-xmlfile==1.1.0 # via openpyxl -exceptiongroup==1.1.2 +exceptiongroup==1.2.1 # via anyio factory-boy==3.3.0 # via wagtail-factories -faker==19.3.0 +faker==25.8.0 # via factory-boy filetype==1.2.0 # via willow graphene==3.3 # via graphene-django -graphene-django==3.1.5 +graphene-django==3.2.2 # via wagtail-grapple graphql-core==3.2.3 # via @@ -167,17 +178,24 @@ graphql-relay==3.2.0 # via # graphene # graphene-django -gunicorn==21.2.0 +gunicorn==22.0.0 # via -r requirements.in h11==0.14.0 - # via uvicorn + # via + # httpcore + # uvicorn html5lib==1.1 # via wagtail -httptools==0.6.0 +httpcore==1.0.5 + # via httpx +httptools==0.6.1 # via uvicorn -idna==3.4 +httpx==0.27.0 + # via python-keycloak +idna==3.7 # via # anyio + # httpx # requests inflection==0.5.1 # via drf-spectacular @@ -189,106 +207,119 @@ jmespath==1.0.1 # botocore jsonfield==3.1.0 # via django-notifications-hq -jsonschema==4.19.0 +jsonschema==4.22.0 # via drf-spectacular -jsonschema-specifications==2023.7.1 +jsonschema-specifications==2023.12.1 # via jsonschema +jwcrypto==1.5.6 + # via python-keycloak l18n==2021.3 # via wagtail -marshmallow==3.20.1 +marshmallow==3.21.3 # via environs -msal==1.23.0 +msal==1.28.1 # via # azure-identity # msal-extensions -msal-extensions==1.0.0 +msal-extensions==1.1.0 # via azure-identity -newrelic==8.11.0 +newrelic==9.11.0 # via -r requirements.in -openpyxl==3.1.2 +openpyxl==3.1.4 # via # -r requirements.in # wagtail -packaging==23.1 +packaging==24.1 # via + # deprecation # gunicorn # marshmallow -paramiko==3.3.1 + # msal-extensions +paramiko==3.4.0 # via -r requirements.in -pillow==10.0.0 +pillow==10.3.0 # via # -r requirements.in # pillow-heif # wagtail -pillow-heif==0.13.0 +pillow-heif==0.16.0 # via willow polib==1.2.0 # via wagtail-localize -portalocker==2.7.0 +portalocker==2.8.2 # via # concurrent-log-handler # msal-extensions promise==2.3 # via graphene-django -psycopg2-binary==2.9.7 +psycopg2-binary==2.9.9 # via -r requirements.in -pycparser==2.21 +pycparser==2.22 # via cffi -pycryptodome==3.18.0 +pycryptodome==3.20.0 # via -r requirements.in pyjwt[crypto]==2.8.0 - # via msal + # via + # msal + # pyjwt pynacl==1.5.0 # via paramiko -python-dateutil==2.8.2 +python-dateutil==2.9.0.post0 # via # -r requirements.in # botocore # faker -python-dotenv==1.0.0 +python-dotenv==1.0.1 # via # environs # uvicorn python-http-client==3.3.7 # via sendgrid +python-ipware==3.0.0 + # via django-ipware python-json-logger==2.0.7 # via -r requirements.in -python-slugify==8.0.1 +python-keycloak==4.1.0 # via -r requirements.in -pytz==2023.3 +python-slugify==8.0.4 + # via -r requirements.in +pytz==2024.1 # via # -r requirements.in # django # django-modelcluster # django-notifications-hq - # djangorestframework # l18n pyyaml==6.0.1 # via # drf-spectacular # uvicorn -redis==4.6.0 +redis==5.0.6 # via # -r requirements.in # django-redis -referencing==0.30.2 +referencing==0.35.1 # via # jsonschema # jsonschema-specifications -requests==2.31.0 +requests==2.32.3 # via # azure-core # msal + # python-keycloak + # requests-toolbelt # wagtail -rpds-py==0.9.2 +requests-toolbelt==1.0.0 + # via python-keycloak +rpds-py==0.18.1 # via # jsonschema # referencing -s3transfer==0.6.1 +s3transfer==0.10.1 # via boto3 -sendgrid==6.10.0 +sendgrid==6.11.0 # via -r requirements.in -sentry-sdk==1.29.2 +sentry-sdk==2.5.1 # via -r requirements.in six==1.16.0 # via @@ -298,15 +329,17 @@ six==1.16.0 # l18n # promise # python-dateutil -sniffio==1.3.0 - # via anyio -soupsieve==2.4.1 +sniffio==1.3.1 + # via + # anyio + # httpx +soupsieve==2.5 # via beautifulsoup4 -sqlparse==0.4.4 +sqlparse==0.5.0 # via django starkbank-ecdsa==2.2.0 # via sendgrid -structlog==23.1.0 +structlog==24.2.0 # via -r requirements.in swapper==1.3.0 # via django-notifications-hq @@ -316,47 +349,52 @@ text-unidecode==1.3 # via # graphene-django # python-slugify -typing-extensions==4.7.1 +typing-extensions==4.12.2 # via + # anyio # asgiref # azure-core + # azure-identity # azure-storage-blob # dj-database-url + # jwcrypto # uvicorn # wagtail-localize uritemplate==4.1.1 # via drf-spectacular -urllib3==1.26.16 +urllib3==2.2.2 # via # botocore # requests # sentry-sdk -uvicorn[standard]==0.23.2 +uvicorn[standard]==0.30.1 # via -r requirements.in -uvloop==0.17.0 +uvloop==0.19.0 # via uvicorn -wagtail==5.1 +wagtail==5.2.5 # via # -r requirements.in # wagtail-factories # wagtail-grapple # wagtail-headless-preview # wagtail-localize -wagtail-factories==4.1.0 +wagtail-factories==4.2.1 # via -r requirements.in -wagtail-grapple==0.20.0 +wagtail-grapple==0.25.1 # via -r requirements.in -wagtail-headless-preview==0.6.0 +wagtail-headless-preview==0.8.0 # via wagtail-grapple -wagtail-localize==1.5.1 +wagtail-localize==1.9 # via -r requirements.in -watchfiles==0.19.0 +watchfiles==0.22.0 # via uvicorn webencodings==0.5.1 # via html5lib -websockets==11.0.3 +websockets==12.0 # via uvicorn -whitenoise[brotli]==6.5.0 +whitenoise[brotli]==6.6.0 # via -r requirements.in -willow[heif]==1.6.1 - # via wagtail +willow[heif]==1.6.3 + # via + # wagtail + # willow diff --git a/server/vbv_lernwelt/course/models.py b/server/vbv_lernwelt/course/models.py index aa94f21b..d6d260c1 100644 --- a/server/vbv_lernwelt/course/models.py +++ b/server/vbv_lernwelt/course/models.py @@ -13,6 +13,11 @@ from vbv_lernwelt.core.model_utils import find_available_slug from vbv_lernwelt.core.models import User from vbv_lernwelt.course.serializer_helpers import get_course_serializer_class from vbv_lernwelt.files.models import UploadFile +from vbv_lernwelt.sso.role_sync.client import ( + add_roles_to_user, + remove_roles_from_user, + update_roles_for_user, +) class CircleContactType(Enum): @@ -293,6 +298,26 @@ class CourseSessionUser(models.Model): def __str__(self): return f"{self.user} ({self.course_session.title})" + def save(self, *args, **kwargs): + if self.created_at is None: + add_roles_to_user( + self.user, [(self.course_session.course.slug, [self.role])] + ) + else: + old_csu = CourseSessionUser.objects.get(pk=self.pk) + update_roles_for_user( + self.user, + add_course_roles=[(self.course_session.course.slug, [self.role])], + remove_course_roles=[(self.course_session.course.slug, [old_csu.role])], + ) + super().save(*args, **kwargs) + + @classmethod + def remove_sso_roles_from_user(cls, instance: "CourseSessionUser"): + remove_roles_from_user( + instance.user, [(instance.course_session.course.slug, [instance.role])] + ) + class CircleDocument(models.Model): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) diff --git a/server/vbv_lernwelt/course/signals.py b/server/vbv_lernwelt/course/signals.py index d474c30a..14bf93b2 100644 --- a/server/vbv_lernwelt/course/signals.py +++ b/server/vbv_lernwelt/course/signals.py @@ -1,10 +1,15 @@ -from django.db.models.signals import post_save +from django.db.models.signals import post_delete, post_save from django.dispatch import receiver -from vbv_lernwelt.course.models import Course, CourseConfiguration +from vbv_lernwelt.course.models import Course, CourseConfiguration, CourseSessionUser @receiver(post_save, sender=Course) def create_course_configuration(sender, instance, created, **kwargs): if created: CourseConfiguration.objects.create(course=instance) + + +@receiver(post_delete, sender=CourseSessionUser) +def after_delete(sender, instance, **kwargs): + CourseSessionUser.remove_sso_roles_from_user(instance) diff --git a/server/vbv_lernwelt/course_session_group/admin.py b/server/vbv_lernwelt/course_session_group/admin.py index 5bda3780..f880cfa3 100644 --- a/server/vbv_lernwelt/course_session_group/admin.py +++ b/server/vbv_lernwelt/course_session_group/admin.py @@ -4,5 +4,4 @@ from vbv_lernwelt.course_session_group.models import CourseSessionGroup @admin.register(CourseSessionGroup) -class CourseSessionAssignmentAdmin(admin.ModelAdmin): - ... +class CourseSessionAssignmentAdmin(admin.ModelAdmin): ... diff --git a/server/vbv_lernwelt/edoniq_test/views.py b/server/vbv_lernwelt/edoniq_test/views.py index 00d8ff7b..e3a71ec2 100644 --- a/server/vbv_lernwelt/edoniq_test/views.py +++ b/server/vbv_lernwelt/edoniq_test/views.py @@ -153,9 +153,9 @@ def fetch_course_session_all_users(courses: List[int], excluded_domains=None): def generate_export_response(cs_users: List[User]) -> HttpResponse: response = HttpResponse(content_type="text/csv; charset=utf-8") - response[ - "Content-Disposition" - ] = f"attachment; filename=edoniq_user_export_{date.today().strftime('%Y%m%d')}.csv" + response["Content-Disposition"] = ( + f"attachment; filename=edoniq_user_export_{date.today().strftime('%Y%m%d')}.csv" + ) response.write("\ufeff".encode("utf8")) # UTF-8 BOM diff --git a/server/vbv_lernwelt/feedback/services.py b/server/vbv_lernwelt/feedback/services.py index d0fefe20..51602502 100644 --- a/server/vbv_lernwelt/feedback/services.py +++ b/server/vbv_lernwelt/feedback/services.py @@ -125,9 +125,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 diff --git a/server/vbv_lernwelt/importer/services.py b/server/vbv_lernwelt/importer/services.py index 1db6fea8..732b840b 100644 --- a/server/vbv_lernwelt/importer/services.py +++ b/server/vbv_lernwelt/importer/services.py @@ -493,6 +493,7 @@ def create_or_update_user( sso_id: str = None, contract_number: str = "", date_of_birth: str = "", + intermediate_sso_id: str = "", # from keycloak ) -> User: logger.debug( "create_or_update_user", @@ -537,6 +538,9 @@ def create_or_update_user( user.first_name = first_name or user.first_name user.last_name = last_name or user.last_name user.username = email + user.additional_json_data = user.additional_json_data | { + "intermediate_sso_id": intermediate_sso_id + } user.set_unusable_password() user.save() diff --git a/server/vbv_lernwelt/notify/tests/test_service.py b/server/vbv_lernwelt/notify/tests/test_service.py index 6d8af677..1feb4bad 100644 --- a/server/vbv_lernwelt/notify/tests/test_service.py +++ b/server/vbv_lernwelt/notify/tests/test_service.py @@ -65,9 +65,9 @@ class TestNotificationService(TestCase): self.assertFalse(notification.emailed) def test_send_notification_with_email(self): - self.recipient.additional_json_data[ - "email_notification_categories" - ] = json.dumps(["USER_INTERACTION"]) + self.recipient.additional_json_data["email_notification_categories"] = ( + json.dumps(["USER_INTERACTION"]) + ) self.recipient.save() verb = "Anne hat deinen Auftrag bewertet" @@ -146,9 +146,9 @@ class TestNotificationService(TestCase): self.assertFalse(notification.emailed) # when the email was not sent, yet it will still send it afterwards... - self.recipient.additional_json_data[ - "email_notification_categories" - ] = json.dumps(["USER_INTERACTION"]) + self.recipient.additional_json_data["email_notification_categories"] = ( + json.dumps(["USER_INTERACTION"]) + ) self.recipient.save() result = self.notification_service._send_notification( @@ -188,9 +188,9 @@ class TestNotificationService(TestCase): self.assertFalse(self._has_sent_emails()) # Assert mail is sent if corresponding email notification type is enabled - self.recipient.additional_json_data[ - "email_notification_categories" - ] = json.dumps(["USER_INTERACTION"]) + self.recipient.additional_json_data["email_notification_categories"] = ( + json.dumps(["USER_INTERACTION"]) + ) self.recipient.save() self.notification_service._send_notification( sender=self.sender, diff --git a/server/vbv_lernwelt/self_evaluation_feedback/serializers.py b/server/vbv_lernwelt/self_evaluation_feedback/serializers.py index fd24d363..0c73c3dc 100644 --- a/server/vbv_lernwelt/self_evaluation_feedback/serializers.py +++ b/server/vbv_lernwelt/self_evaluation_feedback/serializers.py @@ -39,9 +39,9 @@ class SelfEvaluationFeedbackSerializer(serializers.ModelSerializer): return obj.learning_unit.get_circle().title def get_criteria(self, obj): - performance_criteria: List[ - PerformanceCriteria - ] = obj.learning_unit.performancecriteria_set.all() + performance_criteria: List[PerformanceCriteria] = ( + obj.learning_unit.performancecriteria_set.all() + ) criteria = [] diff --git a/server/vbv_lernwelt/sso/role_sync/__init__.py b/server/vbv_lernwelt/sso/role_sync/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/server/vbv_lernwelt/sso/role_sync/client.py b/server/vbv_lernwelt/sso/role_sync/client.py new file mode 100644 index 00000000..dd5dfc2a --- /dev/null +++ b/server/vbv_lernwelt/sso/role_sync/client.py @@ -0,0 +1,92 @@ +import unicodedata +from typing import Dict, List, Tuple + +from django.conf import settings +from keycloak import KeycloakAdmin, KeycloakOpenIDConnection + +from vbv_lernwelt.core.models import User +from vbv_lernwelt.sso.role_sync.roles import ROLE_IDS, SSO_ROLES + +CourseRolesType = List[Tuple[str, List[str]]] + +if settings.OAUTH_SYNC_ROLES: + keycloak_connection = KeycloakOpenIDConnection( + server_url=settings.OAUTH_SIGNIN_URL, + realm_name=settings.OAUTH_SIGNIN_REALM, + user_realm_name=settings.OAUTH_SIGNIN_REALM, + client_id=settings.OAUTH_SIGNIN_ADMIN_CLIENT_ID, + client_secret_key=settings.OAUTH_SIGNIN_ADMIN_CLIENT_SECRET, + verify=True, + ) + + keycloak_admin = KeycloakAdmin(connection=keycloak_connection) + + +# todo: handle errors + + +def add_roles_to_user(user: User, course_roles: CourseRolesType): + user_id = user.additional_json_data.get("intermediate_sso_id", "") + if settings.OAUTH_SYNC_ROLES and user_id: + request_roles = _get_role_request_data(course_roles) + keycloak_admin.assign_realm_roles( + user_id=user_id, + roles=request_roles, + ) + return True + return False + + +def remove_roles_from_user(user: User, course_roles: CourseRolesType): + user_id = user.additional_json_data.get("intermediate_sso_id", "") + if settings.OAUTH_SYNC_ROLES and user_id: + request_roles = _get_role_request_data(course_roles) + keycloak_admin.delete_realm_roles_of_user( + user_id=user_id, + roles=request_roles, + ) + return True + return False + + +def update_roles_for_user( + user: User, add_course_roles: CourseRolesType, remove_course_roles: CourseRolesType +): + if settings.OAUTH_SYNC_ROLES: + add_roles_to_user(user, add_course_roles) + remove_roles_from_user(user, remove_course_roles) + return True + return False + + +def get_roles_for_user(user_id: str): + if settings.OAUTH_SYNC_ROLES: + return keycloak_admin.get_realm_roles_of_user( + user_id=user_id, + ) + return [] + + +# create sso-ID user and set roles +# sync + + +def _get_role_request_data(course_roles: CourseRolesType) -> List[Dict[str, str]]: + request_roles = [] + for item in course_roles: + course_slug, roles = item + sanitized_course_slug = _remove_accents(course_slug) + oauth_roles = _create_role_names(sanitized_course_slug, roles) + return request_roles + [ + {"id": ROLE_IDS[role], "name": role} for role in oauth_roles + ] + return request_roles + + +def _create_role_names(course_slug: str, roles: list) -> List[str]: + return [SSO_ROLES[course_slug][role] for role in roles] + + +def _remove_accents(input_str) -> str: + nfkd_form = unicodedata.normalize("NFKD", input_str) + return "".join([char for char in nfkd_form if not unicodedata.combining(char)]) diff --git a/server/vbv_lernwelt/sso/role_sync/roles.py b/server/vbv_lernwelt/sso/role_sync/roles.py new file mode 100644 index 00000000..630222aa --- /dev/null +++ b/server/vbv_lernwelt/sso/role_sync/roles.py @@ -0,0 +1,54 @@ +SSO_ROLES = { + "uberbetriebliche-kurse": { + "MEMBER": "myvbv-uberbetriebliche-kurse-member", + "EXPERT": "myvbv-uberbetriebliche-kurse-expert", + "SUPERVISOR": "myvbv-uberbetriebliche-kurse-supervisor", + "LEARNING_MENTOR": "myvbv-uberbetriebliche-kurse-mentor", + }, + "cours-interentreprises": { + "MEMBER": "myvbv-cours-interentreprises-member", + "EXPERT": "myvbv-cours-interentreprises-expert", + "SUPERVISOR": "myvbv-cours-interentreprises-supervisor", + "LEARNING_MENTOR": "myvbv-cours-interentreprises-mentor", + }, + "corso-interaziendale": { + "MEMBER": "myvbv-corso-interaziendale-member", + "EXPERT": "myvbv-corso-interaziendale-expert", + "SUPERVISOR": "myvbv-corso-interaziendale-supervisor", + "LEARNING_MENTOR": "myvbv-corso-interaziendale-mentor", + }, + "versicherungsvermittler-in": { + "MEMBER": "myvbv-versicherungsvermittler-in-member", + "LEARNING_MENTOR": "myvbv-versicherungsvermittler-in-mentor", + }, + "intermediaire-dassurance": { + "MEMBER": "myvbv-intermediaire-dassurance-member", + "LEARNING_MENTOR": "myvbv-intermediaire-dassurance-mentor", + }, + "intermediarioa-assicurativoa": { + "MEMBER": "myvbv-intermediarioa-assicurativoa-member", + "LEARNING_MENTOR": "myvbv-intermediarioa-assicurativoa-mentor", + }, +} + +# https://sso.test.b.lernetz.host/auth/admin/vbv/console/#/vbv/roles +ROLE_IDS = { + "myvbv-uberbetriebliche-kurse-member": "0725f2d4-c3f3-48b7-83ec-06acfae630e6", + "myvbv-uberbetriebliche-kurse-expert": "c7e33cb6-d227-4764-9b8e-d42af79fb46d", + "myvbv-uberbetriebliche-kurse-supervisor": "d88a7486-7ff4-475c-b840-2e1e0a9decb8", + "myvbv-uberbetriebliche-kurse-mentor": "db5f0e24-9512-4752-8c51-26b3aa6f7f6a", + "myvbv-cours-interentreprises-member": "458c65f4-e969-4ba7-a546-77948641bc0b", + "myvbv-cours-interentreprises-expert": "2ef51fc6-1e5a-427c-b4a9-314249ea24db", + "myvbv-cours-interentreprises-supervisor": "23e5994e-c499-42e8-b956-bb098be793e1", + "myvbv-cours-interentreprises-mentor": "cb37a093-32a3-479b-981e-0604b6b71f5e", + "myvbv-corso-interaziendale-member": "4d9cfc61-b555-44b1-a52d-76231d12f0cd", + "myvbv-corso-interaziendale-expert": "b2da77bd-c3c8-4d1e-9757-c016eaf219e3", + "myvbv-corso-interaziendale-supervisor": "8e9ea3e4-e814-4704-906e-d39f595811eb", + "myvbv-corso-interaziendale-mentor": "36fae39d-67f0-4ed6-9a1c-7d383be9e463", + "myvbv-versicherungsvermittler-in-member": "3ab4eab2-7d7c-43bb-a927-4cf54f24ccc2", + "myvbv-versicherungsvermittler-in-mentor": "12bf374a-293b-4abe-b255-7899eae31908", + "myvbv-intermediaire-dassurance-member": "5400fdae-2c37-4738-8667-0bcb50ed3609", + "myvbv-intermediaire-dassurance-mentor": "3bd737f9-731a-4548-aaf5-4c80175f2759", + "myvbv-intermediarioa-assicurativoa-member": "9fbaaa0f-cf8c-45f2-93f6-7174cb18a982", + "myvbv-intermediarioa-assicurativoa-mentor": "46b12e54-682e-44c0-b506-eab820138b66", +} diff --git a/server/vbv_lernwelt/sso/views.py b/server/vbv_lernwelt/sso/views.py index d48a019a..a665d7af 100644 --- a/server/vbv_lernwelt/sso/views.py +++ b/server/vbv_lernwelt/sso/views.py @@ -116,6 +116,7 @@ def authorize_signin(request): sso_id=id_token.get("oid"), first_name=id_token.get("given_name", ""), last_name=id_token.get("family_name", ""), + intermediate_sso_id=id_token.get("sub"), ) dj_login(request, user) From 601cf7a12b4d8b347f7ac29c2bef2b1dbe462b5d Mon Sep 17 00:00:00 2001 From: Christian Cueni Date: Wed, 19 Jun 2024 15:34:14 +0200 Subject: [PATCH 02/23] wip: Add signals, change black version --- server/vbv_lernwelt/course/models.py | 21 +++++++++++++++- server/vbv_lernwelt/course/signals.py | 11 ++++++--- .../course_session_group/admin.py | 3 ++- server/vbv_lernwelt/edoniq_test/views.py | 6 ++--- server/vbv_lernwelt/feedback/services.py | 6 ++--- server/vbv_lernwelt/importer/services.py | 19 +++++++++++---- .../vbv_lernwelt/notify/tests/test_service.py | 18 +++++++------- .../self_evaluation_feedback/serializers.py | 6 ++--- .../sso/role_sync/{client.py => services.py} | 24 +++++++++++++++---- 9 files changed, 83 insertions(+), 31 deletions(-) rename server/vbv_lernwelt/sso/role_sync/{client.py => services.py} (83%) diff --git a/server/vbv_lernwelt/course/models.py b/server/vbv_lernwelt/course/models.py index d6d260c1..df1eae9d 100644 --- a/server/vbv_lernwelt/course/models.py +++ b/server/vbv_lernwelt/course/models.py @@ -13,7 +13,7 @@ from vbv_lernwelt.core.model_utils import find_available_slug from vbv_lernwelt.core.models import User from vbv_lernwelt.course.serializer_helpers import get_course_serializer_class from vbv_lernwelt.files.models import UploadFile -from vbv_lernwelt.sso.role_sync.client import ( +from vbv_lernwelt.sso.role_sync.services import ( add_roles_to_user, remove_roles_from_user, update_roles_for_user, @@ -312,6 +312,25 @@ class CourseSessionUser(models.Model): ) super().save(*args, **kwargs) + @classmethod + def update_sso_roles(cls, instance: "CourseSessionUser"): + if instance.created_at is None: + add_roles_to_user( + instance.user, [(instance.course_session.course.slug, [instance.role])] + ) + else: + old_csu = CourseSessionUser.objects.get(pk=instance.pk) + if old_csu.role != instance.role: + update_roles_for_user( + instance.user, + add_course_roles=[ + (instance.course_session.course.slug, [instance.role]) + ], + remove_course_roles=[ + (instance.course_session.course.slug, [old_csu.role]) + ], + ) + @classmethod def remove_sso_roles_from_user(cls, instance: "CourseSessionUser"): remove_roles_from_user( diff --git a/server/vbv_lernwelt/course/signals.py b/server/vbv_lernwelt/course/signals.py index 14bf93b2..8aca4ffb 100644 --- a/server/vbv_lernwelt/course/signals.py +++ b/server/vbv_lernwelt/course/signals.py @@ -1,4 +1,4 @@ -from django.db.models.signals import post_delete, post_save +from django.db.models.signals import post_delete, post_save, pre_save from django.dispatch import receiver from vbv_lernwelt.course.models import Course, CourseConfiguration, CourseSessionUser @@ -10,6 +10,11 @@ def create_course_configuration(sender, instance, created, **kwargs): CourseConfiguration.objects.create(course=instance) -@receiver(post_delete, sender=CourseSessionUser) -def after_delete(sender, instance, **kwargs): +@receiver(post_delete, sender=CourseSessionUser, dispatch_uid="delete_sso_roles") +def delete_sso_roles(sender, instance, **kwargs): CourseSessionUser.remove_sso_roles_from_user(instance) + + +@receiver(pre_save, sender=CourseSessionUser, dispatch_uid="update_sso_roles") +def update_sso_roles(sender, instance: CourseSessionUser, **kwargs): + CourseSessionUser.update_sso_roles(instance) diff --git a/server/vbv_lernwelt/course_session_group/admin.py b/server/vbv_lernwelt/course_session_group/admin.py index f880cfa3..5bda3780 100644 --- a/server/vbv_lernwelt/course_session_group/admin.py +++ b/server/vbv_lernwelt/course_session_group/admin.py @@ -4,4 +4,5 @@ from vbv_lernwelt.course_session_group.models import CourseSessionGroup @admin.register(CourseSessionGroup) -class CourseSessionAssignmentAdmin(admin.ModelAdmin): ... +class CourseSessionAssignmentAdmin(admin.ModelAdmin): + ... diff --git a/server/vbv_lernwelt/edoniq_test/views.py b/server/vbv_lernwelt/edoniq_test/views.py index e3a71ec2..00d8ff7b 100644 --- a/server/vbv_lernwelt/edoniq_test/views.py +++ b/server/vbv_lernwelt/edoniq_test/views.py @@ -153,9 +153,9 @@ def fetch_course_session_all_users(courses: List[int], excluded_domains=None): def generate_export_response(cs_users: List[User]) -> HttpResponse: response = HttpResponse(content_type="text/csv; charset=utf-8") - response["Content-Disposition"] = ( - f"attachment; filename=edoniq_user_export_{date.today().strftime('%Y%m%d')}.csv" - ) + response[ + "Content-Disposition" + ] = f"attachment; filename=edoniq_user_export_{date.today().strftime('%Y%m%d')}.csv" response.write("\ufeff".encode("utf8")) # UTF-8 BOM diff --git a/server/vbv_lernwelt/feedback/services.py b/server/vbv_lernwelt/feedback/services.py index 51602502..d0fefe20 100644 --- a/server/vbv_lernwelt/feedback/services.py +++ b/server/vbv_lernwelt/feedback/services.py @@ -125,9 +125,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 diff --git a/server/vbv_lernwelt/importer/services.py b/server/vbv_lernwelt/importer/services.py index 732b840b..6c390e0d 100644 --- a/server/vbv_lernwelt/importer/services.py +++ b/server/vbv_lernwelt/importer/services.py @@ -25,6 +25,7 @@ from vbv_lernwelt.learnpath.models import ( LearningContentEdoniqTest, ) from vbv_lernwelt.notify.models import NotificationCategory +from vbv_lernwelt.sso.role_sync.services import create_user logger = structlog.get_logger(__name__) @@ -538,9 +539,10 @@ def create_or_update_user( user.first_name = first_name or user.first_name user.last_name = last_name or user.last_name user.username = email - user.additional_json_data = user.additional_json_data | { - "intermediate_sso_id": intermediate_sso_id - } + + sso_data = {"intermediate_sso_id": intermediate_sso_id} + update_user_json_data(user, sso_data) + user.set_unusable_password() user.save() @@ -827,7 +829,7 @@ 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", + "create_or_update_trainer2", course=course_title, data=data, label="import", @@ -839,6 +841,11 @@ def create_or_update_trainer(course: Course, data: Dict[str, Any], language="de" last_name=data["Name"], ) user.language = data["Sprache"] + + # create user in intermediate sso i.e. Keycloak + sso_data = {"intermediate_sso_id": create_user(user)} + update_user_json_data(user, sso_data) + user.save() group = data["Klasse"].strip() @@ -942,6 +949,10 @@ def create_or_update_student(data: Dict[str, Any]): update_user_json_data(user, data) user.save() + # create user in intermediate sso i.e. Keycloak + sso_data = {"intermediate_sso_id": create_user(user)} + update_user_json_data(user, sso_data) + # general expert handling import_id = data["Durchführungen"] course_session = CourseSession.objects.filter(import_id=import_id).first() diff --git a/server/vbv_lernwelt/notify/tests/test_service.py b/server/vbv_lernwelt/notify/tests/test_service.py index 1feb4bad..6d8af677 100644 --- a/server/vbv_lernwelt/notify/tests/test_service.py +++ b/server/vbv_lernwelt/notify/tests/test_service.py @@ -65,9 +65,9 @@ class TestNotificationService(TestCase): self.assertFalse(notification.emailed) def test_send_notification_with_email(self): - self.recipient.additional_json_data["email_notification_categories"] = ( - json.dumps(["USER_INTERACTION"]) - ) + self.recipient.additional_json_data[ + "email_notification_categories" + ] = json.dumps(["USER_INTERACTION"]) self.recipient.save() verb = "Anne hat deinen Auftrag bewertet" @@ -146,9 +146,9 @@ class TestNotificationService(TestCase): self.assertFalse(notification.emailed) # when the email was not sent, yet it will still send it afterwards... - self.recipient.additional_json_data["email_notification_categories"] = ( - json.dumps(["USER_INTERACTION"]) - ) + self.recipient.additional_json_data[ + "email_notification_categories" + ] = json.dumps(["USER_INTERACTION"]) self.recipient.save() result = self.notification_service._send_notification( @@ -188,9 +188,9 @@ class TestNotificationService(TestCase): self.assertFalse(self._has_sent_emails()) # Assert mail is sent if corresponding email notification type is enabled - self.recipient.additional_json_data["email_notification_categories"] = ( - json.dumps(["USER_INTERACTION"]) - ) + self.recipient.additional_json_data[ + "email_notification_categories" + ] = json.dumps(["USER_INTERACTION"]) self.recipient.save() self.notification_service._send_notification( sender=self.sender, diff --git a/server/vbv_lernwelt/self_evaluation_feedback/serializers.py b/server/vbv_lernwelt/self_evaluation_feedback/serializers.py index 0c73c3dc..fd24d363 100644 --- a/server/vbv_lernwelt/self_evaluation_feedback/serializers.py +++ b/server/vbv_lernwelt/self_evaluation_feedback/serializers.py @@ -39,9 +39,9 @@ class SelfEvaluationFeedbackSerializer(serializers.ModelSerializer): return obj.learning_unit.get_circle().title def get_criteria(self, obj): - performance_criteria: List[PerformanceCriteria] = ( - obj.learning_unit.performancecriteria_set.all() - ) + performance_criteria: List[ + PerformanceCriteria + ] = obj.learning_unit.performancecriteria_set.all() criteria = [] diff --git a/server/vbv_lernwelt/sso/role_sync/client.py b/server/vbv_lernwelt/sso/role_sync/services.py similarity index 83% rename from server/vbv_lernwelt/sso/role_sync/client.py rename to server/vbv_lernwelt/sso/role_sync/services.py index dd5dfc2a..d7008ee7 100644 --- a/server/vbv_lernwelt/sso/role_sync/client.py +++ b/server/vbv_lernwelt/sso/role_sync/services.py @@ -16,7 +16,7 @@ if settings.OAUTH_SYNC_ROLES: user_realm_name=settings.OAUTH_SIGNIN_REALM, client_id=settings.OAUTH_SIGNIN_ADMIN_CLIENT_ID, client_secret_key=settings.OAUTH_SIGNIN_ADMIN_CLIENT_SECRET, - verify=True, + verify=False, ) keycloak_admin = KeycloakAdmin(connection=keycloak_connection) @@ -29,7 +29,7 @@ def add_roles_to_user(user: User, course_roles: CourseRolesType): user_id = user.additional_json_data.get("intermediate_sso_id", "") if settings.OAUTH_SYNC_ROLES and user_id: request_roles = _get_role_request_data(course_roles) - keycloak_admin.assign_realm_roles( + some = keycloak_admin.assign_realm_roles( user_id=user_id, roles=request_roles, ) @@ -41,7 +41,7 @@ def remove_roles_from_user(user: User, course_roles: CourseRolesType): user_id = user.additional_json_data.get("intermediate_sso_id", "") if settings.OAUTH_SYNC_ROLES and user_id: request_roles = _get_role_request_data(course_roles) - keycloak_admin.delete_realm_roles_of_user( + some = keycloak_admin.delete_realm_roles_of_user( user_id=user_id, roles=request_roles, ) @@ -53,12 +53,26 @@ def update_roles_for_user( user: User, add_course_roles: CourseRolesType, remove_course_roles: CourseRolesType ): if settings.OAUTH_SYNC_ROLES: - add_roles_to_user(user, add_course_roles) remove_roles_from_user(user, remove_course_roles) + add_roles_to_user(user, add_course_roles) return True return False +def create_user(user: User): + if settings.OAUTH_SYNC_ROLES: + user_data = { + "username": user.email, + "email": user.email, + "enabled": True, + "firstName": user.first_name, + "lastName": user.last_name, + } + user_id = keycloak_admin.create_user(user_data, exist_ok=True) + return user_id + return "" + + def get_roles_for_user(user_id: str): if settings.OAUTH_SYNC_ROLES: return keycloak_admin.get_realm_roles_of_user( @@ -69,6 +83,8 @@ def get_roles_for_user(user_id: str): # create sso-ID user and set roles # sync +# remove all, add all +# display def _get_role_request_data(course_roles: CourseRolesType) -> List[Dict[str, str]]: From 13789a9619de8047860c2736bdcb56ec7576cca3 Mon Sep 17 00:00:00 2001 From: Christian Cueni Date: Wed, 19 Jun 2024 18:50:24 +0200 Subject: [PATCH 03/23] wip: Add sync method --- server/vbv_lernwelt/core/admin.py | 8 +++++ server/vbv_lernwelt/core/signals.py | 7 ++++ server/vbv_lernwelt/course/models.py | 17 +++++++--- server/vbv_lernwelt/course/signals.py | 6 ++++ .../vbv_lernwelt/sso/role_sync/admin_views.py | 0 server/vbv_lernwelt/sso/role_sync/services.py | 34 +++++++++++++------ 6 files changed, 58 insertions(+), 14 deletions(-) create mode 100644 server/vbv_lernwelt/core/signals.py create mode 100644 server/vbv_lernwelt/sso/role_sync/admin_views.py diff --git a/server/vbv_lernwelt/core/admin.py b/server/vbv_lernwelt/core/admin.py index 8c44c349..f7386bf7 100644 --- a/server/vbv_lernwelt/core/admin.py +++ b/server/vbv_lernwelt/core/admin.py @@ -3,11 +3,18 @@ from django.contrib.auth import admin as auth_admin, get_user_model from django.utils.translation import gettext_lazy as _ from vbv_lernwelt.core.models import Country, JobLog, Organisation +from vbv_lernwelt.core.signals import sync_sso_roles_signal from vbv_lernwelt.core.utils import pretty_print_json User = get_user_model() +@admin.action(description="KEYCLOAK: Sync SSO Roles") +def sync_sso_roles(modeladmin, request, queryset): + for user in queryset: + sync_sso_roles_signal.send(sender="core.admin", user=user) + + class LogAdmin(admin.ModelAdmin): def has_add_permission(self, request): return False @@ -83,6 +90,7 @@ class UserAdmin(auth_admin.UserAdmin): "sso_id", ] search_fields = ["first_name", "last_name", "email", "username", "sso_id"] + actions = [sync_sso_roles] @admin.register(JobLog) diff --git a/server/vbv_lernwelt/core/signals.py b/server/vbv_lernwelt/core/signals.py new file mode 100644 index 00000000..b6a52bdd --- /dev/null +++ b/server/vbv_lernwelt/core/signals.py @@ -0,0 +1,7 @@ +from django.dispatch import Signal + +sync_sso_roles_signal = Signal( + providing_args=[ + "user", + ] +) diff --git a/server/vbv_lernwelt/course/models.py b/server/vbv_lernwelt/course/models.py index df1eae9d..a88c7385 100644 --- a/server/vbv_lernwelt/course/models.py +++ b/server/vbv_lernwelt/course/models.py @@ -16,6 +16,7 @@ from vbv_lernwelt.files.models import UploadFile from vbv_lernwelt.sso.role_sync.services import ( add_roles_to_user, remove_roles_from_user, + sync_roles_for_user, update_roles_for_user, ) @@ -316,7 +317,7 @@ class CourseSessionUser(models.Model): def update_sso_roles(cls, instance: "CourseSessionUser"): if instance.created_at is None: add_roles_to_user( - instance.user, [(instance.course_session.course.slug, [instance.role])] + instance.user, [(instance.course_session.course.slug, instance.role)] ) else: old_csu = CourseSessionUser.objects.get(pk=instance.pk) @@ -324,19 +325,27 @@ class CourseSessionUser(models.Model): update_roles_for_user( instance.user, add_course_roles=[ - (instance.course_session.course.slug, [instance.role]) + (instance.course_session.course.slug, instance.role) ], remove_course_roles=[ - (instance.course_session.course.slug, [old_csu.role]) + (instance.course_session.course.slug, old_csu.role) ], ) @classmethod def remove_sso_roles_from_user(cls, instance: "CourseSessionUser"): remove_roles_from_user( - instance.user, [(instance.course_session.course.slug, [instance.role])] + instance.user, [(instance.course_session.course.slug, instance.role)] ) + @classmethod + def sync_sso_roles(cls, user: User): + course_roles = [ + (csu.course_session.course.slug, csu.role) + for csu in CourseSessionUser.objects.filter(user=user) + ] + sync_roles_for_user(user, course_roles) + class CircleDocument(models.Model): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) diff --git a/server/vbv_lernwelt/course/signals.py b/server/vbv_lernwelt/course/signals.py index 8aca4ffb..25b1eb3e 100644 --- a/server/vbv_lernwelt/course/signals.py +++ b/server/vbv_lernwelt/course/signals.py @@ -1,6 +1,7 @@ from django.db.models.signals import post_delete, post_save, pre_save from django.dispatch import receiver +from vbv_lernwelt.core.signals import sync_sso_roles_signal from vbv_lernwelt.course.models import Course, CourseConfiguration, CourseSessionUser @@ -18,3 +19,8 @@ def delete_sso_roles(sender, instance, **kwargs): @receiver(pre_save, sender=CourseSessionUser, dispatch_uid="update_sso_roles") def update_sso_roles(sender, instance: CourseSessionUser, **kwargs): CourseSessionUser.update_sso_roles(instance) + + +@receiver(sync_sso_roles_signal, dispatch_uid="sync_sso_roles") +def sync_sso_roles(sender, user, **kwargs): + CourseSessionUser.sync_sso_roles(user) diff --git a/server/vbv_lernwelt/sso/role_sync/admin_views.py b/server/vbv_lernwelt/sso/role_sync/admin_views.py new file mode 100644 index 00000000..e69de29b diff --git a/server/vbv_lernwelt/sso/role_sync/services.py b/server/vbv_lernwelt/sso/role_sync/services.py index d7008ee7..bc526913 100644 --- a/server/vbv_lernwelt/sso/role_sync/services.py +++ b/server/vbv_lernwelt/sso/role_sync/services.py @@ -7,7 +7,7 @@ from keycloak import KeycloakAdmin, KeycloakOpenIDConnection from vbv_lernwelt.core.models import User from vbv_lernwelt.sso.role_sync.roles import ROLE_IDS, SSO_ROLES -CourseRolesType = List[Tuple[str, List[str]]] +CourseRolesType = List[Tuple[str, str]] if settings.OAUTH_SYNC_ROLES: keycloak_connection = KeycloakOpenIDConnection( @@ -29,7 +29,7 @@ def add_roles_to_user(user: User, course_roles: CourseRolesType): user_id = user.additional_json_data.get("intermediate_sso_id", "") if settings.OAUTH_SYNC_ROLES and user_id: request_roles = _get_role_request_data(course_roles) - some = keycloak_admin.assign_realm_roles( + keycloak_admin.assign_realm_roles( user_id=user_id, roles=request_roles, ) @@ -41,7 +41,7 @@ def remove_roles_from_user(user: User, course_roles: CourseRolesType): user_id = user.additional_json_data.get("intermediate_sso_id", "") if settings.OAUTH_SYNC_ROLES and user_id: request_roles = _get_role_request_data(course_roles) - some = keycloak_admin.delete_realm_roles_of_user( + keycloak_admin.delete_realm_roles_of_user( user_id=user_id, roles=request_roles, ) @@ -59,6 +59,22 @@ def update_roles_for_user( return False +def sync_roles_for_user(user: User, course_roles: CourseRolesType): + if settings.OAUTH_SYNC_ROLES: + user_id = user.additional_json_data.get("intermediate_sso_id", "") + if user_id: + assigned_roles = keycloak_admin.get_realm_roles_of_user(user_id=user_id) + if assigned_roles: + keycloak_admin.delete_realm_roles_of_user( + user_id=user_id, + roles=assigned_roles, + ) + roles = _get_role_request_data(course_roles) + keycloak_admin.assign_realm_roles(user_id=user_id, roles=roles) + return True + return False + + def create_user(user: User): if settings.OAUTH_SYNC_ROLES: user_data = { @@ -90,17 +106,15 @@ def get_roles_for_user(user_id: str): def _get_role_request_data(course_roles: CourseRolesType) -> List[Dict[str, str]]: request_roles = [] for item in course_roles: - course_slug, roles = item + course_slug, role = item sanitized_course_slug = _remove_accents(course_slug) - oauth_roles = _create_role_names(sanitized_course_slug, roles) - return request_roles + [ - {"id": ROLE_IDS[role], "name": role} for role in oauth_roles - ] + oauth_role = _create_role_name(sanitized_course_slug, role) + request_roles.append({"id": ROLE_IDS[oauth_role], "name": oauth_role}) return request_roles -def _create_role_names(course_slug: str, roles: list) -> List[str]: - return [SSO_ROLES[course_slug][role] for role in roles] +def _create_role_name(course_slug: str, role: str) -> List[str]: + return SSO_ROLES[course_slug][role] def _remove_accents(input_str) -> str: From 9437dafb765519d11fff5ef248301a353a5eeb5f Mon Sep 17 00:00:00 2001 From: Christian Cueni Date: Wed, 19 Jun 2024 19:38:12 +0200 Subject: [PATCH 04/23] wip: Add error model, move code, add exception --- server/vbv_lernwelt/core/admin.py | 10 ++- server/vbv_lernwelt/core/signals.py | 6 ++ server/vbv_lernwelt/course/models.py | 63 ++----------- server/vbv_lernwelt/course/signals.py | 20 +---- server/vbv_lernwelt/sso/exceptions.py | 23 +++++ server/vbv_lernwelt/sso/models.py | 23 +++++ server/vbv_lernwelt/sso/role_sync/services.py | 38 ++++---- server/vbv_lernwelt/sso/signals.py | 89 +++++++++++++++++++ 8 files changed, 179 insertions(+), 93 deletions(-) create mode 100644 server/vbv_lernwelt/sso/exceptions.py create mode 100644 server/vbv_lernwelt/sso/models.py create mode 100644 server/vbv_lernwelt/sso/signals.py diff --git a/server/vbv_lernwelt/core/admin.py b/server/vbv_lernwelt/core/admin.py index f7386bf7..64234013 100644 --- a/server/vbv_lernwelt/core/admin.py +++ b/server/vbv_lernwelt/core/admin.py @@ -3,7 +3,7 @@ from django.contrib.auth import admin as auth_admin, get_user_model from django.utils.translation import gettext_lazy as _ from vbv_lernwelt.core.models import Country, JobLog, Organisation -from vbv_lernwelt.core.signals import sync_sso_roles_signal +from vbv_lernwelt.core.signals import create_sso_user_signal, sync_sso_roles_signal from vbv_lernwelt.core.utils import pretty_print_json User = get_user_model() @@ -15,6 +15,12 @@ def sync_sso_roles(modeladmin, request, queryset): sync_sso_roles_signal.send(sender="core.admin", user=user) +@admin.action(description="KEYCLOAK: Create User") +def create_sso_user(modeladmin, request, queryset): + for user in queryset: + create_sso_user_signal.send(sender="core.admin", user=user) + + class LogAdmin(admin.ModelAdmin): def has_add_permission(self, request): return False @@ -90,7 +96,7 @@ class UserAdmin(auth_admin.UserAdmin): "sso_id", ] search_fields = ["first_name", "last_name", "email", "username", "sso_id"] - actions = [sync_sso_roles] + actions = [sync_sso_roles, create_sso_user] @admin.register(JobLog) diff --git a/server/vbv_lernwelt/core/signals.py b/server/vbv_lernwelt/core/signals.py index b6a52bdd..27106448 100644 --- a/server/vbv_lernwelt/core/signals.py +++ b/server/vbv_lernwelt/core/signals.py @@ -5,3 +5,9 @@ sync_sso_roles_signal = Signal( "user", ] ) + +create_sso_user_signal = Signal( + providing_args=[ + "user", + ] +) diff --git a/server/vbv_lernwelt/course/models.py b/server/vbv_lernwelt/course/models.py index a88c7385..d8b6997a 100644 --- a/server/vbv_lernwelt/course/models.py +++ b/server/vbv_lernwelt/course/models.py @@ -13,12 +13,6 @@ from vbv_lernwelt.core.model_utils import find_available_slug from vbv_lernwelt.core.models import User from vbv_lernwelt.course.serializer_helpers import get_course_serializer_class from vbv_lernwelt.files.models import UploadFile -from vbv_lernwelt.sso.role_sync.services import ( - add_roles_to_user, - remove_roles_from_user, - sync_roles_for_user, - update_roles_for_user, -) class CircleContactType(Enum): @@ -198,6 +192,12 @@ class CourseCompletionStatus(Enum): UNKNOWN = "UNKNOWN" +class CourseCompletionStatusChoices(models.TextChoices): + SUCCESS = CourseCompletionStatus.SUCCESS.value, "Success" + FAIL = CourseCompletionStatus.FAIL.value, "Fail" + UNKNOWN = CourseCompletionStatus.UNKNOWN.value, "Unknown" + + class CourseCompletion(models.Model): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) @@ -216,8 +216,8 @@ class CourseCompletion(models.Model): completion_status = models.CharField( max_length=255, - choices=[(status, status.value) for status in CourseCompletionStatus], - default=CourseCompletionStatus.UNKNOWN.value, + choices=CourseCompletionStatusChoices.choices, + default=CourseCompletionStatus.UNKNOWN, ) additional_json_data = models.JSONField(default=dict, blank=True) @@ -299,53 +299,6 @@ class CourseSessionUser(models.Model): def __str__(self): return f"{self.user} ({self.course_session.title})" - def save(self, *args, **kwargs): - if self.created_at is None: - add_roles_to_user( - self.user, [(self.course_session.course.slug, [self.role])] - ) - else: - old_csu = CourseSessionUser.objects.get(pk=self.pk) - update_roles_for_user( - self.user, - add_course_roles=[(self.course_session.course.slug, [self.role])], - remove_course_roles=[(self.course_session.course.slug, [old_csu.role])], - ) - super().save(*args, **kwargs) - - @classmethod - def update_sso_roles(cls, instance: "CourseSessionUser"): - if instance.created_at is None: - add_roles_to_user( - instance.user, [(instance.course_session.course.slug, instance.role)] - ) - else: - old_csu = CourseSessionUser.objects.get(pk=instance.pk) - if old_csu.role != instance.role: - update_roles_for_user( - instance.user, - add_course_roles=[ - (instance.course_session.course.slug, instance.role) - ], - remove_course_roles=[ - (instance.course_session.course.slug, old_csu.role) - ], - ) - - @classmethod - def remove_sso_roles_from_user(cls, instance: "CourseSessionUser"): - remove_roles_from_user( - instance.user, [(instance.course_session.course.slug, instance.role)] - ) - - @classmethod - def sync_sso_roles(cls, user: User): - course_roles = [ - (csu.course_session.course.slug, csu.role) - for csu in CourseSessionUser.objects.filter(user=user) - ] - sync_roles_for_user(user, course_roles) - class CircleDocument(models.Model): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) diff --git a/server/vbv_lernwelt/course/signals.py b/server/vbv_lernwelt/course/signals.py index 25b1eb3e..d474c30a 100644 --- a/server/vbv_lernwelt/course/signals.py +++ b/server/vbv_lernwelt/course/signals.py @@ -1,26 +1,10 @@ -from django.db.models.signals import post_delete, post_save, pre_save +from django.db.models.signals import post_save from django.dispatch import receiver -from vbv_lernwelt.core.signals import sync_sso_roles_signal -from vbv_lernwelt.course.models import Course, CourseConfiguration, CourseSessionUser +from vbv_lernwelt.course.models import Course, CourseConfiguration @receiver(post_save, sender=Course) def create_course_configuration(sender, instance, created, **kwargs): if created: CourseConfiguration.objects.create(course=instance) - - -@receiver(post_delete, sender=CourseSessionUser, dispatch_uid="delete_sso_roles") -def delete_sso_roles(sender, instance, **kwargs): - CourseSessionUser.remove_sso_roles_from_user(instance) - - -@receiver(pre_save, sender=CourseSessionUser, dispatch_uid="update_sso_roles") -def update_sso_roles(sender, instance: CourseSessionUser, **kwargs): - CourseSessionUser.update_sso_roles(instance) - - -@receiver(sync_sso_roles_signal, dispatch_uid="sync_sso_roles") -def sync_sso_roles(sender, user, **kwargs): - CourseSessionUser.sync_sso_roles(user) diff --git a/server/vbv_lernwelt/sso/exceptions.py b/server/vbv_lernwelt/sso/exceptions.py new file mode 100644 index 00000000..2d6a3177 --- /dev/null +++ b/server/vbv_lernwelt/sso/exceptions.py @@ -0,0 +1,23 @@ +from keycloak.exceptions import KeycloakDeleteError, KeycloakPostError + + +class MyVbvKeycloakDeleteError(KeycloakDeleteError): + def __init__( + self, keycloak_error: KeycloakDeleteError, additional_data: list | dict + ): + super().__init__( + keycloak_error.error_message, + keycloak_error.response_code, + keycloak_error.response_body, + ) + self.additional_data = additional_data + + +class MyVbvKeycloakPostError(KeycloakPostError): + def __init__(self, keycloak_error: KeycloakPostError, additional_data: list | dict): + super().__init__( + keycloak_error.error_message, + keycloak_error.response_code, + keycloak_error.response_body, + ) + self.additional_data = additional_data diff --git a/server/vbv_lernwelt/sso/models.py b/server/vbv_lernwelt/sso/models.py new file mode 100644 index 00000000..4b40cfae --- /dev/null +++ b/server/vbv_lernwelt/sso/models.py @@ -0,0 +1,23 @@ +from django.db import models + +from vbv_lernwelt.core.models import User + + +class SsoSyncError(models.Model): + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + user = models.ForeignKey(User, on_delete=models.CASCADE) + + class Action(models.TextChoices): + ADD = "ADD", "Add" + REMOVE = "REMOVE", "Remove" + CREATE = "CREATE", "Create" + + action = models.CharField( + choices=Action.choices, max_length=255, default=Action.ADD + ) + data = models.JSONField(default=dict, blank=True) + + def __str__(self): + return f"{self.user} ({self.action})" diff --git a/server/vbv_lernwelt/sso/role_sync/services.py b/server/vbv_lernwelt/sso/role_sync/services.py index bc526913..ef70dfbe 100644 --- a/server/vbv_lernwelt/sso/role_sync/services.py +++ b/server/vbv_lernwelt/sso/role_sync/services.py @@ -3,8 +3,10 @@ from typing import Dict, List, Tuple from django.conf import settings from keycloak import KeycloakAdmin, KeycloakOpenIDConnection +from keycloak.exceptions import KeycloakDeleteError, KeycloakPostError from vbv_lernwelt.core.models import User +from vbv_lernwelt.sso.exceptions import MyVbvKeycloakDeleteError, MyVbvKeycloakPostError from vbv_lernwelt.sso.role_sync.roles import ROLE_IDS, SSO_ROLES CourseRolesType = List[Tuple[str, str]] @@ -22,17 +24,17 @@ if settings.OAUTH_SYNC_ROLES: keycloak_admin = KeycloakAdmin(connection=keycloak_connection) -# todo: handle errors - - def add_roles_to_user(user: User, course_roles: CourseRolesType): user_id = user.additional_json_data.get("intermediate_sso_id", "") if settings.OAUTH_SYNC_ROLES and user_id: request_roles = _get_role_request_data(course_roles) - keycloak_admin.assign_realm_roles( - user_id=user_id, - roles=request_roles, - ) + try: + keycloak_admin.assign_realm_roles( + user_id=user_id, + roles=request_roles, + ) + except KeycloakPostError as e: + raise MyVbvKeycloakPostError(e, request_roles) return True return False @@ -41,10 +43,13 @@ def remove_roles_from_user(user: User, course_roles: CourseRolesType): user_id = user.additional_json_data.get("intermediate_sso_id", "") if settings.OAUTH_SYNC_ROLES and user_id: request_roles = _get_role_request_data(course_roles) - keycloak_admin.delete_realm_roles_of_user( - user_id=user_id, - roles=request_roles, - ) + try: + keycloak_admin.delete_realm_roles_of_user( + user_id=user_id, + roles=request_roles, + ) + except KeycloakDeleteError as e: + raise MyVbvKeycloakDeleteError(e, request_roles) return True return False @@ -84,7 +89,10 @@ def create_user(user: User): "firstName": user.first_name, "lastName": user.last_name, } - user_id = keycloak_admin.create_user(user_data, exist_ok=True) + try: + user_id = keycloak_admin.create_user(user_data, exist_ok=True) + except KeycloakPostError as e: + raise MyVbvKeycloakPostError(e, user_data) return user_id return "" @@ -97,12 +105,6 @@ def get_roles_for_user(user_id: str): return [] -# create sso-ID user and set roles -# sync -# remove all, add all -# display - - def _get_role_request_data(course_roles: CourseRolesType) -> List[Dict[str, str]]: request_roles = [] for item in course_roles: diff --git a/server/vbv_lernwelt/sso/signals.py b/server/vbv_lernwelt/sso/signals.py new file mode 100644 index 00000000..e23acacb --- /dev/null +++ b/server/vbv_lernwelt/sso/signals.py @@ -0,0 +1,89 @@ +from django.db.models.signals import post_delete, pre_save +from django.dispatch import receiver + +from vbv_lernwelt.core.signals import create_sso_user_signal, sync_sso_roles_signal +from vbv_lernwelt.course.models import CourseSessionUser +from vbv_lernwelt.sso.exceptions import MyVbvKeycloakDeleteError, MyVbvKeycloakPostError +from vbv_lernwelt.sso.models import SsoSyncError +from vbv_lernwelt.sso.role_sync.services import ( + add_roles_to_user, + create_user, + remove_roles_from_user, + sync_roles_for_user, + update_roles_for_user, +) + + +@receiver(post_delete, sender=CourseSessionUser, dispatch_uid="delete_sso_roles") +def delete_sso_roles(sender, instance, **kwargs): + try: + remove_roles_from_user( + instance.user, [(instance.course_session.course.slug, instance.role)] + ) + except MyVbvKeycloakDeleteError as e: + additional_data = getattr(e, "additional_data", {}) + SsoSyncError.objects.create( + user=instance.user, action=SsoSyncError.Action.REMOVE, data=additional_data + ) + + +@receiver(pre_save, sender=CourseSessionUser, dispatch_uid="update_sso_roles") +def update_sso_roles(sender, instance: CourseSessionUser, **kwargs): + try: + if instance.created_at is None: + add_roles_to_user( + instance.user, [(instance.course_session.course.slug, instance.role)] + ) + else: + old_csu = CourseSessionUser.objects.get(pk=instance.pk) + if old_csu.role != instance.role: + update_roles_for_user( + instance.user, + add_course_roles=[ + (instance.course_session.course.slug, instance.role) + ], + remove_course_roles=[ + (instance.course_session.course.slug, old_csu.role) + ], + ) + except MyVbvKeycloakDeleteError as e: + additional_data = getattr(e, "additional_data", {}) + SsoSyncError.objects.create( + user=instance.user, action=SsoSyncError.Action.REMOVE, data=additional_data + ) + except MyVbvKeycloakPostError as e: + additional_data = getattr(e, "additional_data", {}) + SsoSyncError.objects.create( + user=instance.user, action=SsoSyncError.Action.ADD, data=additional_data + ) + + +@receiver(sync_sso_roles_signal, dispatch_uid="sync_sso_roles") +def sync_sso_roles(sender, user, **kwargs): + course_roles = [ + (csu.course_session.course.slug, csu.role) + for csu in CourseSessionUser.objects.filter(user=user) + ] + try: + sync_roles_for_user(user, course_roles) + except MyVbvKeycloakDeleteError as e: + additional_data = getattr(e, "additional_data", {}) + SsoSyncError.objects.create( + user=user, action=SsoSyncError.Action.REMOVE, data=additional_data + ) + except MyVbvKeycloakPostError as e: + additional_data = getattr(e, "additional_data", {}) + SsoSyncError.objects.create( + user=user, action=SsoSyncError.Action.ADD, data=additional_data + ) + + +@receiver(create_sso_user_signal, dispatch_uid="create_sso_user") +def create_sso_user(sender, user, **kwargs): + try: + create_user(user) + except MyVbvKeycloakPostError as e: + additional_data = getattr(e, "additional_data", {}) + SsoSyncError.objects.create( + user=user, action=SsoSyncError.Action.CREATE, data=additional_data + ) From ade89c3c5b4edbb4f8d83b505a218149158a7e4d Mon Sep 17 00:00:00 2001 From: Christian Cueni Date: Thu, 20 Jun 2024 07:20:01 +0200 Subject: [PATCH 05/23] Fix test, remove unused file --- server/vbv_lernwelt/importer/tests/test_import_students.py | 1 + server/vbv_lernwelt/sso/role_sync/admin_views.py | 0 2 files changed, 1 insertion(+) delete mode 100644 server/vbv_lernwelt/sso/role_sync/admin_views.py diff --git a/server/vbv_lernwelt/importer/tests/test_import_students.py b/server/vbv_lernwelt/importer/tests/test_import_students.py index 6bf3f63a..a3ad3565 100644 --- a/server/vbv_lernwelt/importer/tests/test_import_students.py +++ b/server/vbv_lernwelt/importer/tests/test_import_students.py @@ -53,6 +53,7 @@ class CreateOrUpdateStudentTestCase(TestCase): "Tel. Privat": "079 593 83 43", "Geburtsdatum": "01.01.2000", "email_notification_categories": ["INFORMATION"], + 'intermediate_sso_id': '' } def test_create_student(self): diff --git a/server/vbv_lernwelt/sso/role_sync/admin_views.py b/server/vbv_lernwelt/sso/role_sync/admin_views.py deleted file mode 100644 index e69de29b..00000000 From e436c5ddbd61f1bd2422218781a2c1a1633bafe4 Mon Sep 17 00:00:00 2001 From: Christian Cueni Date: Thu, 20 Jun 2024 10:36:43 +0200 Subject: [PATCH 06/23] Add services test --- .../importer/tests/test_import_students.py | 2 +- server/vbv_lernwelt/sso/role_sync/services.py | 65 +++++--- server/vbv_lernwelt/sso/signals.py | 2 +- .../vbv_lernwelt/sso/tests/test_role_sync.py | 157 ++++++++++++++++++ 4 files changed, 203 insertions(+), 23 deletions(-) create mode 100644 server/vbv_lernwelt/sso/tests/test_role_sync.py diff --git a/server/vbv_lernwelt/importer/tests/test_import_students.py b/server/vbv_lernwelt/importer/tests/test_import_students.py index a3ad3565..9b719644 100644 --- a/server/vbv_lernwelt/importer/tests/test_import_students.py +++ b/server/vbv_lernwelt/importer/tests/test_import_students.py @@ -53,7 +53,7 @@ class CreateOrUpdateStudentTestCase(TestCase): "Tel. Privat": "079 593 83 43", "Geburtsdatum": "01.01.2000", "email_notification_categories": ["INFORMATION"], - 'intermediate_sso_id': '' + "intermediate_sso_id": "", } def test_create_student(self): diff --git a/server/vbv_lernwelt/sso/role_sync/services.py b/server/vbv_lernwelt/sso/role_sync/services.py index ef70dfbe..671cee73 100644 --- a/server/vbv_lernwelt/sso/role_sync/services.py +++ b/server/vbv_lernwelt/sso/role_sync/services.py @@ -1,14 +1,17 @@ import unicodedata from typing import Dict, List, Tuple +import structlog from django.conf import settings from keycloak import KeycloakAdmin, KeycloakOpenIDConnection -from keycloak.exceptions import KeycloakDeleteError, KeycloakPostError +from keycloak.exceptions import KeycloakDeleteError, KeycloakError, KeycloakPostError from vbv_lernwelt.core.models import User from vbv_lernwelt.sso.exceptions import MyVbvKeycloakDeleteError, MyVbvKeycloakPostError from vbv_lernwelt.sso.role_sync.roles import ROLE_IDS, SSO_ROLES +logger = structlog.get_logger(__name__) + CourseRolesType = List[Tuple[str, str]] if settings.OAUTH_SYNC_ROLES: @@ -25,31 +28,44 @@ if settings.OAUTH_SYNC_ROLES: def add_roles_to_user(user: User, course_roles: CourseRolesType): - user_id = user.additional_json_data.get("intermediate_sso_id", "") - if settings.OAUTH_SYNC_ROLES and user_id: - request_roles = _get_role_request_data(course_roles) - try: - keycloak_admin.assign_realm_roles( - user_id=user_id, - roles=request_roles, - ) - except KeycloakPostError as e: - raise MyVbvKeycloakPostError(e, request_roles) - return True - return False + return _handle_add_remove_action( + user=user, + course_roles=course_roles, + func=keycloak_admin.assign_realm_roles, + kc_exception=KeycloakPostError, + myvbv_exception=MyVbvKeycloakPostError, + ) def remove_roles_from_user(user: User, course_roles: CourseRolesType): + return _handle_add_remove_action( + user=user, + course_roles=course_roles, + func=keycloak_admin.delete_realm_roles_of_user, + kc_exception=KeycloakDeleteError, + myvbv_exception=MyVbvKeycloakDeleteError, + ) + + +def _handle_add_remove_action( + user: User, + course_roles: CourseRolesType, + func: callable, + kc_exception: KeycloakError, + myvbv_exception: KeycloakPostError or KeycloakDeleteError, +): user_id = user.additional_json_data.get("intermediate_sso_id", "") if settings.OAUTH_SYNC_ROLES and user_id: request_roles = _get_role_request_data(course_roles) + if not request_roles: + return False try: - keycloak_admin.delete_realm_roles_of_user( + func( user_id=user_id, roles=request_roles, ) - except KeycloakDeleteError as e: - raise MyVbvKeycloakDeleteError(e, request_roles) + except kc_exception as e: + raise myvbv_exception(e, request_roles) return True return False @@ -58,9 +74,9 @@ def update_roles_for_user( user: User, add_course_roles: CourseRolesType, remove_course_roles: CourseRolesType ): if settings.OAUTH_SYNC_ROLES: - remove_roles_from_user(user, remove_course_roles) - add_roles_to_user(user, add_course_roles) - return True + remove_ret_value = remove_roles_from_user(user, remove_course_roles) + add_ret_value = add_roles_to_user(user, add_course_roles) + return remove_ret_value and add_ret_value return False @@ -110,8 +126,15 @@ def _get_role_request_data(course_roles: CourseRolesType) -> List[Dict[str, str] for item in course_roles: course_slug, role = item sanitized_course_slug = _remove_accents(course_slug) - oauth_role = _create_role_name(sanitized_course_slug, role) - request_roles.append({"id": ROLE_IDS[oauth_role], "name": oauth_role}) + try: + oauth_role = _create_role_name(sanitized_course_slug, role) + request_roles.append({"id": ROLE_IDS[oauth_role], "name": oauth_role}) + except KeyError: + logger.warning( + "Role or course not found in SSO_ROLES", + course_slug=course_slug, + role=role, + ) return request_roles diff --git a/server/vbv_lernwelt/sso/signals.py b/server/vbv_lernwelt/sso/signals.py index e23acacb..342b7554 100644 --- a/server/vbv_lernwelt/sso/signals.py +++ b/server/vbv_lernwelt/sso/signals.py @@ -15,7 +15,7 @@ from vbv_lernwelt.sso.role_sync.services import ( @receiver(post_delete, sender=CourseSessionUser, dispatch_uid="delete_sso_roles") -def delete_sso_roles(sender, instance, **kwargs): +def remove_sso_roles(sender, instance, **kwargs): try: remove_roles_from_user( instance.user, [(instance.course_session.course.slug, instance.role)] diff --git a/server/vbv_lernwelt/sso/tests/test_role_sync.py b/server/vbv_lernwelt/sso/tests/test_role_sync.py new file mode 100644 index 00000000..be9ad871 --- /dev/null +++ b/server/vbv_lernwelt/sso/tests/test_role_sync.py @@ -0,0 +1,157 @@ +from unittest.mock import patch + +from django.test import override_settings, TestCase +from keycloak.exceptions import KeycloakDeleteError, KeycloakPostError + +from vbv_lernwelt.core.models import User +from vbv_lernwelt.sso.exceptions import MyVbvKeycloakDeleteError, MyVbvKeycloakPostError +from vbv_lernwelt.sso.role_sync.services import ( + _remove_accents, + add_roles_to_user, + create_user, + remove_roles_from_user, + sync_roles_for_user, + update_roles_for_user, +) + + +class ApiTestCase(TestCase): + def setUp(self): + self.user = User(email="test@example.com", first_name="Test", last_name="User") + self.user.additional_json_data = {"intermediate_sso_id": "1234"} + self.course_roles = [ + ("überbetriebliche-kurse", "EXPERT"), + ("versicherungsvermittler-in", "MEMBER"), + ] + self.expected_roles = [ + { + "name": "myvbv-uberbetriebliche-kurse-expert", + "id": "c7e33cb6-d227-4764-9b8e-d42af79fb46d", + }, + { + "name": "myvbv-versicherungsvermittler-in-member", + "id": "3ab4eab2-7d7c-43bb-a927-4cf54f24ccc2", + }, + ] + + @override_settings(OAUTH_SYNC_ROLES=True) + @patch("vbv_lernwelt.sso.role_sync.services.keycloak_admin") + def test_add_roles_to_user_success(self, mock_keycloak_admin): + mock_keycloak_admin.assign_realm_roles.return_value = None + + result = add_roles_to_user(self.user, self.course_roles) + + self.assertTrue(result) + mock_keycloak_admin.assign_realm_roles.assert_called_once_with( + user_id="1234", roles=self.expected_roles + ) + + @override_settings(OAUTH_SYNC_ROLES=True) + @patch("vbv_lernwelt.sso.role_sync.services.keycloak_admin") + def test_add_roles_to_user_keycloak_post_error(self, mock_keycloak_admin): + mock_keycloak_admin.assign_realm_roles.side_effect = KeycloakPostError + + with self.assertRaises(MyVbvKeycloakPostError) as cm: + add_roles_to_user(self.user, self.course_roles) + + exception = cm.exception + self.assertIsInstance(exception, MyVbvKeycloakPostError) + self.assertEqual(exception.additional_data, self.expected_roles) + + @override_settings(OAUTH_SYNC_ROLES=True) + @patch("vbv_lernwelt.sso.role_sync.services.keycloak_admin") + def test_remove_roles_to_user_success(self, mock_keycloak_admin): + mock_keycloak_admin.delete_realm_roles_of_user.return_value = None + + result = remove_roles_from_user(self.user, self.course_roles) + + self.assertTrue(result) + mock_keycloak_admin.delete_realm_roles_of_user.assert_called_once_with( + user_id="1234", roles=self.expected_roles + ) + + @override_settings(OAUTH_SYNC_ROLES=True) + @patch("vbv_lernwelt.sso.role_sync.services.keycloak_admin") + def test_remove_roles_to_user_keycloak_delete_error(self, mock_keycloak_admin): + mock_keycloak_admin.delete_realm_roles_of_user.side_effect = KeycloakDeleteError + + with self.assertRaises(MyVbvKeycloakDeleteError) as cm: + remove_roles_from_user(self.user, self.course_roles) + + exception = cm.exception + self.assertIsInstance(exception, MyVbvKeycloakDeleteError) + self.assertEqual(exception.additional_data, self.expected_roles) + + @override_settings(OAUTH_SYNC_ROLES=True) + @patch("vbv_lernwelt.sso.role_sync.services.remove_roles_from_user") + @patch("vbv_lernwelt.sso.role_sync.services.add_roles_to_user") + def test_update_roles_to_user( + self, mock_add_roles_to_user, mock_remove_roles_from_user + ): + mock_add_roles_to_user.return_value = True + mock_remove_roles_from_user.return_value = True + + update_roles_for_user(self.user, self.course_roles, self.course_roles) + mock_add_roles_to_user.assert_called_once() + mock_remove_roles_from_user.assert_called_once() + + @override_settings(OAUTH_SYNC_ROLES=True) + @patch("vbv_lernwelt.sso.role_sync.services.keycloak_admin") + def test_sync_roles_to_user(self, mock_keycloak_admin): + mock_keycloak_admin.get_realm_roles_of_user.return_value = ( + self.expected_roles + ) # just use them here as well + mock_keycloak_admin.delete_realm_roles_of_user.return_value = True + mock_keycloak_admin.assign_realm_roles.return_value = None + + sync_roles_for_user(self.user, self.course_roles) + mock_keycloak_admin.get_realm_roles_of_user.assert_called_once_with( + user_id="1234" + ) + mock_keycloak_admin.delete_realm_roles_of_user.assert_called_once_with( + user_id="1234", roles=self.expected_roles + ) + mock_keycloak_admin.assign_realm_roles.assert_called_once_with( + user_id="1234", roles=self.expected_roles + ) + + @override_settings(OAUTH_SYNC_ROLES=True) + @patch("vbv_lernwelt.sso.role_sync.services.keycloak_admin") + def test_create_user(self, mock_keycloak_admin): + mock_keycloak_admin.create_user.return_value = "im-an-uuid-1234" + + user_data = { + "username": self.user.email, + "email": self.user.email, + "enabled": True, + "firstName": self.user.first_name, + "lastName": self.user.last_name, + } + + create_user(self.user) + mock_keycloak_admin.create_user.assert_called_once_with( + user_data, exist_ok=True + ) + + @override_settings(OAUTH_SYNC_ROLES=True) + @patch("vbv_lernwelt.sso.role_sync.services.keycloak_admin") + def test_ignore_missing_course(self, mock_keycloak_admin): + mock_keycloak_admin.assign_realm_roles.return_value = None + + course_roles = [ + ("blabla-kurse", "EXPERT"), + ] + result = add_roles_to_user(self.user, course_roles) + + self.assertFalse(result) + mock_keycloak_admin.assign_realm_roles.assert_not_called() + + +class HelpersTestCase(TestCase): + def test_remove_accents(self): + no_accents = _remove_accents("äüöéèà") + self.assertEqual(no_accents, "auoeea") + + +# test helpers +# test wrong key From 857c4a4742d7466a6288cd165bbbaad268dc7559 Mon Sep 17 00:00:00 2001 From: Christian Cueni Date: Mon, 24 Jun 2024 13:04:58 +0200 Subject: [PATCH 07/23] Add sso models, move all code to sso-app --- server/vbv_lernwelt/core/admin.py | 14 -- server/vbv_lernwelt/core/signals.py | 13 -- server/vbv_lernwelt/sso/admin.py | 139 +++++++++++++++++- .../sso/migrations/0001_initial.py | 52 +++++++ server/vbv_lernwelt/sso/models.py | 5 + server/vbv_lernwelt/sso/signals.py | 34 ----- 6 files changed, 194 insertions(+), 63 deletions(-) delete mode 100644 server/vbv_lernwelt/core/signals.py create mode 100644 server/vbv_lernwelt/sso/migrations/0001_initial.py diff --git a/server/vbv_lernwelt/core/admin.py b/server/vbv_lernwelt/core/admin.py index 64234013..8c44c349 100644 --- a/server/vbv_lernwelt/core/admin.py +++ b/server/vbv_lernwelt/core/admin.py @@ -3,24 +3,11 @@ from django.contrib.auth import admin as auth_admin, get_user_model from django.utils.translation import gettext_lazy as _ from vbv_lernwelt.core.models import Country, JobLog, Organisation -from vbv_lernwelt.core.signals import create_sso_user_signal, sync_sso_roles_signal from vbv_lernwelt.core.utils import pretty_print_json User = get_user_model() -@admin.action(description="KEYCLOAK: Sync SSO Roles") -def sync_sso_roles(modeladmin, request, queryset): - for user in queryset: - sync_sso_roles_signal.send(sender="core.admin", user=user) - - -@admin.action(description="KEYCLOAK: Create User") -def create_sso_user(modeladmin, request, queryset): - for user in queryset: - create_sso_user_signal.send(sender="core.admin", user=user) - - class LogAdmin(admin.ModelAdmin): def has_add_permission(self, request): return False @@ -96,7 +83,6 @@ class UserAdmin(auth_admin.UserAdmin): "sso_id", ] search_fields = ["first_name", "last_name", "email", "username", "sso_id"] - actions = [sync_sso_roles, create_sso_user] @admin.register(JobLog) diff --git a/server/vbv_lernwelt/core/signals.py b/server/vbv_lernwelt/core/signals.py deleted file mode 100644 index 27106448..00000000 --- a/server/vbv_lernwelt/core/signals.py +++ /dev/null @@ -1,13 +0,0 @@ -from django.dispatch import Signal - -sync_sso_roles_signal = Signal( - providing_args=[ - "user", - ] -) - -create_sso_user_signal = Signal( - providing_args=[ - "user", - ] -) diff --git a/server/vbv_lernwelt/sso/admin.py b/server/vbv_lernwelt/sso/admin.py index 8c38f3f3..274c49f2 100644 --- a/server/vbv_lernwelt/sso/admin.py +++ b/server/vbv_lernwelt/sso/admin.py @@ -1,3 +1,138 @@ -from django.contrib import admin +from django.contrib import admin, messages +from django.contrib.auth import admin as auth_admin, get_user_model +from django.utils.translation import gettext_lazy as _ -# Register your models here. +from vbv_lernwelt.course.models import CourseSessionUser +from vbv_lernwelt.importer.services import update_user_json_data +from vbv_lernwelt.sso.exceptions import MyVbvKeycloakDeleteError, MyVbvKeycloakPostError +from vbv_lernwelt.sso.models import SsoSyncError, SsoUser +from vbv_lernwelt.sso.role_sync.services import create_user, sync_roles_for_user + +User = get_user_model() + + +def create_sso_user_from_admin(user: User, request): + try: + sso_data = {"intermediate_sso_id": create_user(user)} + update_user_json_data(user, sso_data) + user.save() + messages.add_message( + request, messages.SUCCESS, f"Der Bentuzer wurde in Keycloak erstellt." + ) + except MyVbvKeycloakPostError as e: + additional_data = getattr(e, "additional_data", {}) + SsoSyncError.objects.create( + user=user, action=SsoSyncError.Action.CREATE, data=additional_data + ) + messages.add_message( + request, + messages.WARNING, + f"Der Benutzer ({e}) konnte nicht in Keycloak erstellt werden.", + ) + + +def sync_sso_roles_from_admin(user: User, request): + course_roles = [ + (csu.course_session.course.slug, csu.role) + for csu in CourseSessionUser.objects.filter(user=user) + ] + try: + sync_roles_for_user(user, course_roles) + messages.add_message( + request, messages.SUCCESS, f"Die Daten wurden mit Keycloak synchronisiert." + ) + except MyVbvKeycloakDeleteError as e: + additional_data = getattr(e, "additional_data", {}) + SsoSyncError.objects.create( + user=user, action=SsoSyncError.Action.REMOVE, data=additional_data + ) + messages.add_message( + request, + messages.WARNING, + f"Die bestehenden Rollen für Benutzer ({e}) konnten in Keycloak nicht gelöscht werden.", + ) + except MyVbvKeycloakPostError as e: + additional_data = getattr(e, "additional_data", {}) + SsoSyncError.objects.create( + user=user, action=SsoSyncError.Action.ADD, data=additional_data + ) + messages.add_message( + request, + messages.WARNING, + f"Die neuen Rollen für Benutzer ({e}) konnten in Keycloak nicht erstellt werden.", + ) + + +@admin.action(description="KEYCLOAK: Sync SSO Roles") +def sync_sso_roles(modeladmin, request, queryset): + for user in queryset: + sync_sso_roles_from_admin(user, request) + + +@admin.action(description="KEYCLOAK: Create User") +def create_sso_user(modeladmin, request, queryset): + for user in queryset: + create_sso_user_from_admin(user, request) + + +@admin.register(SsoUser) +class SsoUserAdmin(auth_admin.UserAdmin): + fieldsets = ( + ( + _("Personal info"), + {"fields": ("first_name", "last_name", "email", "sso_id")}, + ), + (_("Additional data"), {"fields": ("additional_json_data",)}), + ) + list_display = [ + "username", + "first_name", + "last_name", + "sso_id", + "intermedia_sso_id", + ] + search_fields = ["first_name", "last_name", "email", "username", "sso_id"] + actions = [sync_sso_roles, create_sso_user] + + # Make fields read-only + readonly_fields = ( + "username", + "password", + "first_name", + "last_name", + "email", + "additional_json_data", + ) + + # Disable delete action + def has_delete_permission(self, request, obj=None): + return False + + def get_actions(self, request): + actions = super().get_actions(request) + if "delete_selected" in actions: + del actions["delete_selected"] + return actions + + def intermedia_sso_id(self, obj): + return obj.additional_json_data.get("intermediate_sso_id", "") + + intermedia_sso_id.short_description = "Keycloak SSO ID" + + +@admin.register(SsoSyncError) +class SsoSyncErrorAdmin(admin.ModelAdmin): + list_display = [ + "created_at", + "user", + "action", + "data", + ] + raw_id_fields = [ + "user", + ] + search_fields = [ + "user.email", + "user.username", + ] + list_filter = ("action",) diff --git a/server/vbv_lernwelt/sso/migrations/0001_initial.py b/server/vbv_lernwelt/sso/migrations/0001_initial.py new file mode 100644 index 00000000..687c6a17 --- /dev/null +++ b/server/vbv_lernwelt/sso/migrations/0001_initial.py @@ -0,0 +1,52 @@ +# Generated by Django 3.2.25 on 2024-06-20 05:24 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="SsoSyncError", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "action", + models.CharField( + choices=[ + ("ADD", "Add"), + ("REMOVE", "Remove"), + ("CREATE", "Create"), + ], + default="ADD", + max_length=255, + ), + ), + ("data", models.JSONField(blank=True, default=dict)), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + ] diff --git a/server/vbv_lernwelt/sso/models.py b/server/vbv_lernwelt/sso/models.py index 4b40cfae..c69a647e 100644 --- a/server/vbv_lernwelt/sso/models.py +++ b/server/vbv_lernwelt/sso/models.py @@ -3,6 +3,11 @@ from django.db import models from vbv_lernwelt.core.models import User +class SsoUser(User): + class Meta: + proxy = True + + class SsoSyncError(models.Model): created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) diff --git a/server/vbv_lernwelt/sso/signals.py b/server/vbv_lernwelt/sso/signals.py index 342b7554..2d3a6aa8 100644 --- a/server/vbv_lernwelt/sso/signals.py +++ b/server/vbv_lernwelt/sso/signals.py @@ -1,15 +1,12 @@ from django.db.models.signals import post_delete, pre_save from django.dispatch import receiver -from vbv_lernwelt.core.signals import create_sso_user_signal, sync_sso_roles_signal from vbv_lernwelt.course.models import CourseSessionUser from vbv_lernwelt.sso.exceptions import MyVbvKeycloakDeleteError, MyVbvKeycloakPostError from vbv_lernwelt.sso.models import SsoSyncError from vbv_lernwelt.sso.role_sync.services import ( add_roles_to_user, - create_user, remove_roles_from_user, - sync_roles_for_user, update_roles_for_user, ) @@ -56,34 +53,3 @@ def update_sso_roles(sender, instance: CourseSessionUser, **kwargs): SsoSyncError.objects.create( user=instance.user, action=SsoSyncError.Action.ADD, data=additional_data ) - - -@receiver(sync_sso_roles_signal, dispatch_uid="sync_sso_roles") -def sync_sso_roles(sender, user, **kwargs): - course_roles = [ - (csu.course_session.course.slug, csu.role) - for csu in CourseSessionUser.objects.filter(user=user) - ] - try: - sync_roles_for_user(user, course_roles) - except MyVbvKeycloakDeleteError as e: - additional_data = getattr(e, "additional_data", {}) - SsoSyncError.objects.create( - user=user, action=SsoSyncError.Action.REMOVE, data=additional_data - ) - except MyVbvKeycloakPostError as e: - additional_data = getattr(e, "additional_data", {}) - SsoSyncError.objects.create( - user=user, action=SsoSyncError.Action.ADD, data=additional_data - ) - - -@receiver(create_sso_user_signal, dispatch_uid="create_sso_user") -def create_sso_user(sender, user, **kwargs): - try: - create_user(user) - except MyVbvKeycloakPostError as e: - additional_data = getattr(e, "additional_data", {}) - SsoSyncError.objects.create( - user=user, action=SsoSyncError.Action.CREATE, data=additional_data - ) From 6f71fc2fd70cf777e3c5e376441915628d49d8c8 Mon Sep 17 00:00:00 2001 From: Christian Cueni Date: Mon, 24 Jun 2024 13:11:48 +0200 Subject: [PATCH 08/23] Don't delete non myvbv roles --- server/vbv_lernwelt/sso/role_sync/services.py | 8 +++++++- server/vbv_lernwelt/sso/tests/test_role_sync.py | 16 +++++++++++++--- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/server/vbv_lernwelt/sso/role_sync/services.py b/server/vbv_lernwelt/sso/role_sync/services.py index 671cee73..e47169c6 100644 --- a/server/vbv_lernwelt/sso/role_sync/services.py +++ b/server/vbv_lernwelt/sso/role_sync/services.py @@ -84,7 +84,9 @@ def sync_roles_for_user(user: User, course_roles: CourseRolesType): if settings.OAUTH_SYNC_ROLES: user_id = user.additional_json_data.get("intermediate_sso_id", "") if user_id: - assigned_roles = keycloak_admin.get_realm_roles_of_user(user_id=user_id) + assigned_roles = _filter_non_myvbv_roles( + keycloak_admin.get_realm_roles_of_user(user_id=user_id) + ) if assigned_roles: keycloak_admin.delete_realm_roles_of_user( user_id=user_id, @@ -145,3 +147,7 @@ def _create_role_name(course_slug: str, role: str) -> List[str]: def _remove_accents(input_str) -> str: nfkd_form = unicodedata.normalize("NFKD", input_str) return "".join([char for char in nfkd_form if not unicodedata.combining(char)]) + + +def _filter_non_myvbv_roles(roles: List[str]) -> List[str]: + return [role for role in roles if role["name"].startswith("myvbv-")] diff --git a/server/vbv_lernwelt/sso/tests/test_role_sync.py b/server/vbv_lernwelt/sso/tests/test_role_sync.py index be9ad871..50ce4583 100644 --- a/server/vbv_lernwelt/sso/tests/test_role_sync.py +++ b/server/vbv_lernwelt/sso/tests/test_role_sync.py @@ -6,6 +6,7 @@ from keycloak.exceptions import KeycloakDeleteError, KeycloakPostError from vbv_lernwelt.core.models import User from vbv_lernwelt.sso.exceptions import MyVbvKeycloakDeleteError, MyVbvKeycloakPostError from vbv_lernwelt.sso.role_sync.services import ( + _filter_non_myvbv_roles, _remove_accents, add_roles_to_user, create_user, @@ -152,6 +153,15 @@ class HelpersTestCase(TestCase): no_accents = _remove_accents("äüöéèà") self.assertEqual(no_accents, "auoeea") - -# test helpers -# test wrong key + def test_filter_non_myvbv_roles(self): + roles = [ + {"name": "myvbv-uberbetriebliche-kurse-expert"}, + {"name": "myvbv-versicherungsvermittler-in-member"}, + {"name": "other-role"}, + ] + filtered_roles = [ + {"name": "myvbv-uberbetriebliche-kurse-expert"}, + {"name": "myvbv-versicherungsvermittler-in-member"}, + ] + result = _filter_non_myvbv_roles(roles) + self.assertEqual(result, filtered_roles) From cb9d5de9a61eb8ef3fff632f5a722e8ea1c6fe6b Mon Sep 17 00:00:00 2001 From: Christian Cueni Date: Mon, 24 Jun 2024 14:47:49 +0200 Subject: [PATCH 09/23] Handle creation and removal of course session group objects --- server/vbv_lernwelt/sso/apps.py | 7 ++ server/vbv_lernwelt/sso/role_sync/services.py | 1 + server/vbv_lernwelt/sso/signals.py | 114 +++++++++++++----- 3 files changed, 92 insertions(+), 30 deletions(-) diff --git a/server/vbv_lernwelt/sso/apps.py b/server/vbv_lernwelt/sso/apps.py index dac416ca..f93f5c57 100644 --- a/server/vbv_lernwelt/sso/apps.py +++ b/server/vbv_lernwelt/sso/apps.py @@ -4,3 +4,10 @@ from django.apps import AppConfig class SsoConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "vbv_lernwelt.sso" + + def ready(self): + try: + # pylint: disable=unused-import,import-outside-toplevel + import vbv_lernwelt.sso.signals # noqa F401 + except ImportError: + pass diff --git a/server/vbv_lernwelt/sso/role_sync/services.py b/server/vbv_lernwelt/sso/role_sync/services.py index e47169c6..fb8f93fd 100644 --- a/server/vbv_lernwelt/sso/role_sync/services.py +++ b/server/vbv_lernwelt/sso/role_sync/services.py @@ -136,6 +136,7 @@ def _get_role_request_data(course_roles: CourseRolesType) -> List[Dict[str, str] "Role or course not found in SSO_ROLES", course_slug=course_slug, role=role, + label="role_sync", ) return request_roles diff --git a/server/vbv_lernwelt/sso/signals.py b/server/vbv_lernwelt/sso/signals.py index 2d3a6aa8..cf90770f 100644 --- a/server/vbv_lernwelt/sso/signals.py +++ b/server/vbv_lernwelt/sso/signals.py @@ -1,7 +1,10 @@ -from django.db.models.signals import post_delete, pre_save +import structlog +from django.db.models.signals import m2m_changed, post_delete, pre_save from django.dispatch import receiver +from vbv_lernwelt.core.models import User from vbv_lernwelt.course.models import CourseSessionUser +from vbv_lernwelt.course_session_group.models import CourseSessionGroup from vbv_lernwelt.sso.exceptions import MyVbvKeycloakDeleteError, MyVbvKeycloakPostError from vbv_lernwelt.sso.models import SsoSyncError from vbv_lernwelt.sso.role_sync.services import ( @@ -10,30 +13,22 @@ from vbv_lernwelt.sso.role_sync.services import ( update_roles_for_user, ) - -@receiver(post_delete, sender=CourseSessionUser, dispatch_uid="delete_sso_roles") -def remove_sso_roles(sender, instance, **kwargs): - try: - remove_roles_from_user( - instance.user, [(instance.course_session.course.slug, instance.role)] - ) - except MyVbvKeycloakDeleteError as e: - additional_data = getattr(e, "additional_data", {}) - SsoSyncError.objects.create( - user=instance.user, action=SsoSyncError.Action.REMOVE, data=additional_data - ) +logger = structlog.get_logger(__name__) -@receiver(pre_save, sender=CourseSessionUser, dispatch_uid="update_sso_roles") -def update_sso_roles(sender, instance: CourseSessionUser, **kwargs): - try: - if instance.created_at is None: - add_roles_to_user( - instance.user, [(instance.course_session.course.slug, instance.role)] - ) - else: - old_csu = CourseSessionUser.objects.get(pk=instance.pk) - if old_csu.role != instance.role: +@receiver(post_delete, sender=CourseSessionUser, dispatch_uid="delete_sso_roles_in_cs") +def remove_sso_roles_in_cs(sender, instance, **kwargs): + _remove_sso_role(instance.user, instance.course_session.course.slug, instance.role) + + +@receiver(pre_save, sender=CourseSessionUser, dispatch_uid="update_sso_roles_in_cs") +def update_sso_roles_in_cs(sender, instance: CourseSessionUser, **kwargs): + if not instance.created_at: + _add_sso_role(instance.user, instance.course_session.course.slug, instance.role) + else: + old_csu = CourseSessionUser.objects.get(pk=instance.pk) + if old_csu.role != instance.role: + try: update_roles_for_user( instance.user, add_course_roles=[ @@ -43,13 +38,72 @@ def update_sso_roles(sender, instance: CourseSessionUser, **kwargs): (instance.course_session.course.slug, old_csu.role) ], ) + except MyVbvKeycloakDeleteError as e: + _handle_remove_exception(instance.user, e) + except MyVbvKeycloakPostError as e: + _handle_add_exception(instance.user, e) + + +@receiver( + post_delete, sender=CourseSessionGroup, dispatch_uid="delete_sso_roles_in_csg" +) +def remove_sso_roles_in_csg(sender, instance: CourseSessionGroup, **kwargs): + for user in instance.supervisor.all(): + _remove_sso_role(user, instance.course.slug, "SUPERVISOR") + + +@receiver( + m2m_changed, + sender=CourseSessionGroup.supervisor.through, + dispatch_uid="update_sso_roles_in_csg", +) +def update_sso_roles_in_csg(sender, instance, action, reverse, model, pk_set, **kwargs): + if action == "pre_add": + added_supervisors = model.objects.filter(pk__in=pk_set) + for user in added_supervisors: + _add_sso_role(user, instance.course.slug, "SUPERVISOR") + + elif action == "pre_remove": + removed_supervisors = model.objects.filter(pk__in=pk_set) + for user in removed_supervisors: + _remove_sso_role(user, instance.course.slug, "SUPERVISOR") + + +def _remove_sso_role(user: User, course_slug: str, role: str): + try: + logger.debug( + "Removing SUPERVISOR role from user", + user=user, + course=course_slug, + label="role_sync", + ) + remove_roles_from_user(user, [(course_slug, role)]) except MyVbvKeycloakDeleteError as e: - additional_data = getattr(e, "additional_data", {}) - SsoSyncError.objects.create( - user=instance.user, action=SsoSyncError.Action.REMOVE, data=additional_data + _handle_remove_exception(user, e) + + +def _add_sso_role(user: User, course_slug: str, role: str): + try: + logger.debug( + "Adding SUPERVISOR role to user", + user=user, + course=course_slug, + label="role_sync", ) + add_roles_to_user(user, [(course_slug, role)]) except MyVbvKeycloakPostError as e: - additional_data = getattr(e, "additional_data", {}) - SsoSyncError.objects.create( - user=instance.user, action=SsoSyncError.Action.ADD, data=additional_data - ) + _handle_add_exception(user, e) + + +def _handle_add_exception(user: User, e: MyVbvKeycloakPostError): + additional_data = getattr(e, "additional_data", {}) + SsoSyncError.objects.create( + user=user, action=SsoSyncError.Action.ADD, data=additional_data + ) + + +def _handle_remove_exception(user: User, e: MyVbvKeycloakDeleteError): + additional_data = getattr(e, "additional_data", {}) + SsoSyncError.objects.create( + user=user, action=SsoSyncError.Action.REMOVE, data=additional_data + ) From fbd40de91812fe4be206dd1c0265b641e6297f62 Mon Sep 17 00:00:00 2001 From: Christian Cueni Date: Mon, 24 Jun 2024 16:01:16 +0200 Subject: [PATCH 10/23] Refactor code --- server/vbv_lernwelt/sso/admin.py | 26 ++--- server/vbv_lernwelt/sso/exceptions.py | 23 ----- server/vbv_lernwelt/sso/role_sync/services.py | 95 +++++++++++-------- server/vbv_lernwelt/sso/signals.py | 34 ++----- .../vbv_lernwelt/sso/tests/test_role_sync.py | 19 ++-- 5 files changed, 84 insertions(+), 113 deletions(-) delete mode 100644 server/vbv_lernwelt/sso/exceptions.py diff --git a/server/vbv_lernwelt/sso/admin.py b/server/vbv_lernwelt/sso/admin.py index 274c49f2..8651a45d 100644 --- a/server/vbv_lernwelt/sso/admin.py +++ b/server/vbv_lernwelt/sso/admin.py @@ -1,10 +1,10 @@ from django.contrib import admin, messages from django.contrib.auth import admin as auth_admin, get_user_model from django.utils.translation import gettext_lazy as _ +from keycloak.exceptions import KeycloakDeleteError, KeycloakPostError from vbv_lernwelt.course.models import CourseSessionUser from vbv_lernwelt.importer.services import update_user_json_data -from vbv_lernwelt.sso.exceptions import MyVbvKeycloakDeleteError, MyVbvKeycloakPostError from vbv_lernwelt.sso.models import SsoSyncError, SsoUser from vbv_lernwelt.sso.role_sync.services import create_user, sync_roles_for_user @@ -19,15 +19,11 @@ def create_sso_user_from_admin(user: User, request): messages.add_message( request, messages.SUCCESS, f"Der Bentuzer wurde in Keycloak erstellt." ) - except MyVbvKeycloakPostError as e: - additional_data = getattr(e, "additional_data", {}) - SsoSyncError.objects.create( - user=user, action=SsoSyncError.Action.CREATE, data=additional_data - ) + except KeycloakPostError as e: messages.add_message( request, messages.WARNING, - f"Der Benutzer ({e}) konnte nicht in Keycloak erstellt werden.", + f"Der Benutzer {user} konnte nicht in Keycloak erstellt werden: {e}", ) @@ -41,25 +37,17 @@ def sync_sso_roles_from_admin(user: User, request): messages.add_message( request, messages.SUCCESS, f"Die Daten wurden mit Keycloak synchronisiert." ) - except MyVbvKeycloakDeleteError as e: - additional_data = getattr(e, "additional_data", {}) - SsoSyncError.objects.create( - user=user, action=SsoSyncError.Action.REMOVE, data=additional_data - ) + except KeycloakDeleteError as e: messages.add_message( request, messages.WARNING, - f"Die bestehenden Rollen für Benutzer ({e}) konnten in Keycloak nicht gelöscht werden.", - ) - except MyVbvKeycloakPostError as e: - additional_data = getattr(e, "additional_data", {}) - SsoSyncError.objects.create( - user=user, action=SsoSyncError.Action.ADD, data=additional_data + f"Die bestehenden Rollen für Benutzer ({user}) konnten in Keycloak nicht gelöscht werden: {e}", ) + except KeycloakPostError as e: messages.add_message( request, messages.WARNING, - f"Die neuen Rollen für Benutzer ({e}) konnten in Keycloak nicht erstellt werden.", + f"Die neuen Rollen für Benutzer ({user}) konnten in Keycloak nicht erstellt werden: {e}", ) diff --git a/server/vbv_lernwelt/sso/exceptions.py b/server/vbv_lernwelt/sso/exceptions.py deleted file mode 100644 index 2d6a3177..00000000 --- a/server/vbv_lernwelt/sso/exceptions.py +++ /dev/null @@ -1,23 +0,0 @@ -from keycloak.exceptions import KeycloakDeleteError, KeycloakPostError - - -class MyVbvKeycloakDeleteError(KeycloakDeleteError): - def __init__( - self, keycloak_error: KeycloakDeleteError, additional_data: list | dict - ): - super().__init__( - keycloak_error.error_message, - keycloak_error.response_code, - keycloak_error.response_body, - ) - self.additional_data = additional_data - - -class MyVbvKeycloakPostError(KeycloakPostError): - def __init__(self, keycloak_error: KeycloakPostError, additional_data: list | dict): - super().__init__( - keycloak_error.error_message, - keycloak_error.response_code, - keycloak_error.response_body, - ) - self.additional_data = additional_data diff --git a/server/vbv_lernwelt/sso/role_sync/services.py b/server/vbv_lernwelt/sso/role_sync/services.py index fb8f93fd..0e1d23cc 100644 --- a/server/vbv_lernwelt/sso/role_sync/services.py +++ b/server/vbv_lernwelt/sso/role_sync/services.py @@ -4,15 +4,16 @@ from typing import Dict, List, Tuple import structlog from django.conf import settings from keycloak import KeycloakAdmin, KeycloakOpenIDConnection -from keycloak.exceptions import KeycloakDeleteError, KeycloakError, KeycloakPostError +from keycloak.exceptions import KeycloakDeleteError, KeycloakPostError from vbv_lernwelt.core.models import User -from vbv_lernwelt.sso.exceptions import MyVbvKeycloakDeleteError, MyVbvKeycloakPostError +from vbv_lernwelt.sso.models import SsoSyncError from vbv_lernwelt.sso.role_sync.roles import ROLE_IDS, SSO_ROLES logger = structlog.get_logger(__name__) CourseRolesType = List[Tuple[str, str]] +KeyCloakRolesType = List[Dict[str, str]] if settings.OAUTH_SYNC_ROLES: keycloak_connection = KeycloakOpenIDConnection( @@ -29,43 +30,32 @@ if settings.OAUTH_SYNC_ROLES: def add_roles_to_user(user: User, course_roles: CourseRolesType): return _handle_add_remove_action( - user=user, - course_roles=course_roles, - func=keycloak_admin.assign_realm_roles, - kc_exception=KeycloakPostError, - myvbv_exception=MyVbvKeycloakPostError, + user=user, course_roles=course_roles, action=SsoSyncError.Action.ADD ) def remove_roles_from_user(user: User, course_roles: CourseRolesType): return _handle_add_remove_action( - user=user, - course_roles=course_roles, - func=keycloak_admin.delete_realm_roles_of_user, - kc_exception=KeycloakDeleteError, - myvbv_exception=MyVbvKeycloakDeleteError, + user=user, course_roles=course_roles, action=SsoSyncError.Action.REMOVE ) def _handle_add_remove_action( user: User, course_roles: CourseRolesType, - func: callable, - kc_exception: KeycloakError, - myvbv_exception: KeycloakPostError or KeycloakDeleteError, + action: SsoSyncError.Action, ): user_id = user.additional_json_data.get("intermediate_sso_id", "") if settings.OAUTH_SYNC_ROLES and user_id: request_roles = _get_role_request_data(course_roles) if not request_roles: return False - try: - func( - user_id=user_id, - roles=request_roles, - ) - except kc_exception as e: - raise myvbv_exception(e, request_roles) + + if action == SsoSyncError.Action.ADD: + _kc_assign_realm_roles(user, user_id, request_roles) + elif action == SsoSyncError.Action.REMOVE: + _kc_delete_realm_roles(user, user_id, request_roles) + return True return False @@ -84,14 +74,14 @@ def sync_roles_for_user(user: User, course_roles: CourseRolesType): if settings.OAUTH_SYNC_ROLES: user_id = user.additional_json_data.get("intermediate_sso_id", "") if user_id: + # ignore for the moment, just in the admin assigned_roles = _filter_non_myvbv_roles( keycloak_admin.get_realm_roles_of_user(user_id=user_id) ) + if assigned_roles: - keycloak_admin.delete_realm_roles_of_user( - user_id=user_id, - roles=assigned_roles, - ) + _kc_delete_realm_roles(user, user_id, assigned_roles) + roles = _get_role_request_data(course_roles) keycloak_admin.assign_realm_roles(user_id=user_id, roles=roles) return True @@ -100,18 +90,7 @@ def sync_roles_for_user(user: User, course_roles: CourseRolesType): def create_user(user: User): if settings.OAUTH_SYNC_ROLES: - user_data = { - "username": user.email, - "email": user.email, - "enabled": True, - "firstName": user.first_name, - "lastName": user.last_name, - } - try: - user_id = keycloak_admin.create_user(user_data, exist_ok=True) - except KeycloakPostError as e: - raise MyVbvKeycloakPostError(e, user_data) - return user_id + return _kc_create_user(user) return "" @@ -123,7 +102,43 @@ def get_roles_for_user(user_id: str): return [] -def _get_role_request_data(course_roles: CourseRolesType) -> List[Dict[str, str]]: +# Keycloak wrappers +def _kc_assign_realm_roles(user: User, user_id: str, roles: List[KeyCloakRolesType]): + try: + keycloak_admin.assign_realm_roles(user_id=user_id, roles=roles) + except KeycloakPostError as e: + SsoSyncError.objects.create( + user=user, action=SsoSyncError.Action.ADD, data=roles + ) + raise e + + +def _kc_delete_realm_roles(user: User, user_id: str, roles: List[KeyCloakRolesType]): + try: + keycloak_admin.delete_realm_roles_of_user(user_id=user_id, roles=roles) + except KeycloakDeleteError as e: + SsoSyncError.objects.create( + user=user, action=SsoSyncError.Action.REMOVE, data=roles + ) + raise e + + +def _kc_create_user(user: User) -> str: + user_data = { + "username": user.email, + "email": user.email, + "enabled": True, + "firstName": user.first_name, + "lastName": user.last_name, + } + try: + return keycloak_admin.create_user(user_data, exist_ok=True) + except KeycloakPostError as e: + SsoSyncError.objects.create(user=user, action=SsoSyncError.Action.ADD, data={}) + raise e + + +def _get_role_request_data(course_roles: CourseRolesType) -> List[KeyCloakRolesType]: request_roles = [] for item in course_roles: course_slug, role = item @@ -150,5 +165,5 @@ def _remove_accents(input_str) -> str: return "".join([char for char in nfkd_form if not unicodedata.combining(char)]) -def _filter_non_myvbv_roles(roles: List[str]) -> List[str]: +def _filter_non_myvbv_roles(roles: List[KeyCloakRolesType]) -> List[KeyCloakRolesType]: return [role for role in roles if role["name"].startswith("myvbv-")] diff --git a/server/vbv_lernwelt/sso/signals.py b/server/vbv_lernwelt/sso/signals.py index cf90770f..13af305e 100644 --- a/server/vbv_lernwelt/sso/signals.py +++ b/server/vbv_lernwelt/sso/signals.py @@ -1,12 +1,11 @@ import structlog from django.db.models.signals import m2m_changed, post_delete, pre_save from django.dispatch import receiver +from keycloak.exceptions import KeycloakDeleteError, KeycloakError, KeycloakPostError from vbv_lernwelt.core.models import User from vbv_lernwelt.course.models import CourseSessionUser from vbv_lernwelt.course_session_group.models import CourseSessionGroup -from vbv_lernwelt.sso.exceptions import MyVbvKeycloakDeleteError, MyVbvKeycloakPostError -from vbv_lernwelt.sso.models import SsoSyncError from vbv_lernwelt.sso.role_sync.services import ( add_roles_to_user, remove_roles_from_user, @@ -38,10 +37,9 @@ def update_sso_roles_in_cs(sender, instance: CourseSessionUser, **kwargs): (instance.course_session.course.slug, old_csu.role) ], ) - except MyVbvKeycloakDeleteError as e: - _handle_remove_exception(instance.user, e) - except MyVbvKeycloakPostError as e: - _handle_add_exception(instance.user, e) + except KeycloakError: + # fail silently, error object is being created in the service + pass @receiver( @@ -78,8 +76,9 @@ def _remove_sso_role(user: User, course_slug: str, role: str): label="role_sync", ) remove_roles_from_user(user, [(course_slug, role)]) - except MyVbvKeycloakDeleteError as e: - _handle_remove_exception(user, e) + except KeycloakDeleteError: + # fail silently, error object is being created in the service + pass def _add_sso_role(user: User, course_slug: str, role: str): @@ -91,19 +90,6 @@ def _add_sso_role(user: User, course_slug: str, role: str): label="role_sync", ) add_roles_to_user(user, [(course_slug, role)]) - except MyVbvKeycloakPostError as e: - _handle_add_exception(user, e) - - -def _handle_add_exception(user: User, e: MyVbvKeycloakPostError): - additional_data = getattr(e, "additional_data", {}) - SsoSyncError.objects.create( - user=user, action=SsoSyncError.Action.ADD, data=additional_data - ) - - -def _handle_remove_exception(user: User, e: MyVbvKeycloakDeleteError): - additional_data = getattr(e, "additional_data", {}) - SsoSyncError.objects.create( - user=user, action=SsoSyncError.Action.REMOVE, data=additional_data - ) + except KeycloakPostError: + # fail silently, error object is being created in the service + pass diff --git a/server/vbv_lernwelt/sso/tests/test_role_sync.py b/server/vbv_lernwelt/sso/tests/test_role_sync.py index 50ce4583..c1372b75 100644 --- a/server/vbv_lernwelt/sso/tests/test_role_sync.py +++ b/server/vbv_lernwelt/sso/tests/test_role_sync.py @@ -4,7 +4,7 @@ from django.test import override_settings, TestCase from keycloak.exceptions import KeycloakDeleteError, KeycloakPostError from vbv_lernwelt.core.models import User -from vbv_lernwelt.sso.exceptions import MyVbvKeycloakDeleteError, MyVbvKeycloakPostError +from vbv_lernwelt.sso.models import SsoSyncError from vbv_lernwelt.sso.role_sync.services import ( _filter_non_myvbv_roles, _remove_accents, @@ -20,6 +20,7 @@ class ApiTestCase(TestCase): def setUp(self): self.user = User(email="test@example.com", first_name="Test", last_name="User") self.user.additional_json_data = {"intermediate_sso_id": "1234"} + self.user.save() self.course_roles = [ ("überbetriebliche-kurse", "EXPERT"), ("versicherungsvermittler-in", "MEMBER"), @@ -52,12 +53,14 @@ class ApiTestCase(TestCase): def test_add_roles_to_user_keycloak_post_error(self, mock_keycloak_admin): mock_keycloak_admin.assign_realm_roles.side_effect = KeycloakPostError - with self.assertRaises(MyVbvKeycloakPostError) as cm: + with self.assertRaises(KeycloakPostError) as cm: add_roles_to_user(self.user, self.course_roles) exception = cm.exception - self.assertIsInstance(exception, MyVbvKeycloakPostError) - self.assertEqual(exception.additional_data, self.expected_roles) + self.assertIsInstance(exception, KeycloakPostError) + error_obj = SsoSyncError.objects.get(user=self.user) + self.assertEqual(error_obj.data, self.expected_roles) + self.assertEqual(error_obj.action, SsoSyncError.Action.ADD) @override_settings(OAUTH_SYNC_ROLES=True) @patch("vbv_lernwelt.sso.role_sync.services.keycloak_admin") @@ -76,12 +79,14 @@ class ApiTestCase(TestCase): def test_remove_roles_to_user_keycloak_delete_error(self, mock_keycloak_admin): mock_keycloak_admin.delete_realm_roles_of_user.side_effect = KeycloakDeleteError - with self.assertRaises(MyVbvKeycloakDeleteError) as cm: + with self.assertRaises(KeycloakDeleteError) as cm: remove_roles_from_user(self.user, self.course_roles) exception = cm.exception - self.assertIsInstance(exception, MyVbvKeycloakDeleteError) - self.assertEqual(exception.additional_data, self.expected_roles) + self.assertIsInstance(exception, KeycloakDeleteError) + error_obj = SsoSyncError.objects.get(user=self.user) + self.assertEqual(error_obj.data, self.expected_roles) + self.assertEqual(error_obj.action, SsoSyncError.Action.REMOVE) @override_settings(OAUTH_SYNC_ROLES=True) @patch("vbv_lernwelt.sso.role_sync.services.remove_roles_from_user") From e6eae79171404de52884c1fc9342fecf8345d2cf Mon Sep 17 00:00:00 2001 From: Christian Cueni Date: Tue, 25 Jun 2024 08:40:58 +0200 Subject: [PATCH 11/23] Refactor json data handling --- server/vbv_lernwelt/core/models.py | 14 ++++++ server/vbv_lernwelt/core/tests/test_utils.py | 32 +++++++++++++ server/vbv_lernwelt/core/utils.py | 19 ++++++++ server/vbv_lernwelt/importer/services.py | 45 +++++-------------- .../importer/tests/test_t2l_sync.py | 31 ------------- server/vbv_lernwelt/sso/admin.py | 9 ++-- server/vbv_lernwelt/sso/role_sync/services.py | 7 +++ server/vbv_lernwelt/sso/signals.py | 2 + 8 files changed, 91 insertions(+), 68 deletions(-) diff --git a/server/vbv_lernwelt/core/models.py b/server/vbv_lernwelt/core/models.py index 781affb5..d92a1396 100644 --- a/server/vbv_lernwelt/core/models.py +++ b/server/vbv_lernwelt/core/models.py @@ -1,4 +1,5 @@ import uuid +from typing import Any, Dict import structlog from django.contrib.auth.models import AbstractUser @@ -6,6 +7,8 @@ from django.db import models from django.db.models import JSONField, Max from django.urls import reverse +from vbv_lernwelt.core.utils import sanitize_json_data_input + logger = structlog.get_logger(__name__) @@ -140,6 +143,17 @@ class User(AbstractUser): logger.warn("could not create avatar url", label="security", exc_info=True) return "/static/avatars/myvbv-default-avatar.png" + def update_additional_json_data(self, data: Dict[str, Any]): + # Set E-Mail notification settings for new users + self.additional_json_data = ( + self.additional_json_data + | sanitize_json_data_input( + { + **data, + } + ) + ) + @property def avatar_url(self): return self.create_avatar_url() diff --git a/server/vbv_lernwelt/core/tests/test_utils.py b/server/vbv_lernwelt/core/tests/test_utils.py index ff47f2e9..0f08fd7a 100644 --- a/server/vbv_lernwelt/core/tests/test_utils.py +++ b/server/vbv_lernwelt/core/tests/test_utils.py @@ -1,7 +1,10 @@ +from datetime import date, datetime, time from unittest import skip from django.test import TestCase +from vbv_lernwelt.core.utils import sanitize_json_data_input + class SimpleTestCase(TestCase): def test_simple(self): @@ -10,3 +13,32 @@ class SimpleTestCase(TestCase): @skip("Do not fail in pipelines") def test_fail(self): self.assertEqual(1, 2) + + +class SanitizerTestCase(TestCase): + def test_date(self): + a_date = date(2021, 1, 1) + user_dict = {"Name": "Rascher", "Datum": a_date} + + expected_sanitized_data = {"Name": "Rascher", "Datum": a_date.isoformat()} + + sanitized_data = sanitize_json_data_input(user_dict) + self.assertEqual(sanitized_data, expected_sanitized_data) + + def test_datetime(self): + a_date = datetime(2021, 1, 1) + user_dict = {"Name": "Rascher", "Datum": a_date} + + expected_sanitized_data = {"Name": "Rascher", "Datum": a_date.isoformat()} + + sanitized_data = sanitize_json_data_input(user_dict) + self.assertEqual(sanitized_data, expected_sanitized_data) + + def test_time(self): + a_date = time(23, 59, 59) + user_dict = {"Name": "Rascher", "Datum": a_date} + + expected_sanitized_data = {"Name": "Rascher", "Datum": a_date.isoformat()} + + sanitized_data = sanitize_json_data_input(user_dict) + self.assertEqual(sanitized_data, expected_sanitized_data) diff --git a/server/vbv_lernwelt/core/utils.py b/server/vbv_lernwelt/core/utils.py index 2b153233..6ac2dc23 100644 --- a/server/vbv_lernwelt/core/utils.py +++ b/server/vbv_lernwelt/core/utils.py @@ -1,5 +1,7 @@ import json import re +from datetime import date, datetime, time +from typing import Any, Dict from django.utils.safestring import mark_safe from rest_framework import serializers @@ -65,3 +67,20 @@ def safe_deque_popleft(deq, default=None): return deq.popleft() except IndexError: return default + + +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/services.py b/server/vbv_lernwelt/importer/services.py index 6c390e0d..298ae7b3 100644 --- a/server/vbv_lernwelt/importer/services.py +++ b/server/vbv_lernwelt/importer/services.py @@ -1,4 +1,4 @@ -from datetime import date, datetime, time +from datetime import date, datetime from typing import Any, Dict, List import structlog @@ -25,7 +25,7 @@ from vbv_lernwelt.learnpath.models import ( LearningContentEdoniqTest, ) from vbv_lernwelt.notify.models import NotificationCategory -from vbv_lernwelt.sso.role_sync.services import create_user +from vbv_lernwelt.sso.role_sync.services import create_and_update_user, create_user logger = structlog.get_logger(__name__) @@ -540,8 +540,8 @@ def create_or_update_user( user.last_name = last_name or user.last_name user.username = email - sso_data = {"intermediate_sso_id": intermediate_sso_id} - update_user_json_data(user, sso_data) + user.update_additional_json_data({"intermediate_sso_id": intermediate_sso_id}) + init_notification_settings(user) user.set_unusable_password() user.save() @@ -843,9 +843,8 @@ def create_or_update_trainer(course: Course, data: Dict[str, Any], language="de" user.language = data["Sprache"] # create user in intermediate sso i.e. Keycloak - sso_data = {"intermediate_sso_id": create_user(user)} - update_user_json_data(user, sso_data) - + create_and_update_user(user) + init_notification_settings(user) user.save() group = data["Klasse"].strip() @@ -1004,32 +1003,12 @@ def sync_students_from_t2l(data): except KeyError: pass - update_user_json_data(user, data) + user.update_additional_json_data(data) user.save() -def update_user_json_data(user: User, data: Dict[str, Any]): - # Set E-Mail notification settings for new users - user.additional_json_data = user.additional_json_data | sanitize_json_data_input( - { - **data, - "email_notification_categories": [str(NotificationCategory.INFORMATION)], - } - ) - - -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 +def init_notification_settings(user: User): + data = { + "email_notification_categories": [str(NotificationCategory.INFORMATION)], + } + user.update_additional_json_data(data) diff --git a/server/vbv_lernwelt/importer/tests/test_t2l_sync.py b/server/vbv_lernwelt/importer/tests/test_t2l_sync.py index c800a52e..3392e022 100644 --- a/server/vbv_lernwelt/importer/tests/test_t2l_sync.py +++ b/server/vbv_lernwelt/importer/tests/test_t2l_sync.py @@ -1,5 +1,4 @@ import os -from datetime import date, datetime, time from django.test import TestCase @@ -7,7 +6,6 @@ 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, - sanitize_json_data_input, sync_students_from_t2l, ) @@ -151,32 +149,3 @@ class SyncT2lTestCase(TestCase): self.fail( f"SyncT2lTestCase.test_ignors_wrong_contract_number: An exception was unexpectedly raised: {str(e)}" ) - - -class SanitizerTestCase(TestCase): - def test_date(self): - a_date = date(2021, 1, 1) - user_dict = {"Name": "Rascher", "Datum": a_date} - - expected_sanitized_data = {"Name": "Rascher", "Datum": a_date.isoformat()} - - sanitized_data = sanitize_json_data_input(user_dict) - self.assertEqual(sanitized_data, expected_sanitized_data) - - def test_datetime(self): - a_date = datetime(2021, 1, 1) - user_dict = {"Name": "Rascher", "Datum": a_date} - - expected_sanitized_data = {"Name": "Rascher", "Datum": a_date.isoformat()} - - sanitized_data = sanitize_json_data_input(user_dict) - self.assertEqual(sanitized_data, expected_sanitized_data) - - def test_time(self): - a_date = time(23, 59, 59) - user_dict = {"Name": "Rascher", "Datum": a_date} - - expected_sanitized_data = {"Name": "Rascher", "Datum": a_date.isoformat()} - - sanitized_data = sanitize_json_data_input(user_dict) - self.assertEqual(sanitized_data, expected_sanitized_data) diff --git a/server/vbv_lernwelt/sso/admin.py b/server/vbv_lernwelt/sso/admin.py index 8651a45d..f843d6a6 100644 --- a/server/vbv_lernwelt/sso/admin.py +++ b/server/vbv_lernwelt/sso/admin.py @@ -4,17 +4,18 @@ from django.utils.translation import gettext_lazy as _ from keycloak.exceptions import KeycloakDeleteError, KeycloakPostError from vbv_lernwelt.course.models import CourseSessionUser -from vbv_lernwelt.importer.services import update_user_json_data from vbv_lernwelt.sso.models import SsoSyncError, SsoUser -from vbv_lernwelt.sso.role_sync.services import create_user, sync_roles_for_user +from vbv_lernwelt.sso.role_sync.services import ( + create_and_update_user, + sync_roles_for_user, +) User = get_user_model() def create_sso_user_from_admin(user: User, request): try: - sso_data = {"intermediate_sso_id": create_user(user)} - update_user_json_data(user, sso_data) + create_and_update_user(user) # noqa user.save() messages.add_message( request, messages.SUCCESS, f"Der Bentuzer wurde in Keycloak erstellt." diff --git a/server/vbv_lernwelt/sso/role_sync/services.py b/server/vbv_lernwelt/sso/role_sync/services.py index 0e1d23cc..e5f9359d 100644 --- a/server/vbv_lernwelt/sso/role_sync/services.py +++ b/server/vbv_lernwelt/sso/role_sync/services.py @@ -94,6 +94,13 @@ def create_user(user: User): return "" +def create_and_update_user(user: User, save=False): + sso_data = {"intermediate_sso_id": create_user(user)} + user.update_additional_json_data(sso_data) + if save: + user.save() + + def get_roles_for_user(user_id: str): if settings.OAUTH_SYNC_ROLES: return keycloak_admin.get_realm_roles_of_user( diff --git a/server/vbv_lernwelt/sso/signals.py b/server/vbv_lernwelt/sso/signals.py index 13af305e..c600df09 100644 --- a/server/vbv_lernwelt/sso/signals.py +++ b/server/vbv_lernwelt/sso/signals.py @@ -15,6 +15,7 @@ from vbv_lernwelt.sso.role_sync.services import ( logger = structlog.get_logger(__name__) +# CourseSessionUser @receiver(post_delete, sender=CourseSessionUser, dispatch_uid="delete_sso_roles_in_cs") def remove_sso_roles_in_cs(sender, instance, **kwargs): _remove_sso_role(instance.user, instance.course_session.course.slug, instance.role) @@ -42,6 +43,7 @@ def update_sso_roles_in_cs(sender, instance: CourseSessionUser, **kwargs): pass +# CourseSessionGroup @receiver( post_delete, sender=CourseSessionGroup, dispatch_uid="delete_sso_roles_in_csg" ) From eb931c86c84c1e936f1c7bb660fb22e598ee9899 Mon Sep 17 00:00:00 2001 From: Christian Cueni Date: Wed, 26 Jun 2024 07:46:31 +0200 Subject: [PATCH 12/23] Add signal tests, add LearningMentor --- server/vbv_lernwelt/importer/services.py | 8 +- server/vbv_lernwelt/sso/signals.py | 34 ++- server/vbv_lernwelt/sso/tests/test_signals.py | 246 ++++++++++++++++++ 3 files changed, 278 insertions(+), 10 deletions(-) create mode 100644 server/vbv_lernwelt/sso/tests/test_signals.py diff --git a/server/vbv_lernwelt/importer/services.py b/server/vbv_lernwelt/importer/services.py index 298ae7b3..7dd59659 100644 --- a/server/vbv_lernwelt/importer/services.py +++ b/server/vbv_lernwelt/importer/services.py @@ -945,12 +945,10 @@ def create_or_update_student(data: Dict[str, Any]): ) user.language = data["Sprache"] - update_user_json_data(user, data) - user.save() - # create user in intermediate sso i.e. Keycloak - sso_data = {"intermediate_sso_id": create_user(user)} - update_user_json_data(user, sso_data) + data["intermediate_sso_id"] = create_user(user) + user.update_additional_json_data(data) + user.save() # general expert handling import_id = data["Durchführungen"] diff --git a/server/vbv_lernwelt/sso/signals.py b/server/vbv_lernwelt/sso/signals.py index c600df09..31c5dc59 100644 --- a/server/vbv_lernwelt/sso/signals.py +++ b/server/vbv_lernwelt/sso/signals.py @@ -1,11 +1,12 @@ import structlog -from django.db.models.signals import m2m_changed, post_delete, pre_save +from django.db.models.signals import m2m_changed, post_delete, pre_delete, pre_save from django.dispatch import receiver from keycloak.exceptions import KeycloakDeleteError, KeycloakError, KeycloakPostError from vbv_lernwelt.core.models import User from vbv_lernwelt.course.models import CourseSessionUser from vbv_lernwelt.course_session_group.models import CourseSessionGroup +from vbv_lernwelt.learning_mentor.models import LearningMentor from vbv_lernwelt.sso.role_sync.services import ( add_roles_to_user, remove_roles_from_user, @@ -18,7 +19,13 @@ logger = structlog.get_logger(__name__) # CourseSessionUser @receiver(post_delete, sender=CourseSessionUser, dispatch_uid="delete_sso_roles_in_cs") def remove_sso_roles_in_cs(sender, instance, **kwargs): - _remove_sso_role(instance.user, instance.course_session.course.slug, instance.role) + # check if the user has any other roles in the course + if not CourseSessionUser.objects.filter( + user=instance.user, course_session__course=instance.course_session.course + ).exists(): + _remove_sso_role( + instance.user, instance.course_session.course.slug, instance.role + ) @receiver(pre_save, sender=CourseSessionUser, dispatch_uid="update_sso_roles_in_cs") @@ -44,9 +51,7 @@ def update_sso_roles_in_cs(sender, instance: CourseSessionUser, **kwargs): # CourseSessionGroup -@receiver( - post_delete, sender=CourseSessionGroup, dispatch_uid="delete_sso_roles_in_csg" -) +@receiver(pre_delete, sender=CourseSessionGroup, dispatch_uid="delete_sso_roles_in_csg") def remove_sso_roles_in_csg(sender, instance: CourseSessionGroup, **kwargs): for user in instance.supervisor.all(): _remove_sso_role(user, instance.course.slug, "SUPERVISOR") @@ -69,6 +74,25 @@ def update_sso_roles_in_csg(sender, instance, action, reverse, model, pk_set, ** _remove_sso_role(user, instance.course.slug, "SUPERVISOR") +# LearningMentor +@receiver(post_delete, sender=LearningMentor, dispatch_uid="delete_sso_roles_in_lm") +def remove_sso_roles_in_lm(sender, instance: LearningMentor, **kwargs): + if not LearningMentor.objects.filter( + mentor=instance.mentor, course_session__course=instance.course_session.course + ).exists(): + _remove_sso_role( + instance.mentor, instance.course_session.course.slug, "LEARNING_MENTOR" + ) + + +@receiver(pre_save, sender=LearningMentor, dispatch_uid="update_sso_roles_in_lm") +def update_sso_roles_in_lm(sender, instance: LearningMentor, **kwargs): + if not instance.pk: + _add_sso_role( + instance.mentor, instance.course_session.course.slug, "LEARNING_MENTOR" + ) + + def _remove_sso_role(user: User, course_slug: str, role: str): try: logger.debug( diff --git a/server/vbv_lernwelt/sso/tests/test_signals.py b/server/vbv_lernwelt/sso/tests/test_signals.py new file mode 100644 index 00000000..7e96b2f5 --- /dev/null +++ b/server/vbv_lernwelt/sso/tests/test_signals.py @@ -0,0 +1,246 @@ +from unittest.mock import call, patch + +from django.db.models.signals import pre_save +from django.dispatch import Signal +from django.test import TestCase + +from vbv_lernwelt.core.constants import ( + TEST_COURSE_SESSION_BERN_ID, + TEST_COURSE_SESSION_ZURICH_ID, + TEST_STUDENT1_USER_ID, + TEST_STUDENT2_USER_ID, + TEST_SUPERVISOR1_USER_ID, + TEST_TRAINER1_USER_ID, +) +from vbv_lernwelt.core.create_default_users import create_default_users +from vbv_lernwelt.core.models import User +from vbv_lernwelt.course.consts import COURSE_TEST_ID +from vbv_lernwelt.course.creators.test_course import create_test_course +from vbv_lernwelt.course.creators.test_utils import ( + add_course_session_user, + create_course, + create_course_session, + create_user, +) +from vbv_lernwelt.course.models import Course, CourseSessionUser +from vbv_lernwelt.course_session_group.models import CourseSessionGroup +from vbv_lernwelt.learning_mentor.models import LearningMentor +from vbv_lernwelt.sso.signals import update_sso_roles_in_cs + + +class CourseSessionUserTests(TestCase): + def setUp(self): + create_default_users() + create_test_course(include_uk=True, with_sessions=True) + + self.student1 = User.objects.get(id=TEST_STUDENT1_USER_ID) + self.csu1_student1 = CourseSessionUser.objects.get( + user=self.student1, course_session__id=TEST_COURSE_SESSION_BERN_ID + ) + self.student2 = User.objects.get(id=TEST_STUDENT2_USER_ID) + self.csu1_student2 = CourseSessionUser.objects.get( + user=self.student2, course_session__id=TEST_COURSE_SESSION_ZURICH_ID + ) + + # Disconnect the actual signal handler to avoid side effects during testing + pre_save.disconnect(receiver=update_sso_roles_in_cs, sender=CourseSessionUser) + + # Connect a mock signal handler + self.mock_pre_save_signal = Signal() + self.mock_pre_save_signal.connect( + receiver=update_sso_roles_in_cs, sender=CourseSessionUser + ) + + @patch("vbv_lernwelt.sso.signals.remove_roles_from_user") + def test_remove_roles_for_single_role_in_cs(self, mock_remove_roles_from_user): + mock_remove_roles_from_user.return_value = None + + self.csu1_student1.delete() + + self.assertEqual(mock_remove_roles_from_user.call_count, 1) + + mock_remove_roles_from_user.assert_called_with( + self.student1, [(self.csu1_student1.course_session.course.slug, "MEMBER")] + ) + + @patch("vbv_lernwelt.sso.signals.remove_roles_from_user") + def test_dont_remove_roles_for_multiple_roles_in_cs( + self, mock_remove_roles_from_user + ): + mock_remove_roles_from_user.return_value = None + + self.csu1_student2.delete() + + self.assertFalse(mock_remove_roles_from_user.called) + + @patch("vbv_lernwelt.sso.signals.add_roles_to_user") + def test_add_role_for_user_on_creation(self, mock_add_roles_from_user): + mock_add_roles_from_user.return_value = None + self.csu1_student1.delete() + csu = CourseSessionUser.objects.create( + user=self.student1, + course_session=self.csu1_student1.course_session, + role="MEMBER", + ) + + self.mock_pre_save_signal.send(sender=CourseSessionUser, instance=csu) + + self.assertEqual(mock_add_roles_from_user.call_count, 1) + + mock_add_roles_from_user.assert_called_with( + self.student1, [(self.csu1_student1.course_session.course.slug, "MEMBER")] + ) + + @patch("vbv_lernwelt.sso.signals.update_roles_for_user") + def test_update_role_for_user_on_creation(self, mock_update_roles_for_user): + mock_update_roles_for_user.return_value = None + self.csu1_student1.role = "TRAINER" + + self.mock_pre_save_signal.send( + sender=CourseSessionUser, instance=self.csu1_student1 + ) + + self.assertEqual(mock_update_roles_for_user.call_count, 1) + + mock_update_roles_for_user.assert_called_with( + self.student1, + add_course_roles=[ + (self.csu1_student1.course_session.course.slug, "TRAINER") + ], + remove_course_roles=[ + (self.csu1_student1.course_session.course.slug, "MEMBER") + ], + ) + + @patch("vbv_lernwelt.sso.signals.update_roles_for_user") + def test_dont_update_role_for_user_on_creation(self, mock_update_roles_for_user): + mock_update_roles_for_user.return_value = None + self.csu1_student1.role = "MEMBER" + + self.mock_pre_save_signal.send( + sender=CourseSessionUser, instance=self.csu1_student1 + ) + + self.assertEqual(mock_update_roles_for_user.call_count, 0) + + +class CourseSessionGroupTests(TestCase): + def setUp(self): + create_default_users() + create_test_course(include_uk=True, with_sessions=True) + + self.csg = CourseSessionGroup.objects.get( + name="Region 1", + ) + course = Course.objects.get(id=COURSE_TEST_ID) + self.slug = course.slug + + self.trainer = User.objects.get(id=TEST_TRAINER1_USER_ID) + self.supervisor = User.objects.get(id=TEST_SUPERVISOR1_USER_ID) + + # m2m_changed.disconnect(receiver=update_sso_roles_in_csg, sender=CourseSessionGroup.supervisor.through) + # + # # Connect a mock signal handler + # self.mock_signal = Signal() + # self.mock_signal.connect(receiver=update_sso_roles_in_csg, sender=CourseSessionGroup.supervisor.through) + + @patch("vbv_lernwelt.sso.signals.remove_roles_from_user") + def test_remove_roles_for_csg_supervisors(self, mock_remove_roles_from_user): + mock_remove_roles_from_user.return_value = None + self.csg.supervisor.set([self.trainer, self.supervisor]) + self.csg.delete() + + expected_calls = [ + call(self.supervisor, [(self.slug, "SUPERVISOR")]), + call(self.trainer, [(self.slug, "SUPERVISOR")]), + ] + mock_remove_roles_from_user.assert_has_calls(expected_calls, any_order=True) + self.assertEqual(mock_remove_roles_from_user.call_count, 2) + + @patch("vbv_lernwelt.sso.signals.remove_roles_from_user") + @patch("vbv_lernwelt.sso.signals.add_roles_to_user") + def test_add_supervisors_to_csg( + self, mock_add_roles_to_user, mock_remove_roles_from_user + ): + mock_remove_roles_from_user.return_value = None + mock_add_roles_to_user.return_value = None + + self.csg.supervisor.add(self.trainer) + + self.assertEqual(mock_add_roles_to_user.call_count, 1) + self.assertEqual(mock_remove_roles_from_user.call_count, 0) + + mock_add_roles_to_user.assert_called_with( + self.trainer, [(self.slug, "SUPERVISOR")] + ) + + @patch("vbv_lernwelt.sso.signals.remove_roles_from_user") + @patch("vbv_lernwelt.sso.signals.add_roles_to_user") + def test_remove_supervisors_to_csg( + self, mock_add_roles_to_user, mock_remove_roles_from_user + ): + mock_remove_roles_from_user.return_value = None + mock_add_roles_to_user.return_value = None + + self.csg.supervisor.remove(self.supervisor) + + self.assertEqual(mock_add_roles_to_user.call_count, 0) + self.assertEqual(mock_remove_roles_from_user.call_count, 1) + + mock_remove_roles_from_user.assert_called_with( + self.supervisor, [(self.slug, "SUPERVISOR")] + ) + + +class LearningMentorTests(TestCase): + def setUp(self): + self.course, self.course_page = create_course("Test Course") + self.course_session = create_course_session(course=self.course, title="Test VV") + + self.user = create_user("mentor") + self.mentor = LearningMentor.objects.create( + mentor=self.user, course_session=self.course_session + ) + + @patch("vbv_lernwelt.sso.signals.remove_roles_from_user") + def test_remove_roles_for_learning_mentor_on_delete( + self, mock_remove_roles_from_user + ): + mock_remove_roles_from_user.return_value = None + + self.mentor.delete() + + self.assertEqual(mock_remove_roles_from_user.call_count, 1) + + mock_remove_roles_from_user.assert_called_with( + self.user, [(self.course.slug, "LEARNING_MENTOR")] + ) + + @patch("vbv_lernwelt.sso.signals.add_roles_to_user") + def test_add_roles_for_learning_mentor_on_create(self, mock_add_roles_from_user): + mock_add_roles_from_user.return_value = None + self.mentor.delete() + + LearningMentor.objects.create( + mentor=self.user, course_session=self.course_session + ) + + self.assertEqual(mock_add_roles_from_user.call_count, 1) + + mock_add_roles_from_user.assert_called_with( + self.user, [(self.course.slug, "LEARNING_MENTOR")] + ) + + @patch("vbv_lernwelt.sso.signals.add_roles_to_user") + def test_no_add_roles_for_learning_mentor_on_update(self, mock_add_roles_from_user): + mock_add_roles_from_user.return_value = None + + participant_1 = add_course_session_user( + self.course_session, + create_user("participant_1"), + role=CourseSessionUser.Role.MEMBER, + ) + + mock_add_roles_from_user.reset_mock() + self.mentor.participants.set([participant_1]) + self.assertEqual(mock_add_roles_from_user.call_count, 0) From 601e0143268b54ea394a45491e16c58cd7560413 Mon Sep 17 00:00:00 2001 From: Christian Cueni Date: Wed, 26 Jun 2024 11:51:02 +0200 Subject: [PATCH 13/23] Make pytest happy --- server/vbv_lernwelt/sso/role_sync/services.py | 2 ++ server/vbv_lernwelt/sso/tests/test_role_sync.py | 10 ++-------- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/server/vbv_lernwelt/sso/role_sync/services.py b/server/vbv_lernwelt/sso/role_sync/services.py index e5f9359d..23df6078 100644 --- a/server/vbv_lernwelt/sso/role_sync/services.py +++ b/server/vbv_lernwelt/sso/role_sync/services.py @@ -15,6 +15,8 @@ logger = structlog.get_logger(__name__) CourseRolesType = List[Tuple[str, str]] KeyCloakRolesType = List[Dict[str, str]] +keycloak_admin = None # Needed for pytest + if settings.OAUTH_SYNC_ROLES: keycloak_connection = KeycloakOpenIDConnection( server_url=settings.OAUTH_SIGNIN_URL, diff --git a/server/vbv_lernwelt/sso/tests/test_role_sync.py b/server/vbv_lernwelt/sso/tests/test_role_sync.py index c1372b75..4d8ade2c 100644 --- a/server/vbv_lernwelt/sso/tests/test_role_sync.py +++ b/server/vbv_lernwelt/sso/tests/test_role_sync.py @@ -16,11 +16,13 @@ from vbv_lernwelt.sso.role_sync.services import ( ) +@override_settings(OAUTH_SYNC_ROLES=True) class ApiTestCase(TestCase): def setUp(self): self.user = User(email="test@example.com", first_name="Test", last_name="User") self.user.additional_json_data = {"intermediate_sso_id": "1234"} self.user.save() + self.course_roles = [ ("überbetriebliche-kurse", "EXPERT"), ("versicherungsvermittler-in", "MEMBER"), @@ -36,7 +38,6 @@ class ApiTestCase(TestCase): }, ] - @override_settings(OAUTH_SYNC_ROLES=True) @patch("vbv_lernwelt.sso.role_sync.services.keycloak_admin") def test_add_roles_to_user_success(self, mock_keycloak_admin): mock_keycloak_admin.assign_realm_roles.return_value = None @@ -48,7 +49,6 @@ class ApiTestCase(TestCase): user_id="1234", roles=self.expected_roles ) - @override_settings(OAUTH_SYNC_ROLES=True) @patch("vbv_lernwelt.sso.role_sync.services.keycloak_admin") def test_add_roles_to_user_keycloak_post_error(self, mock_keycloak_admin): mock_keycloak_admin.assign_realm_roles.side_effect = KeycloakPostError @@ -62,7 +62,6 @@ class ApiTestCase(TestCase): self.assertEqual(error_obj.data, self.expected_roles) self.assertEqual(error_obj.action, SsoSyncError.Action.ADD) - @override_settings(OAUTH_SYNC_ROLES=True) @patch("vbv_lernwelt.sso.role_sync.services.keycloak_admin") def test_remove_roles_to_user_success(self, mock_keycloak_admin): mock_keycloak_admin.delete_realm_roles_of_user.return_value = None @@ -74,7 +73,6 @@ class ApiTestCase(TestCase): user_id="1234", roles=self.expected_roles ) - @override_settings(OAUTH_SYNC_ROLES=True) @patch("vbv_lernwelt.sso.role_sync.services.keycloak_admin") def test_remove_roles_to_user_keycloak_delete_error(self, mock_keycloak_admin): mock_keycloak_admin.delete_realm_roles_of_user.side_effect = KeycloakDeleteError @@ -88,7 +86,6 @@ class ApiTestCase(TestCase): self.assertEqual(error_obj.data, self.expected_roles) self.assertEqual(error_obj.action, SsoSyncError.Action.REMOVE) - @override_settings(OAUTH_SYNC_ROLES=True) @patch("vbv_lernwelt.sso.role_sync.services.remove_roles_from_user") @patch("vbv_lernwelt.sso.role_sync.services.add_roles_to_user") def test_update_roles_to_user( @@ -101,7 +98,6 @@ class ApiTestCase(TestCase): mock_add_roles_to_user.assert_called_once() mock_remove_roles_from_user.assert_called_once() - @override_settings(OAUTH_SYNC_ROLES=True) @patch("vbv_lernwelt.sso.role_sync.services.keycloak_admin") def test_sync_roles_to_user(self, mock_keycloak_admin): mock_keycloak_admin.get_realm_roles_of_user.return_value = ( @@ -121,7 +117,6 @@ class ApiTestCase(TestCase): user_id="1234", roles=self.expected_roles ) - @override_settings(OAUTH_SYNC_ROLES=True) @patch("vbv_lernwelt.sso.role_sync.services.keycloak_admin") def test_create_user(self, mock_keycloak_admin): mock_keycloak_admin.create_user.return_value = "im-an-uuid-1234" @@ -139,7 +134,6 @@ class ApiTestCase(TestCase): user_data, exist_ok=True ) - @override_settings(OAUTH_SYNC_ROLES=True) @patch("vbv_lernwelt.sso.role_sync.services.keycloak_admin") def test_ignore_missing_course(self, mock_keycloak_admin): mock_keycloak_admin.assign_realm_roles.return_value = None From 88b7212465c2a25147594d0e80f5d0c6a144b36b Mon Sep 17 00:00:00 2001 From: Christian Cueni Date: Wed, 26 Jun 2024 13:03:20 +0200 Subject: [PATCH 14/23] Use correct black version --- .../vbv_lernwelt/course_session_group/admin.py | 3 +-- server/vbv_lernwelt/edoniq_test/views.py | 6 +++--- server/vbv_lernwelt/feedback/services.py | 6 +++--- .../vbv_lernwelt/notify/tests/test_service.py | 18 +++++++++--------- .../self_evaluation_feedback/serializers.py | 6 +++--- 5 files changed, 19 insertions(+), 20 deletions(-) diff --git a/server/vbv_lernwelt/course_session_group/admin.py b/server/vbv_lernwelt/course_session_group/admin.py index 5bda3780..f880cfa3 100644 --- a/server/vbv_lernwelt/course_session_group/admin.py +++ b/server/vbv_lernwelt/course_session_group/admin.py @@ -4,5 +4,4 @@ from vbv_lernwelt.course_session_group.models import CourseSessionGroup @admin.register(CourseSessionGroup) -class CourseSessionAssignmentAdmin(admin.ModelAdmin): - ... +class CourseSessionAssignmentAdmin(admin.ModelAdmin): ... diff --git a/server/vbv_lernwelt/edoniq_test/views.py b/server/vbv_lernwelt/edoniq_test/views.py index 00d8ff7b..e3a71ec2 100644 --- a/server/vbv_lernwelt/edoniq_test/views.py +++ b/server/vbv_lernwelt/edoniq_test/views.py @@ -153,9 +153,9 @@ def fetch_course_session_all_users(courses: List[int], excluded_domains=None): def generate_export_response(cs_users: List[User]) -> HttpResponse: response = HttpResponse(content_type="text/csv; charset=utf-8") - response[ - "Content-Disposition" - ] = f"attachment; filename=edoniq_user_export_{date.today().strftime('%Y%m%d')}.csv" + response["Content-Disposition"] = ( + f"attachment; filename=edoniq_user_export_{date.today().strftime('%Y%m%d')}.csv" + ) response.write("\ufeff".encode("utf8")) # UTF-8 BOM diff --git a/server/vbv_lernwelt/feedback/services.py b/server/vbv_lernwelt/feedback/services.py index d0fefe20..51602502 100644 --- a/server/vbv_lernwelt/feedback/services.py +++ b/server/vbv_lernwelt/feedback/services.py @@ -125,9 +125,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 diff --git a/server/vbv_lernwelt/notify/tests/test_service.py b/server/vbv_lernwelt/notify/tests/test_service.py index 6d8af677..1feb4bad 100644 --- a/server/vbv_lernwelt/notify/tests/test_service.py +++ b/server/vbv_lernwelt/notify/tests/test_service.py @@ -65,9 +65,9 @@ class TestNotificationService(TestCase): self.assertFalse(notification.emailed) def test_send_notification_with_email(self): - self.recipient.additional_json_data[ - "email_notification_categories" - ] = json.dumps(["USER_INTERACTION"]) + self.recipient.additional_json_data["email_notification_categories"] = ( + json.dumps(["USER_INTERACTION"]) + ) self.recipient.save() verb = "Anne hat deinen Auftrag bewertet" @@ -146,9 +146,9 @@ class TestNotificationService(TestCase): self.assertFalse(notification.emailed) # when the email was not sent, yet it will still send it afterwards... - self.recipient.additional_json_data[ - "email_notification_categories" - ] = json.dumps(["USER_INTERACTION"]) + self.recipient.additional_json_data["email_notification_categories"] = ( + json.dumps(["USER_INTERACTION"]) + ) self.recipient.save() result = self.notification_service._send_notification( @@ -188,9 +188,9 @@ class TestNotificationService(TestCase): self.assertFalse(self._has_sent_emails()) # Assert mail is sent if corresponding email notification type is enabled - self.recipient.additional_json_data[ - "email_notification_categories" - ] = json.dumps(["USER_INTERACTION"]) + self.recipient.additional_json_data["email_notification_categories"] = ( + json.dumps(["USER_INTERACTION"]) + ) self.recipient.save() self.notification_service._send_notification( sender=self.sender, diff --git a/server/vbv_lernwelt/self_evaluation_feedback/serializers.py b/server/vbv_lernwelt/self_evaluation_feedback/serializers.py index fd24d363..0c73c3dc 100644 --- a/server/vbv_lernwelt/self_evaluation_feedback/serializers.py +++ b/server/vbv_lernwelt/self_evaluation_feedback/serializers.py @@ -39,9 +39,9 @@ class SelfEvaluationFeedbackSerializer(serializers.ModelSerializer): return obj.learning_unit.get_circle().title def get_criteria(self, obj): - performance_criteria: List[ - PerformanceCriteria - ] = obj.learning_unit.performancecriteria_set.all() + performance_criteria: List[PerformanceCriteria] = ( + obj.learning_unit.performancecriteria_set.all() + ) criteria = [] From cc3b6bbf0dd9fc675c2221109325151eec45beb3 Mon Sep 17 00:00:00 2001 From: Christian Cueni Date: Wed, 26 Jun 2024 13:54:55 +0200 Subject: [PATCH 15/23] Clean up some code --- server/vbv_lernwelt/core/models.py | 9 ++++----- server/vbv_lernwelt/importer/services.py | 2 +- server/vbv_lernwelt/sso/role_sync/roles.py | 3 +++ 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/server/vbv_lernwelt/core/models.py b/server/vbv_lernwelt/core/models.py index d92a1396..d0baf2c5 100644 --- a/server/vbv_lernwelt/core/models.py +++ b/server/vbv_lernwelt/core/models.py @@ -144,14 +144,13 @@ class User(AbstractUser): return "/static/avatars/myvbv-default-avatar.png" def update_additional_json_data(self, data: Dict[str, Any]): - # Set E-Mail notification settings for new users self.additional_json_data = ( self.additional_json_data | sanitize_json_data_input( - { - **data, - } - ) + { + **data, + } + ) ) @property diff --git a/server/vbv_lernwelt/importer/services.py b/server/vbv_lernwelt/importer/services.py index 7dd59659..4abc1403 100644 --- a/server/vbv_lernwelt/importer/services.py +++ b/server/vbv_lernwelt/importer/services.py @@ -829,7 +829,7 @@ 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_trainer2", + "create_or_update_trainer", course=course_title, data=data, label="import", diff --git a/server/vbv_lernwelt/sso/role_sync/roles.py b/server/vbv_lernwelt/sso/role_sync/roles.py index 630222aa..91b678e6 100644 --- a/server/vbv_lernwelt/sso/role_sync/roles.py +++ b/server/vbv_lernwelt/sso/role_sync/roles.py @@ -31,6 +31,7 @@ SSO_ROLES = { }, } +# STAGE # https://sso.test.b.lernetz.host/auth/admin/vbv/console/#/vbv/roles ROLE_IDS = { "myvbv-uberbetriebliche-kurse-member": "0725f2d4-c3f3-48b7-83ec-06acfae630e6", @@ -52,3 +53,5 @@ ROLE_IDS = { "myvbv-intermediarioa-assicurativoa-member": "9fbaaa0f-cf8c-45f2-93f6-7174cb18a982", "myvbv-intermediarioa-assicurativoa-mentor": "46b12e54-682e-44c0-b506-eab820138b66", } + +# TODO: Add production roles IDs From 8af955f794fe52a83589e1fef513a81eb12b5a79 Mon Sep 17 00:00:00 2001 From: Christian Cueni Date: Thu, 27 Jun 2024 09:43:34 +0200 Subject: [PATCH 16/23] Add migration, handle sync for mentors and supervisors --- server/vbv_lernwelt/core/models.py | 8 ++-- server/vbv_lernwelt/sso/admin.py | 12 ++++++ .../sso/migrations/0001_initial.py | 18 ++++++++- server/vbv_lernwelt/sso/role_sync/services.py | 13 +++---- server/vbv_lernwelt/sso/signals.py | 7 +++- server/vbv_lernwelt/sso/tests/test_signals.py | 37 +++++++++++++++---- 6 files changed, 73 insertions(+), 22 deletions(-) diff --git a/server/vbv_lernwelt/core/models.py b/server/vbv_lernwelt/core/models.py index d0baf2c5..8fa757bd 100644 --- a/server/vbv_lernwelt/core/models.py +++ b/server/vbv_lernwelt/core/models.py @@ -147,10 +147,10 @@ class User(AbstractUser): self.additional_json_data = ( self.additional_json_data | sanitize_json_data_input( - { - **data, - } - ) + { + **data, + } + ) ) @property diff --git a/server/vbv_lernwelt/sso/admin.py b/server/vbv_lernwelt/sso/admin.py index f843d6a6..afd9b3aa 100644 --- a/server/vbv_lernwelt/sso/admin.py +++ b/server/vbv_lernwelt/sso/admin.py @@ -4,6 +4,8 @@ from django.utils.translation import gettext_lazy as _ from keycloak.exceptions import KeycloakDeleteError, KeycloakPostError from vbv_lernwelt.course.models import CourseSessionUser +from vbv_lernwelt.course_session_group.models import CourseSessionGroup +from vbv_lernwelt.learning_mentor.models import LearningMentor from vbv_lernwelt.sso.models import SsoSyncError, SsoUser from vbv_lernwelt.sso.role_sync.services import ( create_and_update_user, @@ -33,6 +35,16 @@ def sync_sso_roles_from_admin(user: User, request): (csu.course_session.course.slug, csu.role) for csu in CourseSessionUser.objects.filter(user=user) ] + + course_roles += [ + (lm.course_session.course.slug, "LEARNING_MENTOR") + for lm in LearningMentor.objects.filter(mentor=user) + ] + + for csg in CourseSessionGroup.objects.filter(supervisor=user): + for course_session in csg.course_session.all(): + course_roles.append((course_session.course.slug, "SUPERVISOR")) + try: sync_roles_for_user(user, course_roles) messages.add_message( diff --git a/server/vbv_lernwelt/sso/migrations/0001_initial.py b/server/vbv_lernwelt/sso/migrations/0001_initial.py index 687c6a17..7267c8b3 100644 --- a/server/vbv_lernwelt/sso/migrations/0001_initial.py +++ b/server/vbv_lernwelt/sso/migrations/0001_initial.py @@ -1,18 +1,34 @@ -# Generated by Django 3.2.25 on 2024-06-20 05:24 +# Generated by Django 3.2.25 on 2024-06-26 15:34 +import django.contrib.auth.models import django.db.models.deletion from django.conf import settings from django.db import migrations, models class Migration(migrations.Migration): + initial = True dependencies = [ + ("core", "0007_auto_20240220_1058"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ + migrations.CreateModel( + name="SsoUser", + fields=[], + options={ + "proxy": True, + "indexes": [], + "constraints": [], + }, + bases=("core.user",), + managers=[ + ("objects", django.contrib.auth.models.UserManager()), + ], + ), migrations.CreateModel( name="SsoSyncError", fields=[ diff --git a/server/vbv_lernwelt/sso/role_sync/services.py b/server/vbv_lernwelt/sso/role_sync/services.py index 23df6078..e88c25f0 100644 --- a/server/vbv_lernwelt/sso/role_sync/services.py +++ b/server/vbv_lernwelt/sso/role_sync/services.py @@ -24,7 +24,7 @@ if settings.OAUTH_SYNC_ROLES: user_realm_name=settings.OAUTH_SIGNIN_REALM, client_id=settings.OAUTH_SIGNIN_ADMIN_CLIENT_ID, client_secret_key=settings.OAUTH_SIGNIN_ADMIN_CLIENT_SECRET, - verify=False, + verify=True, ) keycloak_admin = KeycloakAdmin(connection=keycloak_connection) @@ -48,7 +48,7 @@ def _handle_add_remove_action( action: SsoSyncError.Action, ): user_id = user.additional_json_data.get("intermediate_sso_id", "") - if settings.OAUTH_SYNC_ROLES and user_id: + if keycloak_admin and user_id: request_roles = _get_role_request_data(course_roles) if not request_roles: return False @@ -65,7 +65,7 @@ def _handle_add_remove_action( def update_roles_for_user( user: User, add_course_roles: CourseRolesType, remove_course_roles: CourseRolesType ): - if settings.OAUTH_SYNC_ROLES: + if keycloak_admin: remove_ret_value = remove_roles_from_user(user, remove_course_roles) add_ret_value = add_roles_to_user(user, add_course_roles) return remove_ret_value and add_ret_value @@ -73,10 +73,9 @@ def update_roles_for_user( def sync_roles_for_user(user: User, course_roles: CourseRolesType): - if settings.OAUTH_SYNC_ROLES: + if keycloak_admin: user_id = user.additional_json_data.get("intermediate_sso_id", "") if user_id: - # ignore for the moment, just in the admin assigned_roles = _filter_non_myvbv_roles( keycloak_admin.get_realm_roles_of_user(user_id=user_id) ) @@ -91,7 +90,7 @@ def sync_roles_for_user(user: User, course_roles: CourseRolesType): def create_user(user: User): - if settings.OAUTH_SYNC_ROLES: + if keycloak_admin: return _kc_create_user(user) return "" @@ -104,7 +103,7 @@ def create_and_update_user(user: User, save=False): def get_roles_for_user(user_id: str): - if settings.OAUTH_SYNC_ROLES: + if keycloak_admin: return keycloak_admin.get_realm_roles_of_user( user_id=user_id, ) diff --git a/server/vbv_lernwelt/sso/signals.py b/server/vbv_lernwelt/sso/signals.py index 31c5dc59..8e189586 100644 --- a/server/vbv_lernwelt/sso/signals.py +++ b/server/vbv_lernwelt/sso/signals.py @@ -34,7 +34,10 @@ def update_sso_roles_in_cs(sender, instance: CourseSessionUser, **kwargs): _add_sso_role(instance.user, instance.course_session.course.slug, instance.role) else: old_csu = CourseSessionUser.objects.get(pk=instance.pk) - if old_csu.role != instance.role: + if ( + old_csu.role != instance.role + or old_csu.course_session.course != instance.course_session.course + ): try: update_roles_for_user( instance.user, @@ -42,7 +45,7 @@ def update_sso_roles_in_cs(sender, instance: CourseSessionUser, **kwargs): (instance.course_session.course.slug, instance.role) ], remove_course_roles=[ - (instance.course_session.course.slug, old_csu.role) + (old_csu.course_session.course.slug, old_csu.role) ], ) except KeycloakError: diff --git a/server/vbv_lernwelt/sso/tests/test_signals.py b/server/vbv_lernwelt/sso/tests/test_signals.py index 7e96b2f5..70697ee7 100644 --- a/server/vbv_lernwelt/sso/tests/test_signals.py +++ b/server/vbv_lernwelt/sso/tests/test_signals.py @@ -92,7 +92,9 @@ class CourseSessionUserTests(TestCase): ) @patch("vbv_lernwelt.sso.signals.update_roles_for_user") - def test_update_role_for_user_on_creation(self, mock_update_roles_for_user): + def test_update_role_for_user_on_update_with_role_change( + self, mock_update_roles_for_user + ): mock_update_roles_for_user.return_value = None self.csu1_student1.role = "TRAINER" @@ -113,7 +115,32 @@ class CourseSessionUserTests(TestCase): ) @patch("vbv_lernwelt.sso.signals.update_roles_for_user") - def test_dont_update_role_for_user_on_creation(self, mock_update_roles_for_user): + def test_update_role_for_user_on_update_with_course_change( + self, mock_update_roles_for_user + ): + mock_update_roles_for_user.return_value = None + course, self.course_page = create_course("Test Course") + course_session = create_course_session(course=course, title="Test VV") + old_course_slug = self.csu1_student1.course_session.course.slug + + self.csu1_student1.course_session = course_session + + self.mock_pre_save_signal.send( + sender=CourseSessionUser, instance=self.csu1_student1 + ) + + self.assertEqual(mock_update_roles_for_user.call_count, 1) + + mock_update_roles_for_user.assert_called_with( + self.student1, + add_course_roles=[ + (self.csu1_student1.course_session.course.slug, "MEMBER") + ], + remove_course_roles=[(old_course_slug, "MEMBER")], + ) + + @patch("vbv_lernwelt.sso.signals.update_roles_for_user") + def test_dont_update_role_for_user_on_update(self, mock_update_roles_for_user): mock_update_roles_for_user.return_value = None self.csu1_student1.role = "MEMBER" @@ -138,12 +165,6 @@ class CourseSessionGroupTests(TestCase): self.trainer = User.objects.get(id=TEST_TRAINER1_USER_ID) self.supervisor = User.objects.get(id=TEST_SUPERVISOR1_USER_ID) - # m2m_changed.disconnect(receiver=update_sso_roles_in_csg, sender=CourseSessionGroup.supervisor.through) - # - # # Connect a mock signal handler - # self.mock_signal = Signal() - # self.mock_signal.connect(receiver=update_sso_roles_in_csg, sender=CourseSessionGroup.supervisor.through) - @patch("vbv_lernwelt.sso.signals.remove_roles_from_user") def test_remove_roles_for_csg_supervisors(self, mock_remove_roles_from_user): mock_remove_roles_from_user.return_value = None From fa22f52bc77153d67d7992688ed812c1e695b7e6 Mon Sep 17 00:00:00 2001 From: Christian Cueni Date: Thu, 27 Jun 2024 10:18:05 +0200 Subject: [PATCH 17/23] =?UTF-8?q?Use=20settings=20so=20that=20pytest=20is?= =?UTF-8?q?=20happy=20=F0=9F=A4=B7=E2=80=8D=E2=99=82=EF=B8=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/requirements/requirements-dev.txt | 31 ------------------- server/vbv_lernwelt/shop/invoice/abacus.py | 20 ++++++------ server/vbv_lernwelt/sso/role_sync/services.py | 6 ++-- 3 files changed, 14 insertions(+), 43 deletions(-) diff --git a/server/requirements/requirements-dev.txt b/server/requirements/requirements-dev.txt index 2e726be0..ccbef00d 100644 --- a/server/requirements/requirements-dev.txt +++ b/server/requirements/requirements-dev.txt @@ -353,17 +353,9 @@ packaging==24.1 # msal-extensions # pytest # pytest-sugar -<<<<<<< HEAD -paramiko==3.3.1 - # via - # -r requirements.in - # sftpserver -parso==0.8.3 -======= paramiko==3.4.0 # via -r requirements.in parso==0.8.4 ->>>>>>> 9e6b9a1e (wip: Add KC-client and basic methods, signal handler) # via jedi pathspec==0.12.1 # via @@ -416,15 +408,10 @@ pyflakes==3.2.0 pygments==2.18.0 # via ipython pyjwt[crypto]==2.8.0 -<<<<<<< HEAD - # via msal -pylint==2.17.5 -======= # via # msal # pyjwt pylint==3.2.3 ->>>>>>> 9e6b9a1e (wip: Add KC-client and basic methods, signal handler) # via # pylint-django # pylint-plugin-utils @@ -447,13 +434,8 @@ pytest==8.2.2 # pytest-xdist pytest-django==4.8.0 # via -r requirements-dev.in -<<<<<<< HEAD pytest-order==1.2.1 # via -r requirements-dev.in -pytest-sugar==0.9.7 -======= -pytest-sugar==1.0.0 ->>>>>>> 9e6b9a1e (wip: Add KC-client and basic methods, signal handler) # via -r requirements-dev.in pytest-xdist==3.6.1 # via -r requirements-dev.in @@ -647,17 +629,11 @@ wagtail-headless-preview==0.8.0 # via wagtail-grapple wagtail-localize==1.9 # via -r requirements.in -<<<<<<< HEAD -watchfiles==0.19.0 - # via uvicorn -wcwidth==0.2.6 -======= watchfiles==0.22.0 # via # django-watchfiles # uvicorn wcwidth==0.2.13 ->>>>>>> 9e6b9a1e (wip: Add KC-client and basic methods, signal handler) # via prompt-toolkit webencodings==0.5.1 # via html5lib @@ -667,17 +643,10 @@ wheel==0.43.0 # via pip-tools whitenoise[brotli]==6.6.0 # via -r requirements.in -<<<<<<< HEAD -willow[heif]==1.6.1 - # via wagtail -wrapt==1.15.0 - # via astroid -======= willow[heif]==1.6.3 # via # wagtail # willow ->>>>>>> 9e6b9a1e (wip: Add KC-client and basic methods, signal handler) # The following packages are considered to be unsafe in a requirements file: # pip diff --git a/server/vbv_lernwelt/shop/invoice/abacus.py b/server/vbv_lernwelt/shop/invoice/abacus.py index 1bc7cd2d..def231ef 100644 --- a/server/vbv_lernwelt/shop/invoice/abacus.py +++ b/server/vbv_lernwelt/shop/invoice/abacus.py @@ -75,9 +75,11 @@ def create_customer_xml(checkout_information: CheckoutInformation): abacus_debitor_number=customer.abacus_debitor_number, last_name=checkout_information.last_name, first_name=checkout_information.first_name, - company_name=checkout_information.organisation_detail_name - if checkout_information.invoice_address == "org" - else "", + company_name=( + checkout_information.organisation_detail_name + if checkout_information.invoice_address == "org" + else "" + ), street=( checkout_information.organisation_street if checkout_information.invoice_address == "org" @@ -145,14 +147,14 @@ def render_invoice_xml( SubElement(sales_order_header_fields, "PaymentCode").text = "9999" # Skender: Bestellzeitpunkt - SubElement( - sales_order_header_fields, "PurchaseOrderDate" - ).text = order_date.isoformat() + SubElement(sales_order_header_fields, "PurchaseOrderDate").text = ( + order_date.isoformat() + ) # Skender: ePayment: TRANSACTION-ID von Datatrans in Bestellreferenz - SubElement( - sales_order_header_fields, "ReferencePurchaseOrder" - ).text = datatrans_transaction_id + SubElement(sales_order_header_fields, "ReferencePurchaseOrder").text = ( + datatrans_transaction_id + ) # Skender: ePayment: OrderID. max 10 Ziffern, erste Ziffer abhängig von der Plattform (4 = LMS, 6 = myVBV, 7 = EduManager) SubElement(sales_order_header_fields, "GroupingNumberAscii1").text = str( diff --git a/server/vbv_lernwelt/sso/role_sync/services.py b/server/vbv_lernwelt/sso/role_sync/services.py index e88c25f0..f3c2c563 100644 --- a/server/vbv_lernwelt/sso/role_sync/services.py +++ b/server/vbv_lernwelt/sso/role_sync/services.py @@ -48,7 +48,7 @@ def _handle_add_remove_action( action: SsoSyncError.Action, ): user_id = user.additional_json_data.get("intermediate_sso_id", "") - if keycloak_admin and user_id: + if settings.OAUTH_SYNC_ROLES and user_id: request_roles = _get_role_request_data(course_roles) if not request_roles: return False @@ -65,7 +65,7 @@ def _handle_add_remove_action( def update_roles_for_user( user: User, add_course_roles: CourseRolesType, remove_course_roles: CourseRolesType ): - if keycloak_admin: + if settings.OAUTH_SYNC_ROLES: remove_ret_value = remove_roles_from_user(user, remove_course_roles) add_ret_value = add_roles_to_user(user, add_course_roles) return remove_ret_value and add_ret_value @@ -73,7 +73,7 @@ def update_roles_for_user( def sync_roles_for_user(user: User, course_roles: CourseRolesType): - if keycloak_admin: + if settings.OAUTH_SYNC_ROLES: user_id = user.additional_json_data.get("intermediate_sso_id", "") if user_id: assigned_roles = _filter_non_myvbv_roles( From 86d0291448fcfa47bbacf4b8e03bbe36b5e79b2c Mon Sep 17 00:00:00 2001 From: Christian Cueni Date: Wed, 3 Jul 2024 14:41:06 +0200 Subject: [PATCH 18/23] Fix logging message, add env variables --- env_secrets/local_chrigu.env | Bin 2746 -> 3056 bytes server/vbv_lernwelt/sso/signals.py | 4 ++-- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/env_secrets/local_chrigu.env b/env_secrets/local_chrigu.env index 23475a25faa8084c1204de1ebf541da650ad45f5..163e86935ad87ad9cfe49b4fa00ff12c24c2ec0c 100644 GIT binary patch literal 3056 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 literal 2746 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 AzyJUM diff --git a/server/vbv_lernwelt/sso/signals.py b/server/vbv_lernwelt/sso/signals.py index 8e189586..56f4f83e 100644 --- a/server/vbv_lernwelt/sso/signals.py +++ b/server/vbv_lernwelt/sso/signals.py @@ -99,7 +99,7 @@ def update_sso_roles_in_lm(sender, instance: LearningMentor, **kwargs): def _remove_sso_role(user: User, course_slug: str, role: str): try: logger.debug( - "Removing SUPERVISOR role from user", + f"Removing {role} role from user", user=user, course=course_slug, label="role_sync", @@ -113,7 +113,7 @@ def _remove_sso_role(user: User, course_slug: str, role: str): def _add_sso_role(user: User, course_slug: str, role: str): try: logger.debug( - "Adding SUPERVISOR role to user", + f"Adding {role} role to user", user=user, course=course_slug, label="role_sync", From b323793a796f438a7f2916cea919fea811649d5e Mon Sep 17 00:00:00 2001 From: Daniel Egger Date: Wed, 3 Jul 2024 15:43:34 +0200 Subject: [PATCH 19/23] Cleanup IT_OAUTH_*-env variables --- env_secrets/caprover_myvbv-prod.env | Bin 2042 -> 1104 bytes env_secrets/caprover_myvbv-stage.env | Bin 1434 -> 900 bytes env_secrets/caprover_vbv-develop.env | Bin 983 -> 1410 bytes env_secrets/local_daniel.env | Bin 855 -> 1281 bytes env_secrets/prod-azure.json | Bin 7620 -> 6657 bytes server/config/settings/test_cypress_oauth.py | 39 +++++++++++++++++++ server/conftest.py | 5 +++ 7 files changed, 44 insertions(+) create mode 100644 server/config/settings/test_cypress_oauth.py diff --git a/env_secrets/caprover_myvbv-prod.env b/env_secrets/caprover_myvbv-prod.env index 6c858195826252c790375d0ba78e030bc71176d1..e57d1105820c4157d6805e596db4d19d48ddd0ef 100644 GIT binary patch literal 1104 zcmV-W1h4x5M@dveQdv+`0LjQybL%@FTDC2zfrE&XHL3-iQ2pJoIu{W!8Ei)|@kn56 zLhus`AmlXB7hD-g#s=WXYT|M0J{?12a|{-~u_|JMC%Vl~mi4j~mdicxa7+3xKnF8h zNbItt&rSN8^cD)Us35Kxy=?t=G0>p#zFk+O-lPk?0~eim>6kL&qJ93^rv#un$5P!^ z5v?y?$a#BfzqCT5(|mTc=m}^RmkwW-^rG@C4XJ&1w*Y0lN*ZLa|e)04N>WbFhHmep;3k5)5Pfq^ujO-_qQFzqMGK0w!#P8ra z_6-y>rOi&Etb2`1L!l|y@XOX7Jc)+ZGlwj70QEre+H(Jr0bl^Ps!}DTj@7ZjR zV*7{BffksAS4Sg?3o%>UhmgMAoZllzuU}Qbb!YES8{YcKM|gJtWkOK8E>29Y8F`6o z$8}zMPuy>kUHu@SHj{?bqQzVl(s#;uwZC0**e%m?Y91_q3ZgD8qZ4d-?2Kyi?m~#0 zO~DCA!Rc%HH1lMU8((B&a368iT6=^1G>bj9pYQ@Jc!CBSIuK3~G&jn@<%v+6hCeq& zNINUX`)9nJxpiy$k)&E*iEcLNgs-0jo>7u=Tj`DC-2b!afA2`bNai~1UnGohjc7Jn zfFewFQU4ZQ5j#*d_pJ`kX7MJLhd6mZ$y)O5BL6{J20z!jsU| z`n>b@tK4B<#OJ>!-nhLENS5q^Qb>r8LNG5DhkX^y7+PCn|6W9twMuLARY;* z8}M)q$sf&}a*|ZQI$OHqLlfz)QPLjMBmeIA+e+n4E=?naayUBm@nLGwyje)Cwa7V6 z1~u4Rhya}fqU0#1Ge7s#7^7v(4DdD{5QGAcS&lSn*o9`wn1GIw#@p+3sk+P8&n|vn zdG8|Q)V8UyF)8BK$wN??Q@)v2tY6*$OtdRU&!8Yr!Kw?H7*xsqr4*UrpNX4z5mrbo zK7l?uHW`eR)zzLXFgkL2hFP!?KtG|2gP#r;ZgC#Np39E|*tvC8_(GZDb#EI&wev;O WNb!G9O*e1Lkl}avHD90I35!JW@FceY literal 2042 zcmV5M@dveQdv+`0O!J?WKEF{trjZr`jQO>B873T!I$*Lb1|w1=2hoM2LQy%dW^`l`1t)ed{M6#S^ib)f@wa6k#C5 zwurfhB>!)Cin$ky$p$kU)dk=Hbe{$Js>-DXt-oZqYn0H?EzuxjvjuKW4KYj(xGK{e z_?7#4N}KDRgR8 zfK~3xzkPwIdKH)Q@r6bpQ*EZu!ARYMdI(Ah{8fA;=|%Pf;qOlyeehl5lr8#nt;AMW zSn|(yPE-MxI;n*z4e4|={k|1VMLna|9Dy7BZ% z#HRlXXIt~zecgkNbXcWmHFmy(4^Mewb(Jc7(8`_ug|B596E(OkE{+e=Kt2QO=qx-K zI!O#&A}(_vZlLs!pyt)hFp!`S4~dY@%dl_ZrXYX0FQkxz1X?qBa}5_2-QOKD?scff z#i8c+PEF8sPG^IyT&1AF9g0jAaL)q|&0Ob_K(gnP*FoNiy~Bz1^@)t?H=ADwk^1KZ z#25s4M^-_Hb7MlMsCbewE$r*RBJCryRJ}&qT9{U;Xyx@9($Qe z?&5w+$|6JWE_oo2K}x6y?0y3iX)v^aEW5iAfQBM|`|=dvm`h~5BhAD}FToPv9Tm~O zmAFf3LqKHs?1mz*Ppd-hna}s_3=RR8)Fb8Xc0(zHK4P?Nh0Ng2Y<2NHn_T#FeGRR; zmue9tEfJZFs_dpJpc8GtU8W?hkboD%3(ck%Mib&lGXeCz{v=o5zr;`;MD0+Rhe??m zC+bTN#MNkf9Q5^qnKCzU1FDm*!g+o#$OGm7pc0+DO-ANodY?c->8O5%hJZZwdb1Y{ z1i~+}{wMbQXWxKE&3la>R^F)W=p2oLTRtFV&;?1W)uNJOiQqqijO^HJYuj^kYLhYF zK*Hi;rAJd_Jn*ZT$Fc{FFu<@`4A=qvfAtH3lYL`ob5G~{l>N9```9J*A`OD z%VAW_i^7cE@)yu7RCcNYJ1L70~F}Kc_?ZN*eJ#M%5cdQNk_{yC(c8Qq|nbr*HArxS8MK^ceab{D-EPYTmPn7%}S8FM#%Ko~D zH)8)PgECzuOO5uCUY)k zy30fXp^ix~cG@WvuA{#T7_(355bZU;5r9xKvo*^FnEd>#6>yW9(`^MS5fsfaApo2t zKEE2uPA+dQ+v%e_CXPTxF61k?-mj_2FSSc+Wyy}O{#`i+j?5oJF}r#5Q3E{#e>!~7 zb5J~Ot47tl|1Cvnw-s-7dMQWvQm&cGXg^XCfseH?N*QMj5=r45U=4S`RC#aL`^Tkv z%Zc})Xu_Kpf<-Rb*pjWAS%X=}41TQ;@FF%&9w%~I)?la2QJVj5L_RiZwstQ7g+$Bk z0yLbwJ%?^WY&7!mnxC^S+U=Rt5{sYfmQvv0tpsp+e2i>rSTi7Xlz155=dF0)lER>7 z;Oz`lJc{ak60GD1>;Vu1DNn{O;m1UAJ`g{oXk_)h=lKS5Vlzr+%nqOa!qkElc zSC8&Y2bP%rA!D@?<9F$xiEV@b3>Csu`ey-V;jk;n5HZTD&_C+{O3kfNBVZN?t0h%W YKWl)=3QDP)tzt^^f+vK41A{YSRLQjZX8-^I diff --git a/env_secrets/caprover_myvbv-stage.env b/env_secrets/caprover_myvbv-stage.env index c70851f3595c1414e7f48c80be8c96a48a41742b..f5c2e32ee8ccf8198a6648bb8b9445916d68acef 100644 GIT binary patch literal 900 zcmV-~1AF`cM@dveQdv+`0HIMV5=|vxao$4qA9xRUZSZ?eh0$_Qa&?cXqR($%}4YB=^LZ2N$hB@uKtvIN~GrnTS(gaigi4x zGdLCE%6JPBRr-w+3;CW<2fgmW@x^)-K^_fRUJ#>N?6pzb%kw)GENd&ot2_Z0H3`vO zCPYmKX^(FG(Tt-i%OS_KV-gDoBn#R^mJX%~`B~z|LwUn8l|w#!Krh}~wCYF*f(wtQ z8NxphF0R4)EhjgZ6o&=LpwqBR8xA!KsjCKf20}kmIVtY8T!=Ave zXnHYLQ6l`JFlC<$2B0GAgV%$sG2%Y;u1nqWHF3-XpwZKOSggy!!+i{- zOd(93C4Zu?crVn~n#00wmW#Q%vpCP`!0|4V)0CKb}jIFH{~I zj4^<828DZZLa$lxBn;%t_U9!%*IV&2dJ!G`3dv0JZ^srk z5R`<7jY+hx*6VZ(9-+`=y@&|+kHt(nvo-Ae*+RncMLCsuM7|g9qma8Y8OEyy`Do8> zA{18Mgc9vK`5h|%U3S_Xb=ZO3IjnP987J@`7KlHRnqTfMBZN7i`Xh6=gSaX3pZ{41 zn-leF+RZLJkgHY=gB4RX-9~IT9Td_-@@1w5{Gs5IEI58t-Zep}-O!u24j7OOnXzz~ zoTgt*c*=PNIyMyUXLtrmlM&$-JP&UBX-=|a*k;l*S+4ndypvD(wF7r+_jPJNx7+S| a_4Gq@W@AB&N1xui^I!b;$s)b&+$>t5z_;}P literal 1434 zcmV;L1!ejGM@dveQdv+`08`C}?PIa#@6U79!gK+5TTTV2^%^N}-P-Syz>)a-nsnvn zdj7~KwOsY52CEkF``G0%j;<;pG3D*E#f=^|oTNG2s;U6A*pa}gC^wEeS3uhp`wD8)Ag)0o%%vw>#v-m97Xcba8J)NdS5^y}TJ~6IKMc}tdjJOQh#H9Zcn4Fe; zf|D?+8Q`^~MsCz(0c*|BF`+2Z;x=ruKFL(wgpAJv?wP`H5F)%=Y8@{Goq(_Xp3)xn zNNqY~NdcO-8QvrIG_1;B&Eig*gQCj7_@aTSsH^jfCY{%bcPD&`y#hMd*muhEVP16H z)sz;K5p~|(_!Y;7qHn=^*^jVJhE{roq7zgb?lO)pFHn`1KAo=SQD&6R7;sH43hS~z zztF(51b>tGoa<{<0iN~VE|U`qxEHQj9u@>ozl9RfxiCLZ3|f`@>A&a zkBYcWAz_AM49CIp$)f+l2?wMHSPCr91N{_y9fQA*&{(*@9u=_o0ZIP042sxYW3!Sr zc#ds~9_n30Lo47PU`U9PtXENkf>8%}WnMMxX7eYcAg}stzzw`+$Iag;{zagQcQKJy zYd$$rmH!pwF{LW>T%3v3^BwK-K9j^(Z8--k?r0iDQHWM2l?a9Xiw@E1`*a-)5Q|E7 zaU_Awg}i zvNYxGb%$N4{1knY@reBT^_thg_3X!kTwxXcOhmr$yQ0o?M&pf!+F|AdV_RgRB9x&-Erha7;yT!9FgdwRmVx0G~QZ3&#HO}lu zfjaf89&b~o5%*I>@E*l$dk@>DccL+OQo=#A20z}lHFnv~+_bz-a@7k~`Z>Ej!+Dr+ zX~%}{MYNOWDk;fw$Ytefaj^;N+{2QlX-(4Kf|5j@FMuJi&h6k0Y=Xpa$tk~8x>)s#5jiQSw{0d4Nvv=kKdf+|B^e-I*%>$I;DsMoZZY~qx z=2pVr&!??>@#nhi9B2!j#9dkISI7agF||2XM% z{zMnn`5JuZH6THNQ9xNrZWFIc4EJiB{&>n83*TM7k%G=deYxRWx*u+Zeg}h3NJ$hu z1fz1qla2&JvZC(H=NJ9<)Jq&+;W*^Wjo0OwL7Q!wevBX4(Y&5^p_PW|BuPpN`q+QS zvTy%&s^~0YKC};6-DBkR@d$`E!KnUYN^dmCm=59e-x30C5EY#VRZRl+hq*0z2bzaF z4o@OrRNQE<@7S)FcIaMBWEGwV&6RbocZheFmdS-ttK$(WV)Y2b6@XqPQ?#uguAx^2%hnYInkDarwjhO*nUb6GJys_ArtRGy`RsaA1 diff --git a/env_secrets/caprover_vbv-develop.env b/env_secrets/caprover_vbv-develop.env index 613e64d5ea9e4e73af1fbbbc6d17d3f369e9588a..c633f417bff7fbf9e1da651afdf0215ac04467d3 100644 GIT binary patch literal 1410 zcmV-|1%3JeM@dveQdv+`0H6*;YgY}<6t)6kQ+BGQ30$%8=Z&H;LwH8|#dM(Qy-L0^4mFL{A z{NOuVz~v=y&_o7Y2`$$o!*}E<`7vnJ!af<$q!0r!FoM@Q#HAMc7Y&puEGvnhUK!Di z&>2kCU#QHjSZ*)RWr}W_-ndPAs6A9{B-bqRRv1_d$1ci^n2h{)voEKLu@-BPHHgj= zIcR*n@Gc5NE4JXOuR{(7cA#3$7&WCYHL|4UuzX<%h`hCe2C#wII#_HxS`rIm{T59> zQIqlroBTHL_d;@R67in5rPfhH=hP||Vx0)R2jyK6QaJis6y(d`{|zqSK?^wz$tQpk z*Ln%bPVBy0MRsqd^E^AV;>Ck45A054(u2={U!U*eAim-{{i!8P^4r$k9zGo;=txaC zbk8H+VA`T(h%-(QoxunK%<5euAlxhd*RHby45xd8Na&=o8V&bp6Ie=13`(wG--U3U z%6uo>T?8;FCcrtkuL_SoNb^C`C11abt?$R$rxb5)Z%Rx0~jI)abor+2+mP?$a0$`>QlwukUn(cQ5@4 zxGGGP^*jr0%RhNrwF3ULY8pz?Q3q1)+YjQav-_RqC|zkG_*1ZK0! z%8dr2?!i9*Zra&A(nkG8Ab&+uW>B>f)4;am&HjfY%OFi;A@(`zq*q>dDN#sP<(&CR`F@$$&20 z3@e~YOhE1In9IS{w8!hest|~(A;56ZAbq9}ujdpnJ6S~TH=OS(d=FmzF;6B`ls!I# z87_xkLl;Ui7k4~2DddBU2}V}r2i#fO@-L56NJ;dWt>3`M1?zebcvO@Uj1a(k2ygSC zIVrx#$;S>X`*z9m+}Xei1Igz|#i!eA`!`r6|8)RF2YG?nE)HXHvZT+b> zk6{cxE^)~X#Al4Ih%`RZf3P=8sEjg+Xu+1po@o8eSVb)V)I6n$9O2`_ZG5ED;ZXFH)nK+YnTHlca;&1`% z0jxg<@ZXhY zUpx6u^|SdDbcl|L(}T!Zq|99VcAqN4frV+d6ES4u3j7*nR)2AN1ohgj>um4M;K-H0d!IS0W+DTr QcJP3A1@w{)_wTYYEKec0#Q*>R literal 983 zcmV;|11S6eM@dveQdv+`00284w0}u=pyxt84URIkw53qFmm-kFEDi~lHw!r1fB&>{ zIP9PRWrbwq*c+H;(SfZZh%ZjHXZ~y-21@lP&3jE^XEeuXBXnD zs;>RO=-32STb73Q;hA7sVr_SeI^X`89i{DYtlC!gtpsY=J&J|IFEnKJq$a336+$jw zL1SfZV?Zbntu>*$cftu=gCJ-BJ~n8HX0P#IGt5BQgxDkfrg8~BIYNc%7E+|O(|k1U zxPUqdhJ@tNp6HWd0n}qZ`6t3!f%5}PTvbXAot;qs^!Y7;_15feNhyo*%3>9ehc|GA zLvidcB;O{>B!$`4aL@QsSO8#ry-~qE-l{&|HTX~Z1FUMqaq8vm{Q~1kIE51i4)x2u zz7GF;M2~ouS30O_^`rr;^%g)QGH8u`l@j-=cw+Fy%p+7H%ty*dj)_*lcN<>Lq@aWv9;JAkOS5g(>KZ6*6 zPwJYEig{xrF+;JD3XxSxJLuqQbPm5~k1YwvGM+2(8}sbm z%)x5tR#lc8&78$-v3G3vN^!V8T4CW7eZV)jt+2kz#jbZYGt0j=nORjJql}U;OA&DE zi5obGlzmTrsKuY)i?*k+DN30spl5g<-mu(|9Oo}Eo8a&(Y9sA*e3a)sfL{{W)D&20 zhZLYf3J*KDtX^@V!dyND=`Y0Wj3bVq9-rtF{rR1#K}SHq8v=zZgESxa7w<2@ zH1do>&!Nhs&?BwgGVl^)8Pag-~_5 Fgl@^j-h}`F diff --git a/env_secrets/local_daniel.env b/env_secrets/local_daniel.env index e42a71d14684667a99f6ebafd30e1774454075a8..7ae4f656c72a7fda433d8e81db09bbca0a48052d 100644 GIT binary patch literal 1281 zcmV+c1^)T~M@dveQdv+`0K~zt1aShaqph1>SC&gOXbPlOPt<_H_XF=zFMrBbRB&HK z>zIo@$>_iMEgH;_Jj`zfn7Q}W)m%2RD@&QnOlSeUhGQ^Ie&karq-#%x2ArcCTcMye zacXtbU;>n5v|($s-;&(Jfw)#UCp0(*k!b2*IepL9U)KT4ok{a(-lNo^u>?9P{CBUfv{I0GWg3vdtjSuJWUSy!9g6La*G)ut$7C8A-A-jfV@Sh<32q9E^ z^aSA0{3U^xSj4G$P$J}d-j0dX+895O2$b(tW^u@#rM+SPG;nnMI7;lfZZn)&S+7*$ zhPCb4wn**kjS>3mN-m3g+K=Y4HX8I&Yb#sAtBl=8Izg%+s;X(L(g(7+wI7Spvf#p8(@E+ zA^+tv>NM|Nx2-8=)l0f?;3OwesAAcBR&M5NhEE$jS&MZNw3-7=KgRZ4WwVD5C%ws150Btv7VWXE)*qrxAX<49+iiA!d({XH8mVo?|Ib^C*o!H}|w06GKhmDtLW5>3|`;~w?0$$=`uz4%K6&;U=kqC`g$@^@= zH0>%!Pnty${^#e6aEIEaqkb;!`I9bXJVpqp;7v+Ft@e|5rI#mUb7mBd#6n*L`8y5e z&622!O$B@CMGSQ!7+`IWiEHW+hkM-9&dyJ(s3^HMc&Yc5^_dcvNUtJl5QNW3s&6vO z0s!Al4hPApLt597{9c|>j^z}Y7EWtTiNeie8J^pi+M`>ew=U&|<{e-O3J@p%irfD{ z?kVNTGtlLFC|}U~P^dL#O%L?PQ6P}loH~0JnfB~~OspUf zhQVS^tE$t32l`>PytLxr&zUh*+V-hME-e=(KHmw&Xkvj*xkvA rA0#!9aELc67R}V0cnQxU;m+5b$i>2A4)aHay0f|KeTBvxI>C);#ZG?e literal 855 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 L$a(W~u%0~s&FKYU literal 7620 zcmV;#9XsLxM@dveQdv+`08X=BXApdU(sOER86ObsF^ykEROUYkQi>*2mJNjfwp{4X zJITWm}29g~o2ob)yV;8cvhoU5txgAZx#~hQBwa&?bF$#@s z$4jYrf8gFGb{H~@YO_qsMiXXAnE=PGsC2_kAx;)5E!WFEY(_@+vOq4*fnHUnYW0U> zXVs%j;v1c^#h#LnY+_(3?o`8Cj2Wf#5@~A3SVSP7!U^=}$ru^So}H*R{C#LSf^vNV z*X1kMfC4fjB#EG`GTjTOpqTE*jU4R*qs$)@CU{*Xb4ZvRRVxdsk{YcQ)C-%)yvDmT zV-3;el=gHp8o&BF>m*MPUB_&Ona!my!O==QOmfXs5n8$Vg^d?Ysy1&C>(^#)+=F-~1b>biN}{M|(96nmXT;^xP<-CH;4*rufE zMGR8chipL}SA#)5 zMT67SLB1E%q%47#4n2YMxBlGjkeLhyk<=4NHp#czf1>%hix6~LW7~lsuG?3^ zLtD&7aTgIUacTVyUG0|1gqpWW5i-sWH_KMIqN4yrX=|c{>Vk-3POY6xZK3kgZ#vKe9M$ zb*gOK$lfh>JMRQGFz&EWjIc>LBA$xMcwMtC%2o^Jv)B`Nk9pLKz-|5lXEqxvwvr&=|1QC*dax47h}JiGE3i=8eb&2ee_qBPoeuY zxF34p%TQ4=-_;saGVl{?!$)iOwV&X#u?Rj93J@sRr5;Yc-2w2vts&yC{OjHj4&f69 znY>P}ows~!_;LU$KkG18ckT8H$8%aOH(!&W{kYQTXp%HS(Fz&tF0QPPP1ZNDm%!!W z{@1-!N%0=Nb^sP9T4K*mgm>FPHu@wZukB^ox{DT!oguUApj+0gk&5FaG`|WY#B!I& zYg-Sg`1ue2;ktu(hKasLnheZMO149inO(6)>&jUR|1EIpoj4&uoHCCQ;#@y≪LZ znb?0{u+#1BD>Flm_?F$^N1g2=#Y|E!U25G>0DMVBkfObAVi<>qU1^25L2JShvkwkg z>;UaDs3aZls3|na(4a<7sk0#5Qt5hA0C`rVN6@>}y`&SBjangbj5z!$Ma80_X?%ib z7t^1;38gE_kF(%uj3Ay#fn0yt_}XbbOY(y`>FsQF9Pd|kE&|`8ZIdkvT>ePZkCpeo z5Kp_!ppyyVQeK4j$uZ>ggQ&=t_1auv3Jd6zPr)kUWEGi@_jJvtTe8K|8&VW6VYHkK zkCJZ?0$LbRm$b66ATCcvx9UCCLeA%VTb#EK>0h(j@Y*}8E=Zl4&kJyv(c%vn6QXi) zl#W4$S8p-g)aO+Hb$UuPmM%}%IzUHXYi(qzNh{Sl`%D253-91(zH4G|L zwr)&#>D2|E;ovv>q`_HP2g-I5dGZ#-P`9Al>t1o>E8IDrJOo{7?8-m!sHcQ2D+VDl zO~L(eQJYW3fgvRv6Q*sL256kYf%Vwdv!vASY-apyA*ry1V+`UkjkfiaEA#@G`Q##z zfVg=q(2&cbhMarN@i5|w==*5eG0)WKrqdtlJm#gGf7FlQ{`+GGq?$$N>moU%!$*OT zBmj4aBJ=+0TDVX%BEll5%&Ytkak;eTdk?26Kl&9W&!Ij4z?;-cu&pKtm>aPvO+`#8 z$$0_pjLj2``x4Lux`O0`!NZvN#oY#%y96%{P~PdQeTXsNAUfEC>Wpl&2Sh8ERyUV( zgcAL^iGj#iYrBoi;V9nf?X`T$=8X3G2Xv^Nw6y!D!}x-_cw>i0NPeTvD-_6G%LIcg z-A7+s=}x4<*RSvF7axK71hBDnbUX33B*d}po&0Q{e0<4nn8t!k9f*tDN+2U}nf?cS z{D2G!*eA?o z29Fsrzu1caznsol6ec4QWbZnQ(xQydi%cyP>dxBE=L1+ftz@=<%Trne#0@ni(`^ET z5U5FSnmBWf=S5sCn*h=STx}1X1AOahyt|j!stZ$UA+kyGw;p*6T-AqlPHUUX^S~BQ z=2D9Hlq}-MOA&NJy8Th2dGyz~gJfE%qSxxZ_-a}#fnza!?qt`W8!>g}7%vwcd-0kFY@czy6KsPV zZ1xChY=2((>?P3MDN4EivP@E_U)TdLlC4mvayp^F*2>~-Zvg($eXt~{`DFcdNvsiS z0v^=%;yMKiJzsCnA8J=!BVQP|<)iIFrp;0KK9dJEP(mgOo6UeF3EZl^K4S1NDE~;hRUG#5PNovJhC>Kj3VZo7qM$# z8VUYV17GLdIGK5#^&DtR!TnaF0e{W70ZofjRAe`Q2FyZosos3UHYCcA#A6yK<_ z67KlGuJkTNy%Z9ylx#q82PyR(OwBn6*y|?0>j!_6hd!BZ`p~7Yg>+_x zGP=v!!&o}ggwt|!I~QVvGy0+6`=lW!w?n&|(PHp$I&fJ=5>(z02XB-FN!N`5PD=HA z#u{l!<<#b{aY=y?L8`zPTc4+~=$t<+-E@jsK{v?>-g==EK;kli#819ivFDFRR90Me zKCQxm(GLdi0hjTZwMPmSHN!A^UGWC6zD>~JouIXPYu7qcB}Ok5ZVV$26ilzcFOR1| zO@I|Hj{(V0E!k)rO37 z{K%{QBzFrIQneuhg5E@_>dH^A0$m4~TWqW#1sB&=C(zQKUe(lgOzQ87Q0=Hzdaa!s zqUbj5rjb0bw}c^%=W~eF{D5$Dt)96C(&zLSQFcy>_I7KTR;kUBOgctj%%9(2MMaoQ z#AaVKg^f&9E#*Q_afzy;!lNDF{ka9tc}i%^v4bA7)!ss$oY*~Ai--3sJieZnV_4gY z44=@q!i8eZ!7ZafFNchHf?eC3s2mN>3|(aLBw=W1^M}IqF@^3)VoWYKo~K=m|#mYDv3MUMHXe z@7nvt2W@b_a7Ejhs(1M~b4nx@#oqij@4JptQ!Lx@Z16T})gb*l*ad_uLY-oFn9_a z^iwKn+7+wQ8N$L45^G;VhA-0rbUKcE2eDy%sZU=w78ZPvkcw?7{6PR!&l_^M6A&=O z`WAljl~-@OC|TEipwufTc@Pwlq677wuo6!H;|xKVS@>u~!G7=IPNyQ!`YC|>_rNS4 z0C#Xyw-X@X1dKObZ+U@y%p^61jvfHDI${5W(ja zgjt=7t<0dx7aM2BCMfT8sE*9;3%OFI?1jUh$Z9B|fWKl`8OeY3;gPk1G8B1)wLYE; zyx-8XCf|te@dB`NTtppzI_8zW3FDTt6fMfI73O z0`tQY5?tI|zg(VZ_8Aoc350gY+)d>-PYKF?FT_9+DUAol^5`&DB!36F$Wm4G`FnbX3XcsK&2(tF=1FWpKFEMO!rmN`@busNybgYsOnau%15EA_8MFHd0THh(lN ziQ`0Wyj{ zAB^$21rgU~!E`rOLLTWr9rtG`KrRh4vw`GSlGjkF`?Qrf;w_gOn!=VfTc5XldBEx& zh=obl{ra*7u;7G*j;zQvbVsc!vN|Mv_E-8_p8O?VIidCA7}i3_M-ElCG=ODPtFZ&@ zEMNA8`xDOmyRiBAN`67iG-yt_?8CIHZ!mjPF0$@YQi6TwPShUYc&E->ayzj}%yuLI z94gb^ofll0O=OijCY5Fr>!g02UDJPL9-nfII?=2bsnihL!Xm+i-spuW!A@AElWIDD z<>oE~@6vs#HtdFVUEDQAx8?uXp5$b3)C6s&YbQv#-?w*ls>#x($uhBk6e0}w(G=iu z^Su(s^k|80H8sx3r*$OXVWHe2(Whla?L8O3izn)kyvcris zdGS6L^BGa&S?xpT;*s80?W~|ULY%F9t%y<~-#O@4u&7G$y%@7%SWsW_s5Lddnh|7) zmoC$t@_h0S$w=R+6PvQx9)yc#Vr^!tWb8h+KUi)UsEn}U9)kh@@KykWhF}yFeb4J$ zRQ)h|{BUzg;f1#nR;oD6{qcP(r#4n8?Z0bP$(wS2Fc6eh8~zVM$n@st3tqL7%O#GsWcqsti z!`R+f%`EHQVd8Z{iIDrSdsbedTwv(g09#O$f^APgq9tR31enE~`z(Dx9TqPF5Ah<3 zh{r>qZC9wKIn@a1U1FW1!(M2K#n7@_80XffjxZcq8?CgxAKj530-$D5zbjbsWyy&NY( zq2R9EkS*w6%k5yUe$hQuv(`F4^D3gLozsJ;Ck^5Egj`$XcRg;peo|xa(sMokM{a^r zE|}Gjcq_j(Q1Zus!eIb3=7 z3+A*e7$=O6duGVN82o2r`m?Y^t!rsMO4Nq+dy;v``pYqM8F4ks;_0 z%XjRFeGF@O0C;w&*~p=sp~%D~Z5SjP@|li~k1#yvA{OgR{Fu(K$Xz!`Q1+d+pE z+L8u4#oVY0$Ad=&RuuRXo*TcXTE_zqta9Ej{%H3NKy{dU=*^|icFB?G?z@+=GSx}$ zt~e>=Wf&%2{Ac0?NH!RMh?oBXcZhv6FOnW)w|q)y=ntbXmt2aZ-yU4U>7kJdq$o$r z{S3h1!hHvsKGSI(1^uifl9{C|Jeg^ry(HSxp(=>1r&@HGS_4BQHK?Q9AqPpGVBEO0 zMB{V@h&##JfBlo#P(t*Yb;SQId{s~ymGI@3ctaLM`hYk;3AP6xlnn7ts+I4NvefV@ zzQh*3I%h2F%eF5)qi?2(ifkbM*hNFd%3f-9Qk>@sZ3R?vWoL0yBu*2S+b7|Cqa=mO zdl#r4B72^A@W&WghS45+7C3|6T~%Rl!8Qu`S%CmF79^ou31V;7Zn;*1ld_F+rVNwq zrev4(`)@)~Sx+2Pwxgfg%)dhwi%9F7b9mod!w%vNfZ*TZg&G1+2 zd@{<~#fMOdqP**_)uK#&pI~iYe%P--|Dm}MoMzCv@c`Q<7r^gFsB&Xx^k5smtpQ)M zlo`$Qtunr|yZm@( zTl*c*s<%mHWR4aP$X6e{Jb$wMY8ne-`if1`h=Mxs^Y5K6cCR$TZk-|tmg^2IvD&X1 zd3J)%{*njghq@$}E%pvYc*4?&&iH4+gS{%3E@gDws62GFcX7b`*jGpJ#jX($ zB4`?n+Qk4wp;mjl8w)XQr_bOLVd9#laQadt}pGbAs zKgmLp>ZDJdF$0q4?gWNR$>p%(`&e5jO?U&%yW5b~yBOO~InSJ9N!02NBcM_p0Pujc8~eA3)Ebl+;!EXRNvNr>^5-KVat2o*J*>8qn#v;#paScq|If+dk_Tt&Q_HHhzO6sQY$)>1iy|q_foSW zs(RedU#6w$ko?uo>$r6B&Qoe6fo072I7K?Dxp3RdW7@y=PCR7zbCpCenJn9x8V_#q z!~pW(WQVqz#v_0U50^WTPN`j`FE1nL3?1+VbBfW?w>N;*5?&_g`Jx>w`9O?e`=XIFPR7>Cs4cCmC%TY_C(Fe&Npqt+29 zu(Nf7Wq^sp?B`66fge@0#x6E`uWY^)_Yl}MjMoWRidBWD6QqV_pK*9#$g#j`*v+zY zQcorZ;Z94L^=YA9Uil&+U`g+gY~x;*^>t#!{U@7a5a z`(g^p+C4e0O9!njk2$dqUDRZT{K;yU-eOObIwa-GIPv}Ld#)q~yd0Jv7P-C;!#WAWv1r|9jWnr?BBvp|gn@CFK2HfwZ7ck0s@n&mdz>%|_ zuYPL~A)RR5EIND_mna)9v`oVaHPX{N4`_|L8DY|X>6rgbV#M$H`Pfd|Kgl#GUO~!3 zh7#699bzAncT6nXA7Fgag7S3RmoiFY#o8i}S}Aa{4(G5KS}H9d(OWIrOdH%kZf@<8 zsNMuDqRli+l29W^w8gjIRT>4lXroC|Q&ZC$$&^1l!0Jvd_ln_fM5%Ojnf9?JosW=> mw&v;J8Y7f{(TmC?<}= Date: Mon, 8 Jul 2024 08:44:05 +0200 Subject: [PATCH 20/23] Use slug not title --- server/vbv_lernwelt/importer/services.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/vbv_lernwelt/importer/services.py b/server/vbv_lernwelt/importer/services.py index 1db6fea8..0121df22 100644 --- a/server/vbv_lernwelt/importer/services.py +++ b/server/vbv_lernwelt/importer/services.py @@ -874,7 +874,7 @@ def create_or_update_trainer(course: Course, data: Dict[str, Any], language="de" # circle expert handling circle_data = parse_circle_group_string(data["Circles"]) for circle_key in circle_data: - circle_name = LP_DATA[circle_key][language]["title"] + circle_slug = LP_DATA[circle_key][language]["slug"] # print(circle_name, groups) import_id = f"{data['Generation'].strip()} {group}" @@ -882,7 +882,7 @@ def create_or_update_trainer(course: Course, data: Dict[str, Any], language="de" import_id=import_id, group=group ).first() circle = Circle.objects.filter( - slug=f"{course.slug}-lp-circle-{circle_name.lower()}" + slug=f"{course.slug}-lp-circle-{circle_slug}" ).first() if course_session and circle: From bdb671c6e97f738cbdd74341cdfb7b33d7264b9e Mon Sep 17 00:00:00 2001 From: Christian Cueni Date: Mon, 8 Jul 2024 13:16:22 +0200 Subject: [PATCH 21/23] Use user language if there's a mismatch --- server/vbv_lernwelt/importer/services.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/server/vbv_lernwelt/importer/services.py b/server/vbv_lernwelt/importer/services.py index 0121df22..d100dff5 100644 --- a/server/vbv_lernwelt/importer/services.py +++ b/server/vbv_lernwelt/importer/services.py @@ -837,6 +837,10 @@ def create_or_update_trainer(course: Course, data: Dict[str, Any], language="de" user.language = data["Sprache"] user.save() + # As the is never set this is the only way to determine the correct course + if user.language != language: + language = user.language + group = data["Klasse"].strip() # general expert handling From 0eeae993cb2e92606917ea1068645f0ac102fcac Mon Sep 17 00:00:00 2001 From: Daniel Egger Date: Tue, 9 Jul 2024 13:21:08 +0200 Subject: [PATCH 22/23] VBV-703: abacus filename: timestamp comes first --- .../abacus_sftp/test_abacus_sftp.py | 2 +- server/vbv_lernwelt/shop/invoice/abacus.py | 25 +++++++++++-------- .../shop/tests/test_abacus_invoice.py | 2 +- 3 files changed, 16 insertions(+), 13 deletions(-) diff --git a/server/integration_tests/abacus_sftp/test_abacus_sftp.py b/server/integration_tests/abacus_sftp/test_abacus_sftp.py index dc747504..483f7102 100644 --- a/server/integration_tests/abacus_sftp/test_abacus_sftp.py +++ b/server/integration_tests/abacus_sftp/test_abacus_sftp.py @@ -102,7 +102,7 @@ def test_upload_abacus_xml(setup_abacus_env): assert "andreas.feuz@eiger-versicherungen.ch" in debi_content order_filepath = os.path.join( - tmppath, "order/myVBV_orde_60000012_20240215083312_6000000124.xml" + tmppath, "order/myVBV_orde_20240215083312_60000012_6000000124.xml" ) assert os.path.exists(order_filepath) with open(order_filepath) as order_file: diff --git a/server/vbv_lernwelt/shop/invoice/abacus.py b/server/vbv_lernwelt/shop/invoice/abacus.py index 201345d3..ffd9ee33 100644 --- a/server/vbv_lernwelt/shop/invoice/abacus.py +++ b/server/vbv_lernwelt/shop/invoice/abacus.py @@ -63,7 +63,7 @@ def create_invoice_xml(checkout_information: CheckoutInformation): # YYYYMMDDhhmmss filename_datetime = checkout_information.created_at.strftime("%Y%m%d%H%M%S") - invoice_xml_filename = f"myVBV_orde_{customer.abacus_debitor_number}_{filename_datetime}_{checkout_information.abacus_order_id}.xml" + invoice_xml_filename = f"myVBV_orde_{filename_datetime}_{customer.abacus_debitor_number}_{checkout_information.abacus_order_id}.xml" return invoice_xml_filename, invoice_xml_content @@ -207,17 +207,20 @@ def render_customer_xml( address_data = SubElement(customer_element, "AddressData", mode="SAVE") SubElement(address_data, "AddressNumber").text = str(abacus_debitor_number) - SubElement(address_data, "Name").text = last_name - SubElement(address_data, "FirstName").text = first_name + SubElement(address_data, "Name").text = last_name[:100] + SubElement(address_data, "FirstName").text = first_name[:50] if company_name: - SubElement(address_data, "Text").text = company_name - SubElement(address_data, "Street").text = street - SubElement(address_data, "HouseNumber").text = house_number - SubElement(address_data, "ZIP").text = zip_code - SubElement(address_data, "City").text = city - SubElement(address_data, "Country").text = country - SubElement(address_data, "Language").text = language - SubElement(address_data, "Email").text = email + SubElement(address_data, "Text").text = company_name[:80] + SubElement(address_data, "Street").text = street[:50] + SubElement(address_data, "HouseNumber").text = house_number[:9] + # only take the numbers from zip_code + SubElement(address_data, "ZIP").text = "".join( + filter(lambda ch: str.isdigit(ch), zip_code) + )[:15] + SubElement(address_data, "City").text = city[:50] + SubElement(address_data, "Country").text = country[:4] + SubElement(address_data, "Language").text = language[:6] + SubElement(address_data, "Email").text = email[:65] return create_xml_string(container) diff --git a/server/vbv_lernwelt/shop/tests/test_abacus_invoice.py b/server/vbv_lernwelt/shop/tests/test_abacus_invoice.py index f9e5e171..e7171ba0 100644 --- a/server/vbv_lernwelt/shop/tests/test_abacus_invoice.py +++ b/server/vbv_lernwelt/shop/tests/test_abacus_invoice.py @@ -43,7 +43,7 @@ class AbacusInvoiceTestCase(TestCase): ) self.assertEqual( - invoice_xml_filename, "myVBV_orde_60000012_20240215083312_6000000124.xml" + invoice_xml_filename, "myVBV_orde_20240215083312_60000012_6000000124.xml" ) print(invoice_xml_content) From 0cf0102f3d7a998c0f8acd6d98c293ccea2644c2 Mon Sep 17 00:00:00 2001 From: Christian Cueni Date: Wed, 10 Jul 2024 09:32:59 +0200 Subject: [PATCH 23/23] Add prod roles --- .../sso/migrations/0001_initial.py | 1 - server/vbv_lernwelt/sso/role_sync/roles.py | 72 ++++++++++++------- 2 files changed, 48 insertions(+), 25 deletions(-) diff --git a/server/vbv_lernwelt/sso/migrations/0001_initial.py b/server/vbv_lernwelt/sso/migrations/0001_initial.py index 7267c8b3..b1e3604c 100644 --- a/server/vbv_lernwelt/sso/migrations/0001_initial.py +++ b/server/vbv_lernwelt/sso/migrations/0001_initial.py @@ -7,7 +7,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - initial = True dependencies = [ diff --git a/server/vbv_lernwelt/sso/role_sync/roles.py b/server/vbv_lernwelt/sso/role_sync/roles.py index 91b678e6..15e0aa54 100644 --- a/server/vbv_lernwelt/sso/role_sync/roles.py +++ b/server/vbv_lernwelt/sso/role_sync/roles.py @@ -1,3 +1,5 @@ +from django.conf import settings + SSO_ROLES = { "uberbetriebliche-kurse": { "MEMBER": "myvbv-uberbetriebliche-kurse-member", @@ -31,27 +33,49 @@ SSO_ROLES = { }, } -# STAGE -# https://sso.test.b.lernetz.host/auth/admin/vbv/console/#/vbv/roles -ROLE_IDS = { - "myvbv-uberbetriebliche-kurse-member": "0725f2d4-c3f3-48b7-83ec-06acfae630e6", - "myvbv-uberbetriebliche-kurse-expert": "c7e33cb6-d227-4764-9b8e-d42af79fb46d", - "myvbv-uberbetriebliche-kurse-supervisor": "d88a7486-7ff4-475c-b840-2e1e0a9decb8", - "myvbv-uberbetriebliche-kurse-mentor": "db5f0e24-9512-4752-8c51-26b3aa6f7f6a", - "myvbv-cours-interentreprises-member": "458c65f4-e969-4ba7-a546-77948641bc0b", - "myvbv-cours-interentreprises-expert": "2ef51fc6-1e5a-427c-b4a9-314249ea24db", - "myvbv-cours-interentreprises-supervisor": "23e5994e-c499-42e8-b956-bb098be793e1", - "myvbv-cours-interentreprises-mentor": "cb37a093-32a3-479b-981e-0604b6b71f5e", - "myvbv-corso-interaziendale-member": "4d9cfc61-b555-44b1-a52d-76231d12f0cd", - "myvbv-corso-interaziendale-expert": "b2da77bd-c3c8-4d1e-9757-c016eaf219e3", - "myvbv-corso-interaziendale-supervisor": "8e9ea3e4-e814-4704-906e-d39f595811eb", - "myvbv-corso-interaziendale-mentor": "36fae39d-67f0-4ed6-9a1c-7d383be9e463", - "myvbv-versicherungsvermittler-in-member": "3ab4eab2-7d7c-43bb-a927-4cf54f24ccc2", - "myvbv-versicherungsvermittler-in-mentor": "12bf374a-293b-4abe-b255-7899eae31908", - "myvbv-intermediaire-dassurance-member": "5400fdae-2c37-4738-8667-0bcb50ed3609", - "myvbv-intermediaire-dassurance-mentor": "3bd737f9-731a-4548-aaf5-4c80175f2759", - "myvbv-intermediarioa-assicurativoa-member": "9fbaaa0f-cf8c-45f2-93f6-7174cb18a982", - "myvbv-intermediarioa-assicurativoa-mentor": "46b12e54-682e-44c0-b506-eab820138b66", -} - -# TODO: Add production roles IDs +if settings.APP_ENVIRONMENT.startswith("prod"): + # PROD + # https://sso.b.lernetz.host/auth/admin/vbv/console/#/vbv/roles + ROLE_IDS = { + "myvbv-uberbetriebliche-kurse-member": "e1acc4bb-46c7-43ae-b109-318380d3e3fa", + "myvbv-uberbetriebliche-kurse-expert": "49d9d279-3d61-4f85-9b1d-1f53a97426dd", + "myvbv-uberbetriebliche-kurse-supervisor": "4e4230c9-e120-44dd-b7e2-3810e3af9cb9", + "myvbv-uberbetriebliche-kurse-mentor": "754258f5-fd36-4a21-8152-78cc890d545a", + "myvbv-cours-interentreprises-member": "1b7c978d-f563-4779-a639-2087e7b585c3", + "myvbv-cours-interentreprises-expert": "76d5b848-260a-4a06-a5ee-96c82bea6168", + "myvbv-cours-interentreprises-supervisor": "bad083cb-5088-4742-8484-fa1146388c5f", + "myvbv-cours-interentreprises-mentor": "a794f2eb-9e99-4332-bdf9-68e9a277212e", + "myvbv-corso-interaziendale-member": "e1f4ea73-730d-4191-96a1-36d33d9f4ebb", + "myvbv-corso-interaziendale-expert": "0a330fd3-a7d2-4e98-a575-2c30585cf576", + "myvbv-corso-interaziendale-supervisor": "c514094f-5450-4f6c-bd52-d6cfa34b8892", + "myvbv-corso-interaziendale-mentor": "255fe575-6191-40ca-8cda-7e2595926ce5", + "myvbv-versicherungsvermittler-in-member": "cac4c013-da20-4f8f-854f-c0fa271500d6", + "myvbv-versicherungsvermittler-in-mentor": "18f12a31-082d-45cf-9560-879ca927552a", + "myvbv-intermediaire-dassurance-member": "35c6071a-dc9a-4071-bbbb-a1a5323ec962", + "myvbv-intermediaire-dassurance-mentor": "d1d40f42-61b2-4ae4-b130-fc3c82492125", + "myvbv-intermediarioa-assicurativoa-member": "1188e214-8aee-4b43-88e9-663e939a4af8", + "myvbv-intermediarioa-assicurativoa-mentor": "5e2ecfc8-c0a8-408f-a78c-4d2fd94cb4fb", + } +else: + # STAGE + # https://sso.test.b.lernetz.host/auth/admin/vbv/console/#/vbv/roles + ROLE_IDS = { + "myvbv-uberbetriebliche-kurse-member": "0725f2d4-c3f3-48b7-83ec-06acfae630e6", + "myvbv-uberbetriebliche-kurse-expert": "c7e33cb6-d227-4764-9b8e-d42af79fb46d", + "myvbv-uberbetriebliche-kurse-supervisor": "d88a7486-7ff4-475c-b840-2e1e0a9decb8", + "myvbv-uberbetriebliche-kurse-mentor": "db5f0e24-9512-4752-8c51-26b3aa6f7f6a", + "myvbv-cours-interentreprises-member": "458c65f4-e969-4ba7-a546-77948641bc0b", + "myvbv-cours-interentreprises-expert": "2ef51fc6-1e5a-427c-b4a9-314249ea24db", + "myvbv-cours-interentreprises-supervisor": "23e5994e-c499-42e8-b956-bb098be793e1", + "myvbv-cours-interentreprises-mentor": "cb37a093-32a3-479b-981e-0604b6b71f5e", + "myvbv-corso-interaziendale-member": "4d9cfc61-b555-44b1-a52d-76231d12f0cd", + "myvbv-corso-interaziendale-expert": "b2da77bd-c3c8-4d1e-9757-c016eaf219e3", + "myvbv-corso-interaziendale-supervisor": "8e9ea3e4-e814-4704-906e-d39f595811eb", + "myvbv-corso-interaziendale-mentor": "36fae39d-67f0-4ed6-9a1c-7d383be9e463", + "myvbv-versicherungsvermittler-in-member": "3ab4eab2-7d7c-43bb-a927-4cf54f24ccc2", + "myvbv-versicherungsvermittler-in-mentor": "12bf374a-293b-4abe-b255-7899eae31908", + "myvbv-intermediaire-dassurance-member": "5400fdae-2c37-4738-8667-0bcb50ed3609", + "myvbv-intermediaire-dassurance-mentor": "3bd737f9-731a-4548-aaf5-4c80175f2759", + "myvbv-intermediarioa-assicurativoa-member": "9fbaaa0f-cf8c-45f2-93f6-7174cb18a982", + "myvbv-intermediarioa-assicurativoa-mentor": "46b12e54-682e-44c0-b506-eab820138b66", + }