Merged in feature/VBV-692-keycloak (pull request #339)

Noch nicht mergen: Feature/VBV-692 keycloak

Approved-by: Daniel Egger
This commit is contained in:
Christian Cueni 2024-07-10 11:19:45 +00:00
commit 527da8d779
36 changed files with 1562 additions and 364 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_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 = { GRAPHENE = {
"SCHEMA": "vbv_lernwelt.core.schema.schema", "SCHEMA": "vbv_lernwelt.core.schema.schema",
"SCHEMA_OUTPUT": "../client/src/gql/schema.graphql", "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

@ -2,6 +2,11 @@ import pytest
from _pytest.runner import runtestprotocol from _pytest.runner import runtestprotocol
def pytest_ignore_collect(path, config):
if path.basename.startswith("test_cypress_"):
return True
@pytest.hookimpl(tryfirst=True) @pytest.hookimpl(tryfirst=True)
def pytest_collection_modifyitems(config, items): def pytest_collection_modifyitems(config, items):
parallel_items = [] parallel_items = []

View File

@ -2,79 +2,84 @@
# This file is autogenerated by pip-compile with Python 3.10 # This file is autogenerated by pip-compile with Python 3.10
# by the following command: # by the following command:
# #
# pip-compile --output-file=requirements-dev.txt requirements-dev.in # pip-compile requirements-dev.in
# #
aniso8601==9.0.1 aniso8601==9.0.1
# via graphene # via graphene
anyascii==0.3.2 anyascii==0.3.2
# via wagtail # via wagtail
anyio==3.7.1 anyio==4.4.0
# via watchfiles # via
appnope==0.1.3 # httpx
# via ipython # watchfiles
argon2-cffi==21.3.0 argon2-cffi==23.1.0
# via -r requirements.in # via -r requirements.in
argon2-cffi-bindings==21.2.0 argon2-cffi-bindings==21.2.0
# via argon2-cffi # via argon2-cffi
asgiref==3.7.2 asgiref==3.8.1
# via django # via
astroid==2.15.6 # django
# django-cors-headers
# django-stubs
astroid==3.2.2
# via pylint # via pylint
asttokens==2.2.1 asttokens==2.4.1
# via stack-data # via stack-data
async-timeout==4.0.2 async-property==0.2.2
# via python-keycloak
async-timeout==4.0.3
# via redis # via redis
attrs==23.1.0 attrs==23.2.0
# via # via
# jsonschema # jsonschema
# referencing # referencing
# usort # usort
authlib==1.2.1 authlib==1.3.1
# via -r requirements.in # via -r requirements.in
azure-core==1.29.1 azure-core==1.30.2
# via # via
# azure-identity # azure-identity
# azure-storage-blob # azure-storage-blob
azure-identity==1.14.0 azure-identity==1.17.0
# via -r requirements.in # via -r requirements.in
azure-storage-blob==12.17.0 azure-storage-blob==12.20.0
# via -r requirements.in # via -r requirements.in
backcall==0.2.0 bcrypt==4.1.3
# via ipython
bcrypt==4.0.1
# via paramiko # via paramiko
beautifulsoup4==4.11.2 beautifulsoup4==4.11.2
# via wagtail # via wagtail
black==23.7.0 black==24.4.2
# via # via
# -r requirements-dev.in # -r requirements-dev.in
# ufmt # ufmt
boto3==1.28.23 boto3==1.34.129
# via -r requirements.in # via -r requirements.in
botocore==1.31.23 botocore==1.34.129
# via # via
# boto3 # boto3
# s3transfer # s3transfer
brotli==1.0.9 brotli==1.1.0
# via whitenoise # via whitenoise
build==0.10.0 build==1.2.1
# via pip-tools # via pip-tools
caprover-api @ git+https://github.com/iterativ/Caprover-API.git@5013f8fc929e8e3281b9d609e968a782e8e99530 caprover-api @ git+https://github.com/iterativ/Caprover-API.git@5013f8fc929e8e3281b9d609e968a782e8e99530
# via -r requirements-dev.in # via -r requirements-dev.in
certifi==2023.7.22 certifi==2024.6.2
# via # via
# httpcore
# httpx
# requests # requests
# sentry-sdk # sentry-sdk
cffi==1.15.1 cffi==1.16.0
# via # via
# argon2-cffi-bindings # argon2-cffi-bindings
# cryptography # cryptography
# pynacl # pynacl
cfgv==3.3.1 cfgv==3.4.0
# via pre-commit # via pre-commit
charset-normalizer==3.2.0 charset-normalizer==3.3.2
# via requests # via requests
click==8.1.6 click==8.1.7
# via # via
# -r requirements.in # -r requirements.in
# black # black
@ -84,17 +89,18 @@ click==8.1.6
# ufmt # ufmt
# usort # usort
# uvicorn # uvicorn
concurrent-log-handler==0.9.24 concurrent-log-handler==0.9.25
# via -r requirements.in # via -r requirements.in
coverage==7.2.7 coverage==7.5.3
# via # via
# -r requirements-dev.in # -r requirements-dev.in
# django-coverage-plugin # django-coverage-plugin
cryptography==41.0.3 cryptography==42.0.8
# via # via
# authlib # authlib
# azure-identity # azure-identity
# azure-storage-blob # azure-storage-blob
# jwcrypto
# msal # msal
# paramiko # paramiko
# pyjwt # pyjwt
@ -104,13 +110,15 @@ decorator==5.1.1
# ipython # ipython
defusedxml==0.7.1 defusedxml==0.7.1
# via willow # via willow
dill==0.3.7 deprecation==2.1.0
# via python-keycloak
dill==0.3.8
# via pylint # via pylint
distlib==0.3.7 distlib==0.3.8
# via virtualenv # via virtualenv
dj-database-url==2.0.0 dj-database-url==2.2.0
# via -r requirements.in # via -r requirements.in
django==3.2.20 django==3.2.25
# via # via
# -r requirements.in # -r requirements.in
# dj-database-url # dj-database-url
@ -137,96 +145,99 @@ django==3.2.20
# jsonfield # jsonfield
# wagtail # wagtail
# wagtail-localize # wagtail-localize
django-click==2.3.0 django-click==2.4.0
# via -r requirements.in # via -r requirements.in
django-constance==3.1.0 django-constance==3.1.0
# via -r requirements.in # via -r requirements.in
django-cors-headers==4.2.0 django-cors-headers==4.3.1
# via -r requirements.in # via -r requirements.in
django-coverage-plugin==3.1.0 django-coverage-plugin==3.1.0
# via -r requirements-dev.in # via -r requirements-dev.in
django-csp==3.7 django-csp==3.8
# via -r requirements.in # via -r requirements.in
django-debug-toolbar==4.1.0 django-debug-toolbar==4.3.0
# via -r requirements-dev.in # via -r requirements-dev.in
django-extensions==3.2.3 django-extensions==3.2.3
# via -r requirements-dev.in # via -r requirements-dev.in
django-filter==23.2 django-filter==23.5
# via wagtail # via wagtail
django-ipware==5.0.0 django-ipware==7.0.1
# via -r requirements.in # via -r requirements.in
django-jsonform==2.18.0 django-jsonform==2.22.0
# via -r requirements.in # via -r requirements.in
django-model-utils==4.3.1 django-model-utils==4.5.1
# via # via
# -r requirements.in # -r requirements.in
# django-notifications-hq # django-notifications-hq
django-modelcluster==6.0 django-modelcluster==6.3
# via wagtail # via wagtail
django-notifications-hq==1.8.2 django-notifications-hq==1.8.3
# via -r requirements.in # via -r requirements.in
django-permissionedforms==0.1 django-permissionedforms==0.1
# via wagtail # via wagtail
django-picklefield==3.1 django-picklefield==3.2
# via django-constance # via django-constance
django-ratelimit==4.1.0 django-ratelimit==4.1.0
# via -r requirements.in # via -r requirements.in
django-redis==5.3.0 django-redis==5.4.0
# via -r requirements.in # via -r requirements.in
django-storages==1.13.2 django-storages==1.14.3
# via -r requirements.in # via -r requirements.in
django-stubs==4.2.3 django-stubs==5.0.2
# via # via
# -r requirements-dev.in # -r requirements-dev.in
# djangorestframework-stubs # djangorestframework-stubs
django-stubs-ext==4.2.2 django-stubs-ext==5.0.2
# via django-stubs # via django-stubs
django-taggit==4.0.0 django-taggit==4.0.0
# via wagtail # via wagtail
django-treebeard==4.7 django-treebeard==4.7.1
# via wagtail # 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 # via
# -r requirements.in # -r requirements.in
# drf-spectacular # drf-spectacular
# wagtail # wagtail
djangorestframework-stubs==3.14.2 djangorestframework-stubs==3.15.0
# via -r requirements-dev.in # via -r requirements-dev.in
draftjs-exporter==2.1.7 draftjs-exporter==2.1.7
# via wagtail # via wagtail
drf-spectacular==0.26.4 drf-spectacular==0.27.2
# via -r requirements.in # via -r requirements.in
environs==9.5.0 environs==11.0.0
# via -r requirements.in # via -r requirements.in
et-xmlfile==1.1.0 et-xmlfile==1.1.0
# via openpyxl # via openpyxl
exceptiongroup==1.1.2 exceptiongroup==1.2.1
# via # via
# anyio # anyio
# ipython
# pytest # pytest
execnet==2.0.2 execnet==2.1.1
# via pytest-xdist # via pytest-xdist
executing==1.2.0 executing==2.0.1
# via stack-data # via stack-data
factory-boy==3.3.0 factory-boy==3.3.0
# via # via
# -r requirements-dev.in # -r requirements-dev.in
# wagtail-factories # wagtail-factories
faker==19.3.0 faker==25.8.0
# via factory-boy # via factory-boy
filelock==3.12.2 filelock==3.15.1
# via virtualenv # via virtualenv
filetype==1.2.0 filetype==1.2.0
# via willow # via willow
flake8==6.1.0 flake8==7.1.0
# via # via
# -r requirements-dev.in # -r requirements-dev.in
# flake8-isort # flake8-isort
flake8-isort==6.0.0 flake8-isort==6.1.1
# via -r requirements-dev.in # via -r requirements-dev.in
freezegun==1.2.2 freezegun==1.5.1
# via -r requirements-dev.in # via -r requirements-dev.in
gitdb==4.0.10 gitdb==4.0.11
# via gitdb2 # via gitdb2
gitdb2==4.0.2 gitdb2==4.0.2
# via gitpython # via gitpython
@ -234,7 +245,7 @@ gitpython==3.0.6
# via trufflehog # via trufflehog
graphene==3.3 graphene==3.3
# via graphene-django # via graphene-django
graphene-django==3.1.5 graphene-django==3.2.2
# via wagtail-grapple # via wagtail-grapple
graphql-core==3.2.3 graphql-core==3.2.3
# via # via
@ -245,19 +256,26 @@ graphql-relay==3.2.0
# via # via
# graphene # graphene
# graphene-django # graphene-django
gunicorn==21.2.0 gunicorn==22.0.0
# via -r requirements.in # via -r requirements.in
h11==0.14.0 h11==0.14.0
# via uvicorn # via
# httpcore
# uvicorn
html5lib==1.1 html5lib==1.1
# via wagtail # via wagtail
httptools==0.6.0 httpcore==1.0.5
# via httpx
httptools==0.6.1
# via uvicorn # via uvicorn
identify==2.5.26 httpx==0.27.0
# via python-keycloak
identify==2.5.36
# via pre-commit # via pre-commit
idna==3.4 idna==3.7
# via # via
# anyio # anyio
# httpx
# requests # requests
inflection==0.5.1 inflection==0.5.1
# via drf-spectacular # via drf-spectacular
@ -265,15 +283,15 @@ iniconfig==2.0.0
# via pytest # via pytest
ipdb==0.13.13 ipdb==0.13.13
# via -r requirements-dev.in # via -r requirements-dev.in
ipython==8.14.0 ipython==8.25.0
# via ipdb # via ipdb
isodate==0.6.1 isodate==0.6.1
# via azure-storage-blob # via azure-storage-blob
isort==5.12.0 isort==5.13.2
# via # via
# flake8-isort # flake8-isort
# pylint # pylint
jedi==0.19.0 jedi==0.19.1
# via ipython # via ipython
jmespath==1.0.1 jmespath==1.0.1
# via # via
@ -281,21 +299,21 @@ jmespath==1.0.1
# botocore # botocore
jsonfield==3.1.0 jsonfield==3.1.0
# via django-notifications-hq # via django-notifications-hq
jsonschema==4.19.0 jsonschema==4.22.0
# via drf-spectacular # via drf-spectacular
jsonschema-specifications==2023.7.1 jsonschema-specifications==2023.12.1
# via jsonschema # via jsonschema
jwcrypto==1.5.6
# via python-keycloak
l18n==2021.3 l18n==2021.3
# via wagtail # via wagtail
lazy-object-proxy==1.9.0 libcst==1.4.0
# via astroid
libcst==1.0.1
# via # via
# ufmt # ufmt
# usort # usort
marshmallow==3.20.1 marshmallow==3.21.3
# via environs # via environs
matplotlib-inline==0.1.6 matplotlib-inline==0.1.7
# via ipython # via ipython
mccabe==0.7.0 mccabe==0.7.0
# via # via
@ -305,148 +323,148 @@ moreorless==0.4.0
# via # via
# ufmt # ufmt
# usort # usort
msal==1.23.0 msal==1.28.1
# via # via
# azure-identity # azure-identity
# msal-extensions # msal-extensions
msal-extensions==1.0.0 msal-extensions==1.1.0
# via azure-identity # via azure-identity
mypy==1.4.1 mypy==1.10.0
# via # via -r requirements-dev.in
# -r requirements-dev.in
# django-stubs
# djangorestframework-stubs
mypy-extensions==1.0.0 mypy-extensions==1.0.0
# via # via
# black # black
# mypy # mypy
# typing-inspect newrelic==9.11.0
newrelic==8.11.0
# via -r requirements.in # via -r requirements.in
nodeenv==1.8.0 nodeenv==1.9.1
# via pre-commit # via pre-commit
openpyxl==3.1.2 openpyxl==3.1.4
# via # via
# -r requirements.in # -r requirements.in
# wagtail # wagtail
packaging==23.1 packaging==24.1
# via # via
# black # black
# build # build
# deprecation
# gunicorn # gunicorn
# marshmallow # marshmallow
# msal-extensions
# pytest # pytest
# pytest-sugar # pytest-sugar
paramiko==3.3.1 paramiko==3.4.0
# via # via -r requirements.in
# -r requirements.in parso==0.8.4
# sftpserver
parso==0.8.3
# via jedi # via jedi
pathspec==0.11.2 pathspec==0.12.1
# via # via
# black # black
# trailrunner # trailrunner
pexpect==4.8.0 pexpect==4.9.0
# via ipython # via ipython
pickleshare==0.7.5 pillow==10.3.0
# via ipython
pillow==10.0.0
# via # via
# -r requirements.in # -r requirements.in
# pillow-heif # pillow-heif
# wagtail # wagtail
pillow-heif==0.13.0 pillow-heif==0.16.0
# via willow # via willow
pip-tools==7.3.0 pip-tools==7.4.1
# via -r requirements-dev.in # via -r requirements-dev.in
platformdirs==3.10.0 platformdirs==4.2.2
# via # via
# black # black
# pylint # pylint
# virtualenv # virtualenv
pluggy==1.2.0 pluggy==1.5.0
# via pytest # via pytest
polib==1.2.0 polib==1.2.0
# via wagtail-localize # via wagtail-localize
portalocker==2.7.0 portalocker==2.8.2
# via # via
# concurrent-log-handler # concurrent-log-handler
# msal-extensions # msal-extensions
pre-commit==3.3.3 pre-commit==3.7.1
# via -r requirements-dev.in # via -r requirements-dev.in
promise==2.3 promise==2.3
# via graphene-django # via graphene-django
prompt-toolkit==3.0.39 prompt-toolkit==3.0.47
# via ipython # via ipython
psycopg2-binary==2.9.7 psycopg2-binary==2.9.9
# via -r requirements.in # via -r requirements.in
ptyprocess==0.7.0 ptyprocess==0.7.0
# via pexpect # via pexpect
pure-eval==0.2.2 pure-eval==0.2.2
# via stack-data # via stack-data
pycodestyle==2.11.0 pycodestyle==2.12.0
# via flake8 # via flake8
pycparser==2.21 pycparser==2.22
# via cffi # via cffi
pycryptodome==3.18.0 pycryptodome==3.20.0
# via -r requirements.in # via -r requirements.in
pyflakes==3.1.0 pyflakes==3.2.0
# via flake8 # via flake8
pygments==2.16.1 pygments==2.18.0
# via ipython # via ipython
pyjwt[crypto]==2.8.0 pyjwt[crypto]==2.8.0
# via msal # via
pylint==2.17.5 # msal
# pyjwt
pylint==3.2.3
# via # via
# pylint-django # pylint-django
# pylint-plugin-utils # pylint-plugin-utils
pylint-django==2.5.3 pylint-django==2.5.5
# via -r requirements-dev.in # via -r requirements-dev.in
pylint-plugin-utils==0.8.2 pylint-plugin-utils==0.8.2
# via pylint-django # via pylint-django
pynacl==1.5.0 pynacl==1.5.0
# via paramiko # via paramiko
pyproject-hooks==1.0.0 pyproject-hooks==1.1.0
# via build # via
pytest==7.4.0 # build
# pip-tools
pytest==8.2.2
# via # via
# -r requirements-dev.in # -r requirements-dev.in
# pytest-django # pytest-django
# pytest-order # pytest-order
# pytest-sugar # pytest-sugar
# pytest-xdist # pytest-xdist
pytest-django==4.5.2 pytest-django==4.8.0
# via -r requirements-dev.in # via -r requirements-dev.in
pytest-order==1.2.1 pytest-order==1.2.1
# via -r requirements-dev.in # via -r requirements-dev.in
pytest-sugar==0.9.7
# via -r requirements-dev.in # via -r requirements-dev.in
pytest-xdist==3.5.0 pytest-xdist==3.6.1
# via -r requirements-dev.in # via -r requirements-dev.in
python-dateutil==2.8.2 python-dateutil==2.9.0.post0
# via # via
# -r requirements.in # -r requirements.in
# botocore # botocore
# faker # faker
# freezegun # freezegun
python-dotenv==1.0.0 python-dotenv==1.0.1
# via # via
# environs # environs
# uvicorn # uvicorn
python-http-client==3.3.7 python-http-client==3.3.7
# via sendgrid # via sendgrid
python-ipware==3.0.0
# via django-ipware
python-json-logger==2.0.7 python-json-logger==2.0.7
# via -r requirements.in # via -r requirements.in
python-slugify==8.0.1 python-keycloak==4.1.0
# via -r requirements.in # via -r requirements.in
pytz==2023.3 python-slugify==8.0.4
# via -r requirements.in
pytz==2024.1
# via # via
# -r requirements.in # -r requirements.in
# django # django
# django-modelcluster # django-modelcluster
# django-notifications-hq # django-notifications-hq
# djangorestframework
# l18n # l18n
pyyaml==6.0.1 pyyaml==6.0.1
# via # via
@ -455,30 +473,34 @@ pyyaml==6.0.1
# libcst # libcst
# pre-commit # pre-commit
# uvicorn # uvicorn
redis==4.6.0 redis==5.0.6
# via # via
# -r requirements.in # -r requirements.in
# django-redis # django-redis
referencing==0.30.2 referencing==0.35.1
# via # via
# jsonschema # jsonschema
# jsonschema-specifications # jsonschema-specifications
requests==2.31.0 requests==2.32.3
# via # via
# azure-core # azure-core
# caprover-api # caprover-api
# djangorestframework-stubs # djangorestframework-stubs
# msal # msal
# python-keycloak
# requests-toolbelt
# wagtail # wagtail
rpds-py==0.9.2 requests-toolbelt==1.0.0
# via python-keycloak
rpds-py==0.18.1
# via # via
# jsonschema # jsonschema
# referencing # referencing
s3transfer==0.6.1 s3transfer==0.10.1
# via boto3 # via boto3
sendgrid==6.10.0 sendgrid==6.11.0
# via -r requirements.in # via -r requirements.in
sentry-sdk==1.29.2 sentry-sdk==2.5.1
# via -r requirements.in # via -r requirements.in
sftpserver @ git+https://github.com/lonetwin/sftpserver.git@1d16896d3f0f90d63d1caaf4e199f2a9dde6456f sftpserver @ git+https://github.com/lonetwin/sftpserver.git@1d16896d3f0f90d63d1caaf4e199f2a9dde6456f
# via -r requirements-dev.in # via -r requirements-dev.in
@ -491,29 +513,31 @@ six==1.16.0
# l18n # l18n
# promise # promise
# python-dateutil # python-dateutil
smmap==5.0.0 smmap==5.0.1
# via gitdb # via gitdb
sniffio==1.3.0 sniffio==1.3.1
# via anyio # via
soupsieve==2.4.1 # anyio
# httpx
soupsieve==2.5
# via beautifulsoup4 # via beautifulsoup4
sqlparse==0.4.4 sqlparse==0.5.0
# via # via
# django # django
# django-debug-toolbar # django-debug-toolbar
stack-data==0.6.2 stack-data==0.6.3
# via ipython # via ipython
starkbank-ecdsa==2.2.0 starkbank-ecdsa==2.2.0
# via sendgrid # via sendgrid
stdlibs==2022.10.9 stdlibs==2024.5.15
# via usort # via usort
structlog==23.1.0 structlog==24.2.0
# via -r requirements.in # via -r requirements.in
swapper==1.3.0 swapper==1.3.0
# via django-notifications-hq # via django-notifications-hq
telepath==0.3.1 telepath==0.3.1
# via wagtail # via wagtail
termcolor==2.3.0 termcolor==2.4.0
# via pytest-sugar # via pytest-sugar
text-unidecode==1.3 text-unidecode==1.3
# via # via
@ -530,9 +554,8 @@ tomli==2.0.1
# mypy # mypy
# pip-tools # pip-tools
# pylint # pylint
# pyproject-hooks
# pytest # pytest
tomlkit==0.12.1 tomlkit==0.12.5
# via # via
# pylint # pylint
# ufmt # ufmt
@ -540,7 +563,7 @@ trailrunner==1.4.0
# via # via
# ufmt # ufmt
# usort # usort
traitlets==5.9.0 traitlets==5.14.3
# via # via
# ipython # ipython
# matplotlib-inline # matplotlib-inline
@ -548,82 +571,82 @@ trufflehog==2.2.1
# via -r requirements-dev.in # via -r requirements-dev.in
trufflehogregexes==0.0.7 trufflehogregexes==0.0.7
# via trufflehog # via trufflehog
types-pytz==2023.3.0.0 types-pyyaml==6.0.12.20240311
# via django-stubs
types-pyyaml==6.0.12.11
# via # via
# django-stubs # django-stubs
# djangorestframework-stubs # djangorestframework-stubs
types-requests==2.31.0.2 types-requests==2.32.0.20240602
# via djangorestframework-stubs # via djangorestframework-stubs
types-urllib3==1.26.25.14 typing-extensions==4.12.2
# via types-requests
typing-extensions==4.7.1
# via # via
# anyio
# asgiref # asgiref
# astroid # astroid
# azure-core # azure-core
# azure-identity
# azure-storage-blob # azure-storage-blob
# black
# dj-database-url # dj-database-url
# django-stubs # django-stubs
# django-stubs-ext # django-stubs-ext
# djangorestframework-stubs # djangorestframework-stubs
# libcst # ipython
# jwcrypto
# mypy # mypy
# typing-inspect
# ufmt # ufmt
# uvicorn # uvicorn
# wagtail-localize # wagtail-localize
typing-inspect==0.9.0 ufmt==2.7.0
# via libcst
ufmt==2.2.0
# via -r requirements-dev.in # via -r requirements-dev.in
uritemplate==4.1.1 uritemplate==4.1.1
# via drf-spectacular # via drf-spectacular
urllib3==1.26.16 urllib3==2.2.2
# via # via
# botocore # botocore
# requests # requests
# sentry-sdk # sentry-sdk
usort==1.0.7 # types-requests
usort==1.0.8.post1
# via ufmt # via ufmt
uvicorn[standard]==0.23.2 uvicorn[standard]==0.30.1
# via -r requirements.in # via -r requirements.in
uvloop==0.17.0 uvloop==0.19.0
# via uvicorn # via uvicorn
virtualenv==20.24.2 virtualenv==20.26.2
# via pre-commit # via pre-commit
wagtail==5.1 wagtail==5.2.5
# via # via
# -r requirements.in # -r requirements.in
# wagtail-factories # wagtail-factories
# wagtail-grapple # wagtail-grapple
# wagtail-headless-preview # wagtail-headless-preview
# wagtail-localize # wagtail-localize
wagtail-factories==4.1.0 wagtail-factories==4.2.1
# via -r requirements.in # via -r requirements.in
wagtail-grapple==0.20.0 wagtail-grapple==0.25.1
# via -r requirements.in # via -r requirements.in
wagtail-headless-preview==0.6.0 wagtail-headless-preview==0.8.0
# via wagtail-grapple # via wagtail-grapple
wagtail-localize==1.5.1 wagtail-localize==1.9
# via -r requirements.in # via -r requirements.in
watchfiles==0.19.0 watchfiles==0.22.0
# via uvicorn # via
wcwidth==0.2.6 # django-watchfiles
# uvicorn
wcwidth==0.2.13
# via prompt-toolkit # via prompt-toolkit
webencodings==0.5.1 webencodings==0.5.1
# via html5lib # via html5lib
websockets==11.0.3 websockets==12.0
# via uvicorn # via uvicorn
wheel==0.41.1 wheel==0.43.0
# via pip-tools # via pip-tools
whitenoise[brotli]==6.5.0 whitenoise[brotli]==6.6.0
# via -r requirements.in # via -r requirements.in
willow[heif]==1.6.1 willow[heif]==1.6.3
# via wagtail # via
wrapt==1.15.0 # wagtail
# via astroid # willow
# The following packages are considered to be unsafe in a requirements file: # The following packages are considered to be unsafe in a requirements file:
# pip # pip

View File

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

View File

@ -2,79 +2,90 @@
# This file is autogenerated by pip-compile with Python 3.10 # This file is autogenerated by pip-compile with Python 3.10
# by the following command: # by the following command:
# #
# pip-compile --output-file=requirements.txt requirements.in # pip-compile requirements.in
# #
aniso8601==9.0.1 aniso8601==9.0.1
# via graphene # via graphene
anyascii==0.3.2 anyascii==0.3.2
# via wagtail # via wagtail
anyio==3.7.1 anyio==4.4.0
# via watchfiles # via
argon2-cffi==21.3.0 # httpx
# watchfiles
argon2-cffi==23.1.0
# via -r requirements.in # via -r requirements.in
argon2-cffi-bindings==21.2.0 argon2-cffi-bindings==21.2.0
# via argon2-cffi # via argon2-cffi
asgiref==3.7.2 asgiref==3.8.1
# via django # via
async-timeout==4.0.2 # django
# django-cors-headers
async-property==0.2.2
# via python-keycloak
async-timeout==4.0.3
# via redis # via redis
attrs==23.1.0 attrs==23.2.0
# via # via
# jsonschema # jsonschema
# referencing # referencing
authlib==1.2.1 authlib==1.3.1
# via -r requirements.in # via -r requirements.in
azure-core==1.29.1 azure-core==1.30.2
# via # via
# azure-identity # azure-identity
# azure-storage-blob # azure-storage-blob
azure-identity==1.14.0 azure-identity==1.17.0
# via -r requirements.in # via -r requirements.in
azure-storage-blob==12.17.0 azure-storage-blob==12.20.0
# via -r requirements.in # via -r requirements.in
bcrypt==4.0.1 bcrypt==4.1.3
# via paramiko # via paramiko
beautifulsoup4==4.11.2 beautifulsoup4==4.11.2
# via wagtail # via wagtail
boto3==1.28.23 boto3==1.34.129
# via -r requirements.in # via -r requirements.in
botocore==1.31.23 botocore==1.34.129
# via # via
# boto3 # boto3
# s3transfer # s3transfer
brotli==1.0.9 brotli==1.1.0
# via whitenoise # via whitenoise
certifi==2023.7.22 certifi==2024.6.2
# via # via
# httpcore
# httpx
# requests # requests
# sentry-sdk # sentry-sdk
cffi==1.15.1 cffi==1.16.0
# via # via
# argon2-cffi-bindings # argon2-cffi-bindings
# cryptography # cryptography
# pynacl # pynacl
charset-normalizer==3.2.0 charset-normalizer==3.3.2
# via requests # via requests
click==8.1.6 click==8.1.7
# via # via
# -r requirements.in # -r requirements.in
# django-click # django-click
# uvicorn # uvicorn
concurrent-log-handler==0.9.24 concurrent-log-handler==0.9.25
# via -r requirements.in # via -r requirements.in
cryptography==41.0.3 cryptography==42.0.8
# via # via
# authlib # authlib
# azure-identity # azure-identity
# azure-storage-blob # azure-storage-blob
# jwcrypto
# msal # msal
# paramiko # paramiko
# pyjwt # pyjwt
defusedxml==0.7.1 defusedxml==0.7.1
# via willow # 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 # via -r requirements.in
django==3.2.20 django==3.2.25
# via # via
# -r requirements.in # -r requirements.in
# dj-database-url # dj-database-url
@ -97,66 +108,66 @@ django==3.2.20
# jsonfield # jsonfield
# wagtail # wagtail
# wagtail-localize # wagtail-localize
django-click==2.3.0 django-click==2.4.0
# via -r requirements.in # via -r requirements.in
django-constance==3.1.0 django-constance==3.1.0
# via -r requirements.in # via -r requirements.in
django-cors-headers==4.2.0 django-cors-headers==4.3.1
# via -r requirements.in # via -r requirements.in
django-csp==3.7 django-csp==3.8
# via -r requirements.in # via -r requirements.in
django-filter==23.2 django-filter==23.5
# via wagtail # via wagtail
django-ipware==5.0.0 django-ipware==7.0.1
# via -r requirements.in # via -r requirements.in
django-jsonform==2.18.0 django-jsonform==2.22.0
# via -r requirements.in # via -r requirements.in
django-model-utils==4.3.1 django-model-utils==4.5.1
# via # via
# -r requirements.in # -r requirements.in
# django-notifications-hq # django-notifications-hq
django-modelcluster==6.0 django-modelcluster==6.3
# via wagtail # via wagtail
django-notifications-hq==1.8.2 django-notifications-hq==1.8.3
# via -r requirements.in # via -r requirements.in
django-permissionedforms==0.1 django-permissionedforms==0.1
# via wagtail # via wagtail
django-picklefield==3.1 django-picklefield==3.2
# via django-constance # via django-constance
django-ratelimit==4.1.0 django-ratelimit==4.1.0
# via -r requirements.in # via -r requirements.in
django-redis==5.3.0 django-redis==5.4.0
# via -r requirements.in # via -r requirements.in
django-storages==1.13.2 django-storages==1.14.3
# via -r requirements.in # via -r requirements.in
django-taggit==4.0.0 django-taggit==4.0.0
# via wagtail # via wagtail
django-treebeard==4.7 django-treebeard==4.7.1
# via wagtail # via wagtail
djangorestframework==3.14.0 djangorestframework==3.15.1
# via # via
# -r requirements.in # -r requirements.in
# drf-spectacular # drf-spectacular
# wagtail # wagtail
draftjs-exporter==2.1.7 draftjs-exporter==2.1.7
# via wagtail # via wagtail
drf-spectacular==0.26.4 drf-spectacular==0.27.2
# via -r requirements.in # via -r requirements.in
environs==9.5.0 environs==11.0.0
# via -r requirements.in # via -r requirements.in
et-xmlfile==1.1.0 et-xmlfile==1.1.0
# via openpyxl # via openpyxl
exceptiongroup==1.1.2 exceptiongroup==1.2.1
# via anyio # via anyio
factory-boy==3.3.0 factory-boy==3.3.0
# via wagtail-factories # via wagtail-factories
faker==19.3.0 faker==25.8.0
# via factory-boy # via factory-boy
filetype==1.2.0 filetype==1.2.0
# via willow # via willow
graphene==3.3 graphene==3.3
# via graphene-django # via graphene-django
graphene-django==3.1.5 graphene-django==3.2.2
# via wagtail-grapple # via wagtail-grapple
graphql-core==3.2.3 graphql-core==3.2.3
# via # via
@ -167,17 +178,24 @@ graphql-relay==3.2.0
# via # via
# graphene # graphene
# graphene-django # graphene-django
gunicorn==21.2.0 gunicorn==22.0.0
# via -r requirements.in # via -r requirements.in
h11==0.14.0 h11==0.14.0
# via uvicorn # via
# httpcore
# uvicorn
html5lib==1.1 html5lib==1.1
# via wagtail # via wagtail
httptools==0.6.0 httpcore==1.0.5
# via httpx
httptools==0.6.1
# via uvicorn # via uvicorn
idna==3.4 httpx==0.27.0
# via python-keycloak
idna==3.7
# via # via
# anyio # anyio
# httpx
# requests # requests
inflection==0.5.1 inflection==0.5.1
# via drf-spectacular # via drf-spectacular
@ -189,106 +207,119 @@ jmespath==1.0.1
# botocore # botocore
jsonfield==3.1.0 jsonfield==3.1.0
# via django-notifications-hq # via django-notifications-hq
jsonschema==4.19.0 jsonschema==4.22.0
# via drf-spectacular # via drf-spectacular
jsonschema-specifications==2023.7.1 jsonschema-specifications==2023.12.1
# via jsonschema # via jsonschema
jwcrypto==1.5.6
# via python-keycloak
l18n==2021.3 l18n==2021.3
# via wagtail # via wagtail
marshmallow==3.20.1 marshmallow==3.21.3
# via environs # via environs
msal==1.23.0 msal==1.28.1
# via # via
# azure-identity # azure-identity
# msal-extensions # msal-extensions
msal-extensions==1.0.0 msal-extensions==1.1.0
# via azure-identity # via azure-identity
newrelic==8.11.0 newrelic==9.11.0
# via -r requirements.in # via -r requirements.in
openpyxl==3.1.2 openpyxl==3.1.4
# via # via
# -r requirements.in # -r requirements.in
# wagtail # wagtail
packaging==23.1 packaging==24.1
# via # via
# deprecation
# gunicorn # gunicorn
# marshmallow # marshmallow
paramiko==3.3.1 # msal-extensions
paramiko==3.4.0
# via -r requirements.in # via -r requirements.in
pillow==10.0.0 pillow==10.3.0
# via # via
# -r requirements.in # -r requirements.in
# pillow-heif # pillow-heif
# wagtail # wagtail
pillow-heif==0.13.0 pillow-heif==0.16.0
# via willow # via willow
polib==1.2.0 polib==1.2.0
# via wagtail-localize # via wagtail-localize
portalocker==2.7.0 portalocker==2.8.2
# via # via
# concurrent-log-handler # concurrent-log-handler
# msal-extensions # msal-extensions
promise==2.3 promise==2.3
# via graphene-django # via graphene-django
psycopg2-binary==2.9.7 psycopg2-binary==2.9.9
# via -r requirements.in # via -r requirements.in
pycparser==2.21 pycparser==2.22
# via cffi # via cffi
pycryptodome==3.18.0 pycryptodome==3.20.0
# via -r requirements.in # via -r requirements.in
pyjwt[crypto]==2.8.0 pyjwt[crypto]==2.8.0
# via msal # via
# msal
# pyjwt
pynacl==1.5.0 pynacl==1.5.0
# via paramiko # via paramiko
python-dateutil==2.8.2 python-dateutil==2.9.0.post0
# via # via
# -r requirements.in # -r requirements.in
# botocore # botocore
# faker # faker
python-dotenv==1.0.0 python-dotenv==1.0.1
# via # via
# environs # environs
# uvicorn # uvicorn
python-http-client==3.3.7 python-http-client==3.3.7
# via sendgrid # via sendgrid
python-ipware==3.0.0
# via django-ipware
python-json-logger==2.0.7 python-json-logger==2.0.7
# via -r requirements.in # via -r requirements.in
python-slugify==8.0.1 python-keycloak==4.1.0
# via -r requirements.in # via -r requirements.in
pytz==2023.3 python-slugify==8.0.4
# via -r requirements.in
pytz==2024.1
# via # via
# -r requirements.in # -r requirements.in
# django # django
# django-modelcluster # django-modelcluster
# django-notifications-hq # django-notifications-hq
# djangorestframework
# l18n # l18n
pyyaml==6.0.1 pyyaml==6.0.1
# via # via
# drf-spectacular # drf-spectacular
# uvicorn # uvicorn
redis==4.6.0 redis==5.0.6
# via # via
# -r requirements.in # -r requirements.in
# django-redis # django-redis
referencing==0.30.2 referencing==0.35.1
# via # via
# jsonschema # jsonschema
# jsonschema-specifications # jsonschema-specifications
requests==2.31.0 requests==2.32.3
# via # via
# azure-core # azure-core
# msal # msal
# python-keycloak
# requests-toolbelt
# wagtail # wagtail
rpds-py==0.9.2 requests-toolbelt==1.0.0
# via python-keycloak
rpds-py==0.18.1
# via # via
# jsonschema # jsonschema
# referencing # referencing
s3transfer==0.6.1 s3transfer==0.10.1
# via boto3 # via boto3
sendgrid==6.10.0 sendgrid==6.11.0
# via -r requirements.in # via -r requirements.in
sentry-sdk==1.29.2 sentry-sdk==2.5.1
# via -r requirements.in # via -r requirements.in
six==1.16.0 six==1.16.0
# via # via
@ -298,15 +329,17 @@ six==1.16.0
# l18n # l18n
# promise # promise
# python-dateutil # python-dateutil
sniffio==1.3.0 sniffio==1.3.1
# via anyio # via
soupsieve==2.4.1 # anyio
# httpx
soupsieve==2.5
# via beautifulsoup4 # via beautifulsoup4
sqlparse==0.4.4 sqlparse==0.5.0
# via django # via django
starkbank-ecdsa==2.2.0 starkbank-ecdsa==2.2.0
# via sendgrid # via sendgrid
structlog==23.1.0 structlog==24.2.0
# via -r requirements.in # via -r requirements.in
swapper==1.3.0 swapper==1.3.0
# via django-notifications-hq # via django-notifications-hq
@ -316,47 +349,52 @@ text-unidecode==1.3
# via # via
# graphene-django # graphene-django
# python-slugify # python-slugify
typing-extensions==4.7.1 typing-extensions==4.12.2
# via # via
# anyio
# asgiref # asgiref
# azure-core # azure-core
# azure-identity
# azure-storage-blob # azure-storage-blob
# dj-database-url # dj-database-url
# jwcrypto
# uvicorn # uvicorn
# wagtail-localize # wagtail-localize
uritemplate==4.1.1 uritemplate==4.1.1
# via drf-spectacular # via drf-spectacular
urllib3==1.26.16 urllib3==2.2.2
# via # via
# botocore # botocore
# requests # requests
# sentry-sdk # sentry-sdk
uvicorn[standard]==0.23.2 uvicorn[standard]==0.30.1
# via -r requirements.in # via -r requirements.in
uvloop==0.17.0 uvloop==0.19.0
# via uvicorn # via uvicorn
wagtail==5.1 wagtail==5.2.5
# via # via
# -r requirements.in # -r requirements.in
# wagtail-factories # wagtail-factories
# wagtail-grapple # wagtail-grapple
# wagtail-headless-preview # wagtail-headless-preview
# wagtail-localize # wagtail-localize
wagtail-factories==4.1.0 wagtail-factories==4.2.1
# via -r requirements.in # via -r requirements.in
wagtail-grapple==0.20.0 wagtail-grapple==0.25.1
# via -r requirements.in # via -r requirements.in
wagtail-headless-preview==0.6.0 wagtail-headless-preview==0.8.0
# via wagtail-grapple # via wagtail-grapple
wagtail-localize==1.5.1 wagtail-localize==1.9
# via -r requirements.in # via -r requirements.in
watchfiles==0.19.0 watchfiles==0.22.0
# via uvicorn # via uvicorn
webencodings==0.5.1 webencodings==0.5.1
# via html5lib # via html5lib
websockets==11.0.3 websockets==12.0
# via uvicorn # via uvicorn
whitenoise[brotli]==6.5.0 whitenoise[brotli]==6.6.0
# via -r requirements.in # via -r requirements.in
willow[heif]==1.6.1 willow[heif]==1.6.3
# via wagtail # via
# wagtail
# willow

View File

@ -1,4 +1,5 @@
import uuid import uuid
from typing import Any, Dict
import structlog import structlog
from django.contrib.auth.models import AbstractUser 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.db.models import JSONField, Max
from django.urls import reverse from django.urls import reverse
from vbv_lernwelt.core.utils import sanitize_json_data_input
logger = structlog.get_logger(__name__) logger = structlog.get_logger(__name__)
@ -140,6 +143,16 @@ class User(AbstractUser):
logger.warn("could not create avatar url", label="security", exc_info=True) logger.warn("could not create avatar url", label="security", exc_info=True)
return "/static/avatars/myvbv-default-avatar.png" 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 @property
def avatar_url(self): def avatar_url(self):
return self.create_avatar_url() return self.create_avatar_url()

View File

@ -1,7 +1,10 @@
from datetime import date, datetime, time
from unittest import skip from unittest import skip
from django.test import TestCase from django.test import TestCase
from vbv_lernwelt.core.utils import sanitize_json_data_input
class SimpleTestCase(TestCase): class SimpleTestCase(TestCase):
def test_simple(self): def test_simple(self):
@ -10,3 +13,32 @@ class SimpleTestCase(TestCase):
@skip("Do not fail in pipelines") @skip("Do not fail in pipelines")
def test_fail(self): def test_fail(self):
self.assertEqual(1, 2) 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 json
import re import re
from datetime import date, datetime, time
from typing import Any, Dict
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from rest_framework import serializers from rest_framework import serializers
@ -65,3 +67,20 @@ def safe_deque_popleft(deq, default=None):
return deq.popleft() return deq.popleft()
except IndexError: except IndexError:
return default 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" 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): class CourseCompletion(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
@ -210,8 +216,8 @@ class CourseCompletion(models.Model):
completion_status = models.CharField( completion_status = models.CharField(
max_length=255, max_length=255,
choices=[(status, status.value) for status in CourseCompletionStatus], choices=CourseCompletionStatusChoices.choices,
default=CourseCompletionStatus.UNKNOWN.value, default=CourseCompletionStatus.UNKNOWN,
) )
additional_json_data = models.JSONField(default=dict, blank=True) 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) @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: def generate_export_response(cs_users: List[User]) -> HttpResponse:
response = HttpResponse(content_type="text/csv; charset=utf-8") response = HttpResponse(content_type="text/csv; charset=utf-8")
response[ response["Content-Disposition"] = (
"Content-Disposition" f"attachment; filename=edoniq_user_export_{date.today().strftime('%Y%m%d')}.csv"
] = f"attachment; filename=edoniq_user_export_{date.today().strftime('%Y%m%d')}.csv" )
response.write("\ufeff".encode("utf8")) # UTF-8 BOM 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( response = HttpResponse(
content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
) )
response[ response["Content-Disposition"] = (
"Content-Disposition" f"attachment; filename={make_export_filename(file_name)}"
] = f"attachment; filename={make_export_filename(file_name)}" )
response.write(excel_bytes) response.write(excel_bytes)
return response 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 from typing import Any, Dict, List
import structlog import structlog
@ -25,6 +25,7 @@ from vbv_lernwelt.learnpath.models import (
LearningContentEdoniqTest, LearningContentEdoniqTest,
) )
from vbv_lernwelt.notify.models import NotificationCategory 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__) logger = structlog.get_logger(__name__)
@ -493,6 +494,7 @@ def create_or_update_user(
sso_id: str = None, sso_id: str = None,
contract_number: str = "", contract_number: str = "",
date_of_birth: str = "", date_of_birth: str = "",
intermediate_sso_id: str = "", # from keycloak
) -> User: ) -> User:
logger.debug( logger.debug(
"create_or_update_user", "create_or_update_user",
@ -537,6 +539,10 @@ def create_or_update_user(
user.first_name = first_name or user.first_name user.first_name = first_name or user.first_name
user.last_name = last_name or user.last_name user.last_name = last_name or user.last_name
user.username = email user.username = email
user.update_additional_json_data({"intermediate_sso_id": intermediate_sso_id})
init_notification_settings(user)
user.set_unusable_password() user.set_unusable_password()
user.save() user.save()
@ -835,6 +841,10 @@ def create_or_update_trainer(course: Course, data: Dict[str, Any], language="de"
last_name=data["Name"], last_name=data["Name"],
) )
user.language = data["Sprache"] user.language = data["Sprache"]
# create user in intermediate sso i.e. Keycloak
create_and_update_user(user)
init_notification_settings(user)
user.save() user.save()
# As the is never set this is the only way to determine the correct course # 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"] 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() user.save()
# general expert handling # general expert handling
@ -993,32 +1005,12 @@ def sync_students_from_t2l(data):
except KeyError: except KeyError:
pass pass
update_user_json_data(user, data) user.update_additional_json_data(data)
user.save() user.save()
def update_user_json_data(user: User, data: Dict[str, Any]): def init_notification_settings(user: User):
# Set E-Mail notification settings for new users data = {
user.additional_json_data = user.additional_json_data | sanitize_json_data_input( "email_notification_categories": [str(NotificationCategory.INFORMATION)],
{ }
**data, user.update_additional_json_data(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

View File

@ -53,6 +53,7 @@ class CreateOrUpdateStudentTestCase(TestCase):
"Tel. Privat": "079 593 83 43", "Tel. Privat": "079 593 83 43",
"Geburtsdatum": "01.01.2000", "Geburtsdatum": "01.01.2000",
"email_notification_categories": ["INFORMATION"], "email_notification_categories": ["INFORMATION"],
"intermediate_sso_id": "",
} }
def test_create_student(self): def test_create_student(self):

View File

@ -1,5 +1,4 @@
import os import os
from datetime import date, datetime, time
from django.test import TestCase 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.course.models import CourseSession, CourseSessionUser
from vbv_lernwelt.importer.services import ( from vbv_lernwelt.importer.services import (
create_or_update_student, create_or_update_student,
sanitize_json_data_input,
sync_students_from_t2l, sync_students_from_t2l,
) )
@ -151,32 +149,3 @@ class SyncT2lTestCase(TestCase):
self.fail( self.fail(
f"SyncT2lTestCase.test_ignors_wrong_contract_number: An exception was unexpectedly raised: {str(e)}" 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) self.assertFalse(notification.emailed)
def test_send_notification_with_email(self): def test_send_notification_with_email(self):
self.recipient.additional_json_data[ self.recipient.additional_json_data["email_notification_categories"] = (
"email_notification_categories" json.dumps(["USER_INTERACTION"])
] = json.dumps(["USER_INTERACTION"]) )
self.recipient.save() self.recipient.save()
verb = "Anne hat deinen Auftrag bewertet" verb = "Anne hat deinen Auftrag bewertet"
@ -146,9 +146,9 @@ class TestNotificationService(TestCase):
self.assertFalse(notification.emailed) self.assertFalse(notification.emailed)
# when the email was not sent, yet it will still send it afterwards... # when the email was not sent, yet it will still send it afterwards...
self.recipient.additional_json_data[ self.recipient.additional_json_data["email_notification_categories"] = (
"email_notification_categories" json.dumps(["USER_INTERACTION"])
] = json.dumps(["USER_INTERACTION"]) )
self.recipient.save() self.recipient.save()
result = self.notification_service._send_notification( result = self.notification_service._send_notification(
@ -188,9 +188,9 @@ class TestNotificationService(TestCase):
self.assertFalse(self._has_sent_emails()) self.assertFalse(self._has_sent_emails())
# Assert mail is sent if corresponding email notification type is enabled # Assert mail is sent if corresponding email notification type is enabled
self.recipient.additional_json_data[ self.recipient.additional_json_data["email_notification_categories"] = (
"email_notification_categories" json.dumps(["USER_INTERACTION"])
] = json.dumps(["USER_INTERACTION"]) )
self.recipient.save() self.recipient.save()
self.notification_service._send_notification( self.notification_service._send_notification(
sender=self.sender, sender=self.sender,

View File

@ -39,9 +39,9 @@ class SelfEvaluationFeedbackSerializer(serializers.ModelSerializer):
return obj.learning_unit.get_circle().title return obj.learning_unit.get_circle().title
def get_criteria(self, obj): def get_criteria(self, obj):
performance_criteria: List[ performance_criteria: List[PerformanceCriteria] = (
PerformanceCriteria obj.learning_unit.performancecriteria_set.all()
] = obj.learning_unit.performancecriteria_set.all() )
criteria = [] criteria = []

View File

@ -147,14 +147,14 @@ def render_invoice_xml(
SubElement(sales_order_header_fields, "PaymentCode").text = "9999" SubElement(sales_order_header_fields, "PaymentCode").text = "9999"
# Skender: Bestellzeitpunkt # Skender: Bestellzeitpunkt
SubElement( SubElement(sales_order_header_fields, "PurchaseOrderDate").text = (
sales_order_header_fields, "PurchaseOrderDate" order_date.isoformat()
).text = order_date.isoformat() )
# Skender: ePayment: TRANSACTION-ID von Datatrans in Bestellreferenz # Skender: ePayment: TRANSACTION-ID von Datatrans in Bestellreferenz
SubElement( SubElement(sales_order_header_fields, "ReferencePurchaseOrder").text = (
sales_order_header_fields, "ReferencePurchaseOrder" datatrans_transaction_id
).text = datatrans_transaction_id )
# Skender: ePayment: OrderID. max 10 Ziffern, erste Ziffer abhängig von der Plattform (4 = LMS, 6 = myVBV, 7 = EduManager) # 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( SubElement(sales_order_header_fields, "GroupingNumberAscii1").text = str(

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): class SsoConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField" default_auto_field = "django.db.models.BigAutoField"
name = "vbv_lernwelt.sso" 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"), sso_id=id_token.get("oid"),
first_name=id_token.get("given_name", ""), first_name=id_token.get("given_name", ""),
last_name=id_token.get("family_name", ""), last_name=id_token.get("family_name", ""),
intermediate_sso_id=id_token.get("sub"),
) )
dj_login(request, user) dj_login(request, user)