Merged develop into branch

This commit is contained in:
Daniel Egger 2024-07-10 18:17:33 +02:00
commit 2c4cd093a2
38 changed files with 1580 additions and 378 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -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",

View File

@ -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"

View File

@ -3,7 +3,7 @@ from _pytest.runner import runtestprotocol
def pytest_ignore_collect(path, config):
if path.basename == "test_cypress_datatrans.py":
if path.basename.startswith("test_cypress_"):
return True

View File

@ -102,7 +102,7 @@ def test_upload_abacus_xml(setup_abacus_env):
assert "<Email>andreas.feuz@eiger-versicherungen.ch</Email>" 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:

View File

@ -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,29 +571,28 @@ 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
@ -578,54 +600,57 @@ typing-inspect==0.9.0
# via libcst
ua-parser==0.18.0
# via -r requirements.in
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

View File

@ -53,3 +53,5 @@ azure-identity
boto3
openpyxl
newrelic
python-keycloak

View File

@ -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,49 +349,54 @@ 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
ua-parser==0.18.0
# via -r requirements.in
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

View File

@ -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__)
@ -145,6 +148,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()

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -4,5 +4,4 @@ from vbv_lernwelt.course_session_group.models import CourseSessionGroup
@admin.register(CourseSessionGroup)
class CourseSessionAssignmentAdmin(admin.ModelAdmin):
...
class CourseSessionAssignmentAdmin(admin.ModelAdmin): ...

View File

@ -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

View File

@ -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

View File

@ -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,8 +841,16 @@ 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
if user.language != language:
language = user.language
group = data["Klasse"].strip()
# general expert handling
@ -874,7 +888,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 +896,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:
@ -935,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
@ -989,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)

View File

@ -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):

View File

@ -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)

View File

@ -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,

View File

@ -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 = []

View File

@ -64,7 +64,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
@ -148,14 +148,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(
@ -208,17 +208,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)

View File

@ -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)

View File

@ -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",)

View File

@ -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

View File

@ -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,
),
),
],
),
]

View File

@ -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})"

View File

@ -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",
}

View File

@ -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-")]

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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)