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:
commit
527da8d779
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -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 = []
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -52,3 +52,5 @@ azure-identity
|
||||||
boto3
|
boto3
|
||||||
openpyxl
|
openpyxl
|
||||||
newrelic
|
newrelic
|
||||||
|
python-keycloak
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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): ...
|
||||||
...
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 = []
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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",)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -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})"
|
||||||
|
|
@ -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",
|
||||||
|
}
|
||||||
|
|
@ -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-")]
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue