Merge branch 'master' into ldap-rewrite
This commit is contained in:
commit
44a3c7fa5f
|
@ -1,5 +1,5 @@
|
|||
[bumpversion]
|
||||
current_version = 0.6.2-beta
|
||||
current_version = 0.6.4-beta
|
||||
tag = True
|
||||
commit = True
|
||||
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\-(?P<release>.*)
|
||||
|
|
|
@ -27,7 +27,7 @@ create-base-image:
|
|||
before_script:
|
||||
- echo "{\"auths\":{\"docker.beryju.org\":{\"auth\":\"$DOCKER_AUTH\"}}}" > /kaniko/.docker/config.json
|
||||
script:
|
||||
- /kaniko/executor --context $CI_PROJECT_DIR --dockerfile $CI_PROJECT_DIR/base.Dockerfile --destination docker.beryju.org/passbook/base:latest --destination docker.beryju.org/passbook/base:0.6.2-beta
|
||||
- /kaniko/executor --context $CI_PROJECT_DIR --dockerfile $CI_PROJECT_DIR/base.Dockerfile --destination docker.beryju.org/passbook/base:latest
|
||||
stage: build-base-image
|
||||
only:
|
||||
refs:
|
||||
|
@ -41,7 +41,7 @@ build-dev-image:
|
|||
before_script:
|
||||
- echo "{\"auths\":{\"docker.beryju.org\":{\"auth\":\"$DOCKER_AUTH\"}}}" > /kaniko/.docker/config.json
|
||||
script:
|
||||
- /kaniko/executor --context $CI_PROJECT_DIR --dockerfile $CI_PROJECT_DIR/dev.Dockerfile --destination docker.beryju.org/passbook/dev:latest --destination docker.beryju.org/passbook/dev:0.6.2-beta
|
||||
- /kaniko/executor --context $CI_PROJECT_DIR --dockerfile $CI_PROJECT_DIR/dev.Dockerfile --destination docker.beryju.org/passbook/dev:latest
|
||||
stage: build-dev-image
|
||||
only:
|
||||
refs:
|
||||
|
@ -70,13 +70,13 @@ migrations:
|
|||
# services:
|
||||
# - postgres:latest
|
||||
# - redis:latest
|
||||
# pylint:
|
||||
# script:
|
||||
# - pylint passbook
|
||||
# stage: test
|
||||
# services:
|
||||
# - postgres:latest
|
||||
# - redis:latest
|
||||
pylint:
|
||||
script:
|
||||
- pylint passbook
|
||||
stage: test
|
||||
services:
|
||||
- postgres:latest
|
||||
- redis:latest
|
||||
coverage:
|
||||
script:
|
||||
- coverage run manage.py test
|
||||
|
@ -95,7 +95,7 @@ build-passbook-server:
|
|||
before_script:
|
||||
- echo "{\"auths\":{\"docker.beryju.org\":{\"auth\":\"$DOCKER_AUTH\"}}}" > /kaniko/.docker/config.json
|
||||
script:
|
||||
- /kaniko/executor --context $CI_PROJECT_DIR --dockerfile $CI_PROJECT_DIR/Dockerfile --destination docker.beryju.org/passbook/server:latest --destination docker.beryju.org/passbook/server:0.6.2-beta
|
||||
- /kaniko/executor --context $CI_PROJECT_DIR --dockerfile $CI_PROJECT_DIR/Dockerfile --destination docker.beryju.org/passbook/server:latest --destination docker.beryju.org/passbook/server:0.6.4-beta
|
||||
only:
|
||||
- tags
|
||||
- /^version/.*$/
|
||||
|
@ -107,7 +107,7 @@ build-passbook-static:
|
|||
before_script:
|
||||
- echo "{\"auths\":{\"docker.beryju.org\":{\"auth\":\"$DOCKER_AUTH\"}}}" > /kaniko/.docker/config.json
|
||||
script:
|
||||
- /kaniko/executor --context $CI_PROJECT_DIR --dockerfile $CI_PROJECT_DIR/static.Dockerfile --destination docker.beryju.org/passbook/static:latest --destination docker.beryju.org/passbook/static:0.6.2-beta
|
||||
- /kaniko/executor --context $CI_PROJECT_DIR --dockerfile $CI_PROJECT_DIR/static.Dockerfile --destination docker.beryju.org/passbook/static:latest --destination docker.beryju.org/passbook/static:0.6.4-beta
|
||||
only:
|
||||
- tags
|
||||
- /^version/.*$/
|
||||
|
|
|
@ -3,7 +3,6 @@ FROM docker.beryju.org/passbook/base:latest
|
|||
COPY ./passbook/ /app/passbook
|
||||
COPY ./manage.py /app/
|
||||
COPY ./docker/uwsgi.ini /app/
|
||||
RUN chown -R passbook: /app
|
||||
|
||||
WORKDIR /app/
|
||||
|
||||
|
|
8
Pipfile
8
Pipfile
|
@ -8,6 +8,7 @@ celery = "*"
|
|||
cherrypy = "*"
|
||||
defusedxml = "*"
|
||||
django = "*"
|
||||
kombu = "==4.5.0"
|
||||
django-cors-middleware = "*"
|
||||
django-filters = "*"
|
||||
django-ipware = "*"
|
||||
|
@ -18,7 +19,6 @@ django-otp = "*"
|
|||
django-recaptcha = "*"
|
||||
django-redis = "*"
|
||||
django-rest-framework = "*"
|
||||
djangorestframework = "==3.9.4"
|
||||
drf-yasg = "*"
|
||||
ldap3 = "*"
|
||||
lxml = "*"
|
||||
|
@ -35,17 +35,17 @@ service_identity = "*"
|
|||
signxml = "*"
|
||||
urllib3 = {extras = ["secure"],version = "*"}
|
||||
structlog = "*"
|
||||
pyuwsgi = "*"
|
||||
|
||||
[requires]
|
||||
python_version = "3.7"
|
||||
|
||||
[dev-packages]
|
||||
astroid = "==2.2.5"
|
||||
coverage = "*"
|
||||
isort = "*"
|
||||
pylint = "==2.3.1"
|
||||
pylint-django = "==2.0.10"
|
||||
prospector = "==1.1.7"
|
||||
pylint-django = "*"
|
||||
prospector = "*"
|
||||
django-debug-toolbar = "*"
|
||||
bumpversion = "*"
|
||||
unittest-xml-reporting = "*"
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"_meta": {
|
||||
"hash": {
|
||||
"sha256": "d03d1e494d28a90b39edd1d489afdb5e39ec09bceb18daa2a54b2cc7de61d83c"
|
||||
"sha256": "94b3d5140f0c31dac1fc77af75a0df30ae4fb0571bf6b7fcd722487c63dc1872"
|
||||
},
|
||||
"pipfile-spec": 6,
|
||||
"requires": {
|
||||
|
@ -18,17 +18,17 @@
|
|||
"default": {
|
||||
"amqp": {
|
||||
"hashes": [
|
||||
"sha256:19a917e260178b8d410122712bac69cb3e6db010d68f6101e7307508aded5e68",
|
||||
"sha256:19d851b879a471fcfdcf01df9936cff924f422baa77653289f7095dedd5fb26a"
|
||||
"sha256:6e649ca13a7df3faacdc8bbb280aa9a6602d22fd9d545336077e573a1f4ff3b8",
|
||||
"sha256:77f1aef9410698d20eaeac5b73a87817365f457a507d82edf292e12cbb83b08d"
|
||||
],
|
||||
"version": "==2.5.1"
|
||||
"version": "==2.5.2"
|
||||
},
|
||||
"asn1crypto": {
|
||||
"hashes": [
|
||||
"sha256:d02bf8ea1b964a5ff04ac7891fe3a39150045d1e5e4fe99273ba677d11b92a04",
|
||||
"sha256:f822954b90c4c44f002e2cd46d636ab630f1fe4df22c816a82b66505c404eb2a"
|
||||
"sha256:0b199f211ae690df3db4fd6c1c4ff976497fb1da689193e368eedbadc53d9292",
|
||||
"sha256:bca90060bd995c3f62c4433168eab407e44bdbdb567b3f3a396a676c1a4c4a3f"
|
||||
],
|
||||
"version": "==1.0.0"
|
||||
"version": "==1.0.1"
|
||||
},
|
||||
"attrs": {
|
||||
"hashes": [
|
||||
|
@ -101,10 +101,10 @@
|
|||
},
|
||||
"cheroot": {
|
||||
"hashes": [
|
||||
"sha256:6168371ab9aaf574ac5f75675f244bbfebf990202bf75048065e9d675b9ae719",
|
||||
"sha256:8cc7c28961db2e13d0cac6b234a589a314c1844f7bbf54e67888ac9a2e25ac59"
|
||||
"sha256:3ff64073efa35b39d5e107410f5c79664dc8c6c5990651e970740c80ab8878a8",
|
||||
"sha256:d523a1525258730026aa35b86c8c47c8d0e3892fb89f0f39157d4b32a50edf05"
|
||||
],
|
||||
"version": "==7.0.0"
|
||||
"version": "==8.1.0"
|
||||
},
|
||||
"cherrypy": {
|
||||
"hashes": [
|
||||
|
@ -242,11 +242,10 @@
|
|||
},
|
||||
"djangorestframework": {
|
||||
"hashes": [
|
||||
"sha256:376f4b50340a46c15ae15ddd0c853085f4e66058f97e4dbe7d43ed62f5e60651",
|
||||
"sha256:c12869cfd83c33d579b17b3cb28a2ae7322a53c3ce85580c2a2ebe4e3f56c4fb"
|
||||
"sha256:5488aed8f8df5ec1d70f04b2114abc52ae6729748a176c453313834a9ee179c8",
|
||||
"sha256:dc81cbf9775c6898a580f6f1f387c4777d12bd87abf0f5406018d32ccae71090"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==3.9.4"
|
||||
"version": "==3.10.3"
|
||||
},
|
||||
"drf-yasg": {
|
||||
"hashes": [
|
||||
|
@ -276,13 +275,6 @@
|
|||
],
|
||||
"version": "==2.8"
|
||||
},
|
||||
"importlib-metadata": {
|
||||
"hashes": [
|
||||
"sha256:aa18d7378b00b40847790e7c27e11673d7fed219354109d0e7b9e5b25dc3ad26",
|
||||
"sha256:d5f18a79777f3aa179c145737780282e27b508fc8fd688cb17c7a813e8bd39af"
|
||||
],
|
||||
"version": "==0.23"
|
||||
},
|
||||
"inflection": {
|
||||
"hashes": [
|
||||
"sha256:18ea7fb7a7d152853386523def08736aa8c32636b047ade55f7578c4edeb16ca"
|
||||
|
@ -304,17 +296,18 @@
|
|||
},
|
||||
"jinja2": {
|
||||
"hashes": [
|
||||
"sha256:065c4f02ebe7f7cf559e49ee5a95fb800a9e4528727aec6f24402a5374c65013",
|
||||
"sha256:14dd6caf1527abb21f08f86c784eac40853ba93edb79552aa1e4b8aef1b61c7b"
|
||||
"sha256:74320bb91f31270f9551d46522e33af46a80c3d619f4a4bf42b3164d30b5911f",
|
||||
"sha256:9fe95f19286cfefaa917656583d020be14e7859c6b0252588391e47db34527de"
|
||||
],
|
||||
"version": "==2.10.1"
|
||||
"version": "==2.10.3"
|
||||
},
|
||||
"kombu": {
|
||||
"hashes": [
|
||||
"sha256:31edb84947996fdda065b6560c128d5673bb913ff34aa19e7b84755217a24deb",
|
||||
"sha256:c9078124ce2616b29cf6607f0ac3db894c59154252dee6392cdbbe15e5c4b566"
|
||||
"sha256:389ba09e03b15b55b1a7371a441c894fd8121d174f5583bbbca032b9ea8c9edd",
|
||||
"sha256:7b92303af381ef02fad6899fd5f5a9a96031d781356cd8e505fa54ae5ddee181"
|
||||
],
|
||||
"version": "==4.6.5"
|
||||
"index": "pypi",
|
||||
"version": "==4.5.0"
|
||||
},
|
||||
"ldap3": {
|
||||
"hashes": [
|
||||
|
@ -466,10 +459,10 @@
|
|||
},
|
||||
"pyasn1-modules": {
|
||||
"hashes": [
|
||||
"sha256:43c17a83c155229839cc5c6b868e8d0c6041dba149789b6d6e28801c64821722",
|
||||
"sha256:e30199a9d221f1b26c885ff3d87fd08694dbbe18ed0e8e405a2a7126d30ce4c0"
|
||||
"sha256:0c35a52e00b672f832e5846826f1fb7507907f7d52fba6faa9e3c4cbe874fe4b",
|
||||
"sha256:b6ada4f840fe51abf5a6bd545b45bf537bea62221fa0dde2e8a553ed9f06a4e3"
|
||||
],
|
||||
"version": "==0.2.6"
|
||||
"version": "==0.2.7"
|
||||
},
|
||||
"pycparser": {
|
||||
"hashes": [
|
||||
|
@ -566,10 +559,40 @@
|
|||
},
|
||||
"pytz": {
|
||||
"hashes": [
|
||||
"sha256:26c0b32e437e54a18161324a2fca3c4b9846b74a8dccddd843113109e1116b32",
|
||||
"sha256:c894d57500a4cd2d5c71114aaab77dbab5eabd9022308ce5ac9bb93a60a6f0c7"
|
||||
"sha256:1c557d7d0e871de1f5ccd5833f60fb2550652da6be2693c1e02300743d21500d",
|
||||
"sha256:b02c06db6cf09c12dd25137e563b31700d3b80fcc4ad23abb7a315f2789819be"
|
||||
],
|
||||
"version": "==2019.2"
|
||||
"version": "==2019.3"
|
||||
},
|
||||
"pyuwsgi": {
|
||||
"hashes": [
|
||||
"sha256:15a4626740753b0d0dfeeac7d367f9b2e89ab6af16c195927e60f75359fc1bbc",
|
||||
"sha256:24c40c3b889eb9f283d43feffbc0f7c7fc024e914451425156ddb68af3df1e71",
|
||||
"sha256:393737bd43a7e38f0a4a1601a37a69c4bf893635b37665ff958170fdb604fdb7",
|
||||
"sha256:5a08308f87e639573c1efaa5966a6d04410cd45a73c4586a932fe3ee4b56369d",
|
||||
"sha256:5f4b36c0dbb9931c4da8008aa423158be596e3b4a23cec95a958631603a94e45",
|
||||
"sha256:7c31794f71bbd0ccf542cab6bddf38aa69e84e31ae0f9657a2e18ebdc150c01a",
|
||||
"sha256:802ec6dad4b6707b934370926ec1866603abe31ba03c472f56149001b3533ba1",
|
||||
"sha256:814d73d4569add69a6c19bb4a27cd5adb72b196e5e080caed17dbda740402072",
|
||||
"sha256:829299cd117cf8abe837796bf587e61ce6bfe18423a3a1c510c21e9825789c2c",
|
||||
"sha256:85f2210ceae5f48b7d8fad2240d831f4b890cac85cd98ca82683ac6aa481dfc8",
|
||||
"sha256:861c94442b28cd64af033e88e0f63c66dbd5609f67952dc18694098b47a43f3a",
|
||||
"sha256:957bc6316ffc8463795d56d9953d58e7f32aa5aad1c5ac80bc45c69f3299961e",
|
||||
"sha256:9760c3f56fb5f15852d163429096600906478e9ed2c189a52f2bb21d8a2a986c",
|
||||
"sha256:a4b24703ea818196d0be1dc64b3b57b79c67e8dee0cfa207a4216220912035a7",
|
||||
"sha256:ad7f4968c1ddbf139a306d9b075360d959cc554d994ba5e1f512af9a40e62357",
|
||||
"sha256:b1127d34b90f74faf1707718c57a4193ac028b9f4aec0238638983132297d456",
|
||||
"sha256:bcb04d6ec644b3e08d03c64851e06edd7110489261e50627a4bcadf66ff6920e",
|
||||
"sha256:bebfebb9ee83d7cf37668bf54275b677b7ae283e84a944f9f3ac6a4b66f95d4b",
|
||||
"sha256:c29892dafc65a8b6eb95823fa4bac7754ca3fd1c28ab8d2a973289531b340a27",
|
||||
"sha256:cb296b50b51ba022b0090b28d032ff1dd395a6db03672b65a39e83532edad527",
|
||||
"sha256:ce777ebdf49ce736fc04abf555b5c41ab3f130127543a689dcf8d4871cd18fe4",
|
||||
"sha256:d8b4bf930b6a19bc9ee982b9163d948c87501ad91b71516924e8ed25fe85d2ee",
|
||||
"sha256:e2a420f2c4d35f3ec0b7e752a80d7bd385e2c5a64f67c05f2d2d74230e3114b6",
|
||||
"sha256:fed899ce96f4f2b4d1b9f338dd145a4040ee1d8a5152213af0dd8d4a4d36e9fe"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==2.0.18.post0"
|
||||
},
|
||||
"pyyaml": {
|
||||
"hashes": [
|
||||
|
@ -736,13 +759,6 @@
|
|||
"sha256:cc33599b549f0c8a248cb72f3bf32d77712de1ff7ee8814312eb6456b42c015f"
|
||||
],
|
||||
"version": "==2.0"
|
||||
},
|
||||
"zipp": {
|
||||
"hashes": [
|
||||
"sha256:3718b1cbcd963c7d4c5511a8240812904164b7f381b647143a89d3b98f9bcd8e",
|
||||
"sha256:f06903e9f1f43b12d371004b4ac7b06ab39a44adc747266928ae6debfa7b3335"
|
||||
],
|
||||
"version": "==0.6.0"
|
||||
}
|
||||
},
|
||||
"develop": {
|
||||
|
@ -751,7 +767,6 @@
|
|||
"sha256:6560e1e1749f68c64a4b5dee4e091fce798d2f0d84ebe638cf0e0585a343acf4",
|
||||
"sha256:b65db1bbaac9f9f4d190199bb8680af6f6f84fd3769a5ea883df8a91fe68b4c4"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==2.2.5"
|
||||
},
|
||||
"autopep8": {
|
||||
|
@ -976,10 +991,10 @@
|
|||
},
|
||||
"pytz": {
|
||||
"hashes": [
|
||||
"sha256:26c0b32e437e54a18161324a2fca3c4b9846b74a8dccddd843113109e1116b32",
|
||||
"sha256:c894d57500a4cd2d5c71114aaab77dbab5eabd9022308ce5ac9bb93a60a6f0c7"
|
||||
"sha256:1c557d7d0e871de1f5ccd5833f60fb2550652da6be2693c1e02300743d21500d",
|
||||
"sha256:b02c06db6cf09c12dd25137e563b31700d3b80fcc4ad23abb7a315f2789819be"
|
||||
],
|
||||
"version": "==2019.2"
|
||||
"version": "==2019.3"
|
||||
},
|
||||
"pyyaml": {
|
||||
"hashes": [
|
||||
|
|
|
@ -3,7 +3,9 @@
|
|||
## Quick instance
|
||||
|
||||
```
|
||||
export PASSBOOK_DOMAIN=domain.tld
|
||||
docker-compose pull
|
||||
docker-compose up -d
|
||||
docker-compose exec server ./manage.py migrate
|
||||
docker-compose exec server ./manage.py createsuperuser
|
||||
```
|
||||
|
|
|
@ -1,19 +1,19 @@
|
|||
FROM python:3.7-slim-stretch
|
||||
FROM python:3.7-slim-buster as locker
|
||||
|
||||
COPY ./Pipfile /app/
|
||||
COPY ./Pipfile.lock /app/
|
||||
|
||||
WORKDIR /app/
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends build-essential && \
|
||||
pip install pipenv uwsgi --no-cache-dir && \
|
||||
apt-get remove -y --purge build-essential && \
|
||||
apt-get autoremove -y --purge && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
RUN pip install pipenv && \
|
||||
pipenv lock -r > requirements.txt && \
|
||||
pipenv lock -rd > requirements-dev.txt
|
||||
|
||||
RUN pipenv lock -r > requirements.txt && \
|
||||
pipenv --rm && \
|
||||
pip install -r requirements.txt --no-cache-dir && \
|
||||
adduser --system --no-create-home passbook && \
|
||||
chown -R passbook /app
|
||||
FROM python:3.7-slim-buster
|
||||
|
||||
COPY --from=locker /app/requirements.txt /app/
|
||||
|
||||
WORKDIR /app/
|
||||
|
||||
RUN pip install -r requirements.txt --no-cache-dir && \
|
||||
adduser --system --no-create-home --uid 1000 --group --home /app passbook
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
FROM docker.beryju.org/passbook/base:latest
|
||||
|
||||
RUN pipenv lock --dev -r > requirements-dev.txt && \
|
||||
pipenv --rm && \
|
||||
pip install -r /app/requirements-dev.txt --no-cache-dir
|
||||
RUN pip install -r /app/requirements-dev.txt --no-cache-dir
|
||||
|
|
|
@ -20,28 +20,15 @@ services:
|
|||
- internal
|
||||
labels:
|
||||
- traefik.enable=false
|
||||
database-migrate:
|
||||
build:
|
||||
context: .
|
||||
image: docker.beryju.org/passbook/server:${TAG:-test}
|
||||
command:
|
||||
- ./manage.py
|
||||
- migrate
|
||||
networks:
|
||||
- internal
|
||||
restart: 'no'
|
||||
environment:
|
||||
- PASSBOOK_REDIS__HOST=redis
|
||||
- PASSBOOK_POSTGRESQL__HOST=postgresql
|
||||
- PASSBOOK_POSTGRESQL__PASSWORD=${PG_PASS:-thisisnotagoodpassword}
|
||||
server:
|
||||
build:
|
||||
context: .
|
||||
image: docker.beryju.org/passbook/server:${TAG:-test}
|
||||
image: docker.beryju.org/passbook/server:${SERVER_TAG:-latest}
|
||||
command:
|
||||
- uwsgi
|
||||
- uwsgi.ini
|
||||
environment:
|
||||
- PASSBOOK_DOMAIN=${PASSBOOK_DOMAIN}
|
||||
- PASSBOOK_REDIS__HOST=redis
|
||||
- PASSBOOK_POSTGRESQL__HOST=postgresql
|
||||
- PASSBOOK_POSTGRESQL__PASSWORD=${PG_PASS:-thisisnotagoodpassword}
|
||||
|
@ -54,15 +41,21 @@ services:
|
|||
- traefik.docker.network=internal
|
||||
- traefik.frontend.rule=PathPrefix:/
|
||||
worker:
|
||||
image: docker.beryju.org/passbook/server:${TAG:-test}
|
||||
image: docker.beryju.org/passbook/server:${SERVER_TAG:-latest}
|
||||
command:
|
||||
- ./manage.py
|
||||
- celery
|
||||
- worker
|
||||
- --autoscale=10,3
|
||||
- -E
|
||||
- -B
|
||||
- -A=passbook.root.celery
|
||||
- -s=/tmp/celerybeat-schedule
|
||||
networks:
|
||||
- internal
|
||||
labels:
|
||||
- traefik.enable=false
|
||||
environment:
|
||||
- PASSBOOK_DOMAIN=${PASSBOOK_DOMAIN}
|
||||
- PASSBOOK_REDIS__HOST=redis
|
||||
- PASSBOOK_POSTGRESQL__HOST=postgresql
|
||||
- PASSBOOK_POSTGRESQL__PASSWORD=${PG_PASS:-thisisnotagoodpassword}
|
||||
|
@ -70,7 +63,7 @@ services:
|
|||
build:
|
||||
context: .
|
||||
dockerfile: static.Dockerfile
|
||||
image: docker.beryju.org/passbook/static:${TAG:-test}
|
||||
image: docker.beryju.org/passbook/static:latest
|
||||
networks:
|
||||
- internal
|
||||
labels:
|
||||
|
|
|
@ -39,7 +39,7 @@ http {
|
|||
gzip on;
|
||||
gzip_types application/javascript image/* text/css;
|
||||
gunzip on;
|
||||
add_header X-passbook-Version 0.6.2-beta;
|
||||
add_header X-passbook-Version 0.6.4-beta;
|
||||
add_header Vary X-passbook-Version;
|
||||
root /data/;
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
apiVersion: v1
|
||||
appVersion: "0.6.2-beta"
|
||||
appVersion: "0.6.4-beta"
|
||||
description: A Helm chart for passbook.
|
||||
name: passbook
|
||||
version: "0.6.2-beta"
|
||||
version: "0.6.4-beta"
|
||||
icon: https://git.beryju.org/uploads/-/system/project/avatar/108/logo.png
|
||||
|
|
|
@ -36,6 +36,7 @@ spec:
|
|||
- -E
|
||||
- -B
|
||||
- -A=passbook.root.celery
|
||||
- -s=/tmp/celerybeat-schedule
|
||||
volumeMounts:
|
||||
- mountPath: /etc/passbook
|
||||
name: config-volume
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
# This is a YAML-formatted file.
|
||||
# Declare variables to be passed into your templates.
|
||||
image:
|
||||
tag: 0.6.2-beta
|
||||
tag: 0.6.4-beta
|
||||
|
||||
nameOverride: ""
|
||||
|
||||
|
|
|
@ -1,2 +1,2 @@
|
|||
"""passbook"""
|
||||
__version__ = '0.6.2-beta'
|
||||
__version__ = '0.6.4-beta'
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 2.2.6 on 2019-10-10 11:48
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('passbook_core', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='nonce',
|
||||
name='description',
|
||||
field=models.TextField(blank=True, default=''),
|
||||
),
|
||||
]
|
|
@ -2,6 +2,7 @@
|
|||
from datetime import timedelta
|
||||
from random import SystemRandom
|
||||
from time import sleep
|
||||
from typing import Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
|
@ -56,6 +57,7 @@ class User(AbstractUser):
|
|||
self.password_change_date = now()
|
||||
return super().set_password(password)
|
||||
|
||||
|
||||
class Provider(models.Model):
|
||||
"""Application-independent Provider instance. For example SAML2 Remote, OAuth2 Application"""
|
||||
|
||||
|
@ -69,11 +71,26 @@ class Provider(models.Model):
|
|||
return getattr(self, 'name')
|
||||
return super().__str__()
|
||||
|
||||
|
||||
class PolicyModel(UUIDModel, CreatedUpdatedModel):
|
||||
"""Base model which can have policies applied to it"""
|
||||
|
||||
policies = models.ManyToManyField('Policy', blank=True)
|
||||
|
||||
|
||||
class UserSettings:
|
||||
"""Dataclass for Factor and Source's user_settings"""
|
||||
|
||||
name: str
|
||||
icon: str
|
||||
view_name: str
|
||||
|
||||
def __init__(self, name: str, icon: str, view_name: str):
|
||||
self.name = name
|
||||
self.icon = icon
|
||||
self.view_name = view_name
|
||||
|
||||
|
||||
class Factor(PolicyModel):
|
||||
"""Authentication factor, multiple instances of the same Factor can be used"""
|
||||
|
||||
|
@ -86,11 +103,10 @@ class Factor(PolicyModel):
|
|||
type = ''
|
||||
form = ''
|
||||
|
||||
def has_user_settings(self):
|
||||
"""Entrypoint to integrate with User settings. Can either return False if no
|
||||
user settings are available, or a tuple or string, string, string where the first string
|
||||
is the name the item has, the second string is the icon and the third is the view-name."""
|
||||
return False
|
||||
def user_settings(self) -> Optional[UserSettings]:
|
||||
"""Entrypoint to integrate with User settings. Can either return None if no
|
||||
user settings are available, or an instanace of UserSettings."""
|
||||
return None
|
||||
|
||||
def __str__(self):
|
||||
return f"Factor {self.slug}"
|
||||
|
@ -147,11 +163,10 @@ class Source(PolicyModel):
|
|||
"""Return additional Info, such as a callback URL. Show in the administration interface."""
|
||||
return None
|
||||
|
||||
def has_user_settings(self):
|
||||
"""Entrypoint to integrate with User settings. Can either return False if no
|
||||
user settings are available, or a tuple or string, string, string where the first string
|
||||
is the name the item has, the second string is the icon and the third is the view-name."""
|
||||
return False
|
||||
def user_settings(self) -> Optional[UserSettings]:
|
||||
"""Entrypoint to integrate with User settings. Can either return None if no
|
||||
user settings are available, or an instanace of UserSettings."""
|
||||
return None
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
@ -242,21 +257,29 @@ class Invitation(UUIDModel):
|
|||
verbose_name = _('Invitation')
|
||||
verbose_name_plural = _('Invitations')
|
||||
|
||||
|
||||
class Nonce(UUIDModel):
|
||||
"""One-time link for password resets/sign-up-confirmations"""
|
||||
|
||||
expires = models.DateTimeField(default=default_nonce_duration)
|
||||
user = models.ForeignKey('User', on_delete=models.CASCADE)
|
||||
expiring = models.BooleanField(default=True)
|
||||
description = models.TextField(default='', blank=True)
|
||||
|
||||
@property
|
||||
def is_expired(self) -> bool:
|
||||
"""Check if nonce is expired yet."""
|
||||
return now() > self.expires
|
||||
|
||||
def __str__(self):
|
||||
return f"Nonce f{self.uuid.hex} (expires={self.expires})"
|
||||
return f"Nonce f{self.uuid.hex} {self.description} (expires={self.expires})"
|
||||
|
||||
class Meta:
|
||||
|
||||
verbose_name = _('Nonce')
|
||||
verbose_name_plural = _('Nonces')
|
||||
|
||||
|
||||
class PropertyMapping(UUIDModel):
|
||||
"""User-defined key -> x mapping which can be used by providers to expose extra data."""
|
||||
|
||||
|
|
|
@ -46,9 +46,6 @@
|
|||
<script src="{% static 'js/passbook.js' %}"></script>
|
||||
{% block scripts %}
|
||||
{% endblock %}
|
||||
<div class="modals">
|
||||
{% include 'partials/about_modal.html' %}
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
|
|
@ -46,9 +46,6 @@
|
|||
<script src="{% static 'js/passbook.js' %}"></script>
|
||||
{% block scripts %}
|
||||
{% endblock %}
|
||||
<div class="modals">
|
||||
{% include 'partials/about_modal.html' %}
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
|
|
@ -23,37 +23,18 @@
|
|||
</div>
|
||||
<nav class="collapse navbar-collapse">
|
||||
<ul class="nav navbar-nav navbar-right navbar-iconic navbar-utility">
|
||||
<li class="dropdown">
|
||||
<button class="btn btn-link dropdown-toggle nav-item-iconic" id="dropdownMenu1" data-toggle="dropdown"
|
||||
aria-haspopup="true" aria-expanded="true">
|
||||
<span title="Help" class="fa pficon-help"></span>
|
||||
</button>
|
||||
<ul class="dropdown-menu" aria-labelledby="dropdownMenu1">
|
||||
{% comment %} <li><a href="#0">Help</a></li> {% endcomment %}
|
||||
<li><a data-toggle="modal" data-target="#about-modal" href="#0">{% trans 'About' %}</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="dropdown">
|
||||
<button class="btn btn-link dropdown-toggle nav-item-iconic" id="dropdownMenu2" data-toggle="dropdown"
|
||||
aria-haspopup="true" aria-expanded="true">
|
||||
<a href="{% url 'passbook_core:auth-logout' %}" class="btn btn-link nav-item-iconic" aria-haspopup="true" aria-expanded="true">
|
||||
<span title="Username" class="fa fa-sign-out"></span>
|
||||
<span class="dropdown-title">
|
||||
{% trans 'Logout' %}
|
||||
</span>
|
||||
</a>
|
||||
<a href="{% url 'passbook_core:user-settings' %}" class="btn btn-link nav-item-iconic" aria-haspopup="true" aria-expanded="true">
|
||||
<span title="Username" class="fa pficon-user"></span>
|
||||
<span class="dropdown-title">
|
||||
{{ user.username }} <span class="caret"></span>
|
||||
{{ user.username }}
|
||||
</span>
|
||||
</button>
|
||||
<ul class="dropdown-menu" aria-labelledby="dropdownMenu2">
|
||||
<li>
|
||||
<a href="{% url 'passbook_core:user-settings' %}">{% trans 'User Settings' %}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{% url 'passbook_core:user-change-password' %}">{% trans 'Change Password' %}</a>
|
||||
</li>
|
||||
<li class="divider"></li>
|
||||
<li>
|
||||
<a href="{% url 'passbook_core:auth-logout' %}">{% trans 'Logout' %}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</a>
|
||||
</ul>
|
||||
</nav>
|
||||
</nav>
|
||||
|
|
|
@ -1,36 +0,0 @@
|
|||
{% load static %}
|
||||
{% load i18n %}
|
||||
{% load cache %}
|
||||
|
||||
{% load utils %}
|
||||
|
||||
<div class="modal fade" id="about-modal" tabindex="-1" role="dialog" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content about-modal-pf">
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">
|
||||
<span class="pficon pficon-close"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<h1>{% trans 'passbook' %}</h1>
|
||||
<div class="product-versions-pf">
|
||||
<ul class="list-unstyled">
|
||||
{% app_versions as vers %}
|
||||
{% cache 600 versions %}
|
||||
{% for app, ver in vers.items %}
|
||||
<li><strong>{{ app }}</strong> {{ ver }}</li>
|
||||
{% endfor %}
|
||||
{% endcache %}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="trademark-pf">
|
||||
Trademark and Copyright Information
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<img style="max-height:64px;" src="{% static 'img/logo.png' %}" alt=" Symbol">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -16,21 +16,25 @@
|
|||
<i class="fa pficon-edit"></i> {% trans 'Details' %}
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-divider"></li>
|
||||
{% user_factors as uf %}
|
||||
{% for name, icon, link in uf %}
|
||||
<li class="{% is_active link %}">
|
||||
<a href="{% url link %}">
|
||||
<i class="{{ icon }}"></i> {{ name }}
|
||||
{% if uf %}
|
||||
<li class="nav-divider"></li>
|
||||
{% endif %}
|
||||
{% for user_settings in uf %}
|
||||
<li class="{% is_active user_settings.view_name %}">
|
||||
<a href="{% url user_settings.view_name %}">
|
||||
<i class="{{ user_settings.icon }}"></i> {{ user_settings.name }}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
<li class="nav-divider"></li>
|
||||
{% user_sources as us %}
|
||||
{% for name, icon, link in us %}
|
||||
<li class="{% if link == request.get_full_path %} active {% endif %}">
|
||||
<a href="{{ link }}">
|
||||
<i class="{{ icon }}"></i> {{ name }}
|
||||
{% if us %}
|
||||
<li class="nav-divider"></li>
|
||||
{% endif %}
|
||||
{% for user_settings in us %}
|
||||
<li class="{% if user_settings.view_name == request.get_full_path %} active {% endif %}">
|
||||
<a href="{{ user_settings.view_name }}">
|
||||
<i class="{{ user_settings.icon }}"></i> {{ user_settings.name }}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
|
|
|
@ -1,37 +1,38 @@
|
|||
"""passbook user settings template tags"""
|
||||
from typing import List
|
||||
|
||||
from django import template
|
||||
from django.template.context import RequestContext
|
||||
|
||||
from passbook.core.models import Factor, Source
|
||||
from passbook.core.models import Factor, Source, UserSettings
|
||||
from passbook.policies.engine import PolicyEngine
|
||||
|
||||
register = template.Library()
|
||||
|
||||
@register.simple_tag(takes_context=True)
|
||||
def user_factors(context: RequestContext):
|
||||
def user_factors(context: RequestContext) -> List[UserSettings]:
|
||||
"""Return list of all factors which apply to user"""
|
||||
user = context.get('request').user
|
||||
_all_factors = Factor.objects.filter(enabled=True).order_by('order').select_subclasses()
|
||||
matching_factors = []
|
||||
matching_factors: List[UserSettings] = []
|
||||
for factor in _all_factors:
|
||||
_link = factor.has_user_settings()
|
||||
user_settings = factor.user_settings()
|
||||
policy_engine = PolicyEngine(factor.policies.all())
|
||||
policy_engine.for_user(user).with_request(context.get('request')).build()
|
||||
if policy_engine.passing and _link:
|
||||
matching_factors.append(_link)
|
||||
if policy_engine.passing and user_settings:
|
||||
matching_factors.append(user_settings)
|
||||
return matching_factors
|
||||
|
||||
@register.simple_tag(takes_context=True)
|
||||
def user_sources(context: RequestContext):
|
||||
def user_sources(context: RequestContext) -> List[UserSettings]:
|
||||
"""Return a list of all sources which are enabled for the user"""
|
||||
user = context.get('request').user
|
||||
_all_sources = Source.objects.filter(enabled=True).select_subclasses()
|
||||
matching_sources = []
|
||||
matching_sources: List[UserSettings] = []
|
||||
for factor in _all_sources:
|
||||
_link = factor.has_user_settings()
|
||||
user_settings = factor.user_settings()
|
||||
policy_engine = PolicyEngine(factor.policies.all())
|
||||
policy_engine.for_user(user).with_request(context.get('request')).build()
|
||||
if policy_engine.passing and _link:
|
||||
matching_sources.append(_link)
|
||||
if policy_engine.passing and user_settings:
|
||||
matching_sources.append(user_settings)
|
||||
return matching_sources
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
"""captcha factor admin"""
|
||||
|
||||
from passbook.lib.admin import admin_autoregister
|
||||
|
||||
admin_autoregister('passbook_factors_captcha')
|
|
@ -1,6 +1,8 @@
|
|||
"""passbook captcha factor forms"""
|
||||
from captcha.fields import ReCaptchaField
|
||||
from django import forms
|
||||
from django.contrib.admin.widgets import FilteredSelectMultiple
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from passbook.factors.captcha.models import CaptchaFactor
|
||||
from passbook.factors.forms import GENERAL_FIELDS
|
||||
|
@ -21,6 +23,7 @@ class CaptchaFactorForm(forms.ModelForm):
|
|||
widgets = {
|
||||
'name': forms.TextInput(),
|
||||
'order': forms.NumberInput(),
|
||||
'policies': FilteredSelectMultiple(_('policies'), False),
|
||||
'public_key': forms.TextInput(),
|
||||
'private_key': forms.TextInput(),
|
||||
}
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
from django.db import models
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from passbook.core.models import Factor
|
||||
from passbook.core.models import Factor, UserSettings
|
||||
|
||||
|
||||
class OTPFactor(Factor):
|
||||
|
@ -15,8 +15,8 @@ class OTPFactor(Factor):
|
|||
type = 'passbook.factors.otp.factors.OTPFactor'
|
||||
form = 'passbook.factors.otp.forms.OTPFactorForm'
|
||||
|
||||
def has_user_settings(self):
|
||||
return _('OTP'), 'pficon-locked', 'passbook_factors_otp:otp-user-settings'
|
||||
def user_settings(self) -> UserSettings:
|
||||
return UserSettings(_('OTP'), 'pficon-locked', 'passbook_factors_otp:otp-user-settings')
|
||||
|
||||
def __str__(self):
|
||||
return f"OTP Factor {self.slug}"
|
||||
|
|
|
@ -3,7 +3,7 @@ from django.contrib.postgres.fields import ArrayField
|
|||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from passbook.core.models import Factor, Policy, User
|
||||
from passbook.core.models import Factor, Policy, User, UserSettings
|
||||
|
||||
|
||||
class PasswordFactor(Factor):
|
||||
|
@ -16,8 +16,9 @@ class PasswordFactor(Factor):
|
|||
type = 'passbook.factors.password.factor.PasswordFactor'
|
||||
form = 'passbook.factors.password.forms.PasswordFactorForm'
|
||||
|
||||
def has_user_settings(self):
|
||||
return _('Change Password'), 'pficon-key', 'passbook_core:user-change-password'
|
||||
def user_settings(self):
|
||||
return UserSettings(_('Change Password'), 'pficon-key',
|
||||
'passbook_core:user-change-password')
|
||||
|
||||
def password_passes(self, user: User) -> bool:
|
||||
"""Return true if user's password passes, otherwise False or raise Exception"""
|
||||
|
|
|
@ -16,7 +16,7 @@ debug: false
|
|||
# Error reporting, sends stacktrace to sentry.services.beryju.org
|
||||
error_report_enabled: true
|
||||
|
||||
domain: passbook.local
|
||||
domain: localhost
|
||||
|
||||
passbook:
|
||||
sign_up:
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
"""passbook Recovery app config"""
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class PassbookRecoveryConfig(AppConfig):
|
||||
"""passbook Recovery app config"""
|
||||
|
||||
name = 'passbook.recovery'
|
||||
label = 'passbook_recovery'
|
||||
verbose_name = 'passbook Recovery'
|
||||
mountpoint = 'recovery/'
|
|
@ -0,0 +1,46 @@
|
|||
"""passbook recovery createkey command"""
|
||||
from datetime import timedelta
|
||||
from getpass import getuser
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.urls import reverse
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext as _
|
||||
from structlog import get_logger
|
||||
|
||||
from passbook.core.models import Nonce, User
|
||||
from passbook.lib.config import CONFIG
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""Create Nonce used to recover access"""
|
||||
|
||||
help = _('Create a Key which can be used to restore access to passbook.')
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('duration', default=1, action='store',
|
||||
help='How long the token is valid for (in years).')
|
||||
parser.add_argument('user', action='store',
|
||||
help='Which user the Token gives access to.')
|
||||
|
||||
def get_url(self, nonce: Nonce) -> str:
|
||||
"""Get full recovery link"""
|
||||
path = reverse('passbook_recovery:use-nonce', kwargs={'uuid': str(nonce.uuid)})
|
||||
return f"https://{CONFIG.y('domain')}{path}"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
"""Create Nonce used to recover access"""
|
||||
duration = int(options.get('duration', 1))
|
||||
delta = timedelta(days=duration * 365.2425)
|
||||
_now = now()
|
||||
expiry = _now + delta
|
||||
user = User.objects.get(username=options.get('user'))
|
||||
nonce = Nonce.objects.create(
|
||||
expires=expiry,
|
||||
user=user,
|
||||
description=f'Recovery Nonce generated by {getuser()} on {_now}')
|
||||
self.stdout.write((f"Store this link safely, as it will allow"
|
||||
f" anyone to access passbook as {user}."))
|
||||
self.stdout.write(self.get_url(nonce))
|
|
@ -0,0 +1,9 @@
|
|||
"""recovery views"""
|
||||
|
||||
from django.urls import path
|
||||
|
||||
from passbook.recovery.views import UseNonceView
|
||||
|
||||
urlpatterns = [
|
||||
path('use-nonce/<uuid:uuid>/', UseNonceView.as_view(), name='use-nonce'),
|
||||
]
|
|
@ -0,0 +1,24 @@
|
|||
"""recovery views"""
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth import login
|
||||
from django.http import Http404, HttpRequest, HttpResponse
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views import View
|
||||
|
||||
from passbook.core.models import Nonce
|
||||
|
||||
|
||||
class UseNonceView(View):
|
||||
"""Use nonce to login"""
|
||||
|
||||
def get(self, request: HttpRequest, uuid: str) -> HttpResponse:
|
||||
"""Check if nonce exists, log user in and delete nonce."""
|
||||
nonce: Nonce = get_object_or_404(Nonce, pk=uuid)
|
||||
if nonce.is_expired:
|
||||
nonce.delete()
|
||||
raise Http404
|
||||
login(request, nonce.user, backend='django.contrib.auth.backends.ModelBackend')
|
||||
nonce.delete()
|
||||
messages.warning(request, _("Used recovery-link to authenticate."))
|
||||
return redirect('passbook_core:overview')
|
|
@ -72,6 +72,7 @@ INSTALLED_APPS = [
|
|||
'passbook.api.apps.PassbookAPIConfig',
|
||||
'passbook.lib.apps.PassbookLibConfig',
|
||||
'passbook.audit.apps.PassbookAuditConfig',
|
||||
'passbook.recovery.apps.PassbookRecoveryConfig',
|
||||
|
||||
'passbook.sources.ldap.apps.PassbookSourceLDAPConfig',
|
||||
'passbook.sources.oauth.apps.PassbookSourceOAuthConfig',
|
||||
|
@ -117,7 +118,7 @@ CACHES = {
|
|||
}
|
||||
DJANGO_REDIS_IGNORE_EXCEPTIONS = True
|
||||
DJANGO_REDIS_LOG_IGNORED_EXCEPTIONS = True
|
||||
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
|
||||
SESSION_ENGINE = "django.contrib.sessions.backends.cached_db"
|
||||
SESSION_CACHE_ALIAS = "default"
|
||||
|
||||
MIDDLEWARE = [
|
||||
|
|
|
@ -4,7 +4,7 @@ from django.db import models
|
|||
from django.urls import reverse, reverse_lazy
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from passbook.core.models import Source, UserSourceConnection
|
||||
from passbook.core.models import Source, UserSettings, UserSourceConnection
|
||||
from passbook.sources.oauth.clients import get_client
|
||||
|
||||
|
||||
|
@ -37,18 +37,15 @@ class OAuthSource(Source):
|
|||
reverse_lazy('passbook_sources_oauth:oauth-client-callback',
|
||||
kwargs={'source_slug': self.slug})
|
||||
|
||||
def has_user_settings(self):
|
||||
"""Entrypoint to integrate with User settings. Can either return False if no
|
||||
user settings are available, or a tuple or string, string, string where the first string
|
||||
is the name the item has, the second string is the icon and the third is the view-name."""
|
||||
def user_settings(self) -> UserSettings:
|
||||
icon_type = self.provider_type
|
||||
if icon_type == 'azure ad':
|
||||
icon_type = 'windows'
|
||||
icon_class = 'fa fa-%s' % icon_type
|
||||
view_name = 'passbook_sources_oauth:oauth-client-user'
|
||||
return self.name, icon_class, reverse((view_name), kwargs={
|
||||
return UserSettings(self.name, icon_class, reverse((view_name), kwargs={
|
||||
'source_slug': self.slug
|
||||
})
|
||||
}))
|
||||
|
||||
class Meta:
|
||||
|
||||
|
|
Reference in New Issue