diff --git a/env_secrets/caprover_myvbv-prod.env b/env_secrets/caprover_myvbv-prod.env index 6c858195..e57d1105 100644 Binary files a/env_secrets/caprover_myvbv-prod.env and b/env_secrets/caprover_myvbv-prod.env differ diff --git a/env_secrets/caprover_myvbv-stage.env b/env_secrets/caprover_myvbv-stage.env index c70851f3..f5c2e32e 100644 Binary files a/env_secrets/caprover_myvbv-stage.env and b/env_secrets/caprover_myvbv-stage.env differ diff --git a/env_secrets/caprover_vbv-develop.env b/env_secrets/caprover_vbv-develop.env index 613e64d5..c633f417 100644 Binary files a/env_secrets/caprover_vbv-develop.env and b/env_secrets/caprover_vbv-develop.env differ diff --git a/env_secrets/local_chrigu.env b/env_secrets/local_chrigu.env index 23475a25..163e8693 100644 Binary files a/env_secrets/local_chrigu.env and b/env_secrets/local_chrigu.env differ diff --git a/env_secrets/local_daniel.env b/env_secrets/local_daniel.env index e42a71d1..7ae4f656 100644 Binary files a/env_secrets/local_daniel.env and b/env_secrets/local_daniel.env differ diff --git a/env_secrets/prod-azure.json b/env_secrets/prod-azure.json index f99f83cc..b8650182 100644 Binary files a/env_secrets/prod-azure.json and b/env_secrets/prod-azure.json differ 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/config/settings/test_cypress_oauth.py b/server/config/settings/test_cypress_oauth.py new file mode 100644 index 00000000..4f17b0ce --- /dev/null +++ b/server/config/settings/test_cypress_oauth.py @@ -0,0 +1,39 @@ +# pylint: disable=unused-wildcard-import,wildcard-import,wrong-import-position +import os + +from dotenv import dotenv_values + +script_path = os.path.abspath(__file__) +script_dir = os.path.dirname(script_path) + +dev_env = dotenv_values(f"{script_dir}/../../../env_secrets/caprover_vbv-develop.env") + +os.environ["IT_APP_ENVIRONMENT"] = "local" + +takeover_settings = [ + "OAUTH_SIGNIN_CLIENT_ID", + "OAUTH_SIGNIN_CLIENT_SECRET", + "OAUTH_SIGNIN_URL", + "OAUTH_SIGNIN_REALM", + "OAUTH_SIGNIN_ADMIN_CLIENT_ID", + "OAUTH_SIGNIN_ADMIN_CLIENT_SECRET", + "OAUTH_SIGNIN_SERVER_METADATA_URL", +] + +for setting in takeover_settings: + os.environ[setting] = dev_env.get(setting) + +os.environ["OAUTH_SIGNUP_REDIRECT_URI"] = "http://localhost:8001/sso/login" +os.environ["OAUTH_SIGNIN_REDIRECT_URI"] = "http://localhost:8001/sso/callback" +os.environ["OAUTH_SYNC_ROLES"] = "true" + +os.environ["IT_APP_ENVIRONMENT"] = "local" +os.environ["AWS_S3_SECRET_ACCESS_KEY"] = os.environ.get( + "AWS_S3_SECRET_ACCESS_KEY", + "!!!default_for_quieting_cypress_within_pycharm!!!", +) + +from .test_cypress import * # noqa + +DATATRANS_API_ENDPOINT = "https://api.sandbox.datatrans.com" +DATATRANS_PAY_URL = "https://pay.sandbox.datatrans.com" diff --git a/server/conftest.py b/server/conftest.py index 74d55690..9690703f 100644 --- a/server/conftest.py +++ b/server/conftest.py @@ -2,6 +2,11 @@ import pytest from _pytest.runner import runtestprotocol +def pytest_ignore_collect(path, config): + if path.basename.startswith("test_cypress_"): + return True + + @pytest.hookimpl(tryfirst=True) def pytest_collection_modifyitems(config, items): parallel_items = [] diff --git a/server/requirements/requirements-dev.txt b/server/requirements/requirements-dev.txt index 7312ba40..ccbef00d 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,148 @@ 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 -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 # 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 - # via msal -pylint==2.17.5 + # via + # msal + # pyjwt +pylint==3.2.3 # 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 pytest-order==1.2.1 # via -r requirements-dev.in -pytest-sugar==0.9.7 # 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 +473,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 +513,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 +554,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 +563,7 @@ trailrunner==1.4.0 # via # ufmt # usort -traitlets==5.9.0 +traitlets==5.14.3 # via # ipython # matplotlib-inline @@ -548,82 +571,82 @@ 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 -watchfiles==0.19.0 - # via uvicorn -wcwidth==0.2.6 +watchfiles==0.22.0 + # via + # django-watchfiles + # uvicorn +wcwidth==0.2.13 # 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 -willow[heif]==1.6.1 - # via wagtail -wrapt==1.15.0 - # via astroid +willow[heif]==1.6.3 + # via + # wagtail + # willow # 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/core/models.py b/server/vbv_lernwelt/core/models.py index 781affb5..8fa757bd 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,16 @@ 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]): + 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/course/models.py b/server/vbv_lernwelt/course/models.py index aa94f21b..d8b6997a 100644 --- a/server/vbv_lernwelt/course/models.py +++ b/server/vbv_lernwelt/course/models.py @@ -192,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) @@ -210,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) 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 d100dff5..37090dca 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,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_and_update_user, create_user logger = structlog.get_logger(__name__) @@ -493,6 +494,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 +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.update_additional_json_data({"intermediate_sso_id": intermediate_sso_id}) + init_notification_settings(user) + user.set_unusable_password() user.save() @@ -835,6 +841,10 @@ 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 + create_and_update_user(user) + init_notification_settings(user) user.save() # As the is never set this is the only way to determine the correct course @@ -939,7 +949,9 @@ def create_or_update_student(data: Dict[str, Any]): ) user.language = data["Sprache"] - update_user_json_data(user, data) + + data["intermediate_sso_id"] = create_user(user) + user.update_additional_json_data(data) user.save() # general expert handling @@ -993,32 +1005,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_import_students.py b/server/vbv_lernwelt/importer/tests/test_import_students.py index 6bf3f63a..9b719644 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/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/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/shop/invoice/abacus.py b/server/vbv_lernwelt/shop/invoice/abacus.py index ffd9ee33..543f9fdd 100644 --- a/server/vbv_lernwelt/shop/invoice/abacus.py +++ b/server/vbv_lernwelt/shop/invoice/abacus.py @@ -147,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/admin.py b/server/vbv_lernwelt/sso/admin.py index 8c38f3f3..afd9b3aa 100644 --- a/server/vbv_lernwelt/sso/admin.py +++ b/server/vbv_lernwelt/sso/admin.py @@ -1,3 +1,139 @@ -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 _ +from keycloak.exceptions import KeycloakDeleteError, KeycloakPostError -# Register your models here. +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, + sync_roles_for_user, +) + +User = get_user_model() + + +def create_sso_user_from_admin(user: User, request): + try: + create_and_update_user(user) # noqa + user.save() + messages.add_message( + request, messages.SUCCESS, f"Der Bentuzer wurde in Keycloak erstellt." + ) + except KeycloakPostError as e: + messages.add_message( + request, + messages.WARNING, + f"Der Benutzer {user} konnte nicht in Keycloak erstellt werden: {e}", + ) + + +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) + ] + + 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( + request, messages.SUCCESS, f"Die Daten wurden mit Keycloak synchronisiert." + ) + except KeycloakDeleteError as e: + messages.add_message( + request, + messages.WARNING, + 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 ({user}) konnten in Keycloak nicht erstellt werden: {e}", + ) + + +@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/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/migrations/0001_initial.py b/server/vbv_lernwelt/sso/migrations/0001_initial.py new file mode 100644 index 00000000..b1e3604c --- /dev/null +++ b/server/vbv_lernwelt/sso/migrations/0001_initial.py @@ -0,0 +1,67 @@ +# 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=[ + ( + "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 new file mode 100644 index 00000000..c69a647e --- /dev/null +++ b/server/vbv_lernwelt/sso/models.py @@ -0,0 +1,28 @@ +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) + + 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/__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/roles.py b/server/vbv_lernwelt/sso/role_sync/roles.py new file mode 100644 index 00000000..15e0aa54 --- /dev/null +++ b/server/vbv_lernwelt/sso/role_sync/roles.py @@ -0,0 +1,81 @@ +from django.conf import settings + +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", + }, +} + +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", + } diff --git a/server/vbv_lernwelt/sso/role_sync/services.py b/server/vbv_lernwelt/sso/role_sync/services.py new file mode 100644 index 00000000..f3c2c563 --- /dev/null +++ b/server/vbv_lernwelt/sso/role_sync/services.py @@ -0,0 +1,177 @@ +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 vbv_lernwelt.core.models import User +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]] + +keycloak_admin = None # Needed for pytest + +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) + + +def add_roles_to_user(user: User, course_roles: CourseRolesType): + return _handle_add_remove_action( + 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, action=SsoSyncError.Action.REMOVE + ) + + +def _handle_add_remove_action( + user: User, + course_roles: CourseRolesType, + 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 + + 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 + + +def update_roles_for_user( + user: User, add_course_roles: CourseRolesType, remove_course_roles: CourseRolesType +): + 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 + 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 = _filter_non_myvbv_roles( + keycloak_admin.get_realm_roles_of_user(user_id=user_id) + ) + + if 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 + return False + + +def create_user(user: User): + if keycloak_admin: + return _kc_create_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 keycloak_admin: + return keycloak_admin.get_realm_roles_of_user( + user_id=user_id, + ) + return [] + + +# 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 + sanitized_course_slug = _remove_accents(course_slug) + 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, + label="role_sync", + ) + return request_roles + + +def _create_role_name(course_slug: str, role: str) -> List[str]: + return SSO_ROLES[course_slug][role] + + +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[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 new file mode 100644 index 00000000..56f4f83e --- /dev/null +++ b/server/vbv_lernwelt/sso/signals.py @@ -0,0 +1,124 @@ +import structlog +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, + update_roles_for_user, +) + +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): + # 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") +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 + or old_csu.course_session.course != instance.course_session.course + ): + try: + update_roles_for_user( + instance.user, + add_course_roles=[ + (instance.course_session.course.slug, instance.role) + ], + remove_course_roles=[ + (old_csu.course_session.course.slug, old_csu.role) + ], + ) + except KeycloakError: + # fail silently, error object is being created in the service + pass + + +# CourseSessionGroup +@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") + + +@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") + + +# 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( + f"Removing {role} role from user", + user=user, + course=course_slug, + label="role_sync", + ) + remove_roles_from_user(user, [(course_slug, role)]) + except KeycloakDeleteError: + # fail silently, error object is being created in the service + pass + + +def _add_sso_role(user: User, course_slug: str, role: str): + try: + logger.debug( + f"Adding {role} role to user", + user=user, + course=course_slug, + label="role_sync", + ) + add_roles_to_user(user, [(course_slug, role)]) + 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 new file mode 100644 index 00000000..4d8ade2c --- /dev/null +++ b/server/vbv_lernwelt/sso/tests/test_role_sync.py @@ -0,0 +1,166 @@ +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.models import SsoSyncError +from vbv_lernwelt.sso.role_sync.services import ( + _filter_non_myvbv_roles, + _remove_accents, + add_roles_to_user, + create_user, + remove_roles_from_user, + sync_roles_for_user, + update_roles_for_user, +) + + +@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"), + ] + 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", + }, + ] + + @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 + ) + + @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(KeycloakPostError) as cm: + add_roles_to_user(self.user, self.course_roles) + + exception = cm.exception + 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) + + @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 + ) + + @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(KeycloakDeleteError) as cm: + remove_roles_from_user(self.user, self.course_roles) + + exception = cm.exception + 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) + + @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() + + @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 + ) + + @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 + ) + + @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") + + 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) 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..70697ee7 --- /dev/null +++ b/server/vbv_lernwelt/sso/tests/test_signals.py @@ -0,0 +1,267 @@ +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_update_with_role_change( + 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_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" + + 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) + + @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) 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)