├── .bowerrc ├── .circleci ├── README.md ├── config.yml ├── deploy-dokku-back.sh ├── get-sentry-dsn.py ├── init-dokku-back.sh └── reconfigure-dokku-back.sh ├── .coveragerc ├── .gitignore ├── .prospector.yaml ├── Dockerfile ├── LICENSE ├── Procfile ├── README.md ├── __data__ └── README.md ├── _bin ├── run_celery_beat.sh ├── run_celery_worker.sh └── run_django_web.sh ├── _project_ ├── __init__.py ├── celery.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ └── create_test_data.py ├── settings.py ├── static │ ├── README.md │ ├── admin_hide_columns │ │ ├── index.css │ │ └── index.js │ ├── favicon.ico │ ├── main.css │ └── main.js ├── swagger.py ├── templates │ ├── README.md │ ├── _base.html │ ├── access_denied.html │ ├── admin │ │ └── base_site.html │ ├── include │ │ ├── footer.html │ │ ├── header.html │ │ └── metrics.html │ ├── index.html │ └── simple_history │ │ └── object_history_form.html ├── tests │ ├── __init__.py │ ├── test_admin.py │ ├── test_celery.py │ ├── test_filestorage.py │ └── test_index.py ├── urls.py └── wsgi.py ├── conftest.py ├── contacts ├── __init__.py ├── admin.py ├── api │ ├── __init__.py │ ├── filters.py │ ├── serializers.py │ └── viewsets.py ├── apps.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_auto_20171204_1531.py │ ├── 0003_auto_20171205_1112.py │ ├── 0004_auto_20180123_1201.py │ ├── 0005_auto_20180216_1255.py │ ├── 0006_auto_20180216_1415.py │ ├── 0007_auto_20180605_1058.py │ ├── 0008_create_table_index.py │ ├── 0009_auto_20181031_1015.py │ ├── 0010_auto_20190129_1759.py │ ├── 0011_auto_20190403_0607.py │ ├── 0012_auto_20190418_0543.py │ ├── 0013_auto_20190418_1508.py │ └── __init__.py ├── models.py ├── tests │ ├── __init__.py │ ├── factories.py │ ├── test_admin_auto_generated_read_cases.py │ ├── test_api_auto_generated_history_cases.py │ ├── test_api_auto_generated_read_cases.py │ ├── test_api_v1_create.py │ ├── test_api_v1_filters.py │ ├── test_api_v1_read.py │ ├── test_lib_integra.py │ ├── test_models.py │ └── test_schema.py └── views.py ├── core ├── README.md ├── __init__.py ├── admin.py ├── api │ ├── README.md │ ├── __init__.py │ ├── auth.py │ ├── exception_handler.py │ ├── exceptions.py │ ├── fields.py │ ├── filters.py │ ├── history │ │ ├── __init__.py │ │ ├── filters.py │ │ ├── serializers.py │ │ └── viewsets.py │ ├── inspectors.py │ ├── mixins │ │ ├── __init__.py │ │ └── common.py │ ├── pagination.py │ ├── permissions.py │ ├── router.py │ ├── schema.py │ ├── serializers.py │ ├── user.py │ └── viewsets.py ├── context_processors.py ├── fields.py ├── metrics.py ├── monitoring.py ├── normalization.py ├── permitted_fields │ ├── README.md │ ├── __init__.py │ ├── admin.py │ ├── api.py │ ├── permitted.py │ └── tests │ │ ├── __init__.py │ │ ├── test_admin │ │ ├── __init__.py │ │ └── test_has_view_permission_only.py │ │ ├── test_permitted.py │ │ └── test_serializer.py ├── tasks │ ├── __init__.py │ └── fixtures.py ├── tests │ ├── __init__.py │ ├── test_api_auth.py │ ├── test_api_user.py │ ├── test_api_v1.py │ ├── test_validators.py │ ├── test_views_task_result_api.py │ └── utils.py ├── utils │ ├── __init__.py │ ├── mail.py │ ├── models.py │ └── permissions.py ├── validators.py └── views │ ├── __init__.py │ ├── permissions.py │ └── task_result_api.py ├── cors ├── __init__.py ├── admin.py ├── apps.py ├── consts.py ├── middleware.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_auto_20180528_1552.py │ ├── 0003_auto_20180531_0800.py │ ├── 0004_auto_20180606_0836.py │ ├── 0005_auto_20181031_1015.py │ ├── 0006_auto_20190129_1759.py │ └── __init__.py ├── models.py └── tests │ ├── __init__.py │ └── test_cors.py ├── docker-compose.yaml ├── docker-entrypoint.sh ├── docker-prepare.sh ├── lib ├── __init__.py ├── codegen │ ├── __init__.py │ ├── codegen_templates │ │ ├── abstract_schema_models.j2 │ │ └── models.j2 │ ├── generator.py │ ├── management │ │ └── commands │ │ │ ├── __init__.py │ │ │ └── schema_to_models.py │ ├── model_field_generator.py │ ├── tests │ │ ├── __init__.py │ │ ├── test_model_field_generator.py │ │ ├── test_schema_cases.py │ │ └── testcases │ │ │ ├── contacts1.json │ │ │ ├── contacts1.json.abstract_schema_models.py │ │ │ └── contacts1.json.models.py │ └── utils.py ├── integra │ ├── README.md │ ├── __init__.py │ ├── management │ │ └── commands │ │ │ └── download_integra_updates.py │ ├── migrations │ │ ├── 0001_initial.py │ │ └── __init__.py │ ├── models.py │ ├── tasks.py │ ├── tests │ │ ├── __init__.py │ │ ├── test_loader.py │ │ ├── test_models.py │ │ └── test_updater.py │ └── utils.py └── oidc_relied │ ├── README.md │ ├── __init__.py │ ├── backends.py │ ├── middleware.py │ ├── pipeline.py │ ├── settings.py │ ├── tests │ ├── __init__.py │ ├── test_backchannel_logout.py │ ├── test_pipeline.py │ ├── test_relied_logout.py │ └── test_token_auth.py │ ├── urls.py │ └── views.py ├── manage.py ├── pytest.ini └── requirements.txt /.bowerrc: -------------------------------------------------------------------------------- 1 | { 2 | "directory": "./_project_/static/bower_components/", 3 | "analytics": false 4 | } 5 | -------------------------------------------------------------------------------- /.circleci/README.md: -------------------------------------------------------------------------------- 1 | # SENTRY # 2 | 3 | If you want to config a SENTRY DSN discovery. You need to export the next variables: SENTRY_URL, SENTRY_TEAM, SENTRY_API_KEY 4 | 5 | You can define it in your `config.yml` or inside CircleCI Environment Variables. 6 | 7 | Example: 8 | ``` 9 | export SENTRY_URL='https://sentry.pik-software.ru/api/' 10 | export SENTRY_TEAM='nsi' 11 | export SENTRY_API_KEY='qwe23adawd21a' 12 | ``` 13 | 14 | # LETSENCRYPT # 15 | 16 | If you want to config Letsencrypt certs. You need to export the next variables: LETSENCRYPT, DOKKU_LETSENCRYPT_EMAIL 17 | 18 | You can define it in your `config.yml` or inside CircleCI Environment Variables. 19 | 20 | Example: 21 | ``` 22 | export LETSENCRYPT=1 23 | export LETSENCRYPT_EMAIL=pik-software-team@pik-comfort.ru 24 | ``` 25 | -------------------------------------------------------------------------------- /.circleci/deploy-dokku-back.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | [[ "${TRACE}" = "1" ]] && set -x 4 | set -eo pipefail 5 | cd "$(dirname "$0")" 6 | echo "$(date +%Y-%m-%d-%H-%M-%S) - deploy-dokku-back.sh $@" 7 | 8 | SSH_HOST=$1 9 | DOMAIN=$2 10 | REPO=$3 11 | BRANCH=$4 12 | ENVIRONMENT=$5 13 | 14 | if [[ -z "${SSH_HOST}" ]] || [[ -z "${DOMAIN}" ]] || [[ -z "$REPO" ]] || [[ -z "$BRANCH" ]] || [[ -z "$ENVIRONMENT" ]]; then 15 | SSH_HOST=8.8.8.8 16 | DOMAIN=pik-software.ru 17 | REPO=$( git config --local remote.origin.url | sed -n 's#.*/\([^.]*\)\.git#\1#p' ) 18 | BRANCH=$( git branch | grep -e "^*" | cut -d' ' -f 2 ) 19 | ENVIRONMENT=production 20 | echo "Use: $0 " 21 | echo "Example: $0 ${SSH_HOST} ${DOMAIN} ${REPO} ${BRANCH} ${ENVIRONMENT}" 22 | exit 1 23 | fi 24 | 25 | function escape { 26 | echo "$1" | tr A-Z a-z | sed "s/[^a-z0-9]/-/g" | sed "s/^-+\|-+$//g" 27 | } 28 | 29 | SSH_HOST=$( echo ${SSH_HOST} ) 30 | DOMAIN=$( echo ${DOMAIN} ) 31 | REPO=$( escape ${REPO} ) 32 | BRANCH=$( escape ${BRANCH} ) 33 | ENVIRONMENT=$( escape ${ENVIRONMENT} ) 34 | 35 | echo "SSH_HOST=${SSH_HOST}" 36 | echo "REPO=${REPO}" 37 | echo "BRANCH=${BRANCH}" 38 | echo "ENVIRONMENT=${ENVIRONMENT}" 39 | 40 | if [[ "$ENVIRONMENT" = "production" ]]; then 41 | CURRENT_BRANCH=$( git branch | grep -e "^*" | cut -d' ' -f 2 ) 42 | HAS_RELEASE_TAG=$( git tag --points-at HEAD | grep -q "^v" && echo 1 || echo 0 ) 43 | 44 | if [[ "$CURRENT_BRANCH" != "master" ]]; then 45 | echo "Deploy only master!" 46 | exit 1 47 | fi 48 | if [[ "$HAS_RELEASE_TAG" != "1" ]]; then 49 | echo "Release tag required!" 50 | exit 2 51 | fi 52 | 53 | SERVICE_NAME="${REPO}" 54 | SERVICE_HOST="${REPO}.${DOMAIN}" 55 | elif [[ "$ENVIRONMENT" = "staging" ]]; then 56 | SERVICE_NAME="${REPO}-${BRANCH}" 57 | SERVICE_HOST="${REPO}-${BRANCH}.${DOMAIN}" 58 | else 59 | echo "!!! ERROR: UNKNOWN ENVIRONMENT !!!" 60 | exit 1 61 | fi 62 | 63 | echo "SERVICE_NAME=${SERVICE_NAME}" 64 | echo "SERVICE_HOST=${SERVICE_HOST}" 65 | echo "SSH: ${SSH_HOST}" 66 | 67 | INIT_LETSENCRYPT=false 68 | 69 | echo "Check SSH access 'ssh ${SSH_HOST} -o ConnectTimeout=10 dokku help'" 70 | ssh ${SSH_HOST} -C dokku help > /dev/null 71 | echo "Check SSH access 'ssh ${SSH_HOST} -o ConnectTimeout=10 docker ps'" 72 | ssh ${SSH_HOST} -C docker ps > /dev/null 73 | echo "Check SSH access 'ssh dokku@${SSH_HOST} -o ConnectTimeout=10 help'" 74 | ssh dokku@${SSH_HOST} -C help > /dev/null 75 | 76 | if ! ssh dokku@${SSH_HOST} -C apps:list | grep -qFx ${SERVICE_NAME}; then 77 | echo "!!! ===> Init <=== !!!" 78 | ./init-dokku-back.sh "${SSH_HOST}" "${SERVICE_HOST}" "${SERVICE_NAME}" "${ENVIRONMENT}" 79 | [[ -n "${LETSENCRYPT}" ]] && export INIT_LETSENCRYPT=true 80 | fi 81 | 82 | echo "!!! ===> Reconfigure <=== !!!" 83 | ./reconfigure-dokku-back.sh "${SSH_HOST}" "${SERVICE_HOST}" "${SERVICE_NAME}" "${BRANCH}" "${ENVIRONMENT}" 84 | 85 | echo "!!! ===> Deploy <=== !!!" 86 | git push ssh://dokku@${SSH_HOST}/${SERVICE_NAME} ${BRANCH}:master 87 | 88 | if ${INIT_LETSENCRYPT}; then 89 | echo "!!! ===> Init letsencrypt certs <=== !!!" 90 | ssh dokku@${SSH_HOST} -C letsencrypt ${SERVICE_NAME} 91 | fi 92 | 93 | echo "!!! ===> Run migrations <=== !!!" 94 | ssh dokku@${SSH_HOST} -C run ${SERVICE_NAME} python manage.py migrate 95 | 96 | echo "!!! ===> http://${SERVICE_HOST}/ <=== !!!" 97 | -------------------------------------------------------------------------------- /.circleci/get-sentry-dsn.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | from datetime import datetime 3 | 4 | import requests 5 | 6 | parser = argparse.ArgumentParser() 7 | 8 | parser.add_argument('-t', '--team', 9 | dest="team", 10 | help="Team slug", default="nsi") 11 | 12 | parser.add_argument('-o', '--organization', 13 | dest="organization", 14 | help="Organization slug", default="sentry") 15 | 16 | parser.add_argument('-p', '--project', 17 | dest="project", 18 | help="Project slug", default="default") 19 | 20 | parser.add_argument('-k', '--api-key', 21 | dest="api_key", action='store', 22 | help="Sentry api key", required=True) 23 | 24 | parser.add_argument('-a', '--api-prefix', 25 | dest="api_prefix", action='store', 26 | help="Sentry api prefix", required=True) 27 | 28 | args = parser.parse_args() 29 | 30 | API_VERSION = '0' 31 | API_URL = f'{args.api_prefix}{API_VERSION}/' 32 | 33 | ORGANIZATION_LIST_URL = f'{API_URL}organizations/' 34 | ORGANIZATION_URL = f'{ORGANIZATION_LIST_URL}{args.organization}/' 35 | 36 | TEAM_LIST_URL = f'{ORGANIZATION_URL}teams/' 37 | TEAM_URL = f'{API_URL}teams/{args.organization}/{args.team}/' 38 | 39 | PROJECT_LIST_URL = f'{TEAM_URL}projects/' 40 | PROJECT_URL = f'{API_URL}projects/{args.organization}/{args.project}/' 41 | 42 | KEYS_URL = f'{PROJECT_URL}keys/' 43 | 44 | session = requests.Session() 45 | session.headers.update({"Authorization": f"Bearer {args.api_key}"}) 46 | 47 | 48 | response = session.get(ORGANIZATION_URL) 49 | if response.status_code == 404: 50 | response = session.post(ORGANIZATION_LIST_URL, data={'name': args.organization, 'slug': args.organization, 'agreeTerms': True}) 51 | response.raise_for_status() 52 | else: 53 | response.raise_for_status() 54 | organization = response.json() 55 | 56 | 57 | response = session.get(TEAM_URL) 58 | if response.status_code == 404: 59 | response = session.post(TEAM_LIST_URL, data={'name': args.team, 'slug': args.team}) 60 | response.raise_for_status() 61 | else: 62 | response.raise_for_status() 63 | team = response.json() 64 | 65 | 66 | response = session.get(PROJECT_URL) 67 | if response.status_code == 404: 68 | response = session.post(PROJECT_LIST_URL, data={'organization': args.organization, 'team': args.team, 'name': args.project, 'slug': args.project}) 69 | response.raise_for_status() 70 | else: 71 | response.raise_for_status() 72 | project = response.json() 73 | 74 | 75 | response = session.get(PROJECT_URL) 76 | if response.status_code == 404: 77 | response = session.post(PROJECT_LIST_URL, data={'organization': args.organization, 'team': args.team, 'name': args.project, 'slug': args.project}) 78 | response.raise_for_status() 79 | else: 80 | response.raise_for_status() 81 | project = response.json() 82 | 83 | 84 | response = session.get(KEYS_URL) 85 | response.raise_for_status() 86 | keys = response.json() 87 | if not keys: 88 | response = session.post(KEYS_URL, data={'organization': args.organization, 'team': args.team, 'name': datetime.now()}) 89 | key = response.json() 90 | else: 91 | key = keys[0] 92 | 93 | print(key['dsn']['public']) 94 | -------------------------------------------------------------------------------- /.circleci/init-dokku-back.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | [[ "${TRACE}" = "1" ]] && set -x 4 | set -eo pipefail 5 | cd "$(dirname "$0")" 6 | echo "$(date +%Y-%m-%d-%H-%M-%S) - init-dokku-back.sh $@" 7 | 8 | SSH_HOST=$1 9 | SERVICE_HOST=$2 10 | SERVICE_NAME=$3 11 | ENVIRONMENT=$4 12 | MEDIA_ROOT=/DATA/${SERVICE_NAME} 13 | 14 | if [[ -z "${SSH_HOST}" ]] || [[ -z "${SERVICE_HOST}" ]] || [[ -z "${SERVICE_NAME}" ]] || [[ -z "${ENVIRONMENT}" ]]; then 15 | echo "Use: $0 " 16 | exit 1 17 | fi 18 | 19 | if ssh dokku@${SSH_HOST} -C apps:list | grep -qFx ${SERVICE_NAME}; then 20 | echo "App ${SERVICE_NAME} is already exists on ${SSH_HOST}"; 21 | exit 2 22 | fi 23 | 24 | ssh ${SSH_HOST} -C sudo mkdir "${MEDIA_ROOT}" -p 25 | ssh ${SSH_HOST} -C dokku events:on 26 | ssh ${SSH_HOST} -C dokku apps:create ${SERVICE_NAME} 27 | ssh dokku@${SSH_HOST} -C storage:mount ${SERVICE_NAME} "${MEDIA_ROOT}:${MEDIA_ROOT}" 28 | ssh dokku@${SSH_HOST} -C domains:set ${SERVICE_NAME} ${SERVICE_HOST} 29 | 30 | # postgres (root required!) 31 | ssh ${SSH_HOST} -C POSTGRES_IMAGE="mdillon/postgis" POSTGRES_IMAGE_VERSION="9.6" dokku postgres:create ${SERVICE_NAME} 32 | ssh dokku@${SSH_HOST} -C postgres:link ${SERVICE_NAME} ${SERVICE_NAME} 33 | 34 | # redis 35 | ssh dokku@${SSH_HOST} -C redis:create ${SERVICE_NAME} 36 | ssh dokku@${SSH_HOST} -C redis:link ${SERVICE_NAME} ${SERVICE_NAME} 37 | 38 | # dd-agent 39 | if ssh ${SSH_HOST} -C docker ps | grep -q dd-agent; then 40 | # link to dd-agent 41 | ssh dokku@${SSH_HOST} -C docker-options:add ${SERVICE_NAME} build,deploy,run "--link dd-agent:dd-agent" 42 | fi 43 | 44 | # CONFIGS 45 | 46 | SECRET_KEY=$( openssl rand -base64 18 ) 47 | 48 | # base 49 | ssh dokku@${SSH_HOST} -C config:set --no-restart ${SERVICE_NAME} SERVICE_NAME=${SERVICE_NAME} 50 | ssh dokku@${SSH_HOST} -C config:set --no-restart ${SERVICE_NAME} DOKKU_APP_TYPE=dockerfile 51 | ssh dokku@${SSH_HOST} -C config:set --no-restart ${SERVICE_NAME} SECRET_KEY=${SECRET_KEY} > /dev/null 52 | ssh dokku@${SSH_HOST} -C config:set --no-restart ${SERVICE_NAME} ENVIRONMENT=${ENVIRONMENT} 53 | ssh dokku@${SSH_HOST} -C config:set --no-restart ${SERVICE_NAME} MEDIA_ROOT=${MEDIA_ROOT} 54 | 55 | # lets encrypt 56 | if [[ -n "${LETSENCRYPT}" && -n "${LETSENCRYPT_EMAIL}" ]]; then 57 | ssh dokku@${SSH_HOST} -C config:set --no-restart ${SERVICE_NAME} DOKKU_LETSENCRYPT_EMAIL="'"${LETSENCRYPT_EMAIL}"'" 58 | fi 59 | 60 | # gcloud file storage 61 | #ssh dokku@${SSH_HOST} -C config:set --no-restart ${SERVICE_NAME} FILE_STORAGE_BACKEND= 62 | #ssh dokku@${SSH_HOST} -C config:set --no-restart ${SERVICE_NAME} FILE_STORAGE_BUCKET_NAME= 63 | #ssh dokku@${SSH_HOST} -C config:set --no-restart ${SERVICE_NAME} FILE_STORAGE_PROJECT_ID= 64 | #ssh dokku@${SSH_HOST} -C config:set --no-restart ${SERVICE_NAME} FILE_STORAGE_BACKEND_CREDENTIALS= 65 | 66 | 67 | # OPTIONS 68 | ssh dokku@${SSH_HOST} -C ps:set-restart-policy ${SERVICE_NAME} always 69 | 70 | # sentry 71 | #ssh dokku@${SSH_HOST} -C config:set --no-restart ${SERVICE_NAME} SENTRY_DSN= 72 | 73 | # SCALE 74 | ssh dokku@${SSH_HOST} -C config:set --no-restart ${SERVICE_NAME} DOKKU_DEFAULT_CHECKS_WAIT=0 75 | ssh dokku@${SSH_HOST} -C ps:scale ${SERVICE_NAME} web=1 worker=1 beat=1 76 | ssh dokku@${SSH_HOST} -C config:set --no-restart ${SERVICE_NAME} DOKKU_DEFAULT_CHECKS_WAIT=5 77 | ssh dokku@${SSH_HOST} -C config:set --no-restart ${SERVICE_NAME} DOKKU_WAIT_TO_RETIRE=60 78 | ssh dokku@${SSH_HOST} -C config:set --no-restart ${SERVICE_NAME} DOKKU_DOCKER_STOP_TIMEOUT=600 79 | 80 | # LIMITS 81 | ssh dokku@${SSH_HOST} -C limit:set --no-restart ${SERVICE_NAME} web memory=1Gb cpu=100 82 | ssh dokku@${SSH_HOST} -C limit:set --no-restart ${SERVICE_NAME} beat memory=1Gb cpu=100 83 | ssh dokku@${SSH_HOST} -C limit:set --no-restart ${SERVICE_NAME} worker memory=1Gb cpu=100 84 | -------------------------------------------------------------------------------- /.circleci/reconfigure-dokku-back.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | [[ "${TRACE}" = "1" ]] && set -x 4 | set -eo pipefail 5 | cd "$(dirname "$0")" 6 | echo "$(date +%Y-%m-%d-%H-%M-%S) - reconfigure-dokku-back.sh $@" 7 | 8 | SSH_HOST=$1 9 | SERVICE_HOST=$2 10 | SERVICE_NAME=$3 11 | BRANCH=$4 12 | ENVIRONMENT=$5 13 | 14 | if [[ -z "${SSH_HOST}" ]] || [[ -z "${SERVICE_HOST}" ]] || [[ -z "${SERVICE_NAME}" ]] || [[ -z "${BRANCH}" ]] || [[ -z "${ENVIRONMENT}" ]]; then 15 | echo "Use: $0 " 16 | exit 1 17 | fi 18 | 19 | RELEASE_DATE=$( date '+%Y-%m-%d-%H-%M-%S' ) 20 | RELEASE=$(git describe --tags --match v[0-9]*) 21 | ssh dokku@${SSH_HOST} -C config:set --no-restart ${SERVICE_NAME} RELEASE_DATE="'"${RELEASE_DATE}"'" SENTRY_RELEASE=${RELEASE} 22 | GIT_REV=$(git rev-parse ${BRANCH}) 23 | ssh dokku@${SSH_HOST} -C config:set --no-restart ${SERVICE_NAME} GIT_REV=${GIT_REV} 24 | 25 | # CONFIGS 26 | case "$ENVIRONMENT" in 27 | production) 28 | ssh dokku@${SSH_HOST} -C config:set --no-restart ${SERVICE_NAME} EXAMPLE=1 29 | ;; 30 | staging) 31 | ssh dokku@${SSH_HOST} -C config:set --no-restart ${SERVICE_NAME} EXAMPLE=2 32 | ssh dokku@${SSH_HOST} -C config:set --no-restart ${SERVICE_NAME} BRANCH=${BRANCH} 33 | ;; 34 | esac 35 | 36 | if [[ -n "${SENTRY_URL}" && -n "${SENTRY_TEAM}" && -n "${SENTRY_API_KEY}" ]]; then 37 | echo "Run SENTRY DSN discovery" 38 | SENTRY_DSN=$(python3 get-sentry-dsn.py -a "${SENTRY_URL}" -p "${SERVICE_NAME}" -t "${SENTRY_TEAM}" -k "${SENTRY_API_KEY}") 39 | ssh dokku@${SSH_HOST} -C config:set --no-restart ${SERVICE_NAME} SENTRY_DSN=${SENTRY_DSN} 40 | else 41 | echo "No SENTRY DSN discovery settings (skip)" 42 | fi 43 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = 3 | # omit anything in a tests directory anywhere 4 | **/tests/* 5 | **/migrations/* 6 | **/templates/* 7 | **/codegen_templates/* 8 | # omit everything in .venv/ 9 | .venv/* 10 | # omit this single file 11 | manage.py 12 | _project_/settings_local.py 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .venv 3 | .cache 4 | .env 5 | .pytest_cache 6 | .benchmarks 7 | .coverage 8 | *.py[cod] 9 | *.log 10 | .python-version 11 | 12 | __data__/* 13 | _project_/settings_local.py 14 | _project_/static/bower_components 15 | -------------------------------------------------------------------------------- /.prospector.yaml: -------------------------------------------------------------------------------- 1 | test-warnings: true 2 | strictness: veryhigh 3 | 4 | ignore-paths: 5 | - docs 6 | - migrations 7 | - templates 8 | - codegen_templates 9 | - .venv 10 | - _project_/settings_local.py 11 | - README.md 12 | 13 | pylint: 14 | disable: 15 | - unused-argument 16 | - too-few-public-methods 17 | - too-many-arguments 18 | - too-many-ancestors 19 | - redefined-outer-name 20 | - relative-beyond-top-level 21 | - abstract-method 22 | - no-self-use 23 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM quay.io/pik-software/base:v1.9 2 | 3 | ENV PYTHONUNBUFFERED 1 4 | 5 | RUN mkdir -p /app /static /media /cache && chown -R unprivileged:unprivileged /media /cache 6 | WORKDIR /app 7 | 8 | COPY ./requirements.txt /requirements.txt 9 | RUN pip install --no-cache-dir -r /requirements.txt 10 | 11 | COPY ./docker-entrypoint.sh /docker-entrypoint.sh 12 | 13 | ENV STATIC_ROOT /static 14 | ENV MEDIA_ROOT /media 15 | 16 | COPY . /app 17 | RUN /app/docker-prepare.sh 18 | 19 | ENTRYPOINT ["/docker-entrypoint.sh"] 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 PIK-SOFTWARE 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: ./_bin/run_django_web.sh 2 | worker: ./_bin/run_celery_worker.sh 3 | beat: ./_bin/run_celery_beat.sh 4 | -------------------------------------------------------------------------------- /__data__/README.md: -------------------------------------------------------------------------------- 1 | # project data directory # 2 | 3 | - [dir] static -- collected static 4 | - [dir] media -- project media 5 | - [file] db.sqlite3 -- project sqlite3 db 6 | 7 | `_project_.settings` contains variables for this directories 8 | -------------------------------------------------------------------------------- /_bin/run_celery_beat.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | exec python -u \ 4 | -m celery beat \ 5 | -A _project_ \ 6 | --loglevel info \ 7 | --pidfile=/tmp/celerybeat.pid \ 8 | --scheduler="redbeat.RedBeatScheduler" 9 | -------------------------------------------------------------------------------- /_bin/run_celery_worker.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if [ "$ENVIRONMENT" = "staging" ] 4 | then 5 | concurrency=1 6 | else 7 | concurrency=${2:-2} 8 | fi 9 | queue_name=${1:-celery} 10 | exec python -u -m celery worker -A _project_ --loglevel info -Q $queue_name -c $concurrency 11 | -------------------------------------------------------------------------------- /_bin/run_django_web.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | NPROCESSORS="${NPROCESSORS:-$(($(nproc) * 2))}" 4 | PORT="${PORT:-5000}" 5 | UWSGI_EXTRA_ARGS="${UWSGI_EXTRA_ARGS:-}" 6 | #UWSGI_EXTRA_ARGS="--wsgi-disable-file-wrapper" 7 | 8 | #python manage.py migrate 9 | exec uwsgi --static-map /static=$STATIC_ROOT --static-map /media=$MEDIA_ROOT --module _project_.wsgi --processes $NPROCESSORS --http :$PORT --master --die-on-term --enable-threads --single-interpreter --limit-post 4294967296 $UWSGI_EXTRA_ARGS 10 | -------------------------------------------------------------------------------- /_project_/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, unicode_literals 2 | 3 | # This will make sure the app is always imported when 4 | # Django starts so that shared_task will use this app. 5 | from .celery import app as celery_app 6 | 7 | __all__ = ['celery_app'] 8 | -------------------------------------------------------------------------------- /_project_/celery.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, unicode_literals 2 | import os 3 | import celery 4 | 5 | # set the default Django settings module for the 'celery' program. 6 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', '_project_.settings') 7 | 8 | app = celery.Celery('_project_') # noqa: pylint=invalid-name 9 | 10 | # Using a string here means the worker don't have to serialize 11 | # the configuration object to child processes. 12 | # - namespace='CELERY' means all celery-related configuration keys 13 | # should have a `CELERY_` prefix. 14 | app.config_from_object('django.conf:settings', namespace='CELERY') 15 | 16 | # Load task modules from all registered Django app configs. 17 | app.autodiscover_tasks() 18 | -------------------------------------------------------------------------------- /_project_/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pik-software/django-service-boilerplate/c39e221660ad85cea73d40cbe74036ef9b03c420/_project_/management/__init__.py -------------------------------------------------------------------------------- /_project_/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pik-software/django-service-boilerplate/c39e221660ad85cea73d40cbe74036ef9b03c420/_project_/management/commands/__init__.py -------------------------------------------------------------------------------- /_project_/management/commands/create_test_data.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django.core.management.base import BaseCommand 4 | 5 | from contacts.tests.factories import ContactFactory 6 | 7 | 8 | LOGGER = logging.getLogger(__name__) 9 | 10 | 11 | class Command(BaseCommand): 12 | 13 | help = 'Create objects to populate DB with some test data' 14 | 15 | def handle(self, *args, **options): 16 | ContactFactory.create_batch(2000) 17 | -------------------------------------------------------------------------------- /_project_/static/README.md: -------------------------------------------------------------------------------- 1 | Project level static files. 2 | 3 | Ex: 4 | - JavaScript libs 5 | - CSS files 6 | - template design images (logo, favicon, ...) 7 | -------------------------------------------------------------------------------- /_project_/static/admin_hide_columns/index.css: -------------------------------------------------------------------------------- 1 | .hideButton { 2 | float: right; 3 | cursor: pointer; 4 | padding: 8px 0; 5 | visibility: hidden; 6 | } 7 | 8 | th:hover .hideButton { 9 | visibility: visible; 10 | } 11 | 12 | .hideButton:hover { 13 | color: red; 14 | } 15 | 16 | .restoreColumns { 17 | display: table-caption; 18 | padding: 10px 0; 19 | font-size: 10px; 20 | min-height: 20px; 21 | } 22 | 23 | .restoreColumns:after { 24 | content: ''; 25 | display: block; 26 | clear: both; 27 | } 28 | 29 | .headerButton { 30 | display: none; 31 | float: left; 32 | padding: 5px 7px; 33 | cursor: pointer; 34 | } 35 | 36 | .headerButton:hover { 37 | background-color: #f6f6f6; 38 | } 39 | -------------------------------------------------------------------------------- /_project_/static/admin_hide_columns/index.js: -------------------------------------------------------------------------------- 1 | ((global) => { 2 | let $ 3 | let key = `${window.location.pathname}_hiddenColumns` 4 | const SORTABLE = 'sortable' 5 | const CHECKBOX = 'action-checkbox' 6 | const HIDEBUTTON = 'hideButton' 7 | const RESTORECOLUMNS = 'restoreColumns' 8 | const HEADERBUTTON = 'headerButton' 9 | 10 | document.addEventListener('DOMContentLoaded', () => { 11 | if (!(global.django && django.jQuery)) return 12 | $ = django.jQuery 13 | let cols = $('th[scope="col"]') 14 | let hiddenColumns = loadHiddenColumns() 15 | if (cols.length) appendHeader() 16 | cols.each((i, col) => { 17 | if(col.className.indexOf(CHECKBOX) === -1) { 18 | let columnName = `.${col.className.replace(SORTABLE,'').trim()}` 19 | let title = col.innerText 20 | prepareColumn(columnName, title) 21 | setState(hiddenColumns, columnName) 22 | } 23 | }) 24 | 25 | }) 26 | 27 | function prepareColumn (columnName, title) { 28 | let hideButton = $(`
`) 29 | .on('click', () => { 30 | $Column(columnName).hide() 31 | }) 32 | 33 | let headerButton = $(`
${title}
`) 34 | .on('click', () => { 35 | $Column(columnName).show() 36 | }) 37 | 38 | $(columnName).prepend(hideButton) 39 | $(`.${RESTORECOLUMNS}`).append(headerButton) 40 | } 41 | 42 | function setState (hiddenColumns, columnName) { 43 | if(hiddenColumns[columnName]) { 44 | $Column(columnName).hide() 45 | } 46 | } 47 | 48 | function appendHeader () { 49 | let restoreColumns = `
 
` 50 | $('table').prepend(restoreColumns) 51 | } 52 | 53 | function $Column (columnName) { 54 | let fieldsName = columnName.replace('column', 'field') 55 | let column = $([columnName, fieldsName].join(',')) 56 | let button = $HeaderButton(columnName) 57 | return { 58 | show () { 59 | column.show() 60 | button.hide() 61 | saveHiddenColumn(columnName, false) 62 | }, 63 | hide () { 64 | column.hide() 65 | button.show() 66 | saveHiddenColumn(columnName, true) 67 | } 68 | } 69 | } 70 | 71 | function $HeaderButton (columnName) { 72 | return $(`.${HEADERBUTTON}${columnName}`) 73 | } 74 | 75 | function loadHiddenColumns () { 76 | let value = localStorage.getItem(key) 77 | return JSON.parse(value) || {} 78 | } 79 | 80 | function saveHiddenColumn (column, state) { 81 | let hiddenColumns = loadHiddenColumns() 82 | hiddenColumns[column] = state 83 | 84 | let value = JSON.stringify(hiddenColumns) 85 | localStorage.setItem(key, value) 86 | } 87 | 88 | })(window) 89 | -------------------------------------------------------------------------------- /_project_/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pik-software/django-service-boilerplate/c39e221660ad85cea73d40cbe74036ef9b03c420/_project_/static/favicon.ico -------------------------------------------------------------------------------- /_project_/static/main.css: -------------------------------------------------------------------------------- 1 | body, html { 2 | height: 100%; 3 | } 4 | 5 | body { 6 | padding-top: 70px; 7 | } 8 | 9 | .big-form .form-control { 10 | border-radius: 0; 11 | border: none; 12 | border-bottom: 2px solid #bdc3c7; 13 | } 14 | 15 | .big-form .form-control:focus { 16 | border-color: #1abc9c; 17 | } 18 | 19 | .big-form .btn { 20 | font-size: 18px; 21 | } 22 | -------------------------------------------------------------------------------- /_project_/static/main.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pik-software/django-service-boilerplate/c39e221660ad85cea73d40cbe74036ef9b03c420/_project_/static/main.js -------------------------------------------------------------------------------- /_project_/swagger.py: -------------------------------------------------------------------------------- 1 | from drf_yasg import openapi 2 | 3 | INFO = openapi.Info( 4 | title="API", 5 | description="API", 6 | default_version="v1", 7 | ) 8 | -------------------------------------------------------------------------------- /_project_/templates/README.md: -------------------------------------------------------------------------------- 1 | Project level templates override. 2 | -------------------------------------------------------------------------------- /_project_/templates/_base.html: -------------------------------------------------------------------------------- 1 | {% load static raven %} 2 | 3 | 4 | 5 | 6 | {% block meta_title %}{% endblock %} 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | {% block css %} 20 | 21 | {% block extra_css %}{% endblock %} 22 | {% endblock %} 23 | 24 | 25 | 29 | {% block extra_head %}{% endblock %} 30 | 31 | 32 | {% include "include/header.html" %} 33 | 34 |
35 | {% block content %} 36 |

Hello, world!

37 | {% endblock %} 38 |
39 | 40 | 41 | {% include "include/footer.html" %} 42 | 43 | {% block js %} 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | {% block extra_js %}{% endblock %} 53 | {% endblock %} 54 | 55 | {% include "include/metrics.html" %} 56 | 57 | {% block extra_body %}{% endblock %} 58 | 59 | -------------------------------------------------------------------------------- /_project_/templates/access_denied.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/base.html" %} 2 | {% load i18n %} 3 | 4 | {% block title %}Доступ закрыт{% endblock %} 5 | 6 | {% block breadcrumbs %}{% endblock %} 7 | 8 | {% block content %} 9 | {% blocktrans %} 10 | 11 |
12 |

Доступ закрыт!

13 |

14 | Извини, но у тебя нет доступа к этому сервису. 15 | Пожалуйста, обратись к системному администратору. 16 |

17 |
18 | {% endblocktrans %} 19 | {% endblock %} 20 | -------------------------------------------------------------------------------- /_project_/templates/admin/base_site.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/base.html" %} 2 | {% load static %} 3 | {% block title %}{{ title }} | {{ settings.SERVICE_TITLE }}{% endblock %} 4 | 5 | {% block extrastyle %} 6 | 7 | {{ block.super }} 8 | 19 | 20 | {% endblock %} 21 | 22 | {% block branding %} 23 |

{{ settings.SERVICE_TITLE }}

24 | {% endblock %} 25 | 26 | {% block nav-global %}{% endblock %} 27 | 28 | {% block footer %} 29 | {{ block.super }} 30 | 36 | 37 | {% include "include/metrics.html" %} 38 | {% endblock %} 39 | -------------------------------------------------------------------------------- /_project_/templates/include/footer.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 | 6 |

Сервис для поиска и заполнения контактов сотрудников
7 |

8 |
9 |
10 |
11 |
12 | -------------------------------------------------------------------------------- /_project_/templates/include/header.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 | 41 | -------------------------------------------------------------------------------- /_project_/templates/include/metrics.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /_project_/templates/simple_history/object_history_form.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/change_form.html" %} 2 | {% load i18n %} 3 | {% load url from simple_history_compat %} 4 | 5 | {% block breadcrumbs %} 6 | 14 | {% endblock %} 15 | 16 | {% block submit_buttons_bottom %} 17 | {# {% include "simple_history/submit_line.html" %}#} 18 | {% endblock %} 19 | 20 | {% block form_top %} 21 | {#

{% blocktrans %}Press the 'Revert' button below to revert to this version of the object.{% endblocktrans %} {% if change_history %}{% blocktrans %}Or press the 'Change History' button to edit the history.{% endblocktrans %}{% endif %}

#} 22 | {% endblock %} 23 | -------------------------------------------------------------------------------- /_project_/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pik-software/django-service-boilerplate/c39e221660ad85cea73d40cbe74036ef9b03c420/_project_/tests/__init__.py -------------------------------------------------------------------------------- /_project_/tests/test_admin.py: -------------------------------------------------------------------------------- 1 | def test_admin_index(admin_client): 2 | response = admin_client.get('/admin/') 3 | assert response.status_code == 200 4 | -------------------------------------------------------------------------------- /_project_/tests/test_celery.py: -------------------------------------------------------------------------------- 1 | from celery import shared_task 2 | 3 | 4 | @shared_task(bind=True) 5 | def debug_task(self): 6 | return 'Request: {0!r}'.format(self.request.task) 7 | 8 | 9 | def test_create_task(celery_worker): 10 | req = debug_task.delay() 11 | assert req.status in ['PENDING', 'SUCCESS'] 12 | assert req.get() == "Request: '_project_.tests.test_celery.debug_task'" 13 | -------------------------------------------------------------------------------- /_project_/tests/test_filestorage.py: -------------------------------------------------------------------------------- 1 | from django.core.files.base import ContentFile 2 | from django.core.files.storage import default_storage 3 | from django.utils.crypto import get_random_string 4 | 5 | 6 | def test_create_new_file(): 7 | data = get_random_string().encode() 8 | path = default_storage.save('test1/test.txt', ContentFile(data)) 9 | assert default_storage.open(path).read() == data 10 | -------------------------------------------------------------------------------- /_project_/tests/test_index.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.mark.selenium 5 | def test_login(base_url, selenium): 6 | selenium.get(f'{base_url}/login') 7 | assert selenium.title == 'Войти | Сервис' 8 | -------------------------------------------------------------------------------- /_project_/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.conf.urls.static import static 3 | from django.contrib import admin 4 | from django.contrib.auth.decorators import login_required 5 | from django.urls import path, include 6 | 7 | from core.api.auth import OBTAIN_AUTH_TOKEN 8 | from core.api.router import StandardizedRouter 9 | from core.views.permissions import permissions_view 10 | from core.api.schema import get_standardized_schema_view 11 | from core.api.user import USER_API_VIEW 12 | from core.views import task_result_api_view 13 | from contacts.api import ContactViewSet, CommentViewSet 14 | 15 | 16 | router = StandardizedRouter() # noqa: pylint=invalid-name 17 | router.register( 18 | 'contact-list', ContactViewSet, 'contact') 19 | router.register( 20 | 'comment-list', CommentViewSet, 'comment') 21 | 22 | 23 | @login_required 24 | def index(request): 25 | from django.shortcuts import render, redirect 26 | if request.user.is_staff: 27 | return redirect(settings.INDEX_STAFF_REDIRECT_URL) 28 | return render(request, 'access_denied.html', {}) 29 | 30 | 31 | api_urlpatterns = [ # noqa: pylint=invalid-name 32 | path('api/v1/', include((router.urls, 'api'))), 33 | ] 34 | 35 | urlpatterns = api_urlpatterns + [ # noqa: pylint=invalid-name 36 | path('', index, name='index'), 37 | path('', include('lib.oidc_relied.urls')), 38 | path('admin/', admin.site.urls), 39 | path('status/', include('health_check.urls')), 40 | path('api/task/result//', task_result_api_view), 41 | path('api-token-auth/', OBTAIN_AUTH_TOKEN), 42 | path('api-user/', USER_API_VIEW), 43 | path('api/v1/permissions/', permissions_view), 44 | path('api/v1/schema/', get_standardized_schema_view(api_urlpatterns), 45 | name='api_schema'), 46 | ] 47 | 48 | urlpatterns += static( 49 | settings.MEDIA_URL, 50 | document_root=settings.MEDIA_ROOT 51 | ) 52 | urlpatterns += static( 53 | settings.STATIC_URL, 54 | document_root=settings.STATIC_ROOT 55 | ) 56 | 57 | if settings.DEBUG: 58 | import debug_toolbar # noqa 59 | 60 | urlpatterns += [ 61 | path('__debug__/', include(debug_toolbar.urls)), 62 | path('explorer/', include('explorer.urls')), 63 | ] 64 | -------------------------------------------------------------------------------- /_project_/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for _project_ project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.11/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "_project_.settings") 15 | 16 | application = get_wsgi_application() # noqa: pylint=invalid-name 17 | -------------------------------------------------------------------------------- /contacts/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = 'contacts.apps.AppConfig' # noqa: invalid-name 2 | -------------------------------------------------------------------------------- /contacts/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.utils.html import format_html_join 3 | from django.utils.safestring import mark_safe 4 | from django.utils.translation import ugettext as _ 5 | 6 | from core.admin import SecuredVersionedModelAdmin, SecuredAdminInline, \ 7 | AutoSetRequestUserMixIn 8 | from .models import Contact, Comment 9 | 10 | 11 | class CommentInline(SecuredAdminInline): 12 | model = Comment 13 | 14 | 15 | @admin.register(Contact) 16 | class ContactAdmin(AutoSetRequestUserMixIn, SecuredVersionedModelAdmin): 17 | list_display = ('name', 'phones', 'display_emails') 18 | search_fields = ('name', 'phones', 'emails') 19 | ordering = ('order_index', '-id') 20 | 21 | fieldsets = (( 22 | None, 23 | {'fields': ( 24 | 'name', 'phones', 'emails', 'order_index' 25 | )}), 26 | ) 27 | 28 | inlines = (CommentInline,) 29 | 30 | def display_emails(self, obj): # noqa: pylint=no-self-use 31 | return format_html_join( 32 | mark_safe('
'), '{0}', 33 | [(x, ) for x in obj.emails]) 34 | 35 | display_emails.short_description = _('e-mail') 36 | display_emails.allow_tags = True 37 | -------------------------------------------------------------------------------- /contacts/api/__init__.py: -------------------------------------------------------------------------------- 1 | from .viewsets import ContactViewSet, CommentViewSet 2 | 3 | __all__ = ['ContactViewSet', 'CommentViewSet'] 4 | -------------------------------------------------------------------------------- /contacts/api/filters.py: -------------------------------------------------------------------------------- 1 | import rest_framework_filters as filters 2 | from django.db.models import DateTimeField 3 | 4 | from ..models import Contact, Comment, Category 5 | 6 | NAME_FILTERS = ['exact', 'in', 'startswith', 'endswith', 'contains'] 7 | 8 | 9 | class CharArrayFilter(filters.BaseCSVFilter, filters.CharFilter): 10 | pass 11 | 12 | 13 | class CategoryFilter(filters.FilterSet): 14 | class Meta: 15 | model = Category 16 | fields = { 17 | 'name': NAME_FILTERS, 18 | 'updated': ['exact', 'gt', 'gte', 'lt', 'lte'], 19 | 'created': ['exact', 'gt', 'gte', 'lt', 'lte'], 20 | } 21 | filter_overrides = { 22 | DateTimeField: {'filter_class': filters.IsoDateTimeFilter} 23 | } 24 | 25 | 26 | class ContactFilter(filters.FilterSet): 27 | phones__contains = CharArrayFilter( 28 | field_name='phones', lookup_expr='contains') 29 | emails__contains = CharArrayFilter( 30 | field_name='emails', lookup_expr='contains') 31 | 32 | class Meta: 33 | model = Contact 34 | fields = { 35 | 'name': NAME_FILTERS, 36 | 'updated': ['exact', 'gt', 'gte', 'lt', 'lte'], 37 | 'created': ['exact', 'gt', 'gte', 'lt', 'lte'], 38 | } 39 | filter_overrides = { 40 | DateTimeField: {'filter_class': filters.IsoDateTimeFilter} 41 | } 42 | 43 | 44 | class CommentFilter(filters.FilterSet): 45 | class Meta: 46 | model = Comment 47 | fields = { 48 | 'message': NAME_FILTERS, 49 | 'user': ['exact', 'in'], 50 | 'updated': ['exact', 'gt', 'gte', 'lt', 'lte'], 51 | 'created': ['exact', 'gt', 'gte', 'lt', 'lte'], 52 | } 53 | filter_overrides = { 54 | DateTimeField: {'filter_class': filters.IsoDateTimeFilter} 55 | } 56 | -------------------------------------------------------------------------------- /contacts/api/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework.fields import IntegerField 2 | 3 | from core.api.serializers import StandardizedModelSerializer 4 | from ..models import Contact, Comment 5 | 6 | 7 | class CategorySerializer(StandardizedModelSerializer): 8 | class Meta: 9 | model = Contact 10 | fields = ( 11 | '_uid', '_type', '_version', 'created', 'updated', 12 | 'name', 'parent') 13 | 14 | 15 | class ContactSerializer(StandardizedModelSerializer): 16 | class Meta: 17 | model = Contact 18 | fields = ( 19 | '_uid', '_type', '_version', 'created', 'updated', 20 | 'name', 'phones', 'emails', 21 | 'order_index') 22 | 23 | 24 | class CommentSerializer(StandardizedModelSerializer): 25 | contact = ContactSerializer() 26 | 27 | user = IntegerField(source='user_id', required=False) 28 | 29 | def create(self, validated_data): 30 | if 'user' not in validated_data: 31 | validated_data['user'] = self.context['request'].user 32 | return super().create(validated_data) 33 | 34 | class Meta: 35 | model = Comment 36 | read_only_fields = ('user',) 37 | fields = ( 38 | '_uid', '_type', '_version', 'created', 'updated', 39 | 'user', 'contact', 'message') 40 | -------------------------------------------------------------------------------- /contacts/api/viewsets.py: -------------------------------------------------------------------------------- 1 | from contacts.models import Contact, Comment, Category 2 | from core.api.filters import StandardizedFieldFilters, \ 3 | StandardizedSearchFilter, StandardizedOrderingFilter 4 | from core.api.viewsets import StandardizedModelViewSet 5 | 6 | from .filters import ContactFilter, CommentFilter, CategoryFilter 7 | from .serializers import ContactSerializer, CommentSerializer, \ 8 | CategorySerializer 9 | 10 | 11 | class ContactViewSet(StandardizedModelViewSet): 12 | lookup_field = 'uid' 13 | lookup_url_kwarg = '_uid' 14 | ordering = '-id' 15 | serializer_class = ContactSerializer 16 | allow_bulk_create = True 17 | allow_history = True 18 | 19 | filter_backends = ( 20 | StandardizedFieldFilters, StandardizedSearchFilter, 21 | StandardizedOrderingFilter) 22 | filter_class = ContactFilter 23 | search_fields = ( 24 | 'name', 'phones', 'emails') 25 | ordering_fields = ('created', 'updated', 'name', 'order_index') 26 | 27 | def get_queryset(self): 28 | return Contact.objects.all() 29 | 30 | 31 | class CommentViewSet(StandardizedModelViewSet): 32 | lookup_field = 'uid' 33 | lookup_url_kwarg = '_uid' 34 | ordering = '-created' 35 | serializer_class = CommentSerializer 36 | allow_bulk_create = True 37 | allow_history = True 38 | 39 | filter_backends = ( 40 | StandardizedFieldFilters, StandardizedSearchFilter, 41 | StandardizedOrderingFilter) 42 | filter_class = CommentFilter 43 | search_fields = ( 44 | 'message', 'user') 45 | ordering_fields = ('created', 'updated') 46 | 47 | def get_queryset(self): 48 | return Comment.objects.all().select_related('contact') 49 | 50 | 51 | class CategoryViewSet(StandardizedModelViewSet): 52 | lookup_field = 'uid' 53 | lookup_url_kwarg = '_uid' 54 | ordering = '-created' 55 | serializer_class = CategorySerializer 56 | allow_bulk_create = True 57 | allow_history = True 58 | 59 | filter_backends = ( 60 | StandardizedFieldFilters, StandardizedSearchFilter, 61 | StandardizedOrderingFilter) 62 | filter_class = CategoryFilter 63 | search_fields = ( 64 | 'name') 65 | ordering_fields = ('created', 'updated') 66 | 67 | def get_queryset(self): 68 | return Category.objects.all() 69 | -------------------------------------------------------------------------------- /contacts/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig as BaseAppConfig 2 | from django.utils.translation import ugettext_lazy as _ 3 | 4 | 5 | class AppConfig(BaseAppConfig): 6 | name = 'contacts' 7 | verbose_name = _('Общие контакты') 8 | -------------------------------------------------------------------------------- /contacts/migrations/0002_auto_20171204_1531.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.5 on 2017-12-04 15:31 3 | from __future__ import unicode_literals 4 | 5 | import django.contrib.postgres.fields 6 | from django.db import migrations, models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ('contacts', '0001_initial'), 13 | ] 14 | 15 | operations = [ 16 | migrations.AlterModelOptions( 17 | name='contacts', 18 | options={'ordering': ['order_index', '-id'], 'verbose_name': 'контакт', 'verbose_name_plural': 'контакты'}, 19 | ), 20 | migrations.AlterModelOptions( 21 | name='historicalcontacts', 22 | options={'get_latest_by': 'history_date', 'ordering': ('-history_date', '-history_id'), 'verbose_name': 'historical контакт'}, 23 | ), 24 | migrations.RemoveField( 25 | model_name='contacts', 26 | name='_contacts_index', 27 | ), 28 | migrations.RemoveField( 29 | model_name='historicalcontacts', 30 | name='_contacts_index', 31 | ), 32 | migrations.AddField( 33 | model_name='contacts', 34 | name='order_index', 35 | field=models.IntegerField(default=100), 36 | ), 37 | migrations.AddField( 38 | model_name='historicalcontacts', 39 | name='order_index', 40 | field=models.IntegerField(default=100), 41 | ), 42 | migrations.AlterField( 43 | model_name='contacts', 44 | name='phones', 45 | field=django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=30), blank=True, default=list, help_text='Номера телефонов вводятся в произвольном формате через запятую', size=None, verbose_name='Номера телефонов служб'), 46 | ), 47 | migrations.AlterField( 48 | model_name='historicalcontacts', 49 | name='phones', 50 | field=django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=30), blank=True, default=list, help_text='Номера телефонов вводятся в произвольном формате через запятую', size=None, verbose_name='Номера телефонов служб'), 51 | ), 52 | ] 53 | -------------------------------------------------------------------------------- /contacts/migrations/0003_auto_20171205_1112.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.5 on 2017-12-05 11:12 3 | from __future__ import unicode_literals 4 | 5 | import django.contrib.postgres.fields 6 | from django.db import migrations, models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ('contacts', '0002_auto_20171204_1531'), 13 | ] 14 | 15 | operations = [ 16 | migrations.AlterModelOptions( 17 | name='contacts', 18 | options={'ordering': ['order_index', '-id'], 'permissions': (('can_edit_contacts', 'Может редактировать контакты'),), 'verbose_name': 'контакт', 'verbose_name_plural': 'контакты'}, 19 | ), 20 | migrations.AlterField( 21 | model_name='contacts', 22 | name='order_index', 23 | field=models.IntegerField(default=100, verbose_name='Индекс для сортировки'), 24 | ), 25 | migrations.AlterField( 26 | model_name='contacts', 27 | name='phones', 28 | field=django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=30), blank=True, default=list, help_text='Номера телефонов вводятся в произвольном формате через запятую', size=None, verbose_name='Номера телефонов'), 29 | ), 30 | migrations.AlterField( 31 | model_name='historicalcontacts', 32 | name='order_index', 33 | field=models.IntegerField(default=100, verbose_name='Индекс для сортировки'), 34 | ), 35 | migrations.AlterField( 36 | model_name='historicalcontacts', 37 | name='phones', 38 | field=django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=30), blank=True, default=list, help_text='Номера телефонов вводятся в произвольном формате через запятую', size=None, verbose_name='Номера телефонов'), 39 | ), 40 | ] 41 | -------------------------------------------------------------------------------- /contacts/migrations/0004_auto_20180123_1201.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.5 on 2018-01-23 12:01 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('contacts', '0003_auto_20171205_1112'), 12 | ] 13 | 14 | operations = [ 15 | migrations.RenameModel( 16 | old_name='Contacts', 17 | new_name='Contact', 18 | ), 19 | migrations.RenameModel( 20 | old_name='HistoricalContacts', 21 | new_name='HistoricalContact', 22 | ), 23 | migrations.AlterModelOptions( 24 | name='contact', 25 | options={'ordering': ['-id'], 'permissions': (('can_edit_contact', 'Может редактировать контакты'),), 'verbose_name': 'контакт', 'verbose_name_plural': 'контакты'}, 26 | ), 27 | ] 28 | -------------------------------------------------------------------------------- /contacts/migrations/0005_auto_20180216_1255.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.9 on 2018-02-16 12:55 3 | from __future__ import unicode_literals 4 | 5 | from django.conf import settings 6 | from django.db import migrations, models 7 | import django.db.models.deletion 8 | import uuid 9 | 10 | 11 | class Migration(migrations.Migration): 12 | 13 | dependencies = [ 14 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 15 | ('contacts', '0004_auto_20180123_1201'), 16 | ] 17 | 18 | operations = [ 19 | migrations.CreateModel( 20 | name='Comment', 21 | fields=[ 22 | ('created', models.DateTimeField(auto_now_add=True, verbose_name='Создан')), 23 | ('updated', models.DateTimeField(auto_now=True, verbose_name='Updated')), 24 | ('uid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), 25 | ('version', models.IntegerField(default=1, editable=False)), 26 | ('message', models.TextField(verbose_name='Сообщение')), 27 | ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to=settings.AUTH_USER_MODEL, verbose_name='Пользователь')), 28 | ], 29 | options={ 30 | 'verbose_name': 'коментарий', 31 | 'verbose_name_plural': 'коментарии', 32 | 'ordering': ['-created'], 33 | 'permissions': (('change_user_comment', 'Может менять автора коментария'), ('can_get_api_comment_history', 'Может читать /api/v/comment-list/history/')), 34 | }, 35 | ), 36 | migrations.CreateModel( 37 | name='HistoricalComment', 38 | fields=[ 39 | ('created', models.DateTimeField(blank=True, editable=False, verbose_name='Создан')), 40 | ('updated', models.DateTimeField(blank=True, editable=False, verbose_name='Updated')), 41 | ('uid', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False)), 42 | ('version', models.IntegerField(default=1, editable=False)), 43 | ('message', models.TextField(verbose_name='Сообщение')), 44 | ('history_id', models.AutoField(primary_key=True, serialize=False)), 45 | ('history_date', models.DateTimeField()), 46 | ('history_change_reason', models.CharField(max_length=100, null=True)), 47 | ('history_type', models.CharField(choices=[('+', 'Создан'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), 48 | ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), 49 | ('user', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL)), 50 | ], 51 | options={ 52 | 'verbose_name': 'historical коментарий', 53 | 'ordering': ('-history_date', '-history_id'), 54 | 'get_latest_by': 'history_date', 55 | }, 56 | ), 57 | migrations.AlterModelOptions( 58 | name='contact', 59 | options={'ordering': ['-id'], 'permissions': (('can_edit_contact', 'Может редактировать контакты'), ('can_get_api_contact_history', 'Может читать /api/v/contact-list/history/')), 'verbose_name': 'контакт', 'verbose_name_plural': 'контакты'}, 60 | ), 61 | migrations.AlterField( 62 | model_name='contact', 63 | name='created', 64 | field=models.DateTimeField(auto_now_add=True, verbose_name='Создан'), 65 | ), 66 | migrations.AlterField( 67 | model_name='historicalcontact', 68 | name='created', 69 | field=models.DateTimeField(blank=True, editable=False, verbose_name='Создан'), 70 | ), 71 | ] 72 | -------------------------------------------------------------------------------- /contacts/migrations/0006_auto_20180216_1415.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.9 on 2018-02-16 14:15 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | import django.db.models.deletion 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ('contacts', '0005_auto_20180216_1255'), 13 | ] 14 | 15 | operations = [ 16 | migrations.AddField( 17 | model_name='comment', 18 | name='contact', 19 | field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to='contacts.Contact'), 20 | preserve_default=False, 21 | ), 22 | migrations.AddField( 23 | model_name='historicalcomment', 24 | name='contact', 25 | field=models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='contacts.Contact'), 26 | ), 27 | ] 28 | -------------------------------------------------------------------------------- /contacts/migrations/0007_auto_20180605_1058.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.12 on 2018-06-05 10:58 3 | from __future__ import unicode_literals 4 | 5 | from django.conf import settings 6 | from django.db import migrations, models 7 | import django.db.models.deletion 8 | import pik.core.models.uided 9 | 10 | 11 | class Migration(migrations.Migration): 12 | 13 | dependencies = [ 14 | ('contacts', '0006_auto_20180216_1415'), 15 | ] 16 | 17 | operations = [ 18 | migrations.AlterModelOptions( 19 | name='comment', 20 | options={'ordering': ['-created'], 'permissions': (('change_user_comment', 'Может менять автора коментария'),), 'verbose_name': 'коментарий', 'verbose_name_plural': 'коментарии'}, 21 | ), 22 | migrations.AlterModelOptions( 23 | name='contact', 24 | options={'ordering': ['-id'], 'permissions': (('can_edit_contact', 'Может редактировать контакты'),), 'verbose_name': 'контакт', 'verbose_name_plural': 'контакты'}, 25 | ), 26 | migrations.AlterField( 27 | model_name='comment', 28 | name='contact', 29 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='contacts.Contact'), 30 | ), 31 | migrations.AlterField( 32 | model_name='comment', 33 | name='uid', 34 | field=models.UUIDField(default=pik.core.models.uided._new_uid, editable=False, primary_key=True, serialize=False), 35 | ), 36 | migrations.AlterField( 37 | model_name='comment', 38 | name='user', 39 | field=models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='comments', to=settings.AUTH_USER_MODEL, verbose_name='Пользователь'), 40 | ), 41 | migrations.AlterField( 42 | model_name='comment', 43 | name='version', 44 | field=models.IntegerField(editable=False), 45 | ), 46 | migrations.AlterField( 47 | model_name='contact', 48 | name='uid', 49 | field=models.UUIDField(default=pik.core.models.uided._new_uid, editable=False, unique=True), 50 | ), 51 | migrations.AlterField( 52 | model_name='contact', 53 | name='version', 54 | field=models.IntegerField(editable=False), 55 | ), 56 | migrations.AlterField( 57 | model_name='historicalcomment', 58 | name='history_type', 59 | field=models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1), 60 | ), 61 | migrations.AlterField( 62 | model_name='historicalcomment', 63 | name='uid', 64 | field=models.UUIDField(db_index=True, default=pik.core.models.uided._new_uid, editable=False), 65 | ), 66 | migrations.AlterField( 67 | model_name='historicalcomment', 68 | name='version', 69 | field=models.IntegerField(editable=False), 70 | ), 71 | migrations.AlterField( 72 | model_name='historicalcontact', 73 | name='history_type', 74 | field=models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1), 75 | ), 76 | migrations.AlterField( 77 | model_name='historicalcontact', 78 | name='uid', 79 | field=models.UUIDField(db_index=True, default=pik.core.models.uided._new_uid, editable=False), 80 | ), 81 | migrations.AlterField( 82 | model_name='historicalcontact', 83 | name='version', 84 | field=models.IntegerField(editable=False), 85 | ), 86 | ] 87 | -------------------------------------------------------------------------------- /contacts/migrations/0008_create_table_index.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.12 on 2018-10-17 14:17 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations 6 | 7 | 8 | CONTACT_UPDATED_INDEX_NAME = 'historical_contact_updated_field' 9 | CONTACT_UPDATED_UID_INDEX_NAME = 'historical_contact_updated_uid_fields' 10 | COMMENT_UPDATED_INDEX_NAME = 'historical_comment_updated_field' 11 | COMMENT_UPDATED_UID_INDEX_NAME = 'historical_comment_updated_uid_fields' 12 | 13 | 14 | 15 | class Migration(migrations.Migration): 16 | 17 | dependencies = [ 18 | ('contacts', '0007_auto_20180605_1058'), 19 | ] 20 | 21 | operations = [ 22 | migrations.RunSQL( 23 | f""" 24 | CREATE INDEX {CONTACT_UPDATED_INDEX_NAME} ON 25 | contacts_historicalcontact (updated); 26 | CREATE INDEX {CONTACT_UPDATED_UID_INDEX_NAME} ON 27 | contacts_historicalcontact (updated, uid); 28 | 29 | CREATE INDEX {COMMENT_UPDATED_INDEX_NAME} ON 30 | contacts_historicalcomment (updated); 31 | CREATE INDEX {COMMENT_UPDATED_UID_INDEX_NAME} ON 32 | contacts_historicalcomment (updated, uid); 33 | """, 34 | reverse_sql=f""" 35 | DROP INDEX {CONTACT_UPDATED_INDEX_NAME}; 36 | DROP INDEX {CONTACT_UPDATED_UID_INDEX_NAME}; 37 | 38 | DROP INDEX {COMMENT_UPDATED_INDEX_NAME}; 39 | DROP INDEX {COMMENT_UPDATED_UID_INDEX_NAME}; 40 | """), 41 | ] 42 | -------------------------------------------------------------------------------- /contacts/migrations/0009_auto_20181031_1015.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.2 on 2018-10-31 10:15 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('contacts', '0008_create_table_index'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name='comment', 17 | name='created', 18 | field=models.DateTimeField(auto_now_add=True, verbose_name='created'), 19 | ), 20 | migrations.AlterField( 21 | model_name='comment', 22 | name='updated', 23 | field=models.DateTimeField(auto_now=True, verbose_name='updated'), 24 | ), 25 | migrations.AlterField( 26 | model_name='comment', 27 | name='user', 28 | field=models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='comments', to=settings.AUTH_USER_MODEL, verbose_name='User'), 29 | ), 30 | migrations.AlterField( 31 | model_name='contact', 32 | name='created', 33 | field=models.DateTimeField(auto_now_add=True, verbose_name='created'), 34 | ), 35 | migrations.AlterField( 36 | model_name='contact', 37 | name='updated', 38 | field=models.DateTimeField(auto_now=True, verbose_name='updated'), 39 | ), 40 | migrations.AlterField( 41 | model_name='historicalcomment', 42 | name='created', 43 | field=models.DateTimeField(blank=True, editable=False, verbose_name='created'), 44 | ), 45 | migrations.AlterField( 46 | model_name='historicalcomment', 47 | name='updated', 48 | field=models.DateTimeField(blank=True, editable=False, verbose_name='updated'), 49 | ), 50 | migrations.AlterField( 51 | model_name='historicalcontact', 52 | name='created', 53 | field=models.DateTimeField(blank=True, editable=False, verbose_name='created'), 54 | ), 55 | migrations.AlterField( 56 | model_name='historicalcontact', 57 | name='updated', 58 | field=models.DateTimeField(blank=True, editable=False, verbose_name='updated'), 59 | ), 60 | ] 61 | -------------------------------------------------------------------------------- /contacts/migrations/0010_auto_20190129_1759.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.3 on 2019-01-29 17:59 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('contacts', '0009_auto_20181031_1015'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='comment', 15 | name='created', 16 | field=models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='created'), 17 | ), 18 | migrations.AlterField( 19 | model_name='comment', 20 | name='updated', 21 | field=models.DateTimeField(auto_now=True, db_index=True, verbose_name='updated'), 22 | ), 23 | migrations.AlterField( 24 | model_name='contact', 25 | name='created', 26 | field=models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='created'), 27 | ), 28 | migrations.AlterField( 29 | model_name='contact', 30 | name='updated', 31 | field=models.DateTimeField(auto_now=True, db_index=True, verbose_name='updated'), 32 | ), 33 | migrations.AlterField( 34 | model_name='historicalcomment', 35 | name='created', 36 | field=models.DateTimeField(blank=True, db_index=True, editable=False, verbose_name='created'), 37 | ), 38 | migrations.AlterField( 39 | model_name='historicalcomment', 40 | name='updated', 41 | field=models.DateTimeField(blank=True, db_index=True, editable=False, verbose_name='updated'), 42 | ), 43 | migrations.AlterField( 44 | model_name='historicalcontact', 45 | name='created', 46 | field=models.DateTimeField(blank=True, db_index=True, editable=False, verbose_name='created'), 47 | ), 48 | migrations.AlterField( 49 | model_name='historicalcontact', 50 | name='updated', 51 | field=models.DateTimeField(blank=True, db_index=True, editable=False, verbose_name='updated'), 52 | ), 53 | ] 54 | -------------------------------------------------------------------------------- /contacts/migrations/0011_auto_20190403_0607.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.3 on 2019-04-03 06:07 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('contacts', '0010_auto_20190129_1759'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name='historicalcomment', 17 | name='user', 18 | field=models.ForeignKey(blank=True, db_constraint=False, editable=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='User'), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /contacts/migrations/0012_auto_20190418_0543.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.7 on 2019-04-18 05:43 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | import pik.core.models.uided 7 | import simple_history.models 8 | 9 | import core.fields 10 | 11 | 12 | class Migration(migrations.Migration): 13 | 14 | dependencies = [ 15 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 16 | ('contacts', '0011_auto_20190403_0607'), 17 | ] 18 | 19 | operations = [ 20 | migrations.CreateModel( 21 | name='Category', 22 | fields=[ 23 | ('created', models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='created')), 24 | ('updated', models.DateTimeField(auto_now=True, db_index=True, verbose_name='updated')), 25 | ('uid', models.UUIDField(default=pik.core.models.uided._new_uid, editable=False, primary_key=True, serialize=False)), 26 | ('version', models.IntegerField(editable=False)), 27 | ('name', core.fields.NormalizedCharField(max_length=255, verbose_name='Название')), 28 | ('parent', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='contacts.Category')), 29 | ], 30 | options={ 31 | 'abstract': False, 32 | }, 33 | ), 34 | migrations.CreateModel( 35 | name='HistoricalCategory', 36 | fields=[ 37 | ('created', models.DateTimeField(blank=True, db_index=True, editable=False, verbose_name='created')), 38 | ('updated', models.DateTimeField(blank=True, db_index=True, editable=False, verbose_name='updated')), 39 | ('uid', models.UUIDField(db_index=True, default=pik.core.models.uided._new_uid, editable=False)), 40 | ('version', models.IntegerField(editable=False)), 41 | ('name', core.fields.NormalizedCharField(max_length=255, verbose_name='Название')), 42 | ('history_id', models.AutoField(primary_key=True, serialize=False)), 43 | ('history_date', models.DateTimeField()), 44 | ('history_change_reason', models.CharField(max_length=100, null=True)), 45 | ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), 46 | ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), 47 | ('parent', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='contacts.Category')), 48 | ], 49 | options={ 50 | 'verbose_name': 'historical category', 51 | 'ordering': ('-history_date', '-history_id'), 52 | 'get_latest_by': 'history_date', 53 | }, 54 | bases=(simple_history.models.HistoricalChanges, models.Model), 55 | ), 56 | migrations.AddField( 57 | model_name='contact', 58 | name='category', 59 | field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='contacts.Category'), 60 | ), 61 | migrations.AddField( 62 | model_name='historicalcontact', 63 | name='category', 64 | field=models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='contacts.Category'), 65 | ), 66 | ] 67 | -------------------------------------------------------------------------------- /contacts/migrations/0013_auto_20190418_1508.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.7 on 2019-04-18 15:08 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('contacts', '0012_auto_20190418_0543'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterModelOptions( 14 | name='category', 15 | options={'ordering': ['-created'], 'verbose_name': 'категория', 'verbose_name_plural': 'категории'}, 16 | ), 17 | migrations.AlterModelOptions( 18 | name='historicalcategory', 19 | options={'get_latest_by': 'history_date', 'ordering': ('-history_date', '-history_id'), 'verbose_name': 'historical категория'}, 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /contacts/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pik-software/django-service-boilerplate/c39e221660ad85cea73d40cbe74036ef9b03c420/contacts/migrations/__init__.py -------------------------------------------------------------------------------- /contacts/models.py: -------------------------------------------------------------------------------- 1 | from django.contrib.postgres.fields import ArrayField 2 | from django.db import models 3 | from django.utils.translation import ugettext_lazy as _ 4 | from pik.core.models import BaseHistorical, BasePHistorical, Owned 5 | 6 | from core.fields import NormalizedCharField 7 | 8 | 9 | class Category(BasePHistorical): 10 | name = NormalizedCharField(_('Название'), max_length=255) 11 | parent = models.ForeignKey('self', null=True, on_delete=models.CASCADE) 12 | 13 | def __str__(self): 14 | return self.name 15 | 16 | class Meta: 17 | verbose_name = _('категория') 18 | verbose_name_plural = _('категории') 19 | ordering = ['-created'] 20 | 21 | 22 | class Contact(BaseHistorical): 23 | permitted_fields = { 24 | '{app_label}.change_{model_name}': [ 25 | 'name', 'phones', 'emails', 'order_index'] 26 | } 27 | 28 | category = models.ForeignKey(Category, null=True, on_delete=models.CASCADE) 29 | name = NormalizedCharField(_('Наименование'), max_length=255) 30 | phones = ArrayField( 31 | models.CharField(max_length=30), blank=True, default=list, 32 | verbose_name=_('Номера телефонов'), 33 | help_text=_( 34 | 'Номера телефонов вводятся в произвольном формате через запятую' 35 | )) 36 | emails = ArrayField( 37 | models.EmailField(), blank=True, default=list, 38 | verbose_name=_('E-mail адреса'), 39 | help_text=_('E-mail адреса вводятся через запятую')) 40 | 41 | order_index = models.IntegerField(_('Индекс для сортировки'), default=100) 42 | 43 | def __str__(self): 44 | return f'{self.name}' 45 | 46 | class Meta: 47 | verbose_name = _('контакт') 48 | verbose_name_plural = _('контакты') 49 | ordering = ['-id'] 50 | permissions = ( 51 | ("can_edit_contact", _("Может редактировать контакты")), 52 | ) 53 | 54 | 55 | class Comment(BasePHistorical, Owned): 56 | permitted_fields = { 57 | '{app_label}.change_{model_name}': ['message', 'contact'], 58 | '{app_label}.change_user_{model_name}': ['user_id'] 59 | } 60 | 61 | contact = models.ForeignKey( 62 | Contact, related_name='comments', 63 | on_delete=models.CASCADE) 64 | message = models.TextField(_('Сообщение')) 65 | 66 | def __str__(self): 67 | return f'{self.user}: {self.message}' 68 | 69 | class Meta: 70 | verbose_name = _('коментарий') 71 | verbose_name_plural = _('коментарии') 72 | ordering = ['-created'] 73 | permissions = ( 74 | ("change_user_comment", 75 | _("Может менять автора коментария")), 76 | ) 77 | -------------------------------------------------------------------------------- /contacts/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pik-software/django-service-boilerplate/c39e221660ad85cea73d40cbe74036ef9b03c420/contacts/tests/__init__.py -------------------------------------------------------------------------------- /contacts/tests/factories.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | import factory 4 | import factory.fuzzy 5 | 6 | from core.tasks.fixtures import create_user 7 | from ..models import Contact, Comment, Category 8 | 9 | 10 | def _get_random_internal_phones(): 11 | return [str(random.randint(1000, 9999))] 12 | 13 | 14 | def _get_random_external_phones(): 15 | count = random.randint(1, 5) 16 | return [ 17 | "+{}".format(random.randint(79068077767, 99968077767)) 18 | for _ in range(count)] 19 | 20 | 21 | def _gen_if_probability(model_factory, probability, **kwargs): 22 | assert 0 < probability < 100 23 | number = random.randint(0, 100) 24 | if number < probability: 25 | return model_factory.create(**kwargs) 26 | return None 27 | 28 | 29 | class CategoryFactory(factory.django.DjangoModelFactory): 30 | class Meta: 31 | model = Category 32 | 33 | name = factory.Faker('name') 34 | 35 | 36 | class ContactFactory(factory.django.DjangoModelFactory): 37 | class Meta: 38 | model = Contact 39 | 40 | name = factory.Faker('name') 41 | phones = factory.LazyAttribute( 42 | lambda x: _get_random_internal_phones()) 43 | emails = factory.LazyAttribute( 44 | lambda x: ['{0}@example.com'.format(x.name).lower()]) 45 | category = factory.LazyAttribute( 46 | lambda x: _gen_if_probability(CategoryFactory, 20)) 47 | 48 | 49 | class CommentFactory(factory.django.DjangoModelFactory): 50 | class Meta: 51 | model = Comment 52 | 53 | contact = factory.SubFactory(ContactFactory) 54 | message = factory.Faker('text') 55 | user = factory.LazyAttribute( 56 | lambda x: create_user()) 57 | -------------------------------------------------------------------------------- /contacts/tests/test_admin_auto_generated_read_cases.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.urls import reverse 3 | from rest_framework import status 4 | 5 | from core.tests.utils import add_admin_access_permission, add_user_permissions 6 | from ..models import Contact 7 | from .factories import ContactFactory 8 | 9 | 10 | BATCH_MODELS = 5 11 | 12 | 13 | @pytest.fixture(params=[ 14 | (Contact, ContactFactory, {}), 15 | ]) 16 | def admin_model(request): 17 | return request.param 18 | 19 | 20 | def _get_admin_changelist_url(model): 21 | meta = model._meta # noqa 22 | return reverse(f'admin:{meta.app_label}_{meta.model_name}_changelist') 23 | 24 | 25 | def _get_admin_change_url(model, obj): 26 | meta = model._meta # noqa 27 | return reverse( 28 | f'admin:{meta.app_label}_{meta.model_name}_change', 29 | kwargs={'object_id': obj.pk}) 30 | 31 | 32 | def _create_few_models(factory, **kwargs): 33 | factory.create_batch(BATCH_MODELS) 34 | last_obj = factory.create(**kwargs) 35 | return last_obj 36 | 37 | 38 | def test_admin_index_access_denied(api_client): 39 | res = api_client.get(reverse('admin:index')) 40 | assert res.status_code == status.HTTP_302_FOUND 41 | 42 | 43 | def test_admin_index(api_user, api_client): 44 | add_admin_access_permission(api_user) 45 | res = api_client.get(reverse('admin:index')) 46 | assert res.status_code == status.HTTP_200_OK 47 | 48 | 49 | def test_admin_model_access_denied(api_user, api_client, admin_model): 50 | model, factory, obj_kwargs = admin_model 51 | _create_few_models(factory, **obj_kwargs) 52 | add_admin_access_permission(api_user) 53 | res = api_client.get(_get_admin_changelist_url(model)) 54 | assert res.status_code == status.HTTP_403_FORBIDDEN 55 | 56 | 57 | def test_admin_model(api_user, api_client, admin_model): 58 | model, factory, obj_kwargs = admin_model 59 | _create_few_models(factory, **obj_kwargs) 60 | add_admin_access_permission(api_user) 61 | add_user_permissions(api_user, model, 'view') 62 | res = api_client.get(_get_admin_changelist_url(model)) 63 | assert res.status_code == status.HTTP_200_OK 64 | 65 | 66 | def test_admin_model_object_access_denied(api_user, api_client, admin_model): 67 | model, factory, obj_kwargs = admin_model 68 | obj = _create_few_models(factory, **obj_kwargs) 69 | add_admin_access_permission(api_user) 70 | res = api_client.get(_get_admin_change_url(model, obj)) 71 | assert res.status_code == status.HTTP_403_FORBIDDEN 72 | 73 | 74 | def test_admin_model_object(api_user, api_client, admin_model): 75 | model, factory, obj_kwargs = admin_model 76 | obj = _create_few_models(factory, **obj_kwargs) 77 | add_admin_access_permission(api_user) 78 | add_user_permissions(api_user, model, 'change') 79 | res = api_client.get(_get_admin_change_url(model, obj)) 80 | assert res.status_code == status.HTTP_200_OK 81 | -------------------------------------------------------------------------------- /contacts/tests/test_api_auto_generated_read_cases.py: -------------------------------------------------------------------------------- 1 | from django.contrib.contenttypes.models import ContentType 2 | import pytest 3 | from rest_framework import status 4 | 5 | from core.tests.utils import add_user_permissions 6 | from ..models import Contact, Comment 7 | from ..tests.factories import ContactFactory, CommentFactory 8 | 9 | 10 | BATCH_MODELS = 5 11 | 12 | 13 | @pytest.fixture(params=[ 14 | (Contact, ContactFactory, {'version': 1, 'queries': BATCH_MODELS}), 15 | (Comment, CommentFactory, {'version': 1, 'queries': BATCH_MODELS}), 16 | ]) 17 | def api_model(request): 18 | return request.param 19 | 20 | 21 | def _url(model, options, obj=None): 22 | _type = ContentType.objects.get_for_model(model).model 23 | if obj: 24 | return f'/api/v{options["version"]}/{_type}-list/{obj.uid}/' 25 | return f'/api/v{options["version"]}/{_type}-list/' 26 | 27 | 28 | def _create_few_models(factory): 29 | factory.create_batch(BATCH_MODELS) 30 | last_obj = factory.create() 31 | return last_obj 32 | 33 | 34 | def test_api_unauthorized_list(anon_api_client, api_model): 35 | model, factory, options = api_model 36 | _create_few_models(factory) 37 | url = _url(model, options) 38 | 39 | res = anon_api_client.get(url) 40 | 41 | assert res.status_code in (status.HTTP_401_UNAUTHORIZED, 42 | status.HTTP_403_FORBIDDEN) 43 | 44 | 45 | def test_api_unauthorized_retrieve(anon_api_client, api_model): 46 | model, factory, options = api_model 47 | last_obj = _create_few_models(factory) 48 | url = _url(model, options, last_obj) 49 | 50 | res = anon_api_client.get(url) 51 | 52 | assert res.status_code in (status.HTTP_401_UNAUTHORIZED, 53 | status.HTTP_403_FORBIDDEN) 54 | 55 | 56 | def test_api_list(api_user, api_client, api_model): 57 | model, factory, options = api_model 58 | last_obj = _create_few_models(factory) 59 | url = _url(model, options) 60 | _type = ContentType.objects.get_for_model(model).model 61 | 62 | add_user_permissions(api_user, model, 'view') 63 | res = api_client.get(url) 64 | assert res.status_code == status.HTTP_200_OK 65 | assert res.data['count'] > BATCH_MODELS 66 | assert res.data['pages'] >= 1 67 | assert res.data['page_size'] >= 20 68 | assert res.data['page'] == 1 69 | assert res.data['page_next'] is None or res.data['page_next'] == 2 70 | assert res.data['page_previous'] is None 71 | assert res.data['results'][0]['_uid'] == last_obj.uid 72 | assert res.data['results'][0]['_type'] == _type 73 | assert len(res.data['results']) > BATCH_MODELS 74 | 75 | 76 | def test_api_retrieve(api_user, api_client, api_model): 77 | model, factory, options = api_model 78 | last_obj = _create_few_models(factory) 79 | url = _url(model, options, last_obj) 80 | _type = ContentType.objects.get_for_model(model).model 81 | 82 | add_user_permissions(api_user, model, 'view') 83 | res = api_client.get(url) 84 | assert res.status_code == status.HTTP_200_OK 85 | assert res.data['_uid'] == last_obj.uid 86 | assert res.data['_type'] == _type 87 | 88 | 89 | def test_api_list_num_queries( 90 | api_user, api_client, api_model, 91 | assert_num_queries_lte 92 | ): 93 | model, factory, options = api_model 94 | _create_few_models(factory) 95 | url = _url(model, options) 96 | add_user_permissions(api_user, model, 'view') 97 | 98 | with assert_num_queries_lte(options["queries"]): 99 | res = api_client.get(url) 100 | assert res.status_code == status.HTTP_200_OK 101 | assert len(res.data['results']) >= BATCH_MODELS 102 | -------------------------------------------------------------------------------- /contacts/tests/test_api_v1_filters.py: -------------------------------------------------------------------------------- 1 | from freezegun import freeze_time 2 | from rest_framework import status 3 | 4 | from core.tests.utils import add_user_permissions 5 | 6 | from .factories import ContactFactory 7 | from ..models import Contact 8 | 9 | 10 | def test_api_filter_by_updated(api_user, api_client): # noqa: pylint=invalid-name 11 | add_user_permissions(api_user, Contact, 'view') 12 | with freeze_time("2012-01-14"): 13 | ContactFactory.create() 14 | obj = ContactFactory.create() 15 | with freeze_time("2013-01-14"): 16 | ContactFactory.create() 17 | obj.name += '(new)' 18 | obj.save() 19 | ContactFactory.create() 20 | 21 | res = api_client.get('/api/v1/contact-list/') 22 | assert res.status_code == status.HTTP_200_OK 23 | assert res.json()['count'] == 4 24 | 25 | filter_date = '2012-04-12T22:33:45.028342' 26 | res = api_client.get(f'/api/v1/contact-list/?updated__gt={filter_date}') 27 | assert res.status_code == status.HTTP_200_OK 28 | assert res.json()['count'] == 3 29 | 30 | filter_date = '2013-01-14T00:00:00' 31 | res = api_client.get(f'/api/v1/contact-list/?updated__gte={filter_date}') 32 | assert res.status_code == status.HTTP_200_OK 33 | assert res.json()['count'] == 3 34 | 35 | filter_date = '2013-01-14T00:00:00' 36 | res = api_client.get(f'/api/v1/contact-list/?updated__gt={filter_date}') 37 | assert res.status_code == status.HTTP_200_OK 38 | assert res.json()['count'] == 1 39 | -------------------------------------------------------------------------------- /contacts/tests/test_models.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from ..models import Contact, Comment, Category 4 | from ..tests.factories import ContactFactory, CommentFactory, CategoryFactory 5 | 6 | 7 | @pytest.fixture(params=[ 8 | (Contact, ContactFactory), 9 | (Comment, CommentFactory), 10 | (Category, CategoryFactory), 11 | ]) 12 | def model_and_factory(request): 13 | return request.param 14 | 15 | 16 | @pytest.fixture(params=[ 17 | (Contact, ContactFactory), 18 | (Comment, CommentFactory), 19 | (Category, CategoryFactory), 20 | ]) 21 | def critical_model_and_factory(request): 22 | return request.param 23 | 24 | 25 | def test_create_model_by_factories(model_and_factory): 26 | model, factory = model_and_factory 27 | obj1 = factory.create() 28 | obj2 = model.objects.last() 29 | if hasattr(obj1, 'uid'): 30 | assert obj1.uid == obj2.uid 31 | if hasattr(obj1, 'id'): 32 | assert obj1.id == obj2.id 33 | assert obj1.pk == obj2.pk 34 | assert str(obj1) == str(obj2) 35 | 36 | 37 | def test_critical_model_protocol(critical_model_and_factory): 38 | model, _ = critical_model_and_factory 39 | fields = [f.name for f in model._meta.get_fields()] # noqa: pylint=protected-access 40 | assert hasattr(model, 'history') 41 | assert 'uid' in fields 42 | assert 'version' in fields 43 | assert 'created' in fields 44 | assert 'updated' in fields 45 | assert hasattr(model._meta, 'verbose_name') # noqa 46 | assert hasattr(model._meta, 'verbose_name_plural') # noqa 47 | assert '__str__' in model.__dict__.keys() # noqa 48 | 49 | 50 | def test_increment_version(critical_model_and_factory): 51 | _, factory = critical_model_and_factory 52 | obj = factory.create() 53 | version1 = obj.version 54 | obj.save() 55 | version2 = obj.version 56 | obj.save() 57 | version3 = obj.version 58 | assert version1 < version2 < version3 59 | 60 | 61 | space_unicodes = [ # noqa: invalid name 62 | '\xa0', '\u1680', '\u2000', '\u2001', '\u2002', '\u2003', 63 | '\u2004', '\u2005', '\u2006', '\u2007', '\u2008', 64 | '\u2009', '\u200A', '\u202F', '\u205F', '\u3000'] 65 | 66 | 67 | @pytest.mark.parametrize('space_unicode', space_unicodes) 68 | def test_escaped_whitespaces_charfield_normalization(space_unicode): 69 | contact = Category.objects.create(name=f'category{space_unicode}1') 70 | assert contact.name == 'category 1' 71 | -------------------------------------------------------------------------------- /contacts/views.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pik-software/django-service-boilerplate/c39e221660ad85cea73d40cbe74036ef9b03c420/contacts/views.py -------------------------------------------------------------------------------- /core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pik-software/django-service-boilerplate/c39e221660ad85cea73d40cbe74036ef9b03c420/core/__init__.py -------------------------------------------------------------------------------- /core/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib.gis import admin 2 | from simple_history.admin import SimpleHistoryAdmin 3 | 4 | from core.permitted_fields.admin import ( 5 | PermittedFieldsAdminMixIn, PermittedFieldsInlineAdminMixIn) 6 | 7 | 8 | class ReasonedMixIn: 9 | def save_model(self, request, obj, form, change): 10 | # add `changeReason` for simple-history 11 | change_prefix = f'Admin: changed by {request.user}: ' 12 | if not change: 13 | obj.changeReason = f'Admin: created by {request.user}: {repr(obj)}' 14 | elif form.changed_data: 15 | obj.changeReason = change_prefix + f'{repr(form.changed_data)}' 16 | else: 17 | obj.changeReason = change_prefix + 'save() without changes' 18 | if len(obj.changeReason) > 100: 19 | obj.changeReason = obj.changeReason[0:97] + '...' 20 | super().save_model(request, obj, form, change) 21 | 22 | def delete_model(self, request, obj): 23 | # add `changeReason` for simple-history 24 | obj.changeReason = f'Admin: deleted by {request.user}: {repr(obj)}' 25 | if len(obj.changeReason) > 100: 26 | obj.changeReason = obj.changeReason[0:97] + '...' 27 | super().delete_model(request, obj) 28 | 29 | 30 | class NonDeletableModelAdminMixIn: 31 | def has_delete_permission(self, request, obj=None): # noqa: pylint=no-self-use 32 | return False 33 | 34 | def get_actions(self, request): 35 | actions = super().get_actions(request) 36 | if 'delete_selected' in actions: 37 | del actions['delete_selected'] 38 | return actions 39 | 40 | 41 | class NonAddableModelAdminMixIn: 42 | def has_add_permission(self, request): # noqa: pylint=no-self-use 43 | return False 44 | 45 | 46 | class StrictMixIn(NonAddableModelAdminMixIn, NonDeletableModelAdminMixIn): 47 | pass 48 | 49 | 50 | class SecuredModelAdmin(PermittedFieldsAdminMixIn, ReasonedMixIn, 51 | admin.ModelAdmin): 52 | pass 53 | 54 | 55 | class StrictSecuredModelAdmin(StrictMixIn, SecuredModelAdmin): 56 | pass 57 | 58 | 59 | class SecuredAdminInline(PermittedFieldsInlineAdminMixIn, ReasonedMixIn, 60 | admin.TabularInline): 61 | extra = 0 62 | 63 | 64 | class StrictSecuredAdminInline(StrictMixIn, SecuredAdminInline): 65 | pass 66 | 67 | 68 | class VersionedModelAdmin(SimpleHistoryAdmin): 69 | pass 70 | 71 | 72 | class SecuredVersionedModelAdmin(VersionedModelAdmin, SecuredModelAdmin): 73 | pass 74 | 75 | 76 | class StrictSecuredVersionedModelAdmin(StrictMixIn, VersionedModelAdmin, 77 | SecuredModelAdmin): 78 | pass 79 | 80 | 81 | class RequiredInlineMixIn: 82 | validate_min = True 83 | extra = 0 84 | min_num = 1 85 | 86 | def get_formset(self, *args, **kwargs): # noqa: pylint=arguments-differ 87 | return super().get_formset(validate_min=self.validate_min, *args, 88 | **kwargs) 89 | 90 | 91 | class AutoSetRequestUserMixIn: 92 | def save_model(self, request, obj, form, change): 93 | if hasattr(obj, 'user_id') and not obj.user_id: 94 | obj.user_id = request.user.pk 95 | super().save_model(request, obj, form, change) 96 | 97 | def save_related(self, request, form, formsets, change): 98 | for formset in formsets: 99 | instances = formset.save(commit=False) 100 | for instance in instances: 101 | if hasattr(instance, 'user_id') and not instance.user_id: 102 | instance.user_id = request.user.pk 103 | super().save_related(request, form, formsets, change) 104 | -------------------------------------------------------------------------------- /core/api/README.md: -------------------------------------------------------------------------------- 1 | # Standardized API CheckList # 2 | 3 | - [ ] Model has: `history = HistoricalRecords()` 4 | - [ ] Model has: `def __str__(self)` 5 | - [ ] Model: `issubclass(Model, BasePHistorical)` 6 | - [ ] Model.Meta: `verbose_name`, `verbose_name_plural` 7 | - [ ] Model.Meta: `ordering = ['-created']` 8 | 9 | - [ ] `module/api/__init__.py` exists 10 | - [ ] `module/api/serializers.py` exists 11 | - [ ] `module/api/filters.py` exists 12 | - [ ] `module/api/viewsets.py` exists 13 | 14 | - [ ] api.serializers.ModelSerializer: `issubclass(ModelSerializer, StandardizedModelSerializer)` 15 | - [ ] api.vewsets.ModelViewSet: `issubclass(ModelViewSet, StandardizedModelViewSet)` 16 | 17 | ``` 18 | class ModelViewSet(StandardizedModelViewSet): 19 | lookup_field = 'uid' 20 | lookup_url_kwarg = '_uid' 21 | ordering = '-created' 22 | serializer_class = 23 | allow_bulk_create = True 24 | allow_history = True 25 | 26 | filter_backends = ( 27 | StandardizedFieldFilters, StandardizedSearchFilter, 28 | StandardizedOrderingFilter) 29 | filter_class = 30 | search_fields = (...) 31 | ordering_fields = (...) 32 | ``` 33 | 34 | - [ ] api.filters.ModelFilter: (optional) 35 | 36 | ``` 37 | class ModelFilter(filters.FilterSet): 38 | class Meta: 39 | model = Model 40 | fields = { 41 | ... 42 | } 43 | ``` 44 | 45 | # SCHEMA # 46 | 47 | DEFAULT_AUTO_SCHEMA_CLASS 48 | `view.swagger_schema` 49 | `view.method._swagger_auto_schema.auto_schema` 50 | `view.method._swagger_auto_schema[method].auto_schema` 51 | -------------------------------------------------------------------------------- /core/api/__init__.py: -------------------------------------------------------------------------------- 1 | VERSION = '1.1' 2 | -------------------------------------------------------------------------------- /core/api/auth.py: -------------------------------------------------------------------------------- 1 | from django.utils.translation import ugettext as _ 2 | from rest_framework import serializers 3 | from rest_framework.authtoken.views import ObtainAuthToken 4 | from rest_framework.authentication import authenticate 5 | 6 | 7 | class AuthTokenSerializer(serializers.Serializer): 8 | def update(self, instance, validated_data): 9 | pass 10 | 11 | def create(self, validated_data): 12 | pass 13 | 14 | username = serializers.CharField(label=_("Username")) 15 | password = serializers.CharField( 16 | label=_("Password"), 17 | style={'input_type': 'password'}, 18 | trim_whitespace=False 19 | ) 20 | 21 | def validate(self, attrs): 22 | username = attrs.get('username') 23 | password = attrs.get('password') 24 | 25 | if username and password: 26 | user = authenticate(request=self.context.get('request'), 27 | username=username, password=password) 28 | 29 | # The authenticate call simply returns None for is_active=False 30 | # users. (Assuming the default ModelBackend authentication 31 | # backend.) 32 | if not user: 33 | msg = _('Unable to log in with provided credentials.') 34 | raise serializers.ValidationError(msg, code='authorization') 35 | else: 36 | msg = _('Must include "username" and "password".') 37 | raise serializers.ValidationError(msg, code='authorization') 38 | 39 | attrs['user'] = user 40 | return attrs 41 | 42 | 43 | class StandardizedObtainAuthToken(ObtainAuthToken): 44 | serializer_class = AuthTokenSerializer 45 | 46 | 47 | OBTAIN_AUTH_TOKEN = StandardizedObtainAuthToken.as_view() 48 | -------------------------------------------------------------------------------- /core/api/exception_handler.py: -------------------------------------------------------------------------------- 1 | from django.http import Http404 2 | from django.utils.translation import ugettext as _ 3 | from rest_framework import exceptions, status 4 | from rest_framework.exceptions import PermissionDenied 5 | from rest_framework.response import Response 6 | from rest_framework.views import set_rollback 7 | 8 | 9 | def standardized_handler(exc, context): # noqa 10 | """ 11 | Returns the response that should be used for any given exception. 12 | 13 | By default we handle the REST framework `APIException`, and also 14 | Django's built-in `Http404` and `PermissionDenied` exceptions. 15 | 16 | Any unhandled exceptions may return `None`, which will cause a 500 error 17 | to be raised. 18 | 19 | Example: 20 | 21 | REST_FRAMEWORK = { 22 | ... 23 | 'EXCEPTION_HANDLER': 24 | 'core.api.exception_handler.standardized_handler', 25 | ... 26 | } 27 | """ 28 | if isinstance(exc, exceptions.APIException): 29 | headers = {} 30 | if getattr(exc, 'auth_header', None): 31 | headers['WWW-Authenticate'] = exc.auth_header 32 | if getattr(exc, 'wait', None): 33 | headers['Retry-After'] = '%d' % exc.wait 34 | 35 | code = exc.default_code 36 | if hasattr(exc.detail, 'code') and exc.detail.code: 37 | code = exc.detail.code 38 | 39 | if isinstance(exc.detail, (list, dict)): 40 | data = { 41 | 'code': code, 42 | 'detail': exc.get_full_details(), 43 | 'message': str(exc.default_detail), 44 | } 45 | else: 46 | data = { 47 | 'code': code, 48 | 'message': str(exc.detail), 49 | } 50 | 51 | set_rollback() 52 | 53 | return Response(data, status=exc.status_code, headers=headers) 54 | 55 | elif isinstance(exc, Http404): 56 | data = { 57 | 'message': _('Not found.'), 58 | 'code': 'not_found'} 59 | set_rollback() 60 | return Response(data, status=status.HTTP_404_NOT_FOUND) 61 | 62 | elif isinstance(exc, PermissionDenied): 63 | data = { 64 | 'message': _('Permission denied.'), 65 | 'code': 'permission_denied'} 66 | set_rollback() 67 | return Response(data, status=status.HTTP_403_FORBIDDEN) 68 | 69 | return None 70 | -------------------------------------------------------------------------------- /core/api/exceptions.py: -------------------------------------------------------------------------------- 1 | from rest_framework import status 2 | from rest_framework.exceptions import APIException 3 | 4 | 5 | class APIRequestError(APIException): 6 | status_code = status.HTTP_400_BAD_REQUEST 7 | -------------------------------------------------------------------------------- /core/api/filters.py: -------------------------------------------------------------------------------- 1 | import coreapi 2 | from django.contrib.postgres.fields import ArrayField 3 | from django.db.models import DateTimeField 4 | from rest_framework_filters import FilterSet, RelatedFilter, BaseCSVFilter, \ 5 | AutoFilter, IsoDateTimeFilter 6 | from rest_framework_filters.backends import RestFrameworkFilterBackend 7 | from rest_framework.filters import SearchFilter, OrderingFilter 8 | 9 | 10 | UID_LOOKUPS = ('exact', 'gt', 'gte', 'lt', 'lte', 'in', 'isnull') 11 | STRING_LOOKUPS = ( 12 | 'exact', 'iexact', 'in', 'startswith', 'endswith', 'contains', 'contains', 13 | 'isnull') 14 | DATE_LOOKUPS = ('exact', 'gt', 'gte', 'lt', 'lte', 'in', 'isnull') 15 | BOOLEAN_LOOKUPS = ('exact', 'in', 'isnull') 16 | ARRAY_LOOKUPS = ['contains', 'contained_by', 'overlap', 'len', 'isnull'] 17 | 18 | 19 | class StandardizedFieldFilters(RestFrameworkFilterBackend): 20 | def get_schema_fields(self, view): 21 | # This is not compatible with widgets where the query param differs 22 | # from the filter's attribute name. Notably, this includes 23 | # `MultiWidget`, where query params will be of 24 | # the format `_0`, `_1`, etc... 25 | 26 | filter_class = getattr(view, 'filter_class', None) 27 | if filter_class is None: 28 | try: 29 | filter_class = self.get_filter_class(view, view.get_queryset()) 30 | except Exception: # noqa 31 | raise RuntimeError( 32 | f"{view.__class__} is not compatible with " 33 | f"schema generation" 34 | ) 35 | 36 | fields = [] 37 | 38 | return self.get_flatten_schema_fields('', fields, filter_class) 39 | 40 | def get_flatten_schema_fields(self, prefix, filters: list, filter_class): 41 | for field_name, field in filter_class.get_filters().items(): 42 | if isinstance(field, RelatedFilter): 43 | self.get_flatten_schema_fields( 44 | prefix + field_name + '__', filters, field.filterset) 45 | else: 46 | filters.append(coreapi.Field( 47 | name=prefix + field_name, 48 | required=False, 49 | location='query', 50 | schema=self.get_coreschema_field(field) 51 | )) 52 | return filters 53 | 54 | 55 | class StandardizedSearchFilter(SearchFilter): 56 | pass 57 | 58 | 59 | class StandardizedOrderingFilter(OrderingFilter): 60 | pass 61 | 62 | 63 | class ArrayFilter(BaseCSVFilter, AutoFilter): 64 | DEFAULT_LOOKUPS = ARRAY_LOOKUPS 65 | 66 | def __init__(self, *args, **kwargs): 67 | kwargs.setdefault('lookups', self.DEFAULT_LOOKUPS) 68 | super().__init__(*args, **kwargs) 69 | 70 | 71 | class StandardizedFilterSet(FilterSet): 72 | FILTER_DEFAULTS = {**FilterSet.FILTER_DEFAULTS, **{ 73 | ArrayField: {'filter_class': ArrayFilter}, 74 | DateTimeField: {'filter_class': IsoDateTimeFilter}, 75 | }} 76 | 77 | class Meta: 78 | model = None 79 | fields = { 80 | 'uid': UID_LOOKUPS, 81 | } 82 | -------------------------------------------------------------------------------- /core/api/history/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pik-software/django-service-boilerplate/c39e221660ad85cea73d40cbe74036ef9b03c420/core/api/history/__init__.py -------------------------------------------------------------------------------- /core/api/history/filters.py: -------------------------------------------------------------------------------- 1 | from django.db.models import DateTimeField 2 | from rest_framework_filters import ( 3 | AutoFilter, FilterSet, IsoDateTimeFilter) 4 | 5 | 6 | class HistoryFilterBase(FilterSet): 7 | LOOKUP_FIELD_FILTERS = ('exact', 'gt', 'gte', 'lt', 'lte', 'in', 'isnull') 8 | 9 | class Meta: 10 | model = None 11 | fields = { 12 | 'history_id': ('exact', 'gt', 'gte', 'lt', 'lte', 'in'), 13 | 'history_type': ('exact', 'in'), 14 | 'history_user_id': ('exact', 'in', 'isnull'), 15 | 'history_date': ('exact', 'gt', 'gte', 'lt', 'lte', 'in'), 16 | } 17 | filter_overrides = { 18 | DateTimeField: {'filter_class': IsoDateTimeFilter}} 19 | 20 | 21 | def get_history_filter_class(model_name, viewset): 22 | name = f'{model_name}FilterSet' 23 | 24 | lookup_filter_name = viewset.lookup_field 25 | if viewset.lookup_url_kwarg: 26 | lookup_filter_name = viewset.lookup_url_kwarg 27 | 28 | model = viewset.serializer_class.Meta.model.history.model 29 | _meta = type("Meta", (HistoryFilterBase.Meta, ), {"model": model}) 30 | 31 | lookup_field_filter = AutoFilter( 32 | field_name=viewset.lookup_field, 33 | lookups=HistoryFilterBase.LOOKUP_FIELD_FILTERS) 34 | 35 | attrs = { 36 | lookup_filter_name: lookup_field_filter, 37 | 'Meta': _meta 38 | } 39 | 40 | return type(name, (HistoryFilterBase, ), attrs) 41 | -------------------------------------------------------------------------------- /core/api/history/serializers.py: -------------------------------------------------------------------------------- 1 | from collections import OrderedDict 2 | 3 | from rest_framework import fields 4 | from rest_framework.serializers import Serializer 5 | 6 | from core.api.serializers import StandardizedModelSerializer 7 | from core.api.user import UserSerializer 8 | 9 | 10 | class HistorySerializerMixIn(Serializer): 11 | history_id = fields.IntegerField() 12 | history_date = fields.DateTimeField() 13 | history_change_reason = fields.CharField() 14 | history_type = fields.CharField() 15 | history_user_id = fields.IntegerField() 16 | history_user = UserSerializer() 17 | 18 | class Meta: 19 | fields = ('history_id', 'history_date', 'history_change_reason', 20 | 'history_type', 'history_type', 'history_user_id', 21 | 'history_user') 22 | 23 | def to_representation(self, instance): 24 | ret = OrderedDict() 25 | fields = self._readable_fields # noqa 26 | 27 | for field in fields: 28 | simplify_nested_serializer(field) 29 | try: 30 | attribute = field.get_attribute(instance) 31 | if attribute is not None: 32 | ret[field.field_name] = field.to_representation( 33 | attribute) 34 | else: 35 | ret[field.field_name] = None 36 | except AttributeError: 37 | ret[field.field_name] = None 38 | 39 | return ret 40 | 41 | 42 | def simplify_nested_serializer(serializer): 43 | if isinstance(serializer, StandardizedModelSerializer): 44 | for _name, _field in list(serializer.fields.items()): 45 | if _name not in ('_uid', '_type'): 46 | serializer.fields.pop(_name) 47 | 48 | 49 | def get_history_serializer_class(model_name, serializer_class): 50 | name = f'{model_name}Serializer' 51 | _model = serializer_class.Meta.model.history.model 52 | fields = HistorySerializerMixIn.Meta.fields + serializer_class.Meta.fields 53 | _meta = type( 54 | 'Meta', (HistorySerializerMixIn.Meta, serializer_class.Meta), { 55 | 'model': _model, 56 | 'fields': fields}) 57 | bases = HistorySerializerMixIn, serializer_class 58 | return type(name, bases, {'Meta': _meta}) 59 | -------------------------------------------------------------------------------- /core/api/history/viewsets.py: -------------------------------------------------------------------------------- 1 | from rest_framework.mixins import ListModelMixin 2 | from rest_framework.viewsets import GenericViewSet 3 | 4 | from core.api.filters import StandardizedFieldFilters, \ 5 | StandardizedOrderingFilter, StandardizedSearchFilter 6 | from core.api.pagination import StandardizedCursorPagination 7 | 8 | from .filters import get_history_filter_class 9 | from .serializers import get_history_serializer_class 10 | 11 | 12 | class HistoryViewSetBase(ListModelMixin, GenericViewSet): 13 | pagination_class = StandardizedCursorPagination 14 | ordering = ('-updated', ) 15 | ordering_fields = ('updated', 'uid', ) 16 | 17 | serializer_class = None 18 | filter_class = None 19 | 20 | filter_backends = ( 21 | StandardizedFieldFilters, StandardizedSearchFilter, 22 | StandardizedOrderingFilter) 23 | 24 | def get_queryset(self): 25 | queryset = super().get_queryset() 26 | if self.select_related_fields: 27 | queryset = queryset.select_related(*self.select_related_fields) 28 | return queryset 29 | 30 | 31 | def get_history_viewset(viewset): 32 | serializer_class = getattr(viewset, 'serializer_class', None) 33 | queryset = serializer_class.Meta.model.history 34 | model = queryset.model 35 | model_name = model._meta.object_name # noqa: protected-access 36 | name = f'{model_name}ViewSet' 37 | 38 | serializer_class = get_history_serializer_class( 39 | model_name, serializer_class) 40 | filter_class = get_history_filter_class(model_name, viewset) 41 | 42 | select_related_fields = viewset.select_related_fields 43 | if select_related_fields: 44 | select_related_fields = filter( 45 | lambda r: '__' not in r, select_related_fields) 46 | 47 | return type(name, (HistoryViewSetBase,), { 48 | 'select_related_fields': select_related_fields, 49 | 'serializer_class': serializer_class, 50 | 'filter_class': filter_class, 51 | 'queryset': queryset 52 | }) 53 | -------------------------------------------------------------------------------- /core/api/inspectors.py: -------------------------------------------------------------------------------- 1 | from collections import OrderedDict 2 | 3 | from deprecated import deprecated 4 | from drf_yasg import openapi 5 | from drf_yasg.inspectors import PaginatorInspector 6 | from rest_framework.metadata import SimpleMetadata 7 | from rest_framework.schemas import AutoSchema 8 | 9 | from .pagination import StandardizedPagination, StandardizedCursorPagination 10 | 11 | 12 | @deprecated 13 | class StandardizedAutoSchema(AutoSchema): 14 | """ 15 | Add this to `settings.py`: 16 | 17 | REST_FRAMEWORK = { 18 | ... 19 | 'DEFAULT_SCHEMA_CLASS': 20 | 'core.api.inspectors.StandardizedAutoSchema', 21 | ... 22 | } 23 | 24 | """ 25 | 26 | 27 | @deprecated 28 | class StandardizedMetadata(SimpleMetadata): 29 | """ 30 | Add this to `settings.py`: 31 | 32 | REST_FRAMEWORK = { 33 | ... 34 | 'DEFAULT_METADATA_CLASS': 35 | 'core.api.inspectors.StandardizedMetadata', 36 | ... 37 | } 38 | 39 | """ 40 | 41 | 42 | class StandardizedPaginationInspector(PaginatorInspector): 43 | def get_paginated_response(self, paginator, response_schema): 44 | paged_schema = None 45 | if isinstance(paginator, StandardizedPagination): 46 | paged_schema = openapi.Schema( 47 | type=openapi.TYPE_OBJECT, 48 | properties=OrderedDict(( 49 | ('count', openapi.Schema(type=openapi.TYPE_INTEGER)), 50 | ('page', openapi.Schema(type=openapi.TYPE_INTEGER)), 51 | ('page_size', openapi.Schema(type=openapi.TYPE_INTEGER)), 52 | ('pages', openapi.Schema(type=openapi.TYPE_INTEGER)), 53 | ('page_next', openapi.Schema( 54 | type=openapi.TYPE_INTEGER, x_nullable=True)), 55 | ('page_previous', openapi.Schema( 56 | type=openapi.TYPE_INTEGER, x_nullable=True)), 57 | ('results', response_schema), 58 | )), 59 | required=['results', 'count', 'page', 'page_size'] 60 | ) 61 | elif isinstance(paginator, StandardizedCursorPagination): 62 | paged_schema = openapi.Schema( 63 | type=openapi.TYPE_OBJECT, 64 | properties=OrderedDict(( 65 | ('next', openapi.Schema( 66 | type=openapi.TYPE_STRING, x_nullable=True)), 67 | ('previous', openapi.Schema( 68 | type=openapi.TYPE_STRING, x_nullable=True)), 69 | ('results', response_schema), 70 | )), 71 | required=['results'] 72 | ) 73 | 74 | return paged_schema 75 | -------------------------------------------------------------------------------- /core/api/mixins/__init__.py: -------------------------------------------------------------------------------- 1 | from .common import BulkCreateModelMixin # noqa 2 | -------------------------------------------------------------------------------- /core/api/mixins/common.py: -------------------------------------------------------------------------------- 1 | from rest_framework import status 2 | from rest_framework.mixins import CreateModelMixin 3 | from rest_framework.response import Response 4 | 5 | 6 | class BulkCreateModelMixin(CreateModelMixin): 7 | """ 8 | Either create a single or many model instances in bulk by using the 9 | Serializers ``many=True``. 10 | 11 | Example: 12 | 13 | class ContactViewSet(StandartizedModelViewSet): 14 | ... 15 | allow_bulk_create = True 16 | ... 17 | """ 18 | allow_bulk_create = False 19 | 20 | def create(self, request, *args, **kwargs): 21 | bulk = isinstance(request.data, list) 22 | 23 | if not bulk: 24 | return super().create(request, *args, **kwargs) 25 | 26 | if not self.allow_bulk_create: 27 | self.permission_denied( 28 | request, 29 | message='You do not have permission to create multiple objects' 30 | ) 31 | 32 | serializer = self.get_serializer(data=request.data, many=True) 33 | serializer.is_valid(raise_exception=True) 34 | self.perform_bulk_create(serializer) 35 | return Response(serializer.data, status=status.HTTP_201_CREATED) 36 | 37 | def perform_bulk_create(self, serializer): 38 | return self.perform_create(serializer) 39 | -------------------------------------------------------------------------------- /core/api/pagination.py: -------------------------------------------------------------------------------- 1 | from collections import OrderedDict 2 | 3 | from rest_framework.response import Response 4 | from rest_framework.pagination import PageNumberPagination, CursorPagination 5 | 6 | 7 | class StandardizedCursorPagination(CursorPagination): 8 | 9 | page_size_query_param = 'page_size' 10 | page_size = 20 11 | max_page_size = 1000 12 | 13 | 14 | class StandardizedPagination(PageNumberPagination): 15 | """ 16 | Example: http://api.example.org/accounts/?page=4&page_size=100 17 | 18 | Add this to `settings.py`: 19 | 20 | REST_FRAMEWORK = { 21 | ... 22 | 'DEFAULT_PAGINATION_CLASS': 23 | 'core.api.pagination.StandardizedPagination', 24 | ... 25 | } 26 | """ 27 | page_size_query_param = 'page_size' 28 | page_size = 20 29 | max_page_size = 1000 30 | 31 | def get_next_link(self): 32 | if not self.page.has_next(): 33 | return None 34 | page_number = self.page.next_page_number() 35 | return page_number 36 | 37 | def get_previous_link(self): 38 | if not self.page.has_previous(): 39 | return None 40 | page_number = self.page.previous_page_number() 41 | return page_number 42 | 43 | def get_paginated_response(self, data): 44 | return Response(OrderedDict([ 45 | ('count', self.page.paginator.count), 46 | ('pages', self.page.paginator.num_pages), 47 | ('page_size', self.page.paginator.per_page), 48 | ('page', self.page.number), 49 | ('page_next', self.get_next_link()), 50 | ('page_previous', self.get_previous_link()), 51 | ('results', data), 52 | ])) 53 | 54 | def get_schema_fields(self, view): # noqa: pylint=useless-super-delegation 55 | return super().get_schema_fields(view) 56 | -------------------------------------------------------------------------------- /core/api/permissions.py: -------------------------------------------------------------------------------- 1 | from rest_framework import permissions 2 | 3 | 4 | class DjangoModelViewPermission(permissions.DjangoModelPermissions): 5 | 6 | perms_map = {**permissions.DjangoModelPermissions.perms_map, 7 | **{'GET': ['%(app_label)s.view_%(model_name)s']}} 8 | -------------------------------------------------------------------------------- /core/api/router.py: -------------------------------------------------------------------------------- 1 | from rest_framework.routers import DefaultRouter 2 | 3 | from core.api.history.viewsets import get_history_viewset 4 | 5 | 6 | class HiddenRouter(DefaultRouter): 7 | include_root_view = False 8 | 9 | 10 | class HistorizedRouter(DefaultRouter): 11 | history_router = None 12 | 13 | def __init__(self, *args, **kwargs): 14 | self.history_router = HiddenRouter() 15 | super(HistorizedRouter, self).__init__(*args, **kwargs) 16 | 17 | def register_history_viewset(self, prefix, viewset, basename): 18 | history_viewset = get_history_viewset(viewset) 19 | history_prefix = f'{prefix}/history' 20 | history_basename = None 21 | if basename: 22 | history_basename = f'{basename}_history' 23 | self.history_router.register( 24 | history_prefix, history_viewset, history_basename) 25 | 26 | def register(self, prefix, viewset, basename=None, base_name=None): 27 | super().register(prefix, viewset, basename, base_name) 28 | if base_name is not None and basename is None: 29 | basename = base_name 30 | if getattr(viewset, 'allow_history', True): 31 | self.register_history_viewset(prefix, viewset, basename) 32 | 33 | def get_urls(self): 34 | return self.history_router.get_urls() + super().get_urls() 35 | 36 | 37 | class StandardizedRouter(HistorizedRouter): 38 | pass 39 | 40 | 41 | class StandardizedHiddenRouter(HiddenRouter, HistorizedRouter): 42 | pass 43 | -------------------------------------------------------------------------------- /core/api/schema.py: -------------------------------------------------------------------------------- 1 | from drf_yasg.generators import OpenAPISchemaGenerator 2 | from drf_yasg.inspectors import SwaggerAutoSchema 3 | from drf_yasg.views import get_schema_view 4 | 5 | 6 | class StandardizedSchemaGenerator(OpenAPISchemaGenerator): 7 | pass 8 | 9 | 10 | class StandardizedAutoSchema(SwaggerAutoSchema): 11 | pass 12 | 13 | 14 | def get_standardized_schema_view(api_urlpatterns): 15 | schema_view = get_schema_view( 16 | patterns=api_urlpatterns, 17 | public=True, 18 | ) 19 | return schema_view.with_ui('redoc', cache_timeout=1) 20 | -------------------------------------------------------------------------------- /core/api/serializers.py: -------------------------------------------------------------------------------- 1 | from collections import OrderedDict 2 | from typing import Optional, Union 3 | from uuid import UUID 4 | 5 | from django.contrib.contenttypes.models import ContentType 6 | from django.utils.translation import ugettext_lazy as _ 7 | from django.db.models import Model 8 | from drf_yasg.utils import swagger_serializer_method 9 | from rest_framework import serializers 10 | from rest_framework.fields import empty 11 | from rest_framework.serializers import ListSerializer 12 | 13 | from core.permitted_fields.api import PermittedFieldsSerializerMixIn 14 | 15 | 16 | class SettableNestedSerializerMixIn: 17 | default_error_messages = { 18 | 'required': _('This field is required.'), 19 | 'does_not_exist': 20 | _('Недопустимый uid "{uid_value}" - объект не существует.'), 21 | 'incorrect_uid_type': 22 | _('Некорректный тип uid. Ожидался uid, получен {data_type}.'), 23 | 'incorrect_type': 24 | _('Некорректный тип объекта. Ожидался {expected_object_type}, ' 25 | 'получен {object_type}.'), 26 | } 27 | 28 | def run_validation(self, data=empty): 29 | if not self.parent: 30 | return super().run_validation(data) 31 | (is_empty_value, data) = self.validate_empty_values(data) 32 | if is_empty_value: 33 | return data 34 | # We are using this class as serializer and serializer field, so need 35 | # to keep both behaviors. 36 | return self.to_internal_value(data) 37 | 38 | def to_internal_value(self, request_data): 39 | if not self.parent or isinstance(self.parent, ListSerializer): 40 | return super().to_internal_value(request_data) 41 | 42 | if isinstance(request_data, (dict, OrderedDict)): 43 | object_type = request_data.get('_type') 44 | expected = ContentType.objects.get_for_model(self.Meta.model).model 45 | if object_type != expected: 46 | self.fail('incorrect_type', 47 | expected_object_type=expected, 48 | object_type=object_type) 49 | uid_value = request_data.get('_uid') 50 | else: 51 | uid_value = request_data 52 | try: 53 | return self.Meta.model.objects.get(uid=uid_value) 54 | except self.Meta.model.DoesNotExist: 55 | self.fail('does_not_exist', uid_value=uid_value) 56 | except (TypeError, ValueError): 57 | self.fail('incorrect_uid_type', data_type=type(uid_value).__name__) 58 | 59 | 60 | class StandardizedProtocolSerializer(serializers.ModelSerializer): 61 | _uid = serializers.SerializerMethodField() 62 | _type = serializers.SerializerMethodField() 63 | _version = serializers.SerializerMethodField() 64 | 65 | @swagger_serializer_method(serializer_or_field=serializers.UUIDField) 66 | def get__uid(self, obj) -> Optional[Union[str, UUID]]: 67 | if not hasattr(obj, 'uid'): 68 | if not hasattr(obj, 'pk'): 69 | return None 70 | return str(obj.pk) 71 | return obj.uid 72 | 73 | def get__type(self, obj) -> Optional[str]: 74 | if not isinstance(obj, Model): 75 | return None 76 | return ContentType.objects.get_for_model(type(obj)).model 77 | 78 | def get__version(self, obj) -> Optional[int]: 79 | if not hasattr(obj, 'version'): 80 | return None 81 | return obj.version 82 | 83 | 84 | class StandardizedModelSerializer(SettableNestedSerializerMixIn, 85 | PermittedFieldsSerializerMixIn, 86 | StandardizedProtocolSerializer): 87 | pass 88 | -------------------------------------------------------------------------------- /core/api/user.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from rest_framework import renderers, serializers, permissions 3 | from rest_framework.response import Response 4 | from rest_framework.serializers import ModelSerializer 5 | from rest_framework.views import APIView 6 | 7 | 8 | class UserSerializer(ModelSerializer): 9 | email = serializers.EmailField() 10 | username = serializers.CharField(max_length=200) 11 | 12 | class Meta: 13 | model = get_user_model() 14 | fields = ('email', 'username') 15 | 16 | 17 | class StandardizedUserApiView(APIView): 18 | throttle_classes = () 19 | permission_classes = (permissions.IsAuthenticated, ) 20 | renderer_classes = (renderers.JSONRenderer,) 21 | serializer_class = UserSerializer 22 | 23 | def get(self, request, *args, **kwargs): 24 | serializer = self.serializer_class(request.user, 25 | context={'request': request}) 26 | return Response(serializer.data) 27 | 28 | 29 | USER_API_VIEW = StandardizedUserApiView.as_view() 30 | -------------------------------------------------------------------------------- /core/api/viewsets.py: -------------------------------------------------------------------------------- 1 | from rest_framework import generics, mixins 2 | from rest_framework.viewsets import ViewSetMixin 3 | 4 | from ..api.mixins import BulkCreateModelMixin 5 | 6 | 7 | class StandardizedGenericViewSet(ViewSetMixin, generics.GenericAPIView): 8 | """ 9 | The GenericViewSet class does not provide any actions by default, 10 | but does include the base set of generic view behavior, such as 11 | the `get_object` and `get_queryset` methods. 12 | """ 13 | select_related_fields = () 14 | 15 | def get_queryset(self): 16 | queryset = super().get_queryset() 17 | if self.select_related_fields: 18 | queryset = queryset.select_related(*self.select_related_fields) 19 | return queryset 20 | 21 | 22 | class StandardizedReadOnlyModelViewSet( 23 | mixins.RetrieveModelMixin, 24 | mixins.ListModelMixin, 25 | StandardizedGenericViewSet 26 | ): 27 | """ 28 | A viewset that provides default `list()` and `retrieve()` actions. 29 | """ 30 | pass 31 | 32 | 33 | class StandardizedModelViewSet( 34 | BulkCreateModelMixin, 35 | mixins.RetrieveModelMixin, 36 | mixins.UpdateModelMixin, 37 | mixins.DestroyModelMixin, 38 | mixins.ListModelMixin, 39 | StandardizedGenericViewSet 40 | ): 41 | """ 42 | A viewset that provides default `create()`, `retrieve()`, `update()`, 43 | `partial_update()`, `destroy()` and `list()` actions. 44 | """ 45 | pass 46 | -------------------------------------------------------------------------------- /core/context_processors.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings as django_settings 2 | 3 | 4 | class _TemplateSettings(dict): 5 | def __init__(self, django_settings_wrapper, allowed): 6 | super().__init__() 7 | self.settings = django_settings_wrapper 8 | self.allowed_settings = set(allowed) 9 | 10 | def __getattr__(self, k): 11 | try: 12 | return self.__getitem__(k) 13 | except KeyError: 14 | raise AttributeError 15 | 16 | def __getitem__(self, k): 17 | if k not in self.allowed_settings: 18 | raise KeyError 19 | 20 | try: 21 | return getattr(self.settings, k) 22 | except AttributeError: 23 | return super().__getitem__(k) 24 | 25 | def __setitem__(self, k, v): 26 | self.allowed_settings.add(k) 27 | super().__setitem__(k, v) 28 | 29 | 30 | def settings(request=None): 31 | """ 32 | You can use settings variable in templates. 33 | 34 | Add this context_processors to `TEMPLATES` then set 35 | the `TEMPLATE_ACCESSIBLE_SETTINGS` add use any of this variables in 36 | templates: 37 | 38 | TEMPLATES = [ 39 | { 40 | ... 41 | 'OPTIONS': { 42 | 'context_processors': [ 43 | ... 44 | 'core.context_processors.django_settings', 45 | ], 46 | }, 47 | }, 48 | ] 49 | 50 | TEMPLATE_ACCESSIBLE_SETTINGS = ['DEBUG'] 51 | """ 52 | allowed = getattr(django_settings, 'TEMPLATE_ACCESSIBLE_SETTINGS', []) 53 | template_settings = _TemplateSettings(django_settings, allowed) 54 | return {"settings": template_settings} 55 | -------------------------------------------------------------------------------- /core/fields.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.utils.translation import ugettext_lazy as _ 3 | from pik.utils.normalization import normalize 4 | 5 | MAX_DIGITS = 16 6 | DECIMAL_PLACES = 2 7 | 8 | 9 | class MoneyField(models.DecimalField): 10 | def __init__( 11 | self, *args, max_digits=MAX_DIGITS, 12 | decimal_places=DECIMAL_PLACES, **kwargs): 13 | super().__init__( 14 | *args, max_digits=max_digits, 15 | decimal_places=decimal_places, **kwargs) 16 | 17 | 18 | class NormalizedCharField(models.CharField): 19 | 20 | description = _('Normalized by pik.utils.normalization.normalize') 21 | 22 | def pre_save(self, model_instance, add): 23 | value = normalize(getattr(model_instance, self.attname)) 24 | setattr(model_instance, self.attname, value) 25 | return value 26 | -------------------------------------------------------------------------------- /core/metrics.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from datadog import DogStatsd 3 | 4 | _STATSD = DogStatsd(host=settings.DD_STATSD_ADDR, port=settings.DD_STATSD_PORT, 5 | namespace=settings.DD_STATSD_NAMESPACE) 6 | 7 | 8 | def _prepare_tags(tags): 9 | """ 10 | >>> _prepare_tags({'protocol': 'http'}) 11 | ['protocol:http'] 12 | """ 13 | if not tags: 14 | return [] 15 | return [f'{k}:{v}' for k, v in tags.items()] 16 | 17 | 18 | def gauge(metric, value, tags=None): 19 | """ 20 | Record the value of a gauge, optionally setting a list of tags and a 21 | sample rate. 22 | 23 | >>> gauge('users.online', 123) 24 | >>> gauge('active.connections', 1001, tags={'protocol': 'http'}) 25 | """ 26 | _STATSD.gauge(metric, value=value, tags=_prepare_tags(tags)) 27 | 28 | 29 | def increment(metric, value=1, tags=None): 30 | """ 31 | Increment a counter, optionally setting a value, tags and a sample 32 | rate. 33 | 34 | >>> increment('page.views') 35 | >>> increment('files.transferred', 124) 36 | """ 37 | _STATSD.increment(metric, value=value, tags=_prepare_tags(tags)) 38 | 39 | 40 | def decrement(metric, value=1, tags=None): 41 | """ 42 | Decrement a counter, optionally setting a value, tags and a sample 43 | rate. 44 | 45 | >>> decrement('files.remaining') 46 | >>> decrement('active.connections', 2) 47 | """ 48 | _STATSD.decrement(metric, value=value, tags=_prepare_tags(tags)) 49 | 50 | 51 | def histogram(metric, value, tags=None): 52 | """ 53 | Sample a histogram value, optionally setting tags and a sample rate. 54 | 55 | >>> histogram('uploaded.file.size', 1445) 56 | >>> histogram('album.photo.count', 26, tags={"gender":"female"}) 57 | """ 58 | _STATSD.histogram(metric, value=value, tags=_prepare_tags(tags)) 59 | 60 | 61 | def timing(metric, value, tags=None): 62 | """ 63 | Record a timing, optionally setting tags and a sample rate. 64 | 65 | >>> timing("query.response.time", 1234) 66 | """ 67 | _STATSD.timing(metric, value=value, tags=_prepare_tags(tags)) 68 | -------------------------------------------------------------------------------- /core/monitoring.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import sentry_sdk 4 | 5 | 6 | def alert(message: str, **kwargs) -> None: 7 | """ 8 | Example: 9 | 10 | alert("webhook '{name}' response status={status}", 11 | name=subscription.name, status=webhook_status, 12 | user=subscription.user.pk, retry=self.retries, 13 | data=webhook_data) 14 | 15 | """ 16 | text = message.format(**kwargs) 17 | sentry_sdk.capture_message( 18 | message=text, level=logging.ERROR) 19 | -------------------------------------------------------------------------------- /core/normalization.py: -------------------------------------------------------------------------------- 1 | from deprecated import deprecated 2 | import pik.utils.normalization 3 | 4 | 5 | @deprecated('use pik.utils.normalization.normalize() instead') 6 | def normalize(text: str): 7 | return pik.utils.normalization.normalize(text) 8 | 9 | 10 | @deprecated('use pik.utils.normalization.company_name_normalization() instead') 11 | def company_name_normalization(name: str): 12 | return pik.utils.normalization.company_name_normalization(name) 13 | -------------------------------------------------------------------------------- /core/permitted_fields/README.md: -------------------------------------------------------------------------------- 1 | Permitted Fields library provides per field permissions limitation. 2 | 3 | permitted_fields - is Model, ModelAdmin or Serializer defined property. It 4 | defines permissions which are required for field edition. 5 | 6 | Example: 7 | 8 | ```python 9 | class Comment(Model): 10 | permitted_fields = { 11 | "comments.change_comment": ['user', 'text'], 12 | "comments.change_user_comment": ['user'] 13 | } 14 | 15 | user = ForeignKey(User) 16 | text = TextField() 17 | ``` 18 | 19 | This construction defines `text` field `change_contact` permission requirement, 20 | and `change_comment`+`change_user_comment` permissions for `user` field. 21 | 22 | It is possible to use templates in permission names: 23 | 24 | ```python 25 | class Comment(Model): 26 | permitted_fields = { 27 | "{app_label}.change_{model_name}": ['user', 'text'], 28 | "{app_label}.change_user_{model_name}": ['user'] 29 | } 30 | 31 | user = ForeignKey(User) 32 | text = TextField() 33 | 34 | ``` 35 | 36 | These restrictions may be defined for `Model` as for 37 | `SecuredVersionedModelAdmin` and `StandardizedModelSerializer` if default model 38 | behaviour overriding needed. 39 | 40 | ```python 41 | @admin.register(Comment) 42 | class CommentAdmin(SecuredVersionedModelAdmin): 43 | permitted_fields = { 44 | "{app_label}.change_{model_name}": ['user', 'text'], 45 | "{app_label}.change_user_{model_name}": ['user'] 46 | } 47 | 48 | ``` 49 | 50 | ```python 51 | class CommentSerializer(StandardizedModelSerializer): 52 | permitted_fields = { 53 | "{app_label}.change_{model_name}": ['user', 'text'], 54 | "{app_label}.change_user_{model_name}": ['user'] 55 | } 56 | class Meta: 57 | model = Comment 58 | read_only_fields = ( 59 | '_uid', '_type', '_version', 'user', 60 | ) 61 | fields = ( 62 | '_uid', '_type', '_version', 'user', 'text', 63 | ) 64 | 65 | ``` 66 | -------------------------------------------------------------------------------- /core/permitted_fields/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pik-software/django-service-boilerplate/c39e221660ad85cea73d40cbe74036ef9b03c420/core/permitted_fields/__init__.py -------------------------------------------------------------------------------- /core/permitted_fields/admin.py: -------------------------------------------------------------------------------- 1 | from django.forms import ALL_FIELDS, modelform_factory 2 | 3 | from .permitted import PermittedFieldsPermissionMixIn 4 | 5 | 6 | class PermittedFieldsAdminMixIn(PermittedFieldsPermissionMixIn): 7 | def _has_view_permission_only(self, request, obj): 8 | if obj is None: 9 | return not self.has_add_permission(request) 10 | 11 | return (self.has_view_permission(request, obj) 12 | and not self.has_change_permission(request, obj)) 13 | 14 | def get_model_fields(self, obj): 15 | form_class = modelform_factory(self.model, self.form, ALL_FIELDS) 16 | form = form_class(instance=obj) 17 | return form.fields.keys() 18 | 19 | def get_readonly_fields(self, request, obj=None): 20 | if self._has_view_permission_only(request, obj): 21 | return super().get_readonly_fields(request, obj) 22 | 23 | fields = { 24 | field 25 | for field in self.get_model_fields(obj) 26 | if not self.has_field_permission(request.user, self.model, field) 27 | } 28 | original = set(super().get_readonly_fields(request, obj)) 29 | return list(original | fields) 30 | 31 | 32 | class PermittedFieldsInlineAdminMixIn(PermittedFieldsAdminMixIn): 33 | def _has_view_permission_only(self, request, obj): 34 | if obj is None: 35 | return not self.has_add_permission(request, obj) 36 | 37 | return (self.has_view_permission(request, obj) 38 | and not self.has_change_permission(request, obj)) 39 | -------------------------------------------------------------------------------- /core/permitted_fields/api.py: -------------------------------------------------------------------------------- 1 | from django.utils.translation import ugettext_lazy as _ 2 | 3 | from rest_framework.exceptions import ValidationError 4 | 5 | from .permitted import PermittedFieldsPermissionMixIn 6 | 7 | 8 | class PermittedFieldsSerializerMixIn(PermittedFieldsPermissionMixIn): 9 | default_error_messages = { 10 | 'field_permission_denied': _('У вас нет прав для ' 11 | 'редактирования этого поля.') 12 | } 13 | 14 | def to_internal_value(self, request_data): 15 | errors = {} 16 | ret = super().to_internal_value(request_data) 17 | user = self.context['request'].user 18 | model = self.Meta.model 19 | 20 | for field in ret.keys(): 21 | if self.has_field_permission(user, model, field): 22 | continue 23 | errors[field] = [self.error_messages['field_permission_denied']] 24 | 25 | if errors: 26 | raise ValidationError(errors) 27 | 28 | return ret 29 | -------------------------------------------------------------------------------- /core/permitted_fields/permitted.py: -------------------------------------------------------------------------------- 1 | class PermittedFieldsPermissionMixIn: 2 | def has_field_permission(self, user, model, field): 3 | permitted_fields = getattr(self, 'permitted_fields', 4 | getattr(model, 'permitted_fields', None)) 5 | if not permitted_fields: 6 | return False 7 | for permission, _fields in permitted_fields.items(): 8 | meta = model._meta # noqa: protected-access 9 | permission = permission.format(app_label=meta.app_label.lower(), 10 | model_name=meta.object_name.lower()) 11 | has_perm = (field in _fields and user.has_perm(permission)) 12 | if has_perm: 13 | return True 14 | return False 15 | -------------------------------------------------------------------------------- /core/permitted_fields/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pik-software/django-service-boilerplate/c39e221660ad85cea73d40cbe74036ef9b03c420/core/permitted_fields/tests/__init__.py -------------------------------------------------------------------------------- /core/permitted_fields/tests/test_admin/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pik-software/django-service-boilerplate/c39e221660ad85cea73d40cbe74036ef9b03c420/core/permitted_fields/tests/test_admin/__init__.py -------------------------------------------------------------------------------- /core/permitted_fields/tests/test_admin/test_has_view_permission_only.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock 2 | 3 | from core.permitted_fields.admin import PermittedFieldsAdminMixIn 4 | 5 | 6 | def test_obj_view_only(): 7 | admin = PermittedFieldsAdminMixIn() 8 | admin.has_view_permission = Mock(return_value=True) 9 | admin.has_change_permission = Mock(return_value=False) # noqa protected-access 10 | 11 | assert admin._has_view_permission_only(request=Mock(), obj=Mock()) # noqa protected-access 12 | 13 | 14 | def test_obj_view_and_change(): 15 | admin = PermittedFieldsAdminMixIn() 16 | admin.has_view_permission = Mock(return_value=True) 17 | admin.has_change_permission = Mock(return_value=True) # noqa protected-access 18 | 19 | assert not admin._has_view_permission_only(request=Mock(), obj=Mock()) # noqa protected-access 20 | 21 | 22 | def test_missing_obj_add_permission(): 23 | admin = PermittedFieldsAdminMixIn() 24 | admin.has_add_permission = Mock(return_value=True) 25 | 26 | assert not admin._has_view_permission_only(request=Mock(), obj=None) # noqa protected-access 27 | 28 | 29 | def test_missing_obj_add_permission_missing(): # noqa: invalid-name 30 | admin = PermittedFieldsAdminMixIn() 31 | admin.has_add_permission = Mock(return_value=False) 32 | 33 | assert admin._has_view_permission_only(request=Mock(), obj=None) # noqa protected-access 34 | -------------------------------------------------------------------------------- /core/permitted_fields/tests/test_permitted.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | import pytest 4 | 5 | from ..permitted import PermittedFieldsPermissionMixIn 6 | 7 | 8 | @pytest.fixture 9 | def model(): 10 | return mock.Mock( 11 | permitted_fields={'{app_label}_can_change_{model_name}': ['a']}, 12 | _meta=mock.Mock( 13 | app_label='MockApp', 14 | object_name='MockModel' 15 | ) 16 | ) 17 | 18 | 19 | def test_perm_format(model): 20 | user = mock.Mock() 21 | PermittedFieldsPermissionMixIn().has_field_permission( 22 | user=user, model=model, field='a') 23 | calls = [mock.call('mockapp_can_change_mockmodel')] 24 | assert user.has_perm.mock_calls == calls 25 | 26 | 27 | def test_got_field_and_perm(model): 28 | user_with_perm = mock.Mock(has_perm=mock.Mock(return_value=True)) 29 | 30 | a_is_editable = PermittedFieldsPermissionMixIn().has_field_permission( 31 | user=user_with_perm, model=model, field='a') 32 | assert a_is_editable 33 | 34 | 35 | def test_missing_field(model): 36 | user_with_perm = mock.Mock(has_perm=mock.Mock(return_value=True)) 37 | 38 | b_is_editable = PermittedFieldsPermissionMixIn().has_field_permission( 39 | user=user_with_perm, model=model, field='b') 40 | assert not b_is_editable 41 | 42 | 43 | def test_missing_perm(model): 44 | user_without_perm = mock.Mock(has_perm=mock.Mock(return_value=False)) 45 | a_is_editable = PermittedFieldsPermissionMixIn().has_field_permission( 46 | user=user_without_perm, model=model, field='a') 47 | assert not a_is_editable 48 | 49 | 50 | def test_missing_perm_and_field(model): 51 | user_without_perm = mock.Mock(has_perm=mock.Mock(return_value=False)) 52 | a_is_editable = PermittedFieldsPermissionMixIn().has_field_permission( 53 | user=user_without_perm, model=model, field='b') 54 | assert not a_is_editable 55 | -------------------------------------------------------------------------------- /core/permitted_fields/tests/test_serializer.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock, patch 2 | 3 | import pytest 4 | from rest_framework.exceptions import ValidationError 5 | from rest_framework.serializers import ModelSerializer 6 | 7 | from core.permitted_fields.api import PermittedFieldsSerializerMixIn 8 | 9 | 10 | @patch('rest_framework.serializers.ModelSerializer.to_internal_value', 11 | Mock(return_value={'a': 1, 'b': 2})) 12 | def test_error(): 13 | class TestSerializer(PermittedFieldsSerializerMixIn, ModelSerializer): 14 | class Meta: 15 | model = Mock() 16 | 17 | def has_field_permission(self, user, model, field): 18 | return field == 'a' 19 | 20 | serializer = TestSerializer(context={'request': Mock()}) 21 | with pytest.raises(ValidationError): 22 | serializer.to_internal_value({'a': 1, 'b': 2}) 23 | 24 | 25 | @patch('rest_framework.serializers.ModelSerializer.to_internal_value', 26 | Mock(return_value={'a': 1, 'b': 2})) 27 | def test_success(): 28 | class TestSerializer(PermittedFieldsSerializerMixIn, ModelSerializer): 29 | class Meta: 30 | model = Mock() 31 | 32 | def has_field_permission(self, user, model, field): 33 | return True 34 | 35 | serializer = TestSerializer(context={'request': Mock()}) 36 | assert serializer.to_internal_value({'a': 1, 'b': 2}) == {'a': 1, 'b': 2} 37 | -------------------------------------------------------------------------------- /core/tasks/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pik-software/django-service-boilerplate/c39e221660ad85cea73d40cbe74036ef9b03c420/core/tasks/__init__.py -------------------------------------------------------------------------------- /core/tasks/fixtures.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from django.utils.crypto import get_random_string 3 | 4 | 5 | def create_user(username=None, password=None, **kwargs): 6 | try: 7 | from custom_auth.tests.factories import UserFactory # noqa 8 | user = UserFactory.create(**kwargs) 9 | if password: 10 | user.set_password(password) 11 | user.save() 12 | return user 13 | except ImportError: 14 | pass 15 | 16 | User = get_user_model() # noqa: pylint=invalid-name 17 | if username is None: 18 | username = kwargs.get(User.USERNAME_FIELD) 19 | 20 | if not username: 21 | username = get_random_string() 22 | 23 | kwargs.update({ 24 | User.USERNAME_FIELD: username, 25 | }) 26 | 27 | user = User.objects.create(**kwargs) 28 | if password: 29 | user.set_password(password) 30 | user.save() 31 | return user 32 | 33 | 34 | def get_user(username=None): # noqa: pylint=invalid-name 35 | User = get_user_model() # noqa: pylint=invalid-name 36 | return User._default_manager.get_by_natural_key(username) # noqa 37 | -------------------------------------------------------------------------------- /core/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pik-software/django-service-boilerplate/c39e221660ad85cea73d40cbe74036ef9b03c420/core/tests/__init__.py -------------------------------------------------------------------------------- /core/tests/test_api_auth.py: -------------------------------------------------------------------------------- 1 | from django.utils.crypto import get_random_string 2 | from rest_framework import status 3 | 4 | from core.tasks.fixtures import create_user 5 | 6 | 7 | REQUIRED_FIELD_ERROR = {'message': 'Это поле обязательно.', 'code': 'required'} 8 | 9 | 10 | def test_api_token_auth_without_data(anon_api_client): # noqa: pylint=invalid-name 11 | res = anon_api_client.post('/api-token-auth/', data={}) 12 | 13 | assert res.status_code == status.HTTP_400_BAD_REQUEST 14 | assert res.data == { 15 | 'code': 'invalid', 16 | 'detail': { 17 | 'username': [REQUIRED_FIELD_ERROR], 18 | 'password': [REQUIRED_FIELD_ERROR], 19 | }, 20 | 'message': 'Invalid input.'} 21 | 22 | 23 | def test_api_token_auth(anon_api_client): 24 | user = create_user() 25 | username = getattr(user, user.USERNAME_FIELD) 26 | password = get_random_string() 27 | user.set_password(password) 28 | user.save() 29 | 30 | res = anon_api_client.post('/api-token-auth/', data={ 31 | 'username': username, 'password': password}) 32 | 33 | assert res.status_code == status.HTTP_200_OK 34 | assert 'token' in res.data 35 | -------------------------------------------------------------------------------- /core/tests/test_api_user.py: -------------------------------------------------------------------------------- 1 | from rest_framework import status 2 | 3 | 4 | def test_api_user_without_data(anon_api_client): 5 | res = anon_api_client.get('/api-user/') 6 | assert res.status_code == status.HTTP_403_FORBIDDEN 7 | assert res.data['code'] == 'not_authenticated' 8 | 9 | 10 | def test_api_user_auth(api_user, api_client): 11 | username = getattr(api_user, api_user.USERNAME_FIELD) 12 | res = api_client.get('/api-user/') 13 | 14 | assert res.status_code == status.HTTP_200_OK 15 | assert res.data == {'email': api_user.email, 'username': username} 16 | -------------------------------------------------------------------------------- /core/tests/test_validators.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.core.exceptions import ValidationError 3 | 4 | from core.validators import snils_validator, inn_validator, kpp_validator 5 | 6 | 7 | def test_valid_snils(): 8 | assert snils_validator('11223344595') is None 9 | assert snils_validator('08765430300') is None 10 | assert snils_validator('08675430300') is None 11 | 12 | 13 | def test_valid_inn(): 14 | assert inn_validator('3257051274') is None 15 | assert inn_validator('7710492102') is None 16 | assert inn_validator('5047166441') is None 17 | 18 | 19 | def test_valid_len_snils(): 20 | with pytest.raises(ValidationError) as excinfo: 21 | snils_validator('1122334459') 22 | assert 'не равна' in str(excinfo.value) 23 | 24 | 25 | def test_valid_len_inn(): 26 | with pytest.raises(ValidationError) as excinfo: 27 | inn_validator('771042102') 28 | assert 'не равна' in str(excinfo.value) 29 | 30 | 31 | def test_valid_len_kpp(): 32 | with pytest.raises(ValidationError) as excinfo: 33 | kpp_validator('50440101') 34 | assert 'не равна' in str(excinfo.value) 35 | 36 | 37 | def test_invalid_snils_with_space(): 38 | with pytest.raises(ValidationError) as excinfo: 39 | snils_validator('112233445 95') 40 | assert 'Лишние символы' in str(excinfo.value) 41 | 42 | 43 | def test_invalid_inn_with_space(): 44 | with pytest.raises(ValidationError) as excinfo: 45 | inn_validator('370263595 2') 46 | assert 'Лишние символы' in str(excinfo.value) 47 | 48 | 49 | def test_invalid_kpp_with_space(): 50 | with pytest.raises(ValidationError) as excinfo: 51 | kpp_validator('77 1001 001') 52 | assert 'Лишние символы' in str(excinfo.value) 53 | -------------------------------------------------------------------------------- /core/tests/test_views_task_result_api.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from celery import shared_task 4 | 5 | from core.views import task_result_api_view 6 | 7 | 8 | @shared_task(bind=True) 9 | def _task1(self): 10 | return 'Request: {0!r}'.format(self.request.task) 11 | 12 | 13 | @shared_task(bind=True) 14 | def _task2(self): 15 | raise RuntimeError('Request: {0!r}'.format(self.request.task)) 16 | 17 | 18 | def test_get_task_result_task_success(rf, celery_worker): # noqa: pylint=invalid-name 19 | request = rf.get('/test/') 20 | task = _task1.delay() 21 | 22 | response = task_result_api_view(request, task.id) 23 | response = json.loads(response.content.decode('utf-8')) 24 | assert response == { 25 | 'result': None, 'state': 'PENDING', 'task-id': task.id 26 | } 27 | 28 | task.get() 29 | 30 | response = task_result_api_view(request, task.id) 31 | response = json.loads(response.content.decode('utf-8')) 32 | assert response == { 33 | 'state': 'SUCCESS', 'task-id': task.id, 34 | 'result': "Request: 'core.tests.test_views_task_result_api._task1'", 35 | } 36 | 37 | 38 | def test_get_task_result_task_failure(rf, celery_worker): # noqa: pylint=invalid-name 39 | request = rf.get('/test/') 40 | task = _task2.delay() 41 | 42 | response = task_result_api_view(request, task.id) 43 | response = json.loads(response.content.decode('utf-8')) 44 | assert response == { 45 | 'result': None, 'state': 'PENDING', 'task-id': task.id 46 | } 47 | 48 | task.get(propagate=False) 49 | 50 | response = task_result_api_view(request, task.id) 51 | response = json.loads(response.content.decode('utf-8')) 52 | assert response == { 53 | 'state': 'FAILURE', 'task-id': task.id, 54 | 'result': 'RuntimeError("Request: \'' 55 | 'core.tests.test_views_task_result_api._task2\'")', 56 | } 57 | -------------------------------------------------------------------------------- /core/tests/utils.py: -------------------------------------------------------------------------------- 1 | from django.contrib.contenttypes.models import ContentType 2 | from django.contrib.auth.models import Permission 3 | 4 | 5 | def add_user_permissions(user, model, *actions): 6 | ctype = ContentType.objects.get_for_model(model) 7 | user.user_permissions.add(*( 8 | Permission.objects.get(codename=f'{action}_{model.__name__.lower()}', 9 | content_type=ctype) 10 | for action in actions 11 | )) 12 | 13 | 14 | def add_admin_access_permission(user): 15 | user.is_staff = True 16 | user.save() 17 | 18 | 19 | def create_permission(model_name, perm_name, perm_codename): 20 | content_type = ContentType.objects.get(model=model_name) 21 | permission = Permission.objects.create( 22 | name=perm_name, content_type=content_type, 23 | codename=perm_codename) 24 | return permission 25 | -------------------------------------------------------------------------------- /core/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pik-software/django-service-boilerplate/c39e221660ad85cea73d40cbe74036ef9b03c420/core/utils/__init__.py -------------------------------------------------------------------------------- /core/utils/mail.py: -------------------------------------------------------------------------------- 1 | from typing import List, Union 2 | from dataclasses import dataclass 3 | 4 | from django.core.mail import EmailMultiAlternatives 5 | 6 | 7 | @dataclass 8 | class EmailFileWrapper: 9 | 10 | content: Union[str, bytes] 11 | filename: str = None 12 | mimetype: str = None 13 | 14 | 15 | class BaseEmailProducer: 16 | 17 | def __init__( 18 | self, emails_to: List, email_from: str, emails_cc: List = None, 19 | emails_bcc: List = None, files: List[EmailFileWrapper] = None): 20 | self._emails_to = emails_to 21 | self._email_from = email_from 22 | self._emails_cc = emails_cc 23 | self._emails_bcc = emails_bcc 24 | self._files = files 25 | 26 | def get_email_subject(self) -> str: 27 | return '' 28 | 29 | def get_plain_email_body(self) -> str: 30 | return '' 31 | 32 | def get_html_email_body(self) -> str: 33 | return '' 34 | 35 | def create_email(self) -> EmailMultiAlternatives: 36 | email = EmailMultiAlternatives( 37 | subject=self.get_email_subject(), 38 | body=self.get_plain_email_body(), 39 | from_email=self._email_from, 40 | to=self._emails_to, cc=self._emails_cc, bcc=self._emails_bcc) 41 | 42 | html_body = self.get_html_email_body() 43 | if html_body: 44 | email.attach_alternative(html_body, 'text/html') 45 | 46 | if self._files: 47 | for file_wrapper in self._files: 48 | email.attach( 49 | filename=file_wrapper.filename, 50 | content=file_wrapper.content, 51 | mimetype=file_wrapper.mimetype) 52 | 53 | return email 54 | -------------------------------------------------------------------------------- /core/utils/models.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, List 2 | 3 | from django.apps import apps 4 | from django.core.exceptions import FieldDoesNotExist 5 | from django.db.models import Model, Manager, Field 6 | 7 | 8 | def has_field(model, field_name: str) -> bool: 9 | try: 10 | model._meta.get_field(field_name) # noqa 11 | return True 12 | except FieldDoesNotExist: 13 | return False 14 | 15 | 16 | def get_fields(model) -> List[Field]: 17 | return model._meta.get_fields() # noqa 18 | 19 | 20 | def get_pk_name(model) -> Optional[str]: 21 | fields = get_fields(model) 22 | for field in fields: 23 | if field.primary_key: 24 | return field.name 25 | return None 26 | 27 | 28 | def get_model(app_label: str, model_name: str) -> Model: 29 | return apps.get_model(app_label, model_name) 30 | 31 | 32 | def get_base_manager(model) -> Manager: 33 | return model._base_manager # noqa 34 | -------------------------------------------------------------------------------- /core/utils/permissions.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from django.contrib.auth.models import Permission 3 | 4 | 5 | class NotAllowedUserTypeError(Exception): 6 | pass 7 | 8 | 9 | def _get_allowed_perms(apps): 10 | allowed_perms_qs = ( 11 | Permission.objects.filter(content_type__app_label__in=apps) 12 | .values_list('codename', 'name')) 13 | allowed_perms = [] 14 | for code_name, verbose_name in allowed_perms_qs: 15 | allowed_perms.append({ 16 | 'code_name': code_name, 17 | 'verbose_name': verbose_name, 'granted': False}) 18 | return allowed_perms 19 | 20 | 21 | def _get_granted_perms(user, apps): 22 | granted_perms = [] 23 | user_perms_qs = user.user_permissions 24 | user_groups_field = get_user_model()._meta.get_field('groups') # noqa: protected-access 25 | user_groups_q = f'group__{user_groups_field.related_query_name()}' 26 | group_perms_qs = Permission.objects.filter(**{user_groups_q: user}) 27 | for perms_qs in (user_perms_qs, group_perms_qs): 28 | granted_perms.extend( 29 | perms_qs.filter(content_type__app_label__in=apps) 30 | .values_list('codename', flat=True)) 31 | return granted_perms 32 | 33 | 34 | def get_permissions_from_allowed_apps(user, apps): 35 | if not user.is_active or user.is_anonymous: 36 | raise NotAllowedUserTypeError( 37 | "You can't use this function for not active or anonymous user") 38 | 39 | allowed_perms = _get_allowed_perms(apps) 40 | if user.is_superuser: 41 | for perm in allowed_perms: 42 | perm['granted'] = True 43 | else: 44 | granted_perms = _get_granted_perms(user, apps) 45 | for perm in allowed_perms: 46 | if user.is_active and perm['code_name'] in granted_perms: 47 | perm['granted'] = True 48 | 49 | return allowed_perms 50 | -------------------------------------------------------------------------------- /core/validators.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import ValidationError 2 | from django.core.validators import RegexValidator 3 | from django.utils.translation import ugettext as _ 4 | 5 | internal_phones_validator = RegexValidator( # noqa: pylint=invalid-name 6 | regex=r'^(\d{4}|\d{6}|\d{7})$', # noqa 7 | message=_('Неверный формат номера внутреннего телефона. ' 8 | 'Используйте XXXX, XXXXXX или XXXXXXX.') 9 | ) 10 | 11 | external_phones_validator = RegexValidator( # noqa: pylint=invalid-name 12 | regex=r'^\+7\d{10}$', # noqa 13 | message=_('Неверный формат номера внешнего телефона. ' 14 | 'Используйте +7XXXXXXXXXX') 15 | ) 16 | 17 | 18 | def snils_validator(value): 19 | if not value.isdigit(): 20 | raise ValidationError( 21 | _('Лишние символы в СНИЛС: %(value)s'), params={'value': value} 22 | ) 23 | if len(value) != 11: 24 | raise ValidationError(_('Длина СНИЛС не равна 11 символам')) 25 | 26 | numbers = list(map(int, list(value[:-2]))) 27 | check_sum = int(value[-2:]) 28 | 29 | a_sum = 0 30 | for i, digit in enumerate(numbers): 31 | a_sum += digit * (9 - i) 32 | if 100 <= a_sum <= 101: 33 | a_sum = 0 34 | elif a_sum > 101: 35 | a_sum = (a_sum % 101) % 100 36 | 37 | if a_sum != check_sum: 38 | raise ValidationError(_('Неверная контрольная сумма СНИЛС')) 39 | 40 | 41 | def inn_validator(value): 42 | if not value.isdigit(): 43 | raise ValidationError( 44 | _('Лишние символы в ИНН: %(value)s'), params={'value': value} 45 | ) 46 | if len(value) != 10: 47 | raise ValidationError(_('Длина ИНН не равна 10 символам')) 48 | 49 | numbers = list(map(int, list(value[:-1]))) 50 | check_sum = int(value[-1:]) 51 | 52 | factors = 2, 4, 10, 3, 5, 9, 4, 6, 8 53 | a_sum = sum([factors[i] * numbers[i] for i in range(9)]) % 11 % 10 54 | 55 | if a_sum != check_sum: 56 | raise ValidationError(_('Неверная контрольная сумма ИНН')) 57 | 58 | 59 | def kpp_validator(value): 60 | if not value.isdigit(): 61 | raise ValidationError( 62 | _('Лишние символы в КПП: %(value)s'), params={'value': value} 63 | ) 64 | if len(value) != 9: 65 | raise ValidationError(_('Длина КПП не равна 9 символам')) 66 | -------------------------------------------------------------------------------- /core/views/__init__.py: -------------------------------------------------------------------------------- 1 | from .task_result_api import task_result_api_view 2 | 3 | __all__ = ['task_result_api_view'] 4 | -------------------------------------------------------------------------------- /core/views/permissions.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.http import JsonResponse 3 | from rest_framework.permissions import IsAuthenticated 4 | from rest_framework.decorators import permission_classes, api_view 5 | 6 | from core.utils.permissions import get_permissions_from_allowed_apps 7 | 8 | 9 | @api_view() 10 | @permission_classes([IsAuthenticated]) 11 | def permissions_view(request): 12 | allowed_apps = getattr(settings, 'ALLOWED_APPS_FOR_PERMISSIONS_VIEW', ()) 13 | if allowed_apps: 14 | perms = get_permissions_from_allowed_apps(request.user, allowed_apps) 15 | return JsonResponse({'permissions': perms}) 16 | return JsonResponse({'permissions': []}) 17 | -------------------------------------------------------------------------------- /core/views/task_result_api.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from celery.result import AsyncResult 4 | from django.http import JsonResponse 5 | 6 | 7 | def _safe_result(result): 8 | """returns json encodable result""" 9 | try: 10 | json.dumps(result) 11 | except TypeError: 12 | return repr(result) 13 | else: 14 | return result 15 | 16 | 17 | def task_result_api_view(request, taskid): 18 | """ 19 | Get task `state` and `result` from API endpoint. 20 | 21 | Use case: you want to provide to some user with async feedback about 22 | about status of some task. 23 | 24 | Example: 25 | 26 | # urls.py 27 | urlpatterns = [ 28 | url(r'^api/task/result/(.+)/', task_result_api_view), 29 | ... 30 | ] 31 | 32 | # some_views.py 33 | context = {} 34 | # ... 35 | async_request = some_important_task.delay(...) 36 | # ... 37 | context['async_task_id'] = str(async_request.id) 38 | 39 | Now we can check the state and result form Front-end side. 40 | """ 41 | result = AsyncResult(taskid) 42 | response = {'task-id': taskid, 'state': result.state} 43 | response.update({'result': _safe_result(result.result)}) 44 | return JsonResponse(response) 45 | -------------------------------------------------------------------------------- /cors/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = 'cors.apps.AppConfig' # noqa: invalid-name 2 | -------------------------------------------------------------------------------- /cors/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from core.admin import SecuredVersionedModelAdmin 4 | 5 | from cors.models import Cors 6 | 7 | 8 | @admin.register(Cors) 9 | class CorsAdmin(SecuredVersionedModelAdmin): 10 | list_display = ['cors'] 11 | search_fields = ['cors'] 12 | -------------------------------------------------------------------------------- /cors/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig as BaseAppConfig 2 | from django.utils.translation import ugettext_lazy as _ 3 | 4 | 5 | class AppConfig(BaseAppConfig): 6 | name = 'cors' 7 | verbose_name = _('Кросс-доменные запросы (CORS)') 8 | -------------------------------------------------------------------------------- /cors/consts.py: -------------------------------------------------------------------------------- 1 | DEFAULT_CACHE_NAME = 'default' 2 | CACHE_KEY = 'cors_{domain}' 3 | -------------------------------------------------------------------------------- /cors/middleware.py: -------------------------------------------------------------------------------- 1 | from corsheaders.middleware import CorsMiddleware 2 | 3 | from pik.core.cache import cachedmethod 4 | 5 | from .consts import CACHE_KEY, DEFAULT_CACHE_NAME 6 | 7 | 8 | class CachedCorsMiddleware(CorsMiddleware): 9 | @cachedmethod(CACHE_KEY.format(domain='{url.netloc}'), 10 | cachename=DEFAULT_CACHE_NAME) 11 | def origin_found_in_model(self, url): 12 | return super().origin_found_in_model(url) 13 | -------------------------------------------------------------------------------- /cors/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.9 on 2018-05-24 10:52 3 | from __future__ import unicode_literals 4 | 5 | from django.conf import settings 6 | from django.db import migrations, models 7 | import django.db.models.deletion 8 | import uuid 9 | 10 | 11 | class Migration(migrations.Migration): 12 | 13 | initial = True 14 | 15 | dependencies = [ 16 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 17 | ] 18 | 19 | operations = [ 20 | migrations.CreateModel( 21 | name='Cors', 22 | fields=[ 23 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 24 | ('cors', models.CharField(max_length=255)), 25 | ('uid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), 26 | ('version', models.IntegerField(default=1, editable=False)), 27 | ], 28 | options={ 29 | 'abstract': False, 30 | }, 31 | ), 32 | migrations.CreateModel( 33 | name='HistoricalCors', 34 | fields=[ 35 | ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), 36 | ('cors', models.CharField(max_length=255)), 37 | ('uid', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False)), 38 | ('version', models.IntegerField(default=1, editable=False)), 39 | ('history_id', models.AutoField(primary_key=True, serialize=False)), 40 | ('history_date', models.DateTimeField()), 41 | ('history_change_reason', models.CharField(max_length=100, null=True)), 42 | ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), 43 | ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), 44 | ], 45 | options={ 46 | 'verbose_name': 'historical cors', 47 | 'ordering': ('-history_date', '-history_id'), 48 | 'get_latest_by': 'history_date', 49 | }, 50 | ), 51 | ] 52 | -------------------------------------------------------------------------------- /cors/migrations/0002_auto_20180528_1552.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.9 on 2018-05-28 15:52 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('cors', '0001_initial'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterModelOptions( 16 | name='cors', 17 | options={'verbose_name': 'Кросс-доменное разрешение', 'verbose_name_plural': 'Кросс-доменные разрешения'}, 18 | ), 19 | migrations.AlterModelOptions( 20 | name='historicalcors', 21 | options={'get_latest_by': 'history_date', 'ordering': ('-history_date', '-history_id'), 'verbose_name': 'historical Кросс-доменное разрешение'}, 22 | ), 23 | migrations.AlterField( 24 | model_name='cors', 25 | name='cors', 26 | field=models.CharField(max_length=255, help_text='Название домена допущенного делать междоменные запросы, например: staff-front.pik-software.ru или localhost:3000', unique=True), 27 | ), 28 | migrations.AlterField( 29 | model_name='historicalcors', 30 | name='cors', 31 | field=models.CharField(db_index=True, help_text='Название домена допущенного делать междоменные запросы, например: staff-front.pik-software.ru или localhost:3000', max_length=255), 32 | ), 33 | ] 34 | -------------------------------------------------------------------------------- /cors/migrations/0003_auto_20180531_0800.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.9 on 2018-05-31 08:00 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('cors', '0002_auto_20180528_1552'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterModelOptions( 16 | name='cors', 17 | options={'verbose_name': 'Разрешение на кросс-доменные запросы', 'verbose_name_plural': 'Разрешения на кросс-доменные запросы'}, 18 | ), 19 | migrations.AlterModelOptions( 20 | name='historicalcors', 21 | options={'get_latest_by': 'history_date', 'ordering': ('-history_date', '-history_id'), 'verbose_name': 'historical Разрешение на кросс-доменные запросы'}, 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /cors/migrations/0004_auto_20180606_0836.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.9 on 2018-06-06 08:36 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | import django.utils.timezone 7 | import pik.core.models.uided 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | dependencies = [ 13 | ('cors', '0003_auto_20180531_0800'), 14 | ] 15 | 16 | operations = [ 17 | migrations.RemoveField( 18 | model_name='cors', 19 | name='id', 20 | ), 21 | migrations.RemoveField( 22 | model_name='historicalcors', 23 | name='id', 24 | ), 25 | migrations.AddField( 26 | model_name='cors', 27 | name='created', 28 | field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now, verbose_name='Создан'), 29 | preserve_default=False, 30 | ), 31 | migrations.AddField( 32 | model_name='cors', 33 | name='updated', 34 | field=models.DateTimeField(auto_now=True, verbose_name='Updated'), 35 | ), 36 | migrations.AddField( 37 | model_name='historicalcors', 38 | name='created', 39 | field=models.DateTimeField(blank=True, default=django.utils.timezone.now, editable=False, verbose_name='Создан'), 40 | preserve_default=False, 41 | ), 42 | migrations.AddField( 43 | model_name='historicalcors', 44 | name='updated', 45 | field=models.DateTimeField(blank=True, default=django.utils.timezone.now, editable=False, verbose_name='Updated'), 46 | preserve_default=False, 47 | ), 48 | migrations.AlterField( 49 | model_name='cors', 50 | name='uid', 51 | field=models.UUIDField(default=pik.core.models.uided._new_uid, editable=False, primary_key=True, serialize=False), 52 | ), 53 | migrations.AlterField( 54 | model_name='cors', 55 | name='version', 56 | field=models.IntegerField(editable=False), 57 | ), 58 | migrations.AlterField( 59 | model_name='historicalcors', 60 | name='uid', 61 | field=models.UUIDField(db_index=True, default=pik.core.models.uided._new_uid, editable=False), 62 | ), 63 | migrations.AlterField( 64 | model_name='historicalcors', 65 | name='version', 66 | field=models.IntegerField(editable=False), 67 | ), 68 | ] 69 | -------------------------------------------------------------------------------- /cors/migrations/0005_auto_20181031_1015.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.2 on 2018-10-31 10:15 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('cors', '0004_auto_20180606_0836'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='cors', 15 | name='created', 16 | field=models.DateTimeField(auto_now_add=True, verbose_name='created'), 17 | ), 18 | migrations.AlterField( 19 | model_name='cors', 20 | name='updated', 21 | field=models.DateTimeField(auto_now=True, verbose_name='updated'), 22 | ), 23 | migrations.AlterField( 24 | model_name='historicalcors', 25 | name='created', 26 | field=models.DateTimeField(blank=True, editable=False, verbose_name='created'), 27 | ), 28 | migrations.AlterField( 29 | model_name='historicalcors', 30 | name='updated', 31 | field=models.DateTimeField(blank=True, editable=False, verbose_name='updated'), 32 | ), 33 | ] 34 | -------------------------------------------------------------------------------- /cors/migrations/0006_auto_20190129_1759.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.3 on 2019-01-29 17:59 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('cors', '0005_auto_20181031_1015'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='cors', 15 | name='created', 16 | field=models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='created'), 17 | ), 18 | migrations.AlterField( 19 | model_name='cors', 20 | name='updated', 21 | field=models.DateTimeField(auto_now=True, db_index=True, verbose_name='updated'), 22 | ), 23 | migrations.AlterField( 24 | model_name='historicalcors', 25 | name='created', 26 | field=models.DateTimeField(blank=True, db_index=True, editable=False, verbose_name='created'), 27 | ), 28 | migrations.AlterField( 29 | model_name='historicalcors', 30 | name='updated', 31 | field=models.DateTimeField(blank=True, db_index=True, editable=False, verbose_name='updated'), 32 | ), 33 | ] 34 | -------------------------------------------------------------------------------- /cors/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pik-software/django-service-boilerplate/c39e221660ad85cea73d40cbe74036ef9b03c420/cors/migrations/__init__.py -------------------------------------------------------------------------------- /cors/models.py: -------------------------------------------------------------------------------- 1 | from django.core.cache import caches 2 | from django.db import models 3 | from django.utils.translation import ugettext_lazy as _ 4 | from pik.core.models import BasePHistorical 5 | 6 | from .consts import DEFAULT_CACHE_NAME, CACHE_KEY 7 | 8 | 9 | class Cors(BasePHistorical): 10 | permitted_fields = {'{app_label}.change_{model_name}': ['cors']} 11 | 12 | cors = models.CharField(max_length=255, unique=True, help_text=_( 13 | "Название домена допущенного делать междоменные запросы, например: " 14 | "staff-front.pik-software.ru или localhost:3000")) 15 | 16 | class Meta: 17 | verbose_name = _("Разрешение на кросс-доменные запросы") 18 | verbose_name_plural = _("Разрешения на кросс-доменные запросы") 19 | 20 | def __str__(self): 21 | return self.cors 22 | 23 | def save(self, *args, **kwargs): 24 | super().save(*args, **kwargs) 25 | key = CACHE_KEY.format(**{'domain': self.cors}) 26 | caches[DEFAULT_CACHE_NAME].delete(key) 27 | -------------------------------------------------------------------------------- /cors/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pik-software/django-service-boilerplate/c39e221660ad85cea73d40cbe74036ef9b03c420/cors/tests/__init__.py -------------------------------------------------------------------------------- /cors/tests/test_cors.py: -------------------------------------------------------------------------------- 1 | import rest_framework.test 2 | from corsheaders.middleware import ACCESS_CONTROL_ALLOW_ORIGIN 3 | 4 | from cors.models import Cors 5 | 6 | 7 | CORS_HEADERS = { 8 | "HTTP_ORIGIN": "http://allien", 9 | "HTTP_REFERER": "http://allien", 10 | } 11 | 12 | 13 | PREFLIGHT_HEADERS = { 14 | 'HTTP_ACCESS_CONTROL_REQUEST_METHOD': 'Authentication', 15 | **CORS_HEADERS 16 | } 17 | 18 | 19 | def test_cors_preflight_missing(): 20 | client = rest_framework.test.APIClient() 21 | response = client.options("/api/v1/", **PREFLIGHT_HEADERS) 22 | assert 'ACCESS_CONTROL_ALLOW_ORIGIN' not in response 23 | assert response.content == b'' 24 | assert response.status_code == 200 25 | 26 | 27 | def test_cors_preflight(): 28 | Cors.objects.create(cors='allien') 29 | client = rest_framework.test.APIClient() 30 | response = client.options("/api/v1/", **PREFLIGHT_HEADERS) 31 | assert response[ACCESS_CONTROL_ALLOW_ORIGIN] == "http://allien" 32 | assert response.content == b'' 33 | assert response.status_code == 200 34 | 35 | 36 | def test_cors_missing(): 37 | client = rest_framework.test.APIClient() 38 | response = client.get("/api/v1/", **CORS_HEADERS) 39 | assert 'ACCESS_CONTROL_ALLOW_ORIGIN' not in response 40 | assert response.json() == { 41 | 'code': 'not_authenticated', 42 | 'message': 'Учетные данные не были предоставлены.'} 43 | 44 | 45 | def test_cors(): 46 | Cors.objects.create(cors='allien') 47 | client = rest_framework.test.APIClient() 48 | 49 | response = client.get("/api/v1/", **CORS_HEADERS) 50 | assert response[ACCESS_CONTROL_ALLOW_ORIGIN] == "http://allien" 51 | assert response.json() == { 52 | 'code': 'not_authenticated', 53 | 'message': 'Учетные данные не были предоставлены.'} 54 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | redis: 5 | image: redis:4.0.11 6 | db: 7 | image: mdillon/postgis:9.6 8 | volumes: 9 | - ./psql_dump:/psql_dump 10 | ports: 11 | - "127.0.0.1:5432:5432" 12 | environment: 13 | POSTGRES_USER: pguser 14 | POSTGRES_PASSWORD: pgpass 15 | POSTGRES_DB: pgdb 16 | web: 17 | build: . 18 | command: bash -c "python manage.py migrate && python manage.py runserver 0.0.0.0:8000" 19 | volumes: 20 | - .:/app 21 | ports: 22 | - "127.0.0.1:8000:8000" 23 | environment: 24 | DATABASE_URL: postgres://pguser:pgpass@db:5432/pgdb 25 | REDIS_URL: redis://@redis:6379 26 | DJANGO_SETTINGS_MODULE: _project_.settings 27 | depends_on: 28 | - redis 29 | - db 30 | -------------------------------------------------------------------------------- /docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | if [[ ! -z "${MEDIA_ROOT}" ]]; then 6 | chown unprivileged:unprivileged ${MEDIA_ROOT} 7 | fi 8 | 9 | exec gosu unprivileged "$@" 10 | -------------------------------------------------------------------------------- /docker-prepare.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -ex 4 | echo "PWD=$PWD" 5 | 6 | ################## 7 | # # 8 | # FRONTEND # 9 | # # 10 | ################## 11 | 12 | if [[ -f "bower.json" ]]; then # BOWER 13 | cat > ".bowerrc" <") }} 5 | 6 | from django.contrib.postgres.fields import JSONField 7 | from django.db import models 8 | 9 | 10 | {% for name, definition in schema.definitions.items() | skip_items_keys(options.skip_models) %} 11 | class Base{{name}}(models.Model): 12 | {% for prop_name, property in definition.properties.items() | skip_items_keys(options.skip_fields) %} 13 | {{ prop_name|to_model_field_name }} = {{ property|to_model_field(prop_name) }} 14 | {% endfor %} 15 | 16 | class Meta: 17 | abstract = True 18 | 19 | 20 | {% endfor %} -------------------------------------------------------------------------------- /lib/codegen/codegen_templates/models.j2: -------------------------------------------------------------------------------- 1 | {% for name, definition in schema.definitions.items() | skip_items_keys(options.skip_models) %} 2 | from .abstract_schema_models import Base{{name}} 3 | {% endfor %} 4 | 5 | 6 | {% for name, definition in schema.definitions.items() | skip_items_keys(options.skip_models) %} 7 | class {{name}}(Base{{name}}): 8 | pass 9 | 10 | 11 | {% endfor %} -------------------------------------------------------------------------------- /lib/codegen/generator.py: -------------------------------------------------------------------------------- 1 | import jinja2 2 | from swagger_parser import SwaggerParser 3 | 4 | from lib.codegen.utils import to_model_field_name, skip_items_keys 5 | from lib.codegen.model_field_generator import ModelFieldGenerator 6 | 7 | 8 | class Generator: 9 | def __init__(self, templates, schema): 10 | self.env = jinja2.Environment( 11 | loader=jinja2.FileSystemLoader(templates), 12 | trim_blocks=True, 13 | lstrip_blocks=True) 14 | self.parser = SwaggerParser(swagger_path=schema) 15 | self.env.filters.update({ 16 | 'to_model_field': ModelFieldGenerator(), 17 | 'to_model_field_name': to_model_field_name, 18 | 'skip_items_keys': skip_items_keys, 19 | }) 20 | 21 | def generate(self, name, options=None): 22 | return self.env.get_template(name + '.j2').render({ 23 | 'schema': self.parser.specification, 24 | 'options': options or {}, 25 | }) 26 | -------------------------------------------------------------------------------- /lib/codegen/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pik-software/django-service-boilerplate/c39e221660ad85cea73d40cbe74036ef9b03c420/lib/codegen/management/commands/__init__.py -------------------------------------------------------------------------------- /lib/codegen/management/commands/schema_to_models.py: -------------------------------------------------------------------------------- 1 | import json 2 | import shlex 3 | import sys 4 | from os import mkdir 5 | from os.path import join, dirname, exists 6 | 7 | from django.core.management.base import BaseCommand 8 | 9 | from lib.codegen.generator import Generator 10 | 11 | TEMPLATES = join(dirname(dirname(dirname(__file__))), 'codegen_templates') 12 | 13 | 14 | def _write_to_file(path, content): 15 | with open(path, 'w') as fileobj: 16 | fileobj.write(content) 17 | 18 | 19 | def _write_if_not_exists(path, content, force=False): 20 | if exists(path) and not force: 21 | return 22 | _write_to_file(path, content) 23 | 24 | 25 | def _create_directory(path): 26 | try: 27 | mkdir(path) 28 | except FileExistsError: 29 | pass 30 | 31 | 32 | class Command(BaseCommand): 33 | help = 'Generate application by OpenAPI schema' 34 | 35 | @staticmethod 36 | def _add_extra_options(options): 37 | command = ' '.join(['python'] + [shlex.quote(s) for s in sys.argv]) 38 | options['command'] = command 39 | 40 | def add_arguments(self, parser): 41 | parser.add_argument('schema') 42 | parser.add_argument('app_name') 43 | parser.add_argument( 44 | '--options', 45 | default={}, 46 | type=lambda x: dict(json.loads(x)), 47 | help='Extra template generation options (json obj format)' 48 | ) 49 | parser.add_argument( 50 | '--force', 51 | action='store_true', 52 | help='Force overwrite existing files', 53 | ) 54 | 55 | def handle(self, schema, app_name, **options): # noqa: pylint=arguments-differ 56 | generator = Generator(TEMPLATES, schema) 57 | self._add_extra_options(options['options']) 58 | 59 | _create_directory(app_name) 60 | _write_if_not_exists( 61 | join(app_name, '__init__.py'), 62 | '') 63 | _write_to_file( 64 | join(app_name, 'abstract_schema_models.py'), 65 | generator.generate('abstract_schema_models', options['options'])) 66 | _write_if_not_exists( 67 | join(app_name, 'models.py'), 68 | generator.generate('models', options['options']), 69 | force=options['force']) 70 | -------------------------------------------------------------------------------- /lib/codegen/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pik-software/django-service-boilerplate/c39e221660ad85cea73d40cbe74036ef9b03c420/lib/codegen/tests/__init__.py -------------------------------------------------------------------------------- /lib/codegen/tests/test_model_field_generator.py: -------------------------------------------------------------------------------- 1 | from lib.codegen.model_field_generator import ModelFieldGenerator 2 | 3 | 4 | class TestModelFieldGenerator: 5 | 6 | GENERATOR = ModelFieldGenerator() 7 | 8 | def generate(self, schema, name=None): 9 | return self.GENERATOR(schema, name=name) 10 | 11 | def test_create_integer_field(self): 12 | schema = {'title': 'version', 'type': 'integer', 'readOnly': True} 13 | assert self.generate(schema) == ( 14 | "models.IntegerField(verbose_name='version', editable=False, " 15 | "null=True)") 16 | 17 | def test_create_uuid_field(self): 18 | schema = { 19 | 'title': 'uid', 'type': 'string', 'readOnly': True, 20 | 'format': 'uuid'} 21 | assert self.generate(schema) == ( 22 | "models.UUIDField(verbose_name='uid', editable=False, null=True)") 23 | 24 | def test_create_protocol_char_field(self): 25 | # IF field name in API start's with underscore. 26 | # It will have title starting with white space. 27 | # Probably a bug in schema generation. 28 | schema = { 29 | 'title': ' type', 'type': 'string', 'readOnly': True} 30 | assert self.generate(schema) == ( 31 | "models.CharField(verbose_name='type', editable=False, " 32 | "null=True, max_length=255)") 33 | 34 | def test_create_uid_pk_field(self): 35 | schema = { 36 | 'title': '_uid', 'type': 'string', 'readOnly': True, 37 | 'format': 'uuid'} 38 | assert self.generate(schema, name='_uid') == ( 39 | "models.UUIDField(verbose_name='uid', primary_key=True)") 40 | 41 | def test_create_char_pk_field(self): 42 | schema = { 43 | 'title': 'uid', 'type': 'string', 'readOnly': True} 44 | assert self.generate(schema, name='_uid') == ( 45 | "models.CharField(verbose_name='uid', primary_key=True, " 46 | "max_length=255)") 47 | 48 | def test_create_char_field(self): 49 | schema = { 50 | 'title': 'Наименование', 'type': 'string', 'maxLength': 510, 51 | 'minLength': 1} 52 | assert self.generate(schema) == ( 53 | "models.CharField(verbose_name='Наименование', editable=False, " 54 | "null=True, max_length=255)") 55 | 56 | def test_create_date_field(self): 57 | schema = { 58 | 'title': 'Дата рождения', 'type': 'string', 'format': 'date'} 59 | assert self.generate(schema) == ( 60 | "models.DateField(verbose_name='Дата рождения', editable=False, " 61 | "null=True)") 62 | 63 | def test_create_datetime_field(self): 64 | schema = {'title': 'Удален', 'type': 'string', 'format': 'date-time'} 65 | assert self.generate(schema) == ( 66 | "models.DateTimeField(verbose_name='Удален', editable=False, " 67 | "null=True)") 68 | 69 | def test_create_boolean_field(self): 70 | schema = { 71 | 'title': 'Используется', 'type': 'boolean', 'readOnly': True} 72 | assert self.generate(schema) == ( 73 | "models.BooleanField(verbose_name='Используется', editable=False, " 74 | "null=True)") 75 | 76 | def test_create_foreign_key_field(self): 77 | schema = {'$ref': '#/definitions/Contact'} 78 | assert self.generate(schema) == ( 79 | "models.ForeignKey('Contact', editable=False, null=True, " 80 | "on_delete=models.CASCADE)") 81 | 82 | def test_create_json_field_for_array(self): 83 | schema = { 84 | 'description': ('Номера телефонов в произвольном формате'), 85 | 'type': 'array', 'items': { 86 | 'title': 'Phones', 'type': 'string', 'maxLength': 30, 87 | 'minLength': 1}} 88 | assert self.generate(schema) == ( 89 | "JSONField(verbose_name='Phones', editable=False, default=dict)") 90 | -------------------------------------------------------------------------------- /lib/codegen/tests/test_schema_cases.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pytest 4 | 5 | from lib.codegen.generator import Generator 6 | 7 | TEMPLATES = os.path.join( 8 | (os.path.dirname(os.path.dirname(__file__))), 9 | 'codegen_templates', 10 | ) 11 | 12 | SCHEMA = os.path.join( 13 | ((os.path.dirname(__file__))), 14 | 'testcases', 'contacts1.json' 15 | ) 16 | 17 | 18 | @pytest.fixture(params=[ 19 | (SCHEMA, 'models', f'{SCHEMA}.models.py'), 20 | (SCHEMA, 'abstract_schema_models', f'{SCHEMA}.abstract_schema_models.py'), 21 | ]) 22 | def cases(request): 23 | return request.param 24 | 25 | 26 | def _read_from_file(path): 27 | with open(path) as fileobj: 28 | return fileobj.read() 29 | 30 | 31 | def test_generate_models(cases): 32 | schema, template, result = cases 33 | generator = Generator(TEMPLATES, schema) 34 | assert generator.generate(template) == _read_from_file(result) 35 | -------------------------------------------------------------------------------- /lib/codegen/tests/testcases/contacts1.json.abstract_schema_models.py: -------------------------------------------------------------------------------- 1 | # ########################################################### # 2 | # This file is automatically generated. Please don't edit it! # 3 | # ########################################################### # 4 | # COMMAND: 5 | 6 | from django.contrib.postgres.fields import JSONField 7 | from django.db import models 8 | 9 | 10 | class BaseContact(models.Model): 11 | uid = models.CharField(verbose_name='uid', primary_key=True, max_length=255) 12 | type = models.CharField(verbose_name='type', editable=False, null=True, max_length=255) 13 | version = models.IntegerField(verbose_name='version', editable=False, null=True) 14 | name = models.CharField(verbose_name='Наименование', editable=False, null=True, max_length=255) 15 | phones = JSONField(verbose_name='Phones', editable=False, default=dict) 16 | emails = JSONField(verbose_name='Emails', editable=False, default=dict) 17 | order_index = models.IntegerField(verbose_name='Индекс для сортировки', editable=False, null=True) 18 | 19 | class Meta: 20 | abstract = True 21 | 22 | 23 | class BaseComment(models.Model): 24 | uid = models.CharField(verbose_name='uid', primary_key=True, max_length=255) 25 | type = models.CharField(verbose_name='type', editable=False, null=True, max_length=255) 26 | version = models.IntegerField(verbose_name='version', editable=False, null=True) 27 | user = models.IntegerField(verbose_name='User', editable=False, null=True) 28 | contact = models.ForeignKey('Contact', editable=False, null=True, on_delete=models.CASCADE) 29 | message = models.CharField(verbose_name='Сообщение', editable=False, null=True, max_length=255) 30 | 31 | class Meta: 32 | abstract = True 33 | 34 | 35 | -------------------------------------------------------------------------------- /lib/codegen/tests/testcases/contacts1.json.models.py: -------------------------------------------------------------------------------- 1 | from .abstract_schema_models import BaseContact 2 | from .abstract_schema_models import BaseComment 3 | 4 | 5 | class Contact(BaseContact): 6 | pass 7 | 8 | 9 | class Comment(BaseComment): 10 | pass 11 | 12 | 13 | -------------------------------------------------------------------------------- /lib/codegen/utils.py: -------------------------------------------------------------------------------- 1 | def skip_items_keys(items, keys=tuple()): 2 | """ 3 | >>> skip_items_keys({'fo': 1}.items()) 4 | [('fo', 1)] 5 | """ 6 | return [x for x in items if x[0] not in keys] 7 | 8 | 9 | def to_model_field_name(name): 10 | return name.lstrip('_').replace('-', '_') 11 | -------------------------------------------------------------------------------- /lib/integra/README.md: -------------------------------------------------------------------------------- 1 | # To manually update data via integra use the following Django command 2 | 3 | `python manage.py download_integra_updates ` 4 | 5 | Required arguments: 6 | - `app` Application name from integra config (case insensitive) 7 | - `model` Model name from integra config (case insensitive) 8 | 9 | Additional options (`True` or `False`, default `False`): 10 | - `--clean-state` Clean last updated timestamp before update. 11 | - `--ignore-version` Ignore version check. Use this option to force object update. 12 | -------------------------------------------------------------------------------- /lib/integra/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pik-software/django-service-boilerplate/c39e221660ad85cea73d40cbe74036ef9b03c420/lib/integra/__init__.py -------------------------------------------------------------------------------- /lib/integra/management/commands/download_integra_updates.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from copy import deepcopy 3 | 4 | from django.db import transaction 5 | from django.conf import settings 6 | from django.core.management.base import BaseCommand 7 | 8 | from lib.integra.models import UpdateState 9 | from lib.integra.tasks import Integra 10 | 11 | 12 | LOGGER = logging.getLogger(__name__) 13 | 14 | 15 | class Command(BaseCommand): 16 | help = 'Download integra updates for specific app model' 17 | 18 | def _get_integra_config(self, app_name, model_name): 19 | integra_configs = getattr(settings, 'INTEGRA_CONFIGS', []) 20 | 21 | if not integra_configs: 22 | raise ValueError('INTEGRA_CONFIGS is empty!') 23 | 24 | for config in integra_configs: 25 | for model_config in config['models']: 26 | app = model_config['app'].lower() 27 | model = model_config['model'].lower() 28 | if app == app_name and model == model_name: 29 | config_copy = deepcopy(config) 30 | config_copy['models'] = [model_config] 31 | return config 32 | raise ValueError( 33 | f'Unknown app and/or model name: {app_name} {model_name}!') 34 | 35 | def add_arguments(self, parser): 36 | parser.add_argument('app', type=str) 37 | parser.add_argument('model', type=str) 38 | parser.add_argument('--clear-state', type=bool, default=False) 39 | parser.add_argument('--ignore-version', type=bool, default=False) 40 | 41 | def handle(self, *args, **options): 42 | app_name = options['app'].lower() 43 | model_name = options['model'].lower() 44 | config = self._get_integra_config(app_name, model_name) 45 | integrator = Integra(config, ignore_version=options['ignore_version']) 46 | 47 | with transaction.atomic(): 48 | if options['clear_state'] is True: 49 | key = f'{app_name}:{model_name}' 50 | UpdateState.objects.set_last_updated(key, None) 51 | 52 | processed, updated, errors = integrator.run() 53 | LOGGER.info(f'ok:{processed}/{updated}:errors:{errors}') 54 | -------------------------------------------------------------------------------- /lib/integra/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.7 on 2019-04-14 14:23 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | initial = True 9 | 10 | dependencies = [ 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name='UpdateState', 16 | fields=[ 17 | ('key', models.CharField(max_length=255, primary_key=True, serialize=False)), 18 | ('updated', models.DateTimeField(null=True)), 19 | ], 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /lib/integra/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pik-software/django-service-boilerplate/c39e221660ad85cea73d40cbe74036ef9b03c420/lib/integra/migrations/__init__.py -------------------------------------------------------------------------------- /lib/integra/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class UpdateStateManager(models.Manager): 5 | 6 | @staticmethod 7 | def get_last_updated(key): 8 | state, _ = UpdateState.objects.get_or_create(key=key) 9 | return state.updated 10 | 11 | @staticmethod 12 | def set_last_updated(key, datetime): 13 | UpdateState.objects.update_or_create( 14 | defaults={'updated': datetime}, key=key) 15 | 16 | 17 | class UpdateState(models.Model): 18 | key = models.CharField(max_length=255, primary_key=True) 19 | updated = models.DateTimeField(null=True) 20 | 21 | objects = UpdateStateManager() 22 | 23 | def __str__(self): 24 | return f'{self.key}: {self.updated.isoformat() if self.updated else 0}' 25 | -------------------------------------------------------------------------------- /lib/integra/tasks.py: -------------------------------------------------------------------------------- 1 | from celery import shared_task 2 | from celery.utils.log import get_task_logger 3 | from django.conf import settings 4 | 5 | from .utils import Loader, Updater 6 | 7 | LOGGER = get_task_logger(__name__) 8 | 9 | 10 | class Integra: 11 | def __init__(self, config, ignore_version=False): 12 | self.models = config['models'] 13 | self.loader = Loader(config) 14 | self.updater = Updater(ignore_version=ignore_version) 15 | 16 | def run(self): 17 | processed = 0 18 | updated = 0 19 | errors = 0 20 | for model in self.models: 21 | has_exception = False 22 | LOGGER.info( 23 | "integra: loading app=%s model=%s", 24 | model['app'], model['model']) 25 | for obj in self.loader.download(model): 26 | try: 27 | processed += 1 28 | status = self.updater.update(obj) 29 | updated += 1 if status else 0 30 | except Exception as exc: # noqa 31 | has_exception = True 32 | errors += 1 33 | LOGGER.exception( 34 | "integra error: %r; app=%s model=%s data=%r", 35 | exc, obj['app'], obj['model'], obj['data']) 36 | if not has_exception: 37 | self.updater.flush_updates() 38 | self.updater.clear_updates() 39 | return processed, updated, errors 40 | 41 | 42 | @shared_task 43 | def download_updates(): 44 | processed, updated, errors = 0, 0, 0 45 | configs = getattr(settings, 'INTEGRA_CONFIGS', None) 46 | if not configs: 47 | return 'no-configs' 48 | for config in configs: 49 | integrator = Integra(config) 50 | c_processed, c_updated, c_errors = integrator.run() 51 | processed += c_processed 52 | updated += c_updated 53 | errors += c_errors 54 | return f'ok:{processed}/{updated}:errors:{errors}' 55 | -------------------------------------------------------------------------------- /lib/integra/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pik-software/django-service-boilerplate/c39e221660ad85cea73d40cbe74036ef9b03c420/lib/integra/tests/__init__.py -------------------------------------------------------------------------------- /lib/integra/tests/test_loader.py: -------------------------------------------------------------------------------- 1 | from lib.integra.utils import Loader 2 | 3 | 4 | def test_loader_protocol(mocker): 5 | model = { 6 | 'url': '/api/v1/contact-list/', 7 | 'app': 'housing', 8 | 'model': 'contact'} 9 | config = { 10 | 'base_url': 'https://housing.pik-software.ru/', 11 | 'request': {}, 12 | 'models': [model]} 13 | result = [{'app': 'housing', 'model': 'contact', 'data': {'_uid': '0'}}] 14 | 15 | loader = Loader(config) 16 | with mocker.patch.object(loader, '_request', return_value=result): 17 | downloaded = list(loader.download(model)) 18 | 19 | assert downloaded == result 20 | loader._request.assert_called_once_with(model, None) # noqa: pylint=protected-access 21 | -------------------------------------------------------------------------------- /lib/integra/tests/test_models.py: -------------------------------------------------------------------------------- 1 | from django.utils.crypto import get_random_string 2 | from django.utils.timezone import now 3 | 4 | from lib.integra.models import UpdateState 5 | 6 | 7 | def test_interface(): 8 | type_name = get_random_string() 9 | now_time = now() 10 | 11 | date_time = UpdateState.objects.get_last_updated(type_name) 12 | assert date_time is None 13 | 14 | date_time = UpdateState.objects.get_last_updated(type_name) 15 | assert date_time is None 16 | 17 | UpdateState.objects.set_last_updated(type_name, now_time) 18 | 19 | date_time = UpdateState.objects.get_last_updated(type_name) 20 | assert date_time == now_time 21 | 22 | date_time = UpdateState.objects.get_last_updated(type_name) 23 | assert date_time == now_time 24 | -------------------------------------------------------------------------------- /lib/integra/tests/test_updater.py: -------------------------------------------------------------------------------- 1 | import dateutil.parser 2 | 3 | from lib.integra.models import UpdateState 4 | from lib.integra.utils import Updater 5 | 6 | 7 | def test_updater_protocol_create(): 8 | updater = Updater() 9 | count = UpdateState.objects.count() 10 | 11 | updater.update({ 12 | 'app': 'integra', 13 | 'model': 'updatestate', 14 | 'data': {'_uid': 'updater1', '_type': 'updatestate'}, 15 | }) 16 | 17 | assert UpdateState.objects.count() == count + 1 18 | assert UpdateState.objects.last().key == 'updater1' 19 | 20 | 21 | def test_updater_protocol_update(): 22 | updater = Updater() 23 | count = UpdateState.objects.count() 24 | updated_value = '2018-01-12T22:33:45.011349' 25 | 26 | updater.update({ 27 | 'app': 'integra', 28 | 'model': 'updatestate', 29 | 'data': {'_uid': 'updater1', '_type': 'updatestate', 30 | 'updated': '2012-04-12T22:33:45.028342'}, 31 | }) 32 | updater.update({ 33 | 'app': 'integra', 34 | 'model': 'updatestate', 35 | 'data': {'_uid': 'updater1', '_type': 'updatestate', 36 | 'updated': updated_value}, 37 | }) 38 | 39 | assert UpdateState.objects.count() == count + 1 40 | assert UpdateState.objects.last().key == 'updater1' 41 | assert UpdateState.objects.last().updated.isoformat() == updated_value 42 | assert updater.last_updated == {} 43 | 44 | 45 | def test_last_updated_counters(): 46 | updater = Updater() 47 | count = UpdateState.objects.count() 48 | updated_value = '2018-01-12T22:33:45.011349' 49 | 50 | updater.update({ 51 | 'app': 'integra', 52 | 'model': 'updatestate', 53 | 'data': {'_uid': 'updater1', '_type': 'updatestate', 54 | 'updated': '2012-04-12T22:33:45.028342'}, 55 | 'last_updated': '2012-04-12T22:33:45.028342', 56 | }) 57 | updater.update({ 58 | 'app': 'integra', 59 | 'model': 'updatestate', 60 | 'data': {'_uid': 'updater1', '_type': 'updatestate', 61 | 'updated': updated_value}, 62 | 'last_updated': updated_value, 63 | }) 64 | 65 | assert UpdateState.objects.count() == count + 1 66 | assert UpdateState.objects.last().key == 'updater1' 67 | assert UpdateState.objects.last().updated.isoformat() == updated_value 68 | assert updater.last_updated == { 69 | 'integra:updatestate': dateutil.parser.parse(updated_value)} 70 | -------------------------------------------------------------------------------- /lib/oidc_relied/README.md: -------------------------------------------------------------------------------- 1 | # Реализация ПИК-Комфорт OpenID Connect 2 | 3 | Данная библиотека реализует механизм авторизации пользователей сервисов 4 | ПИК-Комфорт по протоколу [OpenID Connect](http://openid.net/developers/specs/), 5 | включая реализацию протокола 6 | [Back-channel logout](http://openid.net/specs/openid-connect-backchannel-1_0.html). 7 | В данный момент поддерживаются 8 | [два механизма](http://openid.net/specs/oauth-v2-multiple-response-types-1_0.html): 9 | 10 | - Code (Authorization Code Flow) для авторизации классических приложений, 11 | - id_token token (Implicit Flow) для авторизации SPA. 12 | 13 | 14 | ## Предварительная подготовка 15 | 16 | Обратиться к подразделению, обслуживающему auth.pik-software.ru для 17 | прохождения процесса регистрации и получения `client_id` и `secret` 18 | регистрируемого сервиса. 19 | 20 | 21 | ## Порядок подключения 22 | 23 | 1 Скопировать `lib/oidc_relied` 24 | 25 | 2 В settings.py 26 | 27 | Для удобства интеграции предусмотрено два способа подключения необходимых настроек: 28 | 29 | - `set_oidc_settings()`, 30 | - ручная настройка. 31 | 32 | 2.1 set_oidc_settings 33 | 34 | В конец settings.py нужно добавить: 35 | 36 | ```python 37 | 38 | from lib.oidc_relied.settings import set_oidc_settings 39 | set_oidc_settings(globals()) 40 | 41 | ``` 42 | 43 | 2.2 Ручная настройка 44 | 45 | В случае необходимости тонкой настройки OIDC потребуется применение настроек 46 | врунчную. Описанная ниже последовательность приведет settings к тому же 47 | состоянию, что и вызов `set_oidc_settings`. 48 | 49 | 2.2.1 Подключить приложение `social_django` в `INSTALLED_APPS` 50 | 51 | ```patch 52 | INSTALLED_APPS = [ 53 | ... 54 | + 'social_django', 55 | ] 56 | ``` 57 | 58 | 2.2.2 Подключить `MIDDLEWARE` `OIDCExceptionMiddleware`, для правильного вывода 59 | ошибок 60 | 61 | ```patch 62 | MIDDLEWARE = [ 63 | 'django.middleware.security.SecurityMiddleware', 64 | 'django.contrib.sessions.middleware.SessionMiddleware', 65 | + 'lib.oidc_relied.middleware.OIDCExceptionMiddleware', 66 | ... 67 | ] 68 | ``` 69 | 70 | 2.2.3 Импортировать настройки: 71 | 72 | ```patch 73 | ... 74 | + # OPENID Relied conf 75 | + from lib.oidc_relied.settings import * 76 | ... 77 | ``` 78 | 79 | 2.2.4 Подключить `PIKOpenIdConnectAuth` в `AUTHENTICATION_BACKENDS` 80 | 81 | 82 | ``` 83 | AUTHENTICATION_BACKENDS = [ 84 | ... 85 | + 'lib.oidc_relied.backends.PIKOpenIdConnectAuth', # OIDC relied backend 86 | ] 87 | ``` 88 | 89 | 2.2.5 Добавить `SocialAuthentication` `REST_FRAMEWORK.DEFAULT_AUTHENTICATION_CLASSES`: 90 | 91 | ```patch 92 | REST_FRAMEWORK = { 93 | ... 94 | DEFAULT_AUTHENTICATION_CLASSES: { 95 | ... 96 | + 'rest_framework_social_oauth2.authentication.SocialAuthentication', 97 | } 98 | ... 99 | } 100 | ``` 101 | 102 | 3 Указать OIDC_PIK_ENDPOINT, OIDC_PIK_CLIENT_ID, OIDC_PIK_CLIENT_SECRET в 103 | настройках или ENV переменных. 104 | 105 | ```python 106 | OIDC_PIK_ENDPOINT = 'http://auth.pik-software.ru/openid' 107 | OIDC_PIK_CLIENT_ID = '42' 108 | OIDC_PIK_CLIENT_SECRET = 'SOMESECRET' 109 | ``` 110 | 111 | ```bash 112 | dokku config:set django-service-boilerplate \ 113 | OIDC_PIK_ENDPOINT = http://auth.pik-software.ru/openid \ 114 | OIDC_PIK_CLIENT_ID = 42 \ 115 | OIDC_PIK_CLIENT_SECRET = SOMESECRET 116 | ``` 117 | -------------------------------------------------------------------------------- /lib/oidc_relied/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pik-software/django-service-boilerplate/c39e221660ad85cea73d40cbe74036ef9b03c420/lib/oidc_relied/__init__.py -------------------------------------------------------------------------------- /lib/oidc_relied/middleware.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpResponseForbidden 2 | from social_core.exceptions import SocialAuthBaseException 3 | from social_django.middleware import SocialAuthExceptionMiddleware 4 | 5 | 6 | class OIDCExceptionMiddleware(SocialAuthExceptionMiddleware): 7 | def process_exception(self, request, exception): 8 | if not isinstance(exception, SocialAuthBaseException): 9 | return None 10 | return HttpResponseForbidden(self.get_message(request, exception)) 11 | -------------------------------------------------------------------------------- /lib/oidc_relied/pipeline.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import Group 2 | 3 | from pik.core.cache import cachedmethod 4 | 5 | 6 | SYSTEM_GROUP_PREFIX = "sys-" 7 | 8 | 9 | def associate_by_username(backend, response, *args, **kwargs): 10 | if not hasattr(backend, 'get_user_by_username'): 11 | return None 12 | username = response.get('preferred_username', None) 13 | user = backend.get_user_by_username(username) 14 | return {'user': user, 'is_new': user is None, 'username': username} 15 | 16 | 17 | @cachedmethod('user_details_{response[access_token]}') 18 | def actualize_roles(user, response, *args, **kwargs): 19 | local = user.groups.exclude(name__startswith=SYSTEM_GROUP_PREFIX) 20 | local = set(local.values_list('name', flat=True)) 21 | remote = set(role['name'] for role in response.get('roles', []) 22 | + [{'name': 'default'}]) 23 | 24 | for role in remote - local: 25 | group, _ = Group.objects.get_or_create(name=role) 26 | user.groups.add(group) 27 | 28 | for role in local - remote: 29 | group, _ = Group.objects.get_or_create(name=role) 30 | user.groups.remove(group) 31 | 32 | 33 | def actualize_staff_status(user, *args, **kwargs): 34 | if not user.is_staff: 35 | user.is_staff = True 36 | user.save() 37 | -------------------------------------------------------------------------------- /lib/oidc_relied/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pik-software/django-service-boilerplate/c39e221660ad85cea73d40cbe74036ef9b03c420/lib/oidc_relied/tests/__init__.py -------------------------------------------------------------------------------- /lib/oidc_relied/tests/test_backchannel_logout.py: -------------------------------------------------------------------------------- 1 | from importlib import import_module 2 | from unittest.mock import patch, Mock 3 | 4 | from jwkest import JWKESTException 5 | import pytest 6 | 7 | import django.test 8 | from django.conf import settings 9 | from django.core.cache import cache 10 | from django.urls import reverse 11 | from rest_framework import status 12 | 13 | 14 | @pytest.fixture 15 | def backchannel_logout_url(): 16 | return reverse('oidc_backchannel_logout', kwargs={'backend': 'pik'}) 17 | 18 | 19 | @pytest.fixture 20 | def session_store(): 21 | return import_module(settings.SESSION_ENGINE).SessionStore() 22 | 23 | 24 | def test_wrong_method(backchannel_logout_url): 25 | client = django.test.Client() 26 | response = client.get(backchannel_logout_url) 27 | assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED 28 | 29 | 30 | @patch('lib.oidc_relied.backends.PIKOpenIdConnectAuth.' 31 | 'validate_and_return_logout_token', 32 | Mock(return_value={'sid': 'test_sid'})) 33 | def test_success(backchannel_logout_url, session_store): 34 | cache.set('oidc_sid_userdata_test_sid', ['userdata']) 35 | cache.set('oidc_sid_tokens_test_sid', ['token']) 36 | client = django.test.Client() 37 | session_key = client.session.session_key 38 | cache.set('oidc_sid_sessions_test_sid', [session_key]) 39 | response = client.post(backchannel_logout_url) 40 | assert response.status_code == status.HTTP_200_OK 41 | assert cache.get('oidc_sessions_test_token') is None 42 | assert cache.get('oidc_userdata_test_token') is None 43 | assert cache.get('oidc_tokens_test_token') is None 44 | assert not client.session.exists(client.session.session_key) 45 | 46 | 47 | @patch('lib.oidc_relied.backends.PIKOpenIdConnectAuth.' 48 | 'get_jwks_keys', Mock(return_value={})) 49 | @patch('social_core.backends.open_id_connect.JWS.verify_compact', 50 | Mock(side_effect=JWKESTException('Signature verification failed'))) 51 | def test_wrong_sign(backchannel_logout_url): 52 | client = django.test.Client() 53 | response = client.post(backchannel_logout_url) 54 | assert response.status_code == status.HTTP_403_FORBIDDEN 55 | 56 | 57 | @patch('lib.oidc_relied.backends.JWS.verify_compact', 58 | Mock(return_value={'aud': '24', 'iss': 'test_provider'})) 59 | @patch('lib.oidc_relied.backends.PIKOpenIdConnectAuth.get_jwks_keys', 60 | Mock()) 61 | @patch('lib.oidc_relied.backends.PIKOpenIdConnectAuth.id_token_issuer', 62 | Mock(return_value="test_provider")) 63 | @patch('lib.oidc_relied.backends.PIKOpenIdConnectAuth.get_key_and_secret', 64 | Mock(return_value=('42', ''))) 65 | def test_wrong_client(backchannel_logout_url): 66 | cache.set('oidc_userdata_test_token', 'testuserinfo') 67 | client = django.test.Client() 68 | response = client.post(backchannel_logout_url) 69 | assert response.status_code == status.HTTP_403_FORBIDDEN 70 | assert response.content == b'Token error: Invalid audience' 71 | assert cache.get('oidc_userdata_test_token') == 'testuserinfo' 72 | 73 | 74 | @pytest.mark.django_db 75 | @patch('lib.oidc_relied.backends.PIKOpenIdConnectAuth.' 76 | 'get_jwks_keys', Mock(return_value={})) 77 | def test_missing_token(backchannel_logout_url): 78 | cache.set('oidc_userdata_test_token', 'testuserinfo') 79 | client = django.test.Client() 80 | response = client.post(backchannel_logout_url) 81 | assert response.status_code == status.HTTP_403_FORBIDDEN 82 | assert cache.get('oidc_userdata_test_token') == 'testuserinfo' 83 | -------------------------------------------------------------------------------- /lib/oidc_relied/tests/test_pipeline.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.contrib.auth import get_user_model 3 | from django.contrib.auth.models import Group 4 | 5 | 6 | from lib.oidc_relied.pipeline import actualize_roles 7 | 8 | 9 | @pytest.fixture 10 | def user(): 11 | return get_user_model().objects.create(username="testuser") 12 | 13 | 14 | def test_actualize_roles_system(user): 15 | group = Group.objects.create(name='sys-group') 16 | group.user_set.add(user) 17 | 18 | actualize_roles(user=user, response={'access_token': 'access_token'}) 19 | 20 | assert (set(user.groups.values_list('name', flat=True)) == 21 | {'sys-group', 'default'}) 22 | 23 | 24 | def test_actualize_roles_extra(user): 25 | actualize_roles(user=user, response={'access_token': 'access_token', 26 | 'roles': [{'name': 'extra'}]}) 27 | 28 | assert (set(user.groups.values_list('name', flat=True)) == 29 | {'default', 'extra'}) 30 | 31 | 32 | def test_actualize_roles_redundant(user): 33 | group = Group.objects.create(name='redundant') 34 | group.user_set.add(user) 35 | 36 | actualize_roles(user=user, response={'access_token': 'access_token'}) 37 | 38 | assert set(user.groups.values_list('name', flat=True)) == {'default'} 39 | -------------------------------------------------------------------------------- /lib/oidc_relied/tests/test_relied_logout.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch, Mock 2 | from urllib.parse import urlencode 3 | 4 | 5 | import django.test 6 | from django.urls import reverse 7 | 8 | from rest_framework import status 9 | 10 | 11 | @django.test.override_settings(OIDC_PIK_CLIENT_ID="TEST_CLIENT_ID") 12 | @patch("social_core.backends.open_id_connect.OpenIdConnectAuth.oidc_config", 13 | Mock(return_value={ 14 | 'end_session_endpoint': 'http://op/openid/end-session/'})) 15 | def test_logout(api_client): 16 | api_client.session['id_token'] = '{testidtoken}' 17 | resp = api_client.get(reverse('admin:logout')) 18 | assert resp.status_code == status.HTTP_302_FOUND 19 | assert resp['Location'] == 'http://op/openid/end-session/?{}'.format( 20 | urlencode({'post_logout_redirect_uri': 'http://testserver/logout/'})) 21 | assert api_client.session.get('id_token') is None 22 | -------------------------------------------------------------------------------- /lib/oidc_relied/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url, include 2 | from django.contrib import admin 3 | 4 | from .views import (oidc_admin_login, oidc_admin_logout, 5 | oidc_backchannel_logout, complete) 6 | 7 | 8 | urlpatterns = [ # noqa: invalid-name 9 | 10 | # We need to override default `social_python` `complete` behavior in order 11 | # to provide backchannel logout implementation. 12 | url(r'^openid/complete/(?P[^/]+)/', complete), 13 | url(r'^openid/logout/(?P[^/]+)/$', 14 | oidc_backchannel_logout, name='oidc_backchannel_logout'), 15 | 16 | url(r'^openid/', include('social_django.urls', namespace='social')), 17 | url(r'^login/$', admin.site.login, name='login'), 18 | url(r'^logout/$', admin.site.logout, name='logout'), 19 | url(r'^admin/login/$', oidc_admin_login), 20 | url(r'^admin/logout/$', oidc_admin_logout), 21 | ] 22 | -------------------------------------------------------------------------------- /lib/oidc_relied/views.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | from urllib.parse import urlencode 3 | 4 | from django.conf import settings 5 | from django.contrib.admin import site 6 | from django.contrib.auth import REDIRECT_FIELD_NAME 7 | from django.core.exceptions import PermissionDenied 8 | from django.http import HttpResponseRedirect, HttpRequest, HttpResponse 9 | from django.shortcuts import resolve_url 10 | from django.utils.translation import ugettext as _ 11 | from django.views.decorators.cache import never_cache 12 | from django.views.decorators.csrf import csrf_exempt 13 | from django.views.decorators.http import require_POST 14 | 15 | from social_core.actions import do_complete 16 | from social_core.backends.oauth import OAuthAuth 17 | from social_django.utils import psa 18 | from social_django.views import NAMESPACE, _do_login 19 | 20 | 21 | def oidc_admin_login(request: HttpRequest) -> HttpResponseRedirect: 22 | if settings.OIDC_PIK_CLIENT_ID is None: 23 | return site.login(request) 24 | 25 | if not request.user.is_authenticated: 26 | args = f"?{urlencode(request.GET)}" if request.GET else "" 27 | return HttpResponseRedirect(f"{resolve_url(settings.LOGIN_URL)}{args}") 28 | 29 | if not request.user.is_active: 30 | raise PermissionDenied(_("Данный пользователь отключен")) 31 | 32 | if not request.user.is_staff: 33 | raise PermissionDenied(_("У вас нет доступа к данному интерфейсу")) 34 | 35 | url = request.GET.get("next", settings.LOGIN_REDIRECT_URL) 36 | return HttpResponseRedirect(url) 37 | 38 | 39 | @partial(partial, backend="pik") 40 | @psa() 41 | def oidc_admin_logout(request: HttpRequest, backend: str) -> HttpResponse: 42 | if settings.OIDC_PIK_CLIENT_ID is None: 43 | return site.login(request) 44 | 45 | return request.backend.logout() 46 | 47 | 48 | @csrf_exempt 49 | @psa() 50 | @require_POST 51 | def oidc_backchannel_logout(request: HttpRequest, backend: str, 52 | *args, **kwargs) -> HttpResponse: 53 | logout_token = request.POST.get('logout_token', '') 54 | return request.backend.backchannel_logout(logout_token) 55 | 56 | 57 | @never_cache 58 | @csrf_exempt 59 | @psa('{0}:complete'.format(NAMESPACE)) 60 | def complete(request: HttpRequest, backend: str, 61 | *args, **kwargs) -> HttpResponse: 62 | """ Authentication complete view with custom _do_login method, saving token 63 | to session link """ 64 | return do_complete(request.backend, _do_save_session_login, request.user, 65 | redirect_name=REDIRECT_FIELD_NAME, request=request, 66 | *args, **kwargs) 67 | 68 | 69 | def _do_save_session_login(backend: OAuthAuth, user, social_user) -> None: 70 | """ Login user with saving result token to session link in order to provide 71 | backchannel logut """ 72 | _do_login(backend, user, social_user) 73 | if hasattr(backend, 'save_logout_artefacts'): 74 | backend.save_logout_artefacts() 75 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "_project_.settings") 7 | try: 8 | from django.core.management import execute_from_command_line 9 | except ImportError: 10 | # The above import may fail for some other reason. Ensure that the 11 | # issue is really that Django is missing to avoid masking other 12 | # exceptions on Python 2. 13 | try: 14 | import django # noqa: pylint=unused-import 15 | except ImportError: 16 | raise ImportError( 17 | "Couldn't import Django. Are you sure it's installed and " 18 | "available on your PYTHONPATH environment variable? Did you " 19 | "forget to activate a virtual environment?" 20 | ) 21 | raise 22 | execute_from_command_line(sys.argv) 23 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | doctest_optionflags = NORMALIZE_WHITESPACE IGNORE_EXCEPTION_DETAIL 3 | addopts = --doctest-modules --cov-config .coveragerc --cov-report term-missing --selenium --durations=0 -vvv --reuse-db 4 | norecursedirs = .* tmp* migrations static media templates codegen_templates testcases __data__ 5 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # python utils 2 | Pillow==7.1.0 3 | requests==2.22.0 4 | deprecated==1.2.6 5 | beautifulsoup4==4.8.0 6 | openpyxl==3.0.0 7 | python-dateutil==2.8.0 8 | 9 | Django==2.2.13 10 | 11 | # django utils 12 | django_widget_tweaks==1.4.5 13 | django-bootstrap-form==3.4 14 | django-sql-explorer==1.1.3 15 | dj_database_url==0.5.0 16 | django-storages==1.7.2 17 | pik-django-utils==2.0.1 18 | 19 | # HISTORY 20 | django-simple-history==2.7.3 21 | 22 | # API 23 | djangorestframework==3.10.3 24 | djangorestframework-filters==1.0.0.dev0 25 | django-filter==2.2.0 26 | django-crispy-forms==1.7.2 27 | markdown==3.1.1 28 | # API SCHEMA 29 | #drf-yasg==1.16.1 ( wait PR https://github.com/axnsan12/drf-yasg/pull/428 ) 30 | -e git+https://github.com/pik-software/drf-yasg.git@master#egg=drf-yasg 31 | 32 | # db 33 | redis==3.3.8 34 | psycopg2==2.8.3 35 | django-redis==4.10.0 36 | 37 | # celery 38 | celery==4.3.1 39 | kombu<5.0 40 | django_celery_results==1.1.2 41 | celery-redbeat==0.13.0 42 | 43 | # sentry 44 | sentry-sdk==0.14.1 45 | 46 | # prod 47 | uwsgi==2.0.18 48 | 49 | # dev 50 | django_debug_toolbar==2.2 51 | django_extensions==2.2.1 52 | werkzeug==0.16.0 53 | 54 | # tests 55 | factory_boy==2.12.0 56 | freezegun==0.3.12 57 | pdbpp==0.10.0 58 | pytest==5.2.0 59 | pytest-mock==1.11.0 60 | pytest-cov==2.7.1 61 | pytest-django==3.5.1 62 | pytest-selenium==1.17.0 63 | pytest-base-url==1.4.1 64 | pytest-benchmark==3.2.2 65 | pytest-html==2.0.0 66 | # tests/style 67 | prospector==1.1.2 68 | isort<5.0.0 69 | pylint==2.1.1 70 | flake8<3.6.0 71 | pep8-naming==0.8.2 72 | 73 | # OIDC 74 | django_cors_headers==2.5.2 75 | django-oauth-toolkit<1.1.0 76 | django-rest-framework-social-oauth2==1.1.0 77 | pyjwkest==1.4.0 78 | future>=0.16.0,<0.17.0 79 | social_auth_core>=1.7.0,<2.0.0 80 | 81 | # lib/codegen 82 | swagger-parser==1.0.1 83 | 84 | # metrics/monitoring 85 | ddtrace==0.29.0 86 | datadog==0.30.0 87 | elastic-apm==5.1.2 88 | django-health-check==3.11.0 89 | --------------------------------------------------------------------------------