├── .coveragerc
├── .editorconfig
├── .github
└── workflows
│ └── ci.yml
├── .gitignore
├── .gitlab-ci.yml
├── .pre-commit-config.yaml
├── .readthedocs.yml
├── CHANGES.md
├── Dockerfile
├── LICENSE.md
├── MANIFEST.in
├── README.md
├── ai_django_core
├── __init__.py
├── admin
│ ├── __init__.py
│ ├── model_admins
│ │ ├── __init__.py
│ │ ├── classes.py
│ │ ├── inlines.py
│ │ └── mixins.py
│ └── views
│ │ ├── __init__.py
│ │ ├── forms.py
│ │ └── mixins.py
├── apps.py
├── checks.py
├── context_manager.py
├── context_processors.py
├── drf
│ ├── __init__.py
│ ├── fields.py
│ ├── serializers.py
│ └── tests.py
├── gitlab
│ ├── __init__.py
│ └── coverage.py
├── graphql
│ ├── __init__.py
│ ├── forms
│ │ ├── __init__.py
│ │ └── mutations.py
│ ├── schemes
│ │ ├── __init__.py
│ │ └── mutations.py
│ ├── sentry
│ │ ├── __init__.py
│ │ ├── utils.py
│ │ └── views.py
│ └── tests
│ │ ├── __init__.py
│ │ └── base_test.py
├── locale
│ └── de
│ │ └── LC_MESSAGES
│ │ ├── django.mo
│ │ └── django.po
├── mail
│ ├── __init__.py
│ ├── backends
│ │ ├── __init__.py
│ │ └── whitelist_smtp.py
│ ├── errors.py
│ └── services
│ │ ├── __init__.py
│ │ ├── base.py
│ │ └── tests.py
├── management
│ ├── __init__.py
│ └── commands
│ │ ├── __init__.py
│ │ └── validate_test_structure.py
├── managers.py
├── middleware
│ ├── __init__.py
│ └── current_user.py
├── mixins
│ ├── __init__.py
│ ├── bleacher.py
│ ├── models.py
│ └── validation.py
├── models.py
├── selectors
│ ├── __init__.py
│ ├── base.py
│ └── permission.py
├── sentry
│ ├── __init__.py
│ └── helpers.py
├── services
│ ├── __init__.py
│ └── custom_scrubber.py
├── templatetags
│ ├── __init__.py
│ ├── ai_date_tags.py
│ ├── ai_email_tags.py
│ ├── ai_file_tags.py
│ ├── ai_helper_tags.py
│ ├── ai_number_tags.py
│ ├── ai_object_tags.py
│ └── ai_string_tags.py
├── tests
│ ├── __init__.py
│ ├── errors.py
│ ├── mixins.py
│ └── structure_validator
│ │ ├── __init__.py
│ │ ├── settings.py
│ │ └── test_structure_validator.py
├── utils
│ ├── __init__.py
│ ├── cache.py
│ ├── date.py
│ ├── file.py
│ ├── log_whodid.py
│ ├── math.py
│ ├── model.py
│ ├── named_tuple.py
│ └── string.py
└── view_layer
│ ├── __init__.py
│ ├── form_mixins.py
│ ├── formset_mixins.py
│ ├── formset_view_mixin.py
│ ├── htmx_mixins.py
│ ├── mixins.py
│ ├── tests
│ ├── __init__.py
│ └── mixins.py
│ └── views.py
├── docs
├── Makefile
├── conf.py
├── features
│ ├── admin.md
│ ├── changelog.rst
│ ├── context_manager.md
│ ├── context_processors.md
│ ├── database_anonymisation.md
│ ├── djangorestframework.md
│ ├── gitlab.md
│ ├── graphql.md
│ ├── mail.md
│ ├── managers.md
│ ├── mixins.md
│ ├── models.md
│ ├── selectors.md
│ ├── sentry.md
│ ├── services.md
│ ├── setup.md
│ ├── tests.md
│ ├── utils.rst
│ ├── utils
│ │ ├── cache.md
│ │ ├── date.md
│ │ ├── math.md
│ │ ├── model.md
│ │ ├── named_tuple.md
│ │ └── string.md
│ └── view-layer.md
├── index.rst
└── make.bat
├── manage.py
├── pyproject.toml
├── pytest.ini
├── scripts
├── publish_and_update_mirror.ps1
└── update-mirror.ps1
├── settings.py
├── setup.cfg
├── testapp
├── __init__.py
├── api
│ ├── __init__.py
│ ├── serializers.py
│ ├── urls.py
│ └── views.py
├── forms.py
├── managers.py
├── migrations
│ ├── 0001_initial.py
│ ├── 0002_auto_210407.py
│ ├── 0003_modelwithfktoself.py
│ ├── 0004_auto_20210511_1343.py
│ ├── 0005_modelwithcleanmixin.py
│ ├── 0006_modelwithselector.py
│ ├── 0007_modelwithsavewithoutsignals.py
│ └── __init__.py
├── models.py
├── selectors.py
├── templates
│ ├── 403.html
│ ├── test_email.html
│ └── test_email.txt
├── tests
│ ├── __init__.py
│ ├── missing_init
│ │ └── test_ok.py
│ └── subdirectory
│ │ ├── __init__.py
│ │ ├── missing_test_prefix.py
│ │ └── test_ok.py
├── urls.py
└── views.py
└── tests
├── __init__.py
├── admin
├── __init__.py
└── model_admin_mixins
│ ├── __init__.py
│ ├── test_admin_common_info_mixin.py
│ ├── test_admin_create_form_mixin.py
│ ├── test_admin_no_inlines_for_create_mixin.py
│ ├── test_admin_request_in_form_mixin.py
│ ├── test_deactivatable_change_view_admin_mixin.py
│ ├── test_fetch_object_mixin.py
│ └── test_fetch_parent_object_inline_mixin.py
├── ambient_toolbox
├── __init__.py
└── test_test_structure_validator.py
├── drf
├── __init__.py
└── test_fields.py
├── files
└── testfile.txt
├── mixins
├── __init__.py
└── validation.py
├── selectors
├── __init__.py
├── test_base.py
└── test_permission.py
├── test_admin_forms.py
├── test_admin_inlines.py
├── test_admin_model_admins_classes.py
├── test_admin_view_mixins.py
├── test_context_manager.py
├── test_email_test_service.py
├── test_log_whodid.py
├── test_mail_services.py
├── test_managers.py
├── test_math.py
├── test_middleware.py
├── test_mixins_models.py
├── test_models.py
├── test_rest_api_mixins.py
├── test_scrubbing_service.py
├── test_sentry_helper.py
├── test_utils_cache.py
├── test_utils_date.py
├── test_utils_file.py
├── test_utils_model.py
├── test_utils_named_tuple.py
├── test_utils_string.py
├── tests
├── __init__.py
├── mixins
│ ├── __init__.py
│ ├── models.py
│ ├── test_django_message_framework.py
│ └── test_request_provider_mixin.py
└── test_mail_backends.py
└── view_layer
├── __init__.py
├── test_formset_mixins.py
├── test_htmx_response_mixin.py
├── test_meta_mixins.py
└── test_views.py
/.coveragerc:
--------------------------------------------------------------------------------
1 | [run]
2 | omit =
3 | setup.py,
4 | *_test.py,
5 | tests.py,
6 | *tests*,
7 | conftest.py
8 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | # http://editorconfig.org
2 |
3 | root = true
4 |
5 | [*]
6 | charset = utf-8
7 | end_of_line = lf
8 | insert_final_newline = true
9 | trim_trailing_whitespace = true
10 |
11 | [*.{py,rst,ini}]
12 | indent_style = space
13 | indent_size = 4
14 | ij_continuation_indent_size = 8
15 |
16 | [*.yml]
17 | indent_style = space
18 | indent_size = 2
19 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: Unit tests
2 |
3 | on:
4 | push:
5 | branches: [ '**' ]
6 | pull_request:
7 | branches: [ '**' ]
8 |
9 | jobs:
10 | linting:
11 | runs-on: ubuntu-22.04
12 | steps:
13 | - uses: actions/checkout@v3
14 |
15 | - name: Set up Python 3.11
16 | uses: actions/setup-python@v3
17 | with:
18 | python-version: "3.11"
19 |
20 | - name: Install required packages
21 | run: pip install black ruff
22 |
23 | - name: Run ruff
24 | run: ruff .
25 |
26 | - name: Run black
27 | run: black .
28 |
29 | build:
30 | name: Python ${{ matrix.python-version }}, django ${{ matrix.django-version }}
31 | runs-on: ubuntu-22.04
32 | strategy:
33 | matrix:
34 | python-version: [3.8, 3.9, '3.10', '3.11']
35 | django-version: [22, 30, 31, 32, 40, 41]
36 |
37 | exclude:
38 | - python-version: '3.11'
39 | django-version: 40
40 | - python-version: '3.11'
41 | django-version: 32
42 | - python-version: '3.10'
43 | django-version: 32
44 | - python-version: '3.11'
45 | django-version: 31
46 | - python-version: '3.10'
47 | django-version: 31
48 | - python-version: '3.11'
49 | django-version: 30
50 | - python-version: '3.10'
51 | django-version: 30
52 | - python-version: '3.11'
53 | django-version: 22
54 | - python-version: '3.10'
55 | django-version: 22
56 |
57 | steps:
58 | - uses: actions/checkout@v3
59 | - name: setup python
60 | uses: actions/setup-python@v3
61 | with:
62 | python-version: ${{ matrix.python-version }}
63 | - name: Install tox
64 | run: pip install tox
65 | - name: Run Tests
66 | env:
67 | TOXENV: django${{ matrix.django-version }}
68 | run: tox
69 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *~
2 | *.pyc
3 | .idea
4 | .cache
5 | .coverage
6 |
7 | /*.egg-info/
8 | /attachments/
9 | /build
10 | /dist
11 | /docs/modules/
12 |
13 | # sphinx build folder
14 | _build
15 |
16 | # Compiled source #
17 | ###################
18 | *.com
19 | *.class
20 | *.dll
21 | *.exe
22 | *.o
23 | *.so
24 |
25 | # Logs and databases #
26 | ######################
27 | *.log
28 | *.sql
29 | *.sqlite
30 |
31 | # OS generated files #
32 | ######################
33 | .DS_Store?
34 | ehthumbs.db
35 | Icon?
36 | Thumbs.db
37 |
38 | # Editor backup files #
39 | #######################
40 | *~
41 |
--------------------------------------------------------------------------------
/.gitlab-ci.yml:
--------------------------------------------------------------------------------
1 | include:
2 | - template: Security/Container-Scanning.gitlab-ci.yml
3 |
4 | image: ${CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX}/docker:20.10.16
5 |
6 | stages:
7 | - lint
8 | - build
9 | - security
10 | - security_results
11 | - test
12 |
13 | # When using dind, it's wise to use the overlays driver for
14 | # improved performance.
15 | variables:
16 | IMAGE_TAG: $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG
17 |
18 | DOCKER_HOST: tcp://docker:2376
19 | DOCKER_TLS_CERTDIR: "/certs"
20 | DOCKER_TLS_VERIFY: 1
21 | DOCKER_CERT_PATH: "$DOCKER_TLS_CERTDIR/client"
22 |
23 | services:
24 | # alias is necessary for gitlab to recognise the service correctly because of the prefix:
25 | - name: ${CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX}/docker:20.10.16-dind
26 | alias: docker
27 |
28 | lint:
29 | image: ${CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX}/python:3.11-slim
30 | stage: lint
31 | tags:
32 | - low-load
33 | script:
34 | - apt-get update && apt-get install --no-install-recommends -y git
35 | - pip install pre-commit
36 | - pre-commit install -t pre-push -t pre-commit --install-hooks
37 | - pre-commit run --all-files --hook-stage push
38 |
39 | check python version coding style:
40 | image: ${CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX}/python:3.11-slim
41 | stage: lint
42 | tags:
43 | - low-load
44 | script:
45 | - apt-get update && apt-get install --no-install-recommends -y git
46 | - pip install pre-commit
47 | - pre-commit install -t pre-push -t pre-commit --install-hooks
48 | - pre-commit run --all-files --hook-stage push pyupgrade
49 |
50 | check django version coding style:
51 | image: ${CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX}/python:3.11-slim
52 | stage: lint
53 | tags:
54 | - low-load
55 | script:
56 | - apt-get update && apt-get install --no-install-recommends -y git
57 | - pip install pre-commit
58 | - pre-commit install -t pre-push -t pre-commit --install-hooks
59 | - pre-commit run --all-files --hook-stage push django-upgrade
60 |
61 | build:
62 | stage: build
63 | tags:
64 | - normal-load
65 | before_script:
66 | # generating certs is too slow: https://gitlab.com/gitlab-org/gitlab-runner/-/issues/27384
67 | - until docker info; do sleep 1; done
68 | script:
69 | - docker login -u gitlab-ci-token -p $CI_JOB_TOKEN $CI_REGISTRY
70 | # https://docs.gitlab.com/ee/ci/docker/using_docker_build.html#using-docker-caching
71 | - docker pull $CI_REGISTRY_IMAGE:master || true
72 | - docker build --cache-from $CI_REGISTRY_IMAGE:master -t $IMAGE_TAG .
73 | - docker push $IMAGE_TAG
74 |
75 | container_scanning:
76 | stage: security
77 | tags:
78 | - low-load
79 | variables:
80 | GIT_STRATEGY: fetch
81 | DOCKER_IMAGE: $IMAGE_TAG
82 | CS_DEFAULT_BRANCH_IMAGE: $CI_REGISTRY_IMAGE:develop
83 | CS_SEVERITY_THRESHOLD: HIGH
84 | CS_IGNORE_UNFIXED: "true"
85 | # https://docs.gitlab.com/ee/user/application_security/container_scanning/#report-language-specific-findings
86 | CS_DISABLE_LANGUAGE_VULNERABILITY_SCAN: "false"
87 | DOCKER_FILE: Dockerfile
88 |
89 | check security scan results:
90 | stage: security_results
91 | image: ${CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX}/alpine:latest
92 | dependencies:
93 | - container_scanning
94 | tags:
95 | - low-load
96 | before_script:
97 | - apk update && apk add jq
98 | script:
99 | - jq -e "( .vulnerabilities | length ) == 0" ./gl-container-scanning-report.json
100 | allow_failure: true
101 |
102 | tests:
103 | stage: test
104 | needs: [build]
105 | tags:
106 | - normal-load
107 | variables:
108 | GIT_STRATEGY: none
109 | before_script:
110 | # generating certs is too slow: https://gitlab.com/gitlab-org/gitlab-runner/-/issues/27384
111 | - until docker info; do sleep 1; done
112 | script:
113 | - docker login -u gitlab-ci-token -p $CI_JOB_TOKEN $CI_REGISTRY
114 | - docker pull $IMAGE_TAG
115 | - docker run -v `pwd`:/reports $IMAGE_TAG pytest --ds settings tests --cov=ai_django_core/ --cov-report xml:/reports/cov.xml --junitxml=/reports/junit.xml
116 | coverage: '/(?i)total.*? (100(?:\.0+)?\%|[1-9]?\d(?:\.\d+)?\%)$/'
117 | artifacts:
118 | reports:
119 | junit: ./junit.xml
120 | coverage_report:
121 | coverage_format: cobertura
122 | path: ./cov.xml
123 |
--------------------------------------------------------------------------------
/.pre-commit-config.yaml:
--------------------------------------------------------------------------------
1 | # you find the full pre-commit-tools docu under:
2 | # https://pre-commit.com/
3 |
4 | repos:
5 | - repo: https://github.com/ambv/black
6 | rev: 23.1.0
7 | hooks:
8 | - id: black
9 | args: [ --check, --diff, --config, ./pyproject.toml ]
10 | language_version: python3.11
11 | stages: [ push ]
12 |
13 | - repo: https://github.com/charliermarsh/ruff-pre-commit
14 | # Ruff version.
15 | rev: 'v0.0.259'
16 | hooks:
17 | - id: ruff
18 | args: [ --fix, --exit-non-zero-on-fix ]
19 |
20 | - repo: https://github.com/asottile/pyupgrade
21 | rev: v3.3.1
22 | hooks:
23 | - id: pyupgrade
24 | args: [ --py38-plus ]
25 | language_version: python3.11
26 | stages: [ push ]
27 |
28 | - repo: https://github.com/adamchainz/django-upgrade
29 | rev: 1.13.0
30 | hooks:
31 | - id: django-upgrade
32 | args: [--target-version, "2.2"]
33 | language_version: python3.11
34 | stages: [ push ]
35 |
--------------------------------------------------------------------------------
/.readthedocs.yml:
--------------------------------------------------------------------------------
1 | # .readthedocs.yml
2 | # Read the Docs configuration file
3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
4 |
5 | # Required
6 | version: 2
7 |
8 | # Build documentation in the docs/ directory with Sphinx
9 | sphinx:
10 | configuration: docs/conf.py
11 |
12 | # Optionally set the version of Python and requirements required to build your docs
13 | python:
14 | version: "3.8"
15 | install:
16 | - method: pip
17 | path: .
18 | extra_requirements:
19 | - docs
20 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | ### STAGE 2: Setup ###
2 | FROM python:3.11
3 |
4 | # Update OS dependencies
5 | RUN apt-get update && \
6 | apt-get -y upgrade && \
7 | apt-get clean && \
8 | rm -rf /var/lib/apt/lists/*
9 |
10 | # Set env variables used in this Dockerfile (add a unique prefix, such as DOCKYARD)
11 | # Local directory with project source
12 | ENV AI_CORE_SRC=.
13 | # Directory in container for all project files
14 | ENV AI_CORE_SRVHOME=/src/
15 | # Allow flit to install stuff as root
16 | ENV FLIT_ROOT_INSTALL=1
17 |
18 | # Create application subdirectories
19 | WORKDIR $AI_CORE_SRVHOME
20 |
21 | # Install Python dependencies
22 | COPY pyproject.toml README.md $AI_CORE_SRVHOME
23 | COPY ai_django_core/__init__.py $AI_CORE_SRVHOME/ai_django_core/
24 | RUN pip install -U pip flit
25 | RUN flit install --deps all --extras all
26 | # Install dev dependencies - it's ok to do it here because we never deploy this image
27 | RUN pip install .[dev,drf,graphql,view-layer]
28 |
29 | # Copy application source code to SRCDIR
30 | COPY $AI_CORE_SRC $AI_CORE_SRVHOME
31 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2017 - Ambient Innovation: GmbH
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 |
--------------------------------------------------------------------------------
/MANIFEST.in:
--------------------------------------------------------------------------------
1 | include README.md
2 | include LICENSE.md
3 | recursive-exclude * *.pyc
4 | recursive-include ai_django_core *.py *.html *.js *.cfg *.mo *.po
5 |
--------------------------------------------------------------------------------
/ai_django_core/__init__.py:
--------------------------------------------------------------------------------
1 | """Ambient toolbox - Lots of helper functions and useful widgets"""
2 |
3 | __version__ = '7.0.2'
4 |
--------------------------------------------------------------------------------
/ai_django_core/admin/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ambient-innovation/ai-django-core/35002db94652ba84ece8ec6ae959b12e79ce628f/ai_django_core/admin/__init__.py
--------------------------------------------------------------------------------
/ai_django_core/admin/model_admins/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ambient-innovation/ai-django-core/35002db94652ba84ece8ec6ae959b12e79ce628f/ai_django_core/admin/model_admins/__init__.py
--------------------------------------------------------------------------------
/ai_django_core/admin/model_admins/classes.py:
--------------------------------------------------------------------------------
1 | from django.contrib import admin
2 |
3 |
4 | class ReadOnlyAdmin(admin.ModelAdmin):
5 | """
6 | Class for being extended by ModelAdmin-classes.
7 | Disables all create, delete or edit functionality in the regular admin.
8 | """
9 |
10 | def get_readonly_fields(self, request, obj=None):
11 | if obj:
12 | self.readonly_fields = [field.name for field in self.opts.local_fields] + [
13 | field.name for field in self.opts.local_many_to_many
14 | ]
15 | return self.readonly_fields
16 |
17 | def changeform_view(self, request, object_id=None, form_url='', extra_context=None):
18 | extra_context = extra_context or {}
19 | extra_context['show_save_and_continue'] = False
20 | extra_context['show_save'] = False
21 | return super().changeform_view(request, object_id, extra_context=extra_context)
22 |
23 | def has_add_permission(self, request):
24 | return False
25 |
26 | def has_change_permission(self, request, obj=None):
27 | return False
28 |
29 | def has_delete_permission(self, request, obj=None):
30 | return False
31 |
32 |
33 | class EditableOnlyAdmin(admin.ModelAdmin):
34 | """
35 | Class for being extended by ModelAdmin-classes.
36 | Disables all create and delete functionality so all records can only be edited.
37 | """
38 |
39 | def get_actions(self, request):
40 | # Disable delete
41 | actions = super().get_actions(request)
42 | if 'delete_selected' in actions:
43 | del actions['delete_selected']
44 | return actions
45 |
46 | def has_add_permission(self, request):
47 | return False
48 |
49 | def has_delete_permission(self, request, obj=None):
50 | return False
51 |
--------------------------------------------------------------------------------
/ai_django_core/admin/model_admins/inlines.py:
--------------------------------------------------------------------------------
1 | from django.contrib import admin
2 |
3 |
4 | class ReadOnlyTabularInline(admin.TabularInline):
5 | """
6 | Class for being extended by TabularInline-classes.
7 | Disables all create, delete or edit functionality in the tabular inline admin.
8 | """
9 |
10 | can_delete = False
11 |
12 | def has_add_permission(self, *args, **kwargs):
13 | return False
14 |
15 | def has_change_permission(self, *args, **kwargs):
16 | return False
17 |
18 | def has_delete_permission(self, *args, **kwargs):
19 | return False
20 |
21 | def get_readonly_fields(self, request, obj=None):
22 | result = list(
23 | set(
24 | [field.name for field in self.opts.local_fields]
25 | + [field.name for field in self.opts.local_many_to_many]
26 | )
27 | )
28 | result.remove('id')
29 | return result
30 |
--------------------------------------------------------------------------------
/ai_django_core/admin/views/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ambient-innovation/ai-django-core/35002db94652ba84ece8ec6ae959b12e79ce628f/ai_django_core/admin/views/__init__.py
--------------------------------------------------------------------------------
/ai_django_core/admin/views/forms.py:
--------------------------------------------------------------------------------
1 | from crispy_forms.helper import FormHelper
2 | from crispy_forms.layout import HTML, Div, Fieldset, Layout, Submit
3 | from django import forms
4 | from django.utils.translation import gettext_lazy as _
5 |
6 |
7 | class AdminCrispyForm(forms.Form):
8 | """
9 | Base crispy form to be used in custom views within the django admin.
10 | """
11 |
12 | section_title = _('No title defined')
13 | button_label = _('Save')
14 |
15 | def __init__(self, *args, **kwargs):
16 | super().__init__(*args, **kwargs)
17 |
18 | # Build fieldset
19 | fieldset_list = ['']
20 | for field in self.fields:
21 | fieldset_list.append(Div(field, css_class='form-row field-name'))
22 |
23 | # Crispy
24 | self.helper = FormHelper()
25 | self.helper.form_method = 'post'
26 | self.helper.add_input(Submit('submit', self.button_label, css_class="button btn-primary"))
27 | self.helper.layout = Layout(
28 | Div(
29 | Div(HTML(f'
{self.section_title}
'), Fieldset(*fieldset_list), css_class='module aligned'),
30 | css_class='custom-form',
31 | ),
32 | )
33 |
--------------------------------------------------------------------------------
/ai_django_core/admin/views/mixins.py:
--------------------------------------------------------------------------------
1 | from django.contrib import admin
2 | from django.core.exceptions import PermissionDenied
3 |
4 |
5 | class AdminViewMixin:
6 | """
7 | Mixin to provide a custom view with all the attributes it needs to look like a regular django admin page.
8 | """
9 |
10 | model = None
11 | admin_page_title = ''
12 |
13 | def has_view_permission(self, user, **kwargs) -> bool:
14 | """
15 | Custom admin views are prone to be left open for all users.
16 | This method is called in the `dispatch()` and ensures the user has the required permissions to access
17 | this view. Defaults to the `is_superuser` flag of the django user.
18 | Can be overwritten if a different permission logic is required.
19 | """
20 | return user.is_superuser
21 |
22 | def dispatch(self, request, *args, **kwargs):
23 | """
24 | Runs a custom validation to ensure user has the permissions to access this page before running the default
25 | dispatch logic.
26 | """
27 | if self.has_view_permission(request.user):
28 | return super().dispatch(request, *args, **kwargs)
29 | raise PermissionDenied
30 |
31 | def get_admin_site(self):
32 | """
33 | Returns the set admin site. Can be overwritten if needed.
34 | """
35 | return admin.site
36 |
37 | def get_context_data(self, **kwargs):
38 | context = super().get_context_data(**kwargs)
39 | admin_site = self.get_admin_site()
40 | context.update(
41 | {
42 | 'site_header': admin_site.site_header,
43 | 'site_title': admin_site.site_title,
44 | 'title': self.admin_page_title,
45 | 'name': self.admin_page_title,
46 | 'original': self.admin_page_title,
47 | 'is_nav_sidebar_enabled': True,
48 | 'available_apps': admin.site.get_app_list(self.request),
49 | 'opts': {
50 | 'app_label': self.model._meta.app_label,
51 | 'verbose_name': self.model._meta.verbose_name,
52 | 'verbose_name_plural': self.model._meta.verbose_name_plural,
53 | 'model_name': self.model._meta.model_name,
54 | 'app_config': {
55 | 'verbose_name': self.model._meta.app_config.verbose_name,
56 | },
57 | },
58 | 'has_permission': admin_site.has_permission(request=self.request),
59 | }
60 | )
61 | return context
62 |
--------------------------------------------------------------------------------
/ai_django_core/apps.py:
--------------------------------------------------------------------------------
1 | from django.apps import AppConfig
2 | from django.utils.translation import gettext_lazy as _
3 |
4 |
5 | class AmbientToolboxConfig(AppConfig):
6 | name = 'ai_django_core'
7 | verbose_name = _('Ambient Toolbox')
8 |
9 | def ready(self):
10 | from ai_django_core.checks import package_deprecation_warning # noqa: F401
11 |
12 | super().ready()
13 |
--------------------------------------------------------------------------------
/ai_django_core/checks.py:
--------------------------------------------------------------------------------
1 | from django.core.checks import Tags, Warning, register
2 |
3 |
4 | @register(Tags.compatibility)
5 | def package_deprecation_warning(*args, **kwargs):
6 | return [
7 | Warning(
8 | "This package is deprecated and was superseded by Ambient Toolbox. ",
9 | hint="Please replace it: https://pypi.org/project/ambient-toolbox/",
10 | obj="ai_django_core",
11 | id="W001",
12 | )
13 | ]
14 |
--------------------------------------------------------------------------------
/ai_django_core/context_manager.py:
--------------------------------------------------------------------------------
1 | class TempDisconnectSignal:
2 | """
3 | Context manager to temporarily disconnect a model from a signal.
4 | Attention: If your signal has a `dispatch_uid` set, you need to pass it to this class!
5 | Use with a "with" tag like this:
6 | ```
7 | with TempDisconnectSignal(**kwargs):
8 | obj.save()
9 | ```
10 | """
11 |
12 | def __init__(self, signal, receiver, sender, dispatch_uid=None):
13 | self.signal = signal
14 | self.receiver = receiver
15 | self.sender = sender
16 | self.dispatch_uid = dispatch_uid
17 |
18 | def __enter__(self):
19 | self.signal.disconnect(
20 | receiver=self.receiver,
21 | sender=self.sender,
22 | dispatch_uid=self.dispatch_uid,
23 | )
24 |
25 | def __exit__(self, type, value, traceback): # noqa A002
26 | self.signal.connect(
27 | receiver=self.receiver,
28 | sender=self.sender,
29 | dispatch_uid=self.dispatch_uid,
30 | )
31 |
--------------------------------------------------------------------------------
/ai_django_core/context_processors.py:
--------------------------------------------------------------------------------
1 | from django.conf import settings
2 |
3 |
4 | def server_settings(request):
5 | return {'DEBUG_MODE': settings.DEBUG, 'SERVER_URL': settings.SERVER_URL}
6 |
--------------------------------------------------------------------------------
/ai_django_core/drf/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ambient-innovation/ai-django-core/35002db94652ba84ece8ec6ae959b12e79ce628f/ai_django_core/drf/__init__.py
--------------------------------------------------------------------------------
/ai_django_core/drf/fields.py:
--------------------------------------------------------------------------------
1 | from rest_framework import serializers
2 |
3 |
4 | class RecursiveField(serializers.Serializer):
5 | """
6 | Custom field for using the same serializer as a field. Useful for foreign keys to the same model.
7 | """
8 |
9 | def update(self, instance, validated_data):
10 | super().update(instance, validated_data)
11 |
12 | def create(self, validated_data):
13 | super().create(validated_data)
14 |
15 | def to_representation(self, value):
16 | # If the field is used with `many=True` we need to go one more parent level up to get the "real"
17 | # parent serializer.
18 | # Explanation: With `many=True` DRF creates an intermediate `ListSerializer`. It has `many=True`, so we'll end
19 | # up in the first if-case. If we do not use `many=True`, the "many" attribute is not set.
20 | if getattr(self.parent, 'many', False):
21 | parent = self.parent.parent
22 | else:
23 | parent = self.parent
24 | serializer = parent.__class__(value, context=self.context)
25 | return serializer.data
26 |
--------------------------------------------------------------------------------
/ai_django_core/drf/serializers.py:
--------------------------------------------------------------------------------
1 | from rest_framework.serializers import ModelSerializer
2 |
3 |
4 | class BaseModelSerializer(ModelSerializer):
5 | def validate(self, data):
6 | """
7 | Call Model's clean() method to ensure model-level-validation
8 | :param data:
9 | :return:
10 | """
11 | cleaned_data = super().validate(data)
12 | instance = self.Meta.model(**cleaned_data)
13 | instance.clean()
14 | return cleaned_data
15 |
16 |
17 | class CommonInfoSerializer(BaseModelSerializer):
18 | """
19 | This serializer should be used for all models that extend "CommonInfo". It adds the data for
20 | `lastmodified_by` and `created_by` when saving a model through the serializer.
21 | This cannot be done in the model's `save` function, since the request is required.
22 | """
23 |
24 | def validate(self, data):
25 | data = super().validate(data)
26 | request = self.context.get('request', None)
27 |
28 | if request.user:
29 | data['lastmodified_by'] = request.user
30 | if not self.instance:
31 | # If this is a new instance, set created_by
32 | data['created_by'] = request.user
33 |
34 | return data
35 |
--------------------------------------------------------------------------------
/ai_django_core/drf/tests.py:
--------------------------------------------------------------------------------
1 | from django.contrib.auth.models import AbstractUser
2 | from rest_framework import status
3 | from rest_framework.response import Response
4 | from rest_framework.test import APIRequestFactory, force_authenticate
5 | from rest_framework.viewsets import GenericViewSet
6 |
7 |
8 | class BaseViewSetTestMixin:
9 | """
10 | Base test mixin to lower the pain of testing API calls. Focuses on ViewSets.
11 | """
12 |
13 | default_api_user = None
14 | view_class = None
15 |
16 | @classmethod
17 | def setUpTestData(cls) -> None:
18 | super().setUpTestData()
19 |
20 | # Create test factory
21 | cls.factory = APIRequestFactory()
22 |
23 | def setUp(self) -> None:
24 | super().setUp()
25 |
26 | # Create API user with proper permissions
27 | self.default_api_user = self.get_default_api_user()
28 |
29 | def get_default_api_user(self) -> AbstractUser:
30 | """
31 | Method to create the API request user in.
32 | """
33 | raise NotImplementedError
34 |
35 | def validate_authentication_required(self, *, url: str, method: str, view: str):
36 | """
37 | Helper method to quickly ensure that a given view needs authorisation.
38 | """
39 | response = self.execute_request(method=method, url=url, viewset_kwargs={method: view})
40 |
41 | self.assertIn(response.status_code, [status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN])
42 |
43 | def execute_request(
44 | self,
45 | *,
46 | url,
47 | view_kwargs=None,
48 | method='get',
49 | data=None,
50 | view_class=None,
51 | user=None,
52 | viewset_kwargs=None,
53 | data_format='json',
54 | ) -> Response:
55 | """
56 | Helper method which wraps all relevant setup to execute a request to the backends api
57 |
58 | view_kwargs: Kwargs from the URL (need to be passed manually to the view class as well)
59 | method: Request method (get, post, delete...)
60 | data: Dict of data to pass to the request, usually for post, put, patch
61 | view_class: Will overwrite the classes default view_class if set
62 | user: User to authenticate the request with. If no user is set, there will be no authentication
63 | viewset_kwargs: ViewSets need a dict to tell it, what action we want to execute
64 | data_format: Format of the sent data, defaults to JSON, might be "multipart" etc.
65 | """
66 |
67 | # Set proper default fallback value for dicts
68 | if view_kwargs is None:
69 | view_kwargs = {}
70 | if viewset_kwargs is None:
71 | viewset_kwargs = {}
72 |
73 | # Create request
74 | request = getattr(self.factory, method)(path=url, data=data, format=data_format)
75 |
76 | # Authentication
77 | if user:
78 | force_authenticate(request, user=user)
79 |
80 | # Check if we want to override the view class for this single call
81 | view_class = view_class if view_class else self.view_class
82 |
83 | # Add viewset kwargs if we have a viewset here
84 | if isinstance(view_class(), GenericViewSet):
85 | view = view_class.as_view(viewset_kwargs)
86 | else:
87 | view = view_class.as_view()
88 |
89 | # Get response
90 | response = view(request, **view_kwargs)
91 |
92 | # Return response
93 | return response
94 |
--------------------------------------------------------------------------------
/ai_django_core/gitlab/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ambient-innovation/ai-django-core/35002db94652ba84ece8ec6ae959b12e79ce628f/ai_django_core/gitlab/__init__.py
--------------------------------------------------------------------------------
/ai_django_core/graphql/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ambient-innovation/ai-django-core/35002db94652ba84ece8ec6ae959b12e79ce628f/ai_django_core/graphql/__init__.py
--------------------------------------------------------------------------------
/ai_django_core/graphql/forms/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ambient-innovation/ai-django-core/35002db94652ba84ece8ec6ae959b12e79ce628f/ai_django_core/graphql/forms/__init__.py
--------------------------------------------------------------------------------
/ai_django_core/graphql/forms/mutations.py:
--------------------------------------------------------------------------------
1 | from django.utils.decorators import method_decorator
2 | from graphene_django.forms.mutation import DjangoModelFormMutation
3 | from graphql import GraphQLError
4 | from graphql_jwt.decorators import login_required
5 | from promise import Promise, is_thenable
6 |
7 |
8 | class DjangoValidatedModelFormMutation(DjangoModelFormMutation):
9 | """
10 | Takes django ModelForm validations and passes them to GraphQL like any other validation error
11 | """
12 |
13 | class Meta:
14 | abstract = True
15 |
16 | @classmethod
17 | def mutate(cls, root, info, input): # noqa A002
18 | """
19 | Handle mutation logic.
20 | Most code derived one-to-one from base class.
21 | """
22 |
23 | def on_resolve(payload):
24 | try:
25 | payload.client_mutation_id = input.get("client_mutation_id")
26 | except Exception as e:
27 | raise Exception(f"Cannot set client_mutation_id in the payload object {repr(payload)}") from e
28 | return payload
29 |
30 | result = cls.mutate_and_get_payload(root, info, **input)
31 |
32 | if result.errors:
33 | err_msg = ''
34 | for err in result.errors:
35 | err_msg += f"Field '{err.field}': {err.messages[0]} "
36 |
37 | raise GraphQLError(err_msg.strip())
38 |
39 | if is_thenable(result):
40 | return Promise.resolve(result).then(on_resolve)
41 |
42 | return on_resolve(result)
43 |
44 |
45 | @method_decorator(login_required, name='perform_mutate')
46 | class LoginRequiredDjangoModelFormMutation(DjangoValidatedModelFormMutation):
47 | """
48 | Ensures that you need to be logged in with GraphQL JWT (json web token) authentication
49 | """
50 |
51 | class Meta:
52 | abstract = True
53 |
--------------------------------------------------------------------------------
/ai_django_core/graphql/schemes/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ambient-innovation/ai-django-core/35002db94652ba84ece8ec6ae959b12e79ce628f/ai_django_core/graphql/schemes/__init__.py
--------------------------------------------------------------------------------
/ai_django_core/graphql/schemes/mutations.py:
--------------------------------------------------------------------------------
1 | import graphene
2 | from django.utils.decorators import method_decorator
3 | from graphql import GraphQLError
4 | from graphql_jwt.decorators import login_required
5 |
6 |
7 | class DeleteMutation(graphene.ClientIDMutation):
8 | """
9 | Provides a mutation for handling common delete cases. Exposes methods for custom validation and queryset filtering.
10 | """
11 |
12 | success = graphene.Boolean()
13 | model = None
14 |
15 | class Meta:
16 | abstract = True
17 |
18 | class Input:
19 | id = graphene.ID()
20 |
21 | @classmethod
22 | def __init_subclass_with_meta__(cls, resolver=None, output=None, arguments=None, _meta=None, model=None, **options):
23 | if not model:
24 | raise AttributeError('DeleteMutation needs a valid model to be set.')
25 | super().__init_subclass_with_meta__(resolver, output, arguments, _meta, **options)
26 | cls.model = model
27 |
28 | @classmethod
29 | def validate(cls, request):
30 | """
31 | Feel free to put any kind of custom validation rules here
32 | """
33 | return True
34 |
35 | @classmethod
36 | def get_queryset(cls, request):
37 | """
38 | Defines the queryset on which the object with the given ID can be chosen
39 | """
40 | return cls.model.objects.all()
41 |
42 | @classmethod
43 | def mutate_and_get_payload(cls, root, info, **input_data):
44 | """
45 | Ensure custom validation, fetch object and delete it afterwards.
46 | """
47 | if not cls.validate(info.context):
48 | raise GraphQLError('Delete method not allowed.')
49 |
50 | # Get object id
51 | object_id = int(input_data.get('id', None))
52 |
53 | # Find and delete object
54 | obj = cls.get_queryset(info.context).get(pk=object_id)
55 | obj.delete()
56 |
57 | # Return success
58 | return DeleteMutation()
59 |
60 |
61 | @method_decorator(login_required, name='mutate_and_get_payload')
62 | class LoginRequiredDeleteMutation(DeleteMutation):
63 | """
64 | Deletes an object from the database.
65 | Ensures user is authenticated with GraphQL-JWT
66 | """
67 |
68 | class Meta:
69 | abstract = True
70 |
--------------------------------------------------------------------------------
/ai_django_core/graphql/sentry/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ambient-innovation/ai-django-core/35002db94652ba84ece8ec6ae959b12e79ce628f/ai_django_core/graphql/sentry/__init__.py
--------------------------------------------------------------------------------
/ai_django_core/graphql/sentry/utils.py:
--------------------------------------------------------------------------------
1 | from sentry_sdk.integrations.logging import ignore_logger
2 |
3 |
4 | def ignore_graphene_logger():
5 | """
6 | A utils function that can be called to ignore a graphene logger that logs errors as strings instead of errors.
7 | This leads to a loss of Sentry's features. Instead, you can report the original exception.
8 |
9 | Test for:
10 | * sentry_sdk >= 0.13.0
11 | """
12 | ignore_logger('graphql.execution.utils')
13 |
--------------------------------------------------------------------------------
/ai_django_core/graphql/sentry/views.py:
--------------------------------------------------------------------------------
1 | import sentry_sdk
2 | from graphene_django.views import GraphQLView
3 |
4 |
5 | class SentryGraphQLView(GraphQLView):
6 | """
7 | Graphene sentries are all merged into a single issue.
8 | This code wraps the normal GraphQLView with a handler that deals with Sentry errors properly.
9 |
10 | Copied from:
11 | https://github.com/phalt/graphene-django-sentry/blob/master/graphene_django_sentry/views.py
12 |
13 | This class was tested with the following versions of these libraries. You must have them installed. If your
14 | version does not meet the requirements this code may break. Use on your own risk.
15 | * sentry_sdk >= 0.13.0
16 | * graphene_django >=2.9.1, <3.0
17 | """
18 |
19 | def execute_graphql_request(self, *args, **kwargs):
20 | """Extract any exceptions and send them to Sentry"""
21 | result = super().execute_graphql_request(*args, **kwargs)
22 |
23 | if result.errors:
24 | self._capture_sentry_exceptions(result.errors)
25 | return result
26 |
27 | def _capture_sentry_exceptions(self, errors):
28 | """
29 | Capture each exception and get its original error to send it to sentry.
30 | """
31 | for error in errors:
32 | try:
33 | sentry_sdk.capture_exception(error.original_error)
34 | except AttributeError:
35 | sentry_sdk.capture_exception(error)
36 |
--------------------------------------------------------------------------------
/ai_django_core/graphql/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ambient-innovation/ai-django-core/35002db94652ba84ece8ec6ae959b12e79ce628f/ai_django_core/graphql/tests/__init__.py
--------------------------------------------------------------------------------
/ai_django_core/graphql/tests/base_test.py:
--------------------------------------------------------------------------------
1 | import json
2 |
3 | from django.test import Client, TestCase
4 |
5 |
6 | class GraphQLTestCase(TestCase):
7 | """
8 | Provides a best-practice wrapper for easily testing GraphQL endpoints.
9 | """
10 |
11 | # URL to graphql endpoint
12 | GRAPHQL_URL = '/graphql/'
13 | # Here you need to set your graphql schema for the tests
14 | GRAPHQL_SCHEMA = None
15 |
16 | @classmethod
17 | def setUpClass(cls):
18 | super().setUpClass()
19 |
20 | if not cls.GRAPHQL_SCHEMA:
21 | raise AttributeError('Variable GRAPHQL_SCHEMA not defined in GraphQLTestCase.')
22 |
23 | cls._client = Client(cls.GRAPHQL_SCHEMA)
24 |
25 | def query(self, query: str, op_name: str = None, input_data: dict = None):
26 | """
27 | :param query: GraphQL query to run
28 | :param op_name: If the query is a mutation or named query, you must supply the op_name.
29 | For annon queries ("{ ... }"), should be None (default).
30 | :param input_data: If provided, the $input variable in GraphQL will be set to this value
31 | :return: Response object from client
32 | """
33 |
34 | body = {'query': query}
35 | if op_name:
36 | body['operation_name'] = op_name
37 | if input_data:
38 | body['variables'] = {'input': input_data}
39 |
40 | resp = self._client.post(self.GRAPHQL_URL, json.dumps(body), content_type='application/json')
41 | return resp
42 |
43 | def assertResponseNoErrors(self, resp): # noqa N802
44 | """
45 | Assert that the call went through correctly. 200 means the syntax is ok, if there are no `errors`,
46 | the call was fine.
47 | :resp HttpResponse: Response
48 | """
49 | content = json.loads(resp.content)
50 | self.assertEqual(resp.status_code, 200)
51 | self.assertNotIn('errors', list(content.keys()))
52 |
53 | def assertResponseHasErrors(self, resp): # noqa N802
54 | """
55 | Assert that the call was failing. Take care: Even with errors, GraphQL returns status 200!
56 | :resp HttpResponse: Response
57 | """
58 | content = json.loads(resp.content)
59 | self.assertIn('errors', list(content.keys()))
60 |
--------------------------------------------------------------------------------
/ai_django_core/locale/de/LC_MESSAGES/django.mo:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ambient-innovation/ai-django-core/35002db94652ba84ece8ec6ae959b12e79ce628f/ai_django_core/locale/de/LC_MESSAGES/django.mo
--------------------------------------------------------------------------------
/ai_django_core/locale/de/LC_MESSAGES/django.po:
--------------------------------------------------------------------------------
1 | # Translations for ai-django-core package
2 | # Copyright (C) 2021 Ambient Innovation: GmbH
3 | # This file is distributed under the same license as the PACKAGE package.
4 | # Ronny Vedrilla, 2021
5 | #
6 | #, fuzzy
7 | msgid ""
8 | msgstr ""
9 | "Project-Id-Version: PACKAGE VERSION\n"
10 | "Report-Msgid-Bugs-To: \n"
11 | "POT-Creation-Date: 2022-08-15 16:12+0200\n"
12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
13 | "Last-Translator: FULL NAME \n"
14 | "Language-Team: LANGUAGE \n"
15 | "Language: \n"
16 | "MIME-Version: 1.0\n"
17 | "Content-Type: text/plain; charset=UTF-8\n"
18 | "Content-Transfer-Encoding: 8bit\n"
19 | "Plural-Forms: nplurals=2; plural=(n != 1);\n"
20 |
21 | #: .\ai_django_core\admin\views\forms.py:12
22 | msgid "No title defined"
23 | msgstr "Kein Titel gesetzt"
24 |
25 | #: .\ai_django_core\admin\views\forms.py:13
26 | #: .\ai_django_core\view_layer\form_mixins.py:16
27 | msgid "Save"
28 | msgstr "Speichern"
29 |
30 | #: .\ai_django_core\mail\services\base.py:42
31 | msgid "Email factory requires a mail service class."
32 | msgstr "E-Mailfactory benötigt eine Mailservice-Klasse."
33 |
34 | #: .\ai_django_core\mail\services\base.py:44
35 | msgid "Email factory requires a target mail address."
36 | msgstr "E-Mailfactory benötigt eine Ziel-Mailadresse."
37 |
38 | #: .\ai_django_core\mail\services\base.py:212
39 | msgid "Missing or mislabeled data provided for email attachment."
40 | msgstr "Fehlende oder falsch deklarierte Daten für E-Mailanhänge übergeben."
41 |
42 | #: .\ai_django_core\mail\services\base.py:269
43 | msgid "Email service requires a subject."
44 | msgstr "E-Mailservice benötigt einen Betreff."
45 |
46 | #: .\ai_django_core\mail\services\base.py:271
47 | msgid "Email service requires a template."
48 | msgstr "E-Mailservice benötigt ein Template."
49 |
50 | #: .\ai_django_core\mail\services\base.py:273
51 | msgid "Email service requires a target mail address."
52 | msgstr "E-Mailservice benötigt eine Ziel-Mailadresse."
53 |
54 | #: .\ai_django_core\models.py:10
55 | msgid "Created at"
56 | msgstr "Erstellt am"
57 |
58 | #: .\ai_django_core\models.py:25
59 | msgid "Created by"
60 | msgstr "Erstellt von"
61 |
62 | #: .\ai_django_core\models.py:31
63 | msgid "Last modified at"
64 | msgstr "Zuletzt geändert am"
65 |
66 | #: .\ai_django_core\models.py:34
67 | msgid "Last modified by"
68 | msgstr "Zuletzt geändert von"
69 |
70 | #: .\ai_django_core\tests\mixins.py:92
71 | msgid "Please pass a user object to RequestProviderMixin."
72 | msgstr "Bitte ein User-Objekt an RequestProviderMixin übergeben."
73 |
74 | #: .\ai_django_core\utils\date.py:74
75 | msgid "Seconds must be positive."
76 | msgstr "Sekunden müssen eine positive Zahl sein."
77 |
78 | #: .\ai_django_core\view_layer\mixins.py:20
79 | msgid ""
80 | "Class-based view using DjangoPermissionRequiredMixin without defining a "
81 | "permission list."
82 | msgstr ""
83 | "Klassen-basierter View verwendet DjangoPermissionRequiredMixin ohne "
84 | "definierte Berechtigungen."
85 |
86 | #: .\ai_django_core\view_layer\tests\mixins.py:31
87 | msgid "BaseViewPermissionTestMixin used without setting a \"view_class\"."
88 | msgstr ""
89 | "BaseViewPermissionTestMixin verwendet ohne eine View-Klasse gesetzt zu haben."
90 |
91 | #: .\testapp\templates\403.html:53
92 | msgid "Error 403"
93 | msgstr "Fehler 403"
94 |
--------------------------------------------------------------------------------
/ai_django_core/mail/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ambient-innovation/ai-django-core/35002db94652ba84ece8ec6ae959b12e79ce628f/ai_django_core/mail/__init__.py
--------------------------------------------------------------------------------
/ai_django_core/mail/backends/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ambient-innovation/ai-django-core/35002db94652ba84ece8ec6ae959b12e79ce628f/ai_django_core/mail/backends/__init__.py
--------------------------------------------------------------------------------
/ai_django_core/mail/backends/whitelist_smtp.py:
--------------------------------------------------------------------------------
1 | import re
2 |
3 | from django.conf import settings
4 | from django.core.mail.backends.smtp import EmailBackend as SMTPEmailBackend
5 |
6 |
7 | class WhitelistEmailBackend(SMTPEmailBackend):
8 | """
9 | Via the following settings it is possible to configure if mails are sent to all domains.
10 | If not, you can configure a redirect to an inbox via CATCHALL.
11 |
12 | EMAIL_BACKEND = 'ai_django_core.mail.backends.whitelist_smtp.WhitelistEmailBackend'
13 | EMAIL_BACKEND_DOMAIN_WHITELIST = ['ambient.digital']
14 | EMAIL_BACKEND_REDIRECT_ADDRESS = '%s@testuser.ambient.digital'
15 |
16 | If `EMAIL_BACKEND_REDIRECT_ADDRESS` is set, a mail to `john.doe@example.com` will be redirected to
17 | `john.doe_example.com@testuser.ambient.digital`
18 | """
19 |
20 | @staticmethod
21 | def get_domain_whitelist() -> list:
22 | """
23 | Getter for configuration variable from the settings.
24 | Will return a list of domains: ['ambient.digital', 'ambient.digital']
25 | """
26 | return getattr(settings, 'EMAIL_BACKEND_DOMAIN_WHITELIST', [])
27 |
28 | @staticmethod
29 | def get_email_regex():
30 | """
31 | Getter for configuration variable from the settings.
32 | Will return a RegEX to match email whitelisted domains.
33 | """
34 | return r'^[\w\-\.]+@(%s)$' % '|'.join(x for x in WhitelistEmailBackend.get_domain_whitelist()).replace(
35 | '.', r'\.'
36 | )
37 |
38 | @staticmethod
39 | def get_backend_redirect_address() -> str:
40 | """
41 | Getter for configuration variable from the settings.
42 | Will return a string with a placeholder for redirecting non-whitelisted domains.
43 | """
44 | return settings.EMAIL_BACKEND_REDIRECT_ADDRESS
45 |
46 | @staticmethod
47 | def whitify_mail_addresses(mail_address_list: list) -> list:
48 | """
49 | Check for every recipient in the list if its domain is included in the whitelist.
50 | If not, and we have a redirect address configured, we change the original mail address to something new,
51 | according to our configuration.
52 | """
53 | allowed_recipients = []
54 | for to in mail_address_list:
55 | if re.search(WhitelistEmailBackend.get_email_regex(), to):
56 | allowed_recipients.append(to)
57 | elif WhitelistEmailBackend.get_backend_redirect_address():
58 | # Send not allowed emails to the configured redirect address (with CATCHALL)
59 | allowed_recipients.append(WhitelistEmailBackend.get_backend_redirect_address() % to.replace('@', '_'))
60 | return allowed_recipients
61 |
62 | def _process_recipients(self, email_messages):
63 | """
64 | Helper method to wrap custom logic of this backend. Required to make it testable.
65 | """
66 | for email in email_messages:
67 | allowed_recipients = self.whitify_mail_addresses(email.to)
68 | email.to = allowed_recipients
69 | return email_messages
70 |
71 | def send_messages(self, email_messages):
72 | """
73 | Checks if email-recipients are in allowed domains and cancels if not.
74 | Uses regular smtp-sending afterwards.
75 | """
76 | email_messages = self._process_recipients(email_messages)
77 | super().send_messages(email_messages)
78 |
--------------------------------------------------------------------------------
/ai_django_core/mail/errors.py:
--------------------------------------------------------------------------------
1 | class EmailServiceConfigError(RuntimeError):
2 | pass
3 |
4 |
5 | class EmailServiceAttachmentError(RuntimeError):
6 | pass
7 |
--------------------------------------------------------------------------------
/ai_django_core/mail/services/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ambient-innovation/ai-django-core/35002db94652ba84ece8ec6ae959b12e79ce628f/ai_django_core/mail/services/__init__.py
--------------------------------------------------------------------------------
/ai_django_core/management/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ambient-innovation/ai-django-core/35002db94652ba84ece8ec6ae959b12e79ce628f/ai_django_core/management/__init__.py
--------------------------------------------------------------------------------
/ai_django_core/management/commands/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ambient-innovation/ai-django-core/35002db94652ba84ece8ec6ae959b12e79ce628f/ai_django_core/management/commands/__init__.py
--------------------------------------------------------------------------------
/ai_django_core/management/commands/validate_test_structure.py:
--------------------------------------------------------------------------------
1 | from django.core.management.base import BaseCommand
2 |
3 | from ai_django_core.tests.structure_validator.test_structure_validator import TestStructureValidator
4 |
5 |
6 | class Command(BaseCommand):
7 | def handle(self, *args, **options):
8 | service = TestStructureValidator()
9 | service.process()
10 |
--------------------------------------------------------------------------------
/ai_django_core/managers.py:
--------------------------------------------------------------------------------
1 | from django.db.models import Manager, QuerySet
2 |
3 |
4 | class AbstractPermissionMixin:
5 | """
6 | Mixin that provides an interface for a basic per-object permission system.
7 | Single objects cannot be checked individually, but can be matched with the corresponding query set.
8 | Please append further methods here, if necessary, in order to make them accessible at all inheriting classes
9 | (query sets AND managers).
10 | """
11 |
12 | def visible_for(self, user):
13 | raise NotImplementedError('Please implement this method')
14 |
15 | def editable_for(self, user):
16 | raise NotImplementedError('Please implement this method')
17 |
18 | def deletable_for(self, user):
19 | raise NotImplementedError('Please implement this method')
20 |
21 |
22 | class AbstractUserSpecificQuerySet(QuerySet, AbstractPermissionMixin):
23 | """
24 | Extend this queryset in your model if you want to implement a visible_for functionality.
25 | """
26 |
27 | def default(self, user):
28 | return self
29 |
30 | def visible_for(self, user):
31 | raise NotImplementedError('Please implement this method')
32 |
33 | def editable_for(self, user):
34 | raise NotImplementedError('Please implement this method')
35 |
36 | def deletable_for(self, user):
37 | raise NotImplementedError('Please implement this method')
38 |
39 |
40 | class AbstractUserSpecificManager(Manager, AbstractPermissionMixin):
41 | """
42 | The UserSpecificQuerySet has a method 'as_manger', which can be used for creating a default manager,
43 | which inherits all methods of the queryset and invokes the respective method of it's queryset, respectively.
44 | If the manager has to be declared separately for some reasons, all queryset methods, have to be declared twice,
45 | once in the QuerySet, once in the manager class.
46 | For consistency reasons, both inherit from the same mixin, to ensure the equality of the method's names.
47 | """
48 |
49 | def visible_for(self, user):
50 | return self.get_queryset().visible_for(user)
51 |
52 | def editable_for(self, user):
53 | return self.get_queryset().editable_for(user)
54 |
55 | def deletable_for(self, user):
56 | return self.get_queryset().deletable_for(user)
57 |
58 |
59 | class GloballyVisibleQuerySet(AbstractUserSpecificQuerySet):
60 | """
61 | Manager (QuerySet) for classes which do NOT have any visibility restrictions.
62 | """
63 |
64 | def visible_for(self, user):
65 | return self.all()
66 |
67 | def editable_for(self, user):
68 | return self.visible_for(user)
69 |
70 | def deletable_for(self, user):
71 | return self.visible_for(user)
72 |
--------------------------------------------------------------------------------
/ai_django_core/middleware/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ambient-innovation/ai-django-core/35002db94652ba84ece8ec6ae959b12e79ce628f/ai_django_core/middleware/__init__.py
--------------------------------------------------------------------------------
/ai_django_core/middleware/current_user.py:
--------------------------------------------------------------------------------
1 | from threading import local
2 |
3 | try:
4 | # Mixin for compatible middleware (to be refactored when all projects use Django 2):
5 | # https://docs.djangoproject.com/en/2.1/topics/http/middleware/#writing-your-own-middleware
6 | from django.utils.deprecation import MiddlewareMixin
7 | except ImportError:
8 | MiddlewareMixin = object
9 |
10 | _user = local()
11 |
12 |
13 | class CurrentUserMiddleware(MiddlewareMixin):
14 | """
15 | Middleware which stores request's user into global thread-safe variable.
16 | Must be introduced AFTER `django.contrib.auth.middleware.AuthenticationMiddleware`.
17 | """
18 |
19 | def process_request(self, request):
20 | _user.value = request.user
21 |
22 | def process_response(self, request, response):
23 | # this cleanup is required e.g. when running tests single-threaded
24 | try:
25 | del _user.value
26 | except AttributeError:
27 | pass
28 | return response
29 |
30 | @staticmethod
31 | def get_current_user():
32 | if hasattr(_user, 'value') and _user.value:
33 | return _user.value
34 |
--------------------------------------------------------------------------------
/ai_django_core/mixins/__init__.py:
--------------------------------------------------------------------------------
1 | __author__ = 'dennismosemann'
2 |
--------------------------------------------------------------------------------
/ai_django_core/mixins/bleacher.py:
--------------------------------------------------------------------------------
1 | import bleach
2 |
3 |
4 | class BleacherMixin:
5 | """
6 | Removes HTML tags and attributes from the fields defined in :py:attr:`BLEACH_FIELD_LIST`.
7 | Allowed tags and attributes are defined in the :py:attr:`ALLOWED_TAGS` and :py:attr:`ALLOWED_ATTRIBUTES` attributes.
8 | If :py:attr:`ALLOWED_TAGS` and :py:attr:`ALLOWED_ATTRIBUTES` are not specified on a model a set of default value is
9 | used:
10 |
11 | ALLOWED_TAGS:
12 | * span
13 | * p
14 | * h1
15 | * h2
16 | * h3
17 | * h4
18 | * h5
19 | * h6
20 | * img
21 | * div
22 | * u
23 | * br
24 | * blockquote
25 |
26 | ALLOWED_ATTRIBUTES:
27 | * all tags: class, style, id
28 | * a: href, rel
29 | * img: alt, src
30 | """
31 |
32 | BLEACH_FIELD_LIST = []
33 |
34 | DEFAULT_ALLOWED_ATTRIBUTES = {
35 | '*': ['class', 'style', 'id'],
36 | 'a': ['href', 'rel'],
37 | 'img': ['alt', 'src'],
38 | }
39 |
40 | DEFAULT_ALLOWED_TAGS = bleach.ALLOWED_TAGS + [
41 | 'span',
42 | 'p',
43 | 'h1',
44 | 'h2',
45 | 'h3',
46 | 'h4',
47 | 'h5',
48 | 'h6',
49 | 'img',
50 | 'div',
51 | 'u',
52 | 'br',
53 | 'blockquote',
54 | ]
55 |
56 | def __init__(self, *args, **kwargs):
57 | super().__init__(*args, **kwargs)
58 | self.fields_to_bleach = getattr(self, 'BLEACH_FIELD_LIST', [])
59 | self.allowed_tags = getattr(self, 'ALLOWED_TAGS', self.DEFAULT_ALLOWED_TAGS)
60 | self.allowed_attributes = getattr(self, 'ALLOWED_ATTRIBUTES', self.DEFAULT_ALLOWED_ATTRIBUTES)
61 |
62 | def _bleach_field(self, field_name):
63 | str_to_bleach = getattr(self, field_name, '')
64 | if str_to_bleach:
65 | cleaned_value = bleach.clean(str_to_bleach, tags=self.allowed_tags, attributes=self.allowed_attributes)
66 | setattr(self, field_name, cleaned_value)
67 |
68 | def save(self, *args, **kwargs):
69 | for field in self.fields_to_bleach:
70 | self._bleach_field(field)
71 |
72 | super().save(*args, **kwargs)
73 |
--------------------------------------------------------------------------------
/ai_django_core/mixins/models.py:
--------------------------------------------------------------------------------
1 | from django.db.models.signals import post_save, pre_save
2 |
3 |
4 | class PermissionModelMixin:
5 | """
6 | Abstract model class to put permissions in which don't belong to a "real" database model.
7 | Take care that you have to set "Meta.default_permissions" to an empty tuple/list if you want to avoid having the
8 | Django default permissions created.
9 | Inspiration / Source: https://stackoverflow.com/a/37988537/1331671
10 | """
11 |
12 | class Meta:
13 | # No database table creation or deletion operations will be performed for this model.
14 | managed = False
15 |
16 |
17 | class SaveWithoutSignalsMixin:
18 | """
19 | Mixin to provide a save method that temporarily unhooks all signals and restores them after save/error
20 | """
21 |
22 | def save_without_signals(self, *args, **kwargs):
23 | # Temporarily store signal receivers to restore them later
24 | pre_save_receivers = pre_save.receivers
25 | post_save_receivers = post_save.receivers
26 |
27 | # Clear signal receivers to make sure, that no signal is triggered when .save() is called
28 | pre_save.receivers = []
29 | post_save.receivers = []
30 |
31 | # Save without any signals
32 | instance = self.save(*args, **kwargs)
33 |
34 | # Restore signal receivers
35 | pre_save.receivers = pre_save_receivers
36 | post_save.receivers = post_save_receivers
37 |
38 | return instance
39 |
--------------------------------------------------------------------------------
/ai_django_core/mixins/validation.py:
--------------------------------------------------------------------------------
1 | class CleanOnSaveMixin:
2 | """
3 | Mixin which ensures model-level validation ("clean()") is called on saving the current instance.
4 | """
5 |
6 | def save(self, *args, **kwargs):
7 | self.clean()
8 | super().save(*args, **kwargs)
9 |
--------------------------------------------------------------------------------
/ai_django_core/models.py:
--------------------------------------------------------------------------------
1 | from django.conf import settings
2 | from django.db import models
3 | from django.utils.timezone import now
4 | from django.utils.translation import gettext_lazy as _
5 |
6 | from ai_django_core.middleware.current_user import CurrentUserMiddleware
7 |
8 |
9 | class CreatedAtInfo(models.Model):
10 | created_at = models.DateTimeField(_("Created at"), default=now, db_index=True)
11 |
12 | class Meta:
13 | abstract = True
14 |
15 | def save(self, *args, **kwargs):
16 | # just a fallback for old data
17 | if not self.created_at:
18 | self.created_at = now()
19 | super().save(*args, **kwargs)
20 |
21 |
22 | class CommonInfo(CreatedAtInfo, models.Model):
23 | # Automatically add the model's fields to the 'update_fields' list if specified on save()
24 | ALWAYS_UPDATE_FIELDS = True
25 |
26 | created_by = models.ForeignKey(
27 | settings.AUTH_USER_MODEL,
28 | verbose_name=_("Created by"),
29 | blank=True,
30 | null=True,
31 | related_name="%(app_label)s_%(class)s_created",
32 | on_delete=models.SET_NULL,
33 | )
34 | lastmodified_at = models.DateTimeField(_("Last modified at"), default=now, db_index=True)
35 | lastmodified_by = models.ForeignKey(
36 | settings.AUTH_USER_MODEL,
37 | verbose_name=_("Last modified by"),
38 | blank=True,
39 | null=True,
40 | related_name="%(app_label)s_%(class)s_lastmodified",
41 | on_delete=models.SET_NULL,
42 | )
43 |
44 | class Meta:
45 | abstract = True
46 |
47 | def save(self, *args, **kwargs):
48 | self.lastmodified_at = now()
49 | current_user = self.get_current_user()
50 | self.set_user_fields(current_user)
51 |
52 | # Handle case that somebody only wants to update some fields
53 | if 'update_fields' in kwargs and self.ALWAYS_UPDATE_FIELDS:
54 | kwargs['update_fields'] += ('lastmodified_at', 'lastmodified_by', 'created_at', 'created_by')
55 |
56 | super().save(*args, **kwargs)
57 |
58 | @staticmethod
59 | def get_current_user():
60 | """
61 | Get the currently logged-in user over middleware.
62 | Can be overwritten to use e.g. other middleware or additional functionality.
63 | :return: user instance
64 | """
65 | return CurrentUserMiddleware.get_current_user()
66 |
67 | def set_user_fields(self, user):
68 | """
69 | Set user-related fields before saving the instance.
70 | If no user with primary key is given the fields are not set.
71 | :param user: user instance of current user
72 | """
73 | if user and user.pk:
74 | if not self.pk:
75 | self.created_by = user
76 | self.lastmodified_by = user
77 |
--------------------------------------------------------------------------------
/ai_django_core/selectors/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ambient-innovation/ai-django-core/35002db94652ba84ece8ec6ae959b12e79ce628f/ai_django_core/selectors/__init__.py
--------------------------------------------------------------------------------
/ai_django_core/selectors/base.py:
--------------------------------------------------------------------------------
1 | from django.db.models import manager
2 |
3 |
4 | class Selector(manager.Manager):
5 | """
6 | This is the base class for query selectors. Please refer to the docs for further enlightenment how this novel
7 | pattern works.
8 | It's derived from the manager to use Django's magic to inject the current class into the "model" attribute.
9 | """
10 |
11 | model = None
12 |
--------------------------------------------------------------------------------
/ai_django_core/selectors/permission.py:
--------------------------------------------------------------------------------
1 | from django.db.models import QuerySet
2 |
3 | from ai_django_core.selectors.base import Selector
4 |
5 |
6 | class AbstractUserSpecificSelectorMixin:
7 | """
8 | Abstract selector mixin to inherit from to implement a basic permission pattern.
9 | Refer to the documentation for further details.
10 | """
11 |
12 | def visible_for(self, user_id: int) -> QuerySet:
13 | raise NotImplementedError
14 |
15 | def editable_for(self, user_id: int) -> QuerySet:
16 | raise NotImplementedError
17 |
18 | def deletable_for(self, user_id: int) -> QuerySet:
19 | raise NotImplementedError
20 |
21 |
22 | class GloballyVisibleSelector(AbstractUserSpecificSelectorMixin, Selector):
23 | """
24 | Selector for classes which do NOT have any visibility restrictions. Use with caution!
25 | """
26 |
27 | def visible_for(self, user_id: int) -> QuerySet:
28 | return self.all()
29 |
30 | def editable_for(self, user_id: int) -> QuerySet:
31 | return self.visible_for(user_id=user_id)
32 |
33 | def deletable_for(self, user_id: int) -> QuerySet:
34 | return self.visible_for(user_id=user_id)
35 |
--------------------------------------------------------------------------------
/ai_django_core/sentry/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ambient-innovation/ai-django-core/35002db94652ba84ece8ec6ae959b12e79ce628f/ai_django_core/sentry/__init__.py
--------------------------------------------------------------------------------
/ai_django_core/sentry/helpers.py:
--------------------------------------------------------------------------------
1 | def strip_sensitive_data_from_sentry_event(event, hint):
2 | """
3 | Helper method to strip sensitive user data from default sentry event when "send_default_pii" is set to True.
4 | All user-related data except the internal user id will be removed.
5 | Variable "hint" contains information about the error itself which we don't need here.
6 | Requires "sentry-sdk>=1.5.0" to work.
7 | """
8 | try:
9 | del event['user']['username']
10 | except KeyError:
11 | pass
12 | try:
13 | del event['user']['email']
14 | except KeyError:
15 | pass
16 | try:
17 | del event['user']['ip_address']
18 | except KeyError:
19 | pass
20 | return event
21 |
--------------------------------------------------------------------------------
/ai_django_core/services/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ambient-innovation/ai-django-core/35002db94652ba84ece8ec6ae959b12e79ce628f/ai_django_core/services/__init__.py
--------------------------------------------------------------------------------
/ai_django_core/services/custom_scrubber.py:
--------------------------------------------------------------------------------
1 | import logging
2 |
3 | from django.conf import settings
4 | from django.contrib.admin.models import LogEntry
5 | from django.contrib.auth.hashers import make_password
6 | from django.contrib.sessions.models import Session
7 | from django.core.management import call_command
8 | from django.db import connections
9 |
10 |
11 | class AbstractScrubbingService:
12 | DEFAULT_USER_PASSWORD = 'Admin0404!'
13 |
14 | # Overwritable values
15 | keep_session_data = False
16 | keep_scrubber_data = False
17 | keep_django_admin_log = False
18 | pre_scrub_functions = []
19 | post_scrub_functions = []
20 |
21 | def __init__(self):
22 | self._logger = logging.getLogger('django_scrubber')
23 |
24 | def _get_hashed_default_password(self):
25 | return make_password(self.DEFAULT_USER_PASSWORD)
26 |
27 | def _validation(self):
28 | if not settings.DEBUG:
29 | self._logger.warning('Attention! Has to run in DEBUG mode!')
30 | return False
31 |
32 | if 'django_scrubber' not in settings.INSTALLED_APPS:
33 | self._logger.warning('Attention! django-scrubber needs to be installed!')
34 | return False
35 |
36 | if 'django_scrubber' not in list(settings.LOGGING['loggers'].keys()):
37 | self._logger.warning('Attention! Logging for django-scrubber is not activated!')
38 |
39 | return True
40 |
41 | def process(self):
42 | self._logger.info('Start scrubbing process...')
43 |
44 | self._logger.info('Validating setup...')
45 | if not self._validation():
46 | self._logger.warning('Aborting process!')
47 | return False
48 |
49 | # Custom pre-scrubbing
50 | for name in self.pre_scrub_functions:
51 | self._logger.info(f'Pre-Scrubbing: Calling "{name}()"...')
52 | method = getattr(self, name)
53 | method()
54 |
55 | self._logger.info('Scrubbing data with "scrub_data"...')
56 | call_command('scrub_data')
57 |
58 | # Custom post-scrubbing
59 | for name in self.post_scrub_functions:
60 | self._logger.info(f'Post-Scrubbing: Calling "{name}()"...')
61 | method = getattr(self, name)
62 | method()
63 |
64 | # Empty django admin log (may contain user-related data)
65 | if not self.keep_django_admin_log:
66 | LogEntry.objects.all().delete()
67 |
68 | # Clear django session table
69 | if not self.keep_session_data:
70 | self._logger.info('Clearing data from "django_session" ...')
71 | # Sessions might contain private information and furthermore cannot be used anyway because we anonymised
72 | # all the users. Therefore it is being cleared by default
73 | Session.objects.all().delete()
74 |
75 | # Reset scrubber data to avoid huge db dumps
76 | if not self.keep_scrubber_data:
77 | self._logger.info('Clearing data from "django_scrubber_fakedata" ...')
78 | # We truncate and don't scrub because the table is huge and clearing on object-level might take a while.
79 | # Furthermore can we avoid having a direct dependency to django-scrubber this way.
80 | cursor = connections['default'].cursor()
81 | cursor.execute('TRUNCATE TABLE django_scrubber_fakedata;')
82 |
83 | self._logger.info('Scrubbing finished!')
84 |
85 | return True
86 |
--------------------------------------------------------------------------------
/ai_django_core/templatetags/__init__.py:
--------------------------------------------------------------------------------
1 | __author__ = 'dennismosemann'
2 |
--------------------------------------------------------------------------------
/ai_django_core/templatetags/ai_date_tags.py:
--------------------------------------------------------------------------------
1 | from django import template
2 |
3 | register = template.Library()
4 |
5 |
6 | @register.filter
7 | def format_to_minutes(time):
8 | """
9 | Converts the seconds to minutes
10 |
11 | :param time:
12 | :return minutes:
13 | """
14 |
15 | return time.seconds // 60
16 |
--------------------------------------------------------------------------------
/ai_django_core/templatetags/ai_email_tags.py:
--------------------------------------------------------------------------------
1 | from django import template
2 | from django.template.defaultfilters import stringfilter
3 | from django.utils.safestring import mark_safe
4 |
5 | register = template.Library()
6 |
7 |
8 | def obfuscate_string(value):
9 | return ''.join([f'{str(ord(char)):s};' for char in value])
10 |
11 |
12 | @register.filter
13 | @stringfilter
14 | def obfuscate(value):
15 | return mark_safe(obfuscate_string(value))
16 |
17 |
18 | @register.filter
19 | @stringfilter
20 | def obfuscate_mailto(value, text=False):
21 | mail = obfuscate_string(value)
22 |
23 | if text:
24 | link_text = text
25 | else:
26 | link_text = mail
27 |
28 | return mark_safe('{:s}'.format(obfuscate_string('mailto:'), mail, link_text))
29 |
--------------------------------------------------------------------------------
/ai_django_core/templatetags/ai_file_tags.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | from django import template
4 | from django.conf import settings
5 |
6 | register = template.Library()
7 |
8 |
9 | @register.filter
10 | def filename(value, max_length=25):
11 | """
12 | Shortens the filename to maxlength 25 without loosing the file extension
13 |
14 | :param value:
15 | :param max_length:
16 | :return filename with a max length of 25
17 | """
18 | name = os.path.basename(value.url)
19 | if len(name) > max_length:
20 | ext = name.split('.')[-1]
21 | name = f"{name[:max_length]}[..].{ext}"
22 | return name
23 |
24 |
25 | @register.filter
26 | def filesize(value):
27 | """
28 | Returns the filesize of the filename given in value
29 |
30 | :param value:
31 | :return filesize:
32 | """
33 | try:
34 | return os.path.getsize(f"{settings.MEDIA_ROOT}{value}")
35 | except Exception:
36 | return 0
37 |
--------------------------------------------------------------------------------
/ai_django_core/templatetags/ai_helper_tags.py:
--------------------------------------------------------------------------------
1 | from django import template
2 | from django.utils import timezone
3 |
4 | register = template.Library()
5 |
6 |
7 | @register.simple_tag
8 | def js_versiontag():
9 | return "?s=%s" % timezone.now().microsecond
10 |
--------------------------------------------------------------------------------
/ai_django_core/templatetags/ai_number_tags.py:
--------------------------------------------------------------------------------
1 | from django import template
2 |
3 | register = template.Library()
4 |
5 |
6 | @register.filter(name='multiply')
7 | def multiply(value, arg):
8 | """
9 | Multiplies the arg and the value
10 |
11 | :param value:
12 | :param arg:
13 | :return:
14 | """
15 | if value:
16 | value = "%s" % value
17 | if type(value) is str and len(value) > 0:
18 | return float(value.replace(",", ".")) * float(arg)
19 |
20 | return None
21 |
22 |
23 | @register.filter(name='subtract')
24 | def subtract(value, arg):
25 | """
26 | Subtracts the arg from the value
27 |
28 | :param value:
29 | :param arg:
30 | :return:
31 | """
32 | value = value if value is not None else 0
33 | arg = value if arg is not None else 0
34 | return int(value) - int(arg)
35 |
36 |
37 | @register.filter(name='divide')
38 | def divide(value, arg):
39 | """
40 | Divides the value by the arg
41 |
42 | :param value:
43 | :param arg:
44 | :return:
45 | """
46 | if value:
47 | return value / arg
48 | else:
49 | return None
50 |
51 |
52 | @register.filter(name='to_int')
53 | def to_int(value):
54 | """
55 | Parses a string to int value
56 |
57 | :param value:
58 | :return:
59 | """
60 | return int(value) if value else 0
61 |
62 |
63 | @register.filter(name="currency")
64 | def currency(value):
65 | """
66 | Converts the number to an €-amount
67 | """
68 | if value:
69 | return (("%.2f" % round(value, 2)) + "€").replace(".", ",")
70 | else:
71 | return "-"
72 |
--------------------------------------------------------------------------------
/ai_django_core/templatetags/ai_object_tags.py:
--------------------------------------------------------------------------------
1 | from django import template
2 |
3 | register = template.Library()
4 |
5 |
6 | @register.simple_tag
7 | def dict_key_lookup(the_dict, key):
8 | """
9 | Checks if the given key exists in given dict
10 |
11 | :param the_dict:
12 | :param key:
13 | :return: str
14 | """
15 | return the_dict.get(key, '')
16 |
17 |
18 | @register.filter(name='label')
19 | def label(value):
20 | return value.field.__class__.__name__
21 |
--------------------------------------------------------------------------------
/ai_django_core/templatetags/ai_string_tags.py:
--------------------------------------------------------------------------------
1 | from django import template
2 | from django.template.defaultfilters import stringfilter
3 |
4 | register = template.Library()
5 |
6 |
7 | @register.filter(name='get_first_char')
8 | def get_first_char(value):
9 | """
10 | Returns the first char of the given string
11 | :param value:
12 | :return:
13 | """
14 | return value[:1]
15 |
16 |
17 | @register.filter(name='concat')
18 | def concat(obj, value: str) -> str:
19 | """
20 | Concatenates the two given strings
21 | """
22 | return f"{obj}{value}"
23 |
24 |
25 | @register.filter
26 | @stringfilter
27 | def trim(value):
28 | """
29 | Strips the whitespaces of the given value
30 |
31 | :param value:
32 | :return:
33 | """
34 | return value.strip()
35 |
--------------------------------------------------------------------------------
/ai_django_core/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ambient-innovation/ai-django-core/35002db94652ba84ece8ec6ae959b12e79ce628f/ai_django_core/tests/__init__.py
--------------------------------------------------------------------------------
/ai_django_core/tests/errors.py:
--------------------------------------------------------------------------------
1 | class TestSetupConfigurationError(RuntimeError):
2 | pass
3 |
--------------------------------------------------------------------------------
/ai_django_core/tests/structure_validator/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ambient-innovation/ai-django-core/35002db94652ba84ece8ec6ae959b12e79ce628f/ai_django_core/tests/structure_validator/__init__.py
--------------------------------------------------------------------------------
/ai_django_core/tests/structure_validator/settings.py:
--------------------------------------------------------------------------------
1 | from django.conf import settings
2 |
3 | # Test validator
4 | TEST_STRUCTURE_VALIDATOR_FILE_WHITELIST = []
5 | try:
6 | TEST_STRUCTURE_VALIDATOR_BASE_DIR = settings.BASE_DIR
7 | except AttributeError:
8 | TEST_STRUCTURE_VALIDATOR_BASE_DIR = ''
9 | TEST_STRUCTURE_VALIDATOR_BASE_APP_NAME = 'apps'
10 | TEST_STRUCTURE_VALIDATOR_APP_LIST = settings.INSTALLED_APPS
11 | TEST_STRUCTURE_VALIDATOR_IGNORED_DIRECTORY_LIST = []
12 |
--------------------------------------------------------------------------------
/ai_django_core/utils/__init__.py:
--------------------------------------------------------------------------------
1 | from .cache import * # noqa: F403, F401
2 | from .date import * # noqa: F403, F401
3 | from .log_whodid import * # noqa: F403, F401
4 | from .math import * # noqa: F403, F401
5 | from .model import * # noqa: F403, F401
6 | from .named_tuple import * # noqa: F403, F401
7 |
--------------------------------------------------------------------------------
/ai_django_core/utils/cache.py:
--------------------------------------------------------------------------------
1 | from django.core.cache import cache
2 |
3 |
4 | def clear_cache():
5 | """
6 | Clears the django cache
7 | """
8 | cache.clear()
9 |
--------------------------------------------------------------------------------
/ai_django_core/utils/file.py:
--------------------------------------------------------------------------------
1 | import hashlib
2 | import zlib
3 |
4 |
5 | def get_filename_without_ending(file_path: str) -> str:
6 | """
7 | Returns the filename without extension
8 | :param file_path:
9 | :return:
10 | """
11 |
12 | # if filename has file_path parts
13 | if '/' in file_path:
14 | filename = file_path.rsplit('/')[-1]
15 | else:
16 | filename = file_path
17 |
18 | return filename.rsplit('.', 1)[0]
19 |
20 |
21 | def crc(file_path: str) -> str:
22 | """Calculates the cyclic redundancy checksum (CRC) of the given file.
23 |
24 | See ``open`` for all the exceptins that can be raised.
25 |
26 | :param file_path: the file for which the CRC checksum should be calculated.
27 | :return: returns the CRC checksum of the file in hexadecimal format (8 characters).
28 | """
29 | prev = 0
30 | with open(file_path, "rb") as f:
31 | for line in f:
32 | prev = zlib.crc32(line, prev)
33 | return "%08X" % (prev & 0xFFFFFFFF)
34 |
35 |
36 | def md5_checksum(file_path: str) -> str:
37 | """
38 | Returns the md5 checksum of the file from the given file_path.
39 |
40 | See ``open`` for all the exceptins that can be raised.
41 |
42 | :param file_path: the file for which the MD5 hashsum should be calculated.
43 | :return: returns the MD5 of the file in hexadecimal format.
44 | """
45 | with open(file_path, 'rb') as fh:
46 | m = hashlib.md5()
47 | while True:
48 | data = fh.read(8192)
49 | if not data:
50 | break
51 | m.update(data)
52 | return m.hexdigest()
53 |
--------------------------------------------------------------------------------
/ai_django_core/utils/log_whodid.py:
--------------------------------------------------------------------------------
1 | from django.db import models
2 |
3 |
4 | def log_whodid(obj: models.Model, user) -> None:
5 | """
6 | Stores the given user as creator or editor of the given object
7 | """
8 | if hasattr(obj, 'created_by') and obj.created_by is None:
9 | obj.created_by = user
10 |
11 | if hasattr(obj, 'lastmodified_by'):
12 | obj.lastmodified_by = user
13 |
--------------------------------------------------------------------------------
/ai_django_core/utils/math.py:
--------------------------------------------------------------------------------
1 | from math import ceil
2 |
3 |
4 | def round_to_decimal(value, precision: float = 0.5) -> float:
5 | """
6 | Helper function to round a given value to a specific precision, for example *.5
7 | So 5.4 will be rounded to 5.5
8 | """
9 | return round(precision * round(float(value) / precision), 1)
10 |
11 |
12 | def round_up_decimal(value, precision: float = 0.5) -> float:
13 | """
14 | Helper function to round a given value up a specific precision, for example *.5
15 | So 5.4 will be rounded to 5.5 and 5.6 to 6.0
16 | """
17 | return ceil(value * (1 / precision)) / (1 / precision)
18 |
--------------------------------------------------------------------------------
/ai_django_core/utils/model.py:
--------------------------------------------------------------------------------
1 | from django.db.models import ForeignKey
2 |
3 |
4 | def object_to_dict(obj, blacklisted_fields: list = None, include_id: bool = False) -> dict:
5 | """
6 | Returns a dict with all data defined in the model class as a key-value-dict
7 | Attention: Does not work for M2M fields!
8 | """
9 |
10 | # Default blacklist
11 | blacklisted_fields = blacklisted_fields if blacklisted_fields else []
12 |
13 | # Add default django primary key to blacklist
14 | if not include_id:
15 | blacklisted_fields.append('id')
16 |
17 | data = vars(obj)
18 | valid_data = {}
19 |
20 | valid_fields = []
21 | for f in obj.__class__._meta.get_fields():
22 | if type(f) != ForeignKey:
23 | valid_fields.append(f.name)
24 | else:
25 | valid_fields.append(f'{f.name}_id')
26 |
27 | for key, value in list(data.items()):
28 | if key in valid_fields and key not in blacklisted_fields:
29 | valid_data[key] = value
30 |
31 | return valid_data
32 |
--------------------------------------------------------------------------------
/ai_django_core/utils/string.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | import os
3 | from typing import Optional, Union
4 |
5 | from django.contrib.humanize.templatetags.humanize import intcomma
6 | from django.template.defaultfilters import floatformat, slugify
7 | from django.utils.encoding import smart_str
8 |
9 | from ai_django_core.utils import datetime_format
10 |
11 |
12 | def distinct(not_distinct_list: list) -> list:
13 | """
14 | Returns a list with no duplicate elements
15 | """
16 | return list(set(not_distinct_list))
17 |
18 |
19 | def slugify_file_name(file_name: str, length: int = 40) -> str:
20 | """
21 | Slugify the given file name
22 | """
23 | name, ext = os.path.splitext(file_name)
24 | name = smart_str(slugify(name).replace('-', '_'))
25 | ext = smart_str(slugify(ext))
26 | result = '{}{}{}'.format(name[:length], "." if ext else "", ext)
27 | return result
28 |
29 |
30 | def smart_truncate(text: str, max_length: int = 100, suffix: str = '...') -> str:
31 | """
32 | Returns a string of at most `max_length` characters, cutting
33 | only at word-boundaries. If the string was truncated, `suffix`
34 | will be appended.
35 | In comparison to Djangos default filter `truncatechars` this method does NOT break words and you
36 | can choose a custom suffix.
37 | """
38 | if text is None:
39 | return ''
40 |
41 | # Return the string itself if length is smaller or equal to the limit
42 | if len(text) <= max_length:
43 | return text
44 |
45 | # Cut the string
46 | value = text[:max_length]
47 |
48 | # Break into words and remove the last
49 | words = value.split(' ')[:-1]
50 |
51 | # Join the words and return
52 | return ' '.join(words) + suffix
53 |
54 |
55 | def float_to_string(value: Optional[float], replacement: str = "0,00") -> str:
56 | """
57 | Converts a float to a properly, German-formatted string value
58 | # todo name is misleading, should contain "de"
59 | # todo thousand separator would be nice
60 | If the passed object is None, it will return `replacement`.
61 | """
62 | return ("%.2f" % value).replace('.', ',') if value is not None else replacement
63 |
64 |
65 | def date_to_string(value: Optional[datetime.date], replacement: str = "-", str_format: str = "%d.%m.%Y") -> str:
66 | """
67 | Converts a given date "value" to a string formatted with `str_format`.
68 | If the passed "value" is None, it will return `replacement`.
69 | """
70 | return value.strftime(str_format) if value is not None else replacement
71 |
72 |
73 | def datetime_to_string(
74 | value: Optional[datetime.datetime], replacement: str = "-", str_format: str = "%d.%m.%Y %H:%M"
75 | ) -> str:
76 | """
77 | Converts a given datetime "value" to a string formatted with `str_format`.
78 | If the passed "value" is None, it will return `replacement`.
79 | """
80 | return datetime_format(value, str_format) if value is not None else replacement
81 |
82 |
83 | def number_to_string(value: Optional[Union[int, float]], decimal_digits: int = 0, replacement: str = "-") -> str:
84 | """
85 | Converts a given int or float number to a string. Decimal places can be configured via `decimal_digits`.
86 | If the passed "value" is None, it will return `replacement`.
87 | Attention: Will not localise the return value!
88 | """
89 | return intcomma(floatformat(value, decimal_digits)) if value is not None else replacement
90 |
91 |
92 | def string_or_none_to_string(value: Optional[any], replacement: str = "-") -> str:
93 | """
94 | Converts a given "value" to a string.
95 | If the passed "value" is None, it will return `replacement`.
96 | """
97 | return value if value is not None else replacement
98 |
99 |
100 | def encode_to_xml(text: str) -> str:
101 | """
102 | Encodes ampersand, greater and lower characters in a given string to HTML-entities.
103 | """
104 | text_str = str(text)
105 | text_str = text_str.replace('&', '&')
106 | text_str = text_str.replace('<', '<')
107 | text_str = text_str.replace('>', '>')
108 |
109 | return text_str
110 |
--------------------------------------------------------------------------------
/ai_django_core/view_layer/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ambient-innovation/ai-django-core/35002db94652ba84ece8ec6ae959b12e79ce628f/ai_django_core/view_layer/__init__.py
--------------------------------------------------------------------------------
/ai_django_core/view_layer/form_mixins.py:
--------------------------------------------------------------------------------
1 | from crispy_forms.helper import FormHelper
2 | from crispy_forms.layout import Submit
3 | from django.utils.translation import gettext_lazy as _
4 |
5 |
6 | class CrispyLayoutFormMixin:
7 | """
8 | Styles the form in bootstrap style
9 | """
10 |
11 | def __init__(self, *args, **kwargs):
12 | # Crispy
13 | self.helper = FormHelper()
14 | self.helper.form_class = 'form-horizontal form-bordered form-row-stripped'
15 | self.helper.form_method = 'post'
16 | self.helper.add_input(Submit('submit_button', _('Save')))
17 | self.helper.form_tag = True
18 | self.helper.label_class = 'col-md-3'
19 | self.helper.field_class = 'col-md-9'
20 | self.helper.label_size = ' col-md-offset-3'
21 |
22 | super().__init__(*args, **kwargs)
23 |
--------------------------------------------------------------------------------
/ai_django_core/view_layer/formset_mixins.py:
--------------------------------------------------------------------------------
1 | class CountChildrenFormsetMixin:
2 | """
3 | Provides a method to count the valid children of a formset. Takes care about records which are to be deleted.
4 | """
5 |
6 | def get_number_of_children(self):
7 | # Count all choices which are not being deleted right now
8 | no_choices = 0
9 | for form in self.forms:
10 | if getattr(form, 'cleaned_data', None) and not form.cleaned_data.get('DELETE'):
11 | no_choices += 1
12 | return no_choices
13 |
--------------------------------------------------------------------------------
/ai_django_core/view_layer/formset_view_mixin.py:
--------------------------------------------------------------------------------
1 | from django.shortcuts import render
2 |
3 |
4 | class _FormsetMixin:
5 | """
6 | Mixin for handling a django form with a formset. Extends the form handling logic to handle a formset object.
7 |
8 | Attention: Do NOT use directly. Do use FormsetUpdateViewMixin or FormsetCreateViewMixin
9 | """
10 |
11 | formset_class = None
12 | object = None
13 |
14 | def get_formset_kwargs(self):
15 | # may be overridden or extended
16 | return dict(instance=self.object)
17 |
18 | def get_context_data(self, **kwargs):
19 | context = super().get_context_data(**kwargs)
20 | context['formset'] = self.formset_class(**self.get_formset_kwargs())
21 | return context
22 |
23 | def form_valid(self, form, formset):
24 | # Updates `self.object` internally and returns a redirect response instance
25 | response = super().form_valid(form=form)
26 |
27 | # Update formset
28 | formset.instance = self.object
29 | formset.save()
30 |
31 | if hasattr(self, 'additional_is_valid'):
32 | self.additional_is_valid(form, formset)
33 |
34 | # Return response (a redirect)
35 | return response
36 |
37 | def post(self, request, *args, **kwargs):
38 | form_class = self.get_form_class()
39 | form = form_class(**self.get_form_kwargs())
40 | formset = self.formset_class(request.POST, request.FILES, **self.get_formset_kwargs())
41 |
42 | # Form and formset valid?
43 | if form.is_valid() and formset.is_valid():
44 | return self.form_valid(form=form, formset=formset)
45 |
46 | # Get all context data
47 | context = self.get_context_data()
48 |
49 | # Update form and formset variables
50 | context['form'] = form
51 | context['formset'] = formset
52 |
53 | # Pass all data to template
54 | return render(request, self.get_template_names(), context)
55 |
56 |
57 | class FormsetUpdateViewMixin(_FormsetMixin):
58 | """
59 | Can be used to validate and save a formset.
60 | Usage is similar to regular UpdateView:
61 |
62 | class MyModelUpdateView(FormsetUpdateViewMixin, generic.UpdateView):
63 | form_class = MyModelSettingsForm
64 | template_name = 'myapp/my_model_edit.html'
65 | formset_class = inlineformset_factory(MyModel, MyFkRelatedModel, form=MyFkRelatedModelForm, extra=0, \
66 | can_delete=False)
67 | model = MyModel
68 | success_url = reverse_lazy('my_model:edit')
69 |
70 | def additional_is_valid(self, form, formset):
71 | messages.add_message(self.request, messages.INFO, 'Update was successful.')
72 | """
73 |
74 | def post(self, request, *args, **kwargs):
75 | self.object = self.get_object()
76 | return super().post(request, *args, **kwargs)
77 |
78 |
79 | class FormsetCreateViewMixin(_FormsetMixin):
80 | """
81 | Can be used to validate and save a formset.
82 | Usage is similar to regular CreateView. See FormsetUpdateViewMixin.
83 | """
84 |
85 | def post(self, request, *args, **kwargs):
86 | self.object = None
87 | return super().post(request, *args, **kwargs)
88 |
--------------------------------------------------------------------------------
/ai_django_core/view_layer/htmx_mixins.py:
--------------------------------------------------------------------------------
1 | import json
2 | from typing import Dict, Union
3 |
4 |
5 | class HtmxResponseMixin:
6 | """
7 | View mixin to be able to simply set "HX-Redirect" and "HX-Trigger" for using HTMX in the frontend.
8 | "hx_redirect_url": Takes a reverse_lazy URL to a valid Django URL
9 | "hx_trigger": Takes either a string "updateComponentX" or a dictionary with a key-value-pair, where the key is
10 | the signal and the value is a parameter passed to the frontend. If you don't need the value, set it to None.
11 | """
12 |
13 | hx_redirect_url: str = None
14 | hx_trigger: Union[str, Dict[str, str]] = None
15 |
16 | def dispatch(self, request, *args, **kwargs):
17 | response = super().dispatch(request, *args, **kwargs)
18 |
19 | # Get attributes
20 | hx_redirect_url = self.get_hx_redirect_url()
21 | hx_trigger = self.get_hx_trigger()
22 |
23 | # Set redirect header if set
24 | if hx_redirect_url:
25 | response['HX-Redirect'] = hx_redirect_url
26 |
27 | # Set trigger header if set
28 | if isinstance(hx_trigger, dict):
29 | response['HX-Trigger'] = json.dumps(hx_trigger)
30 | elif isinstance(hx_trigger, str):
31 | response['HX-Trigger'] = hx_trigger
32 |
33 | # Return augmented response
34 | return response
35 |
36 | def get_hx_redirect_url(self):
37 | """
38 | Getter for "hx_redirect_url" to be able to work with dynamic data
39 | """
40 | return self.hx_redirect_url
41 |
42 | def get_hx_trigger(self):
43 | """
44 | Getter for "hx_trigger" to be able to work with dynamic data
45 | """
46 | return self.hx_trigger
47 |
--------------------------------------------------------------------------------
/ai_django_core/view_layer/mixins.py:
--------------------------------------------------------------------------------
1 | from django.contrib.auth.models import User
2 | from django.shortcuts import redirect, render
3 | from django.urls import reverse
4 | from django.utils.translation import gettext_lazy as _
5 |
6 |
7 | class DjangoPermissionRequiredMixin:
8 | """
9 | View mixin to enforce logged-in state and Django permissions.
10 | """
11 |
12 | permission_list = None
13 | login_required = True
14 |
15 | def __init__(self):
16 | super().__init__()
17 |
18 | if self.permission_list is None:
19 | raise RuntimeError(
20 | _('Class-based view using DjangoPermissionRequiredMixin without defining a permission list.')
21 | )
22 |
23 | def get_login_url(self) -> str:
24 | """
25 | Method that can be overwritten to define the login url. If a user has to be logged in but is not, he/she
26 | will be forwarded to the login view.
27 | """
28 | return reverse('login-view')
29 |
30 | def passes_login_barrier(self, user) -> bool:
31 | """
32 | If user does not have to be logged in, we just let anybody pass. Otherwise, user has to be logged in.
33 | """
34 | if not self.login_required or user.is_authenticated:
35 | return True
36 |
37 | return False
38 |
39 | def has_permissions(self, user: User) -> bool:
40 | # Check every permission...
41 | for permission in self.permission_list:
42 | # If user is missing one, the user is not allowed to see the view
43 | if not user.has_perm(permission):
44 | return False
45 |
46 | # If all permissions validate, the user can access the view
47 | return True
48 |
49 | def dispatch(self, request, *args, **kwargs):
50 | # Validate user is either logged in or doesn't have to be logged in
51 | if not self.passes_login_barrier(request.user):
52 | return redirect(self.get_login_url())
53 |
54 | # Validate that user has all required permissions
55 | if not self.has_permissions(request.user):
56 | return render(request, '403.html', status=403)
57 |
58 | # If everything goes well, we'll continue to the view
59 | return super().dispatch(request, *args, **kwargs)
60 |
--------------------------------------------------------------------------------
/ai_django_core/view_layer/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ambient-innovation/ai-django-core/35002db94652ba84ece8ec6ae959b12e79ce628f/ai_django_core/view_layer/tests/__init__.py
--------------------------------------------------------------------------------
/ai_django_core/view_layer/tests/mixins.py:
--------------------------------------------------------------------------------
1 | from typing import Union
2 | from unittest import mock
3 |
4 | from django.contrib.auth import get_user_model
5 | from django.contrib.auth.base_user import AbstractBaseUser
6 | from django.contrib.auth.models import AnonymousUser, Permission
7 | from django.utils.translation import gettext_lazy as _
8 |
9 | from ai_django_core.tests.errors import TestSetupConfigurationError
10 | from ai_django_core.tests.mixins import RequestProviderMixin
11 | from ai_django_core.view_layer.mixins import DjangoPermissionRequiredMixin
12 |
13 |
14 | class BaseViewPermissionTestMixin(RequestProviderMixin):
15 | view_class = None
16 | permission_list = None
17 | view_kwargs = {}
18 |
19 | @classmethod
20 | def setUpTestData(cls):
21 | super().setUpTestData()
22 |
23 | cls.user = cls.get_test_user()
24 |
25 | @classmethod
26 | def get_test_user(cls):
27 | return get_user_model().objects.create(username='test_user', email='test.user@ai-django-core.com')
28 |
29 | def __init__(self, *args, **kwargs) -> None:
30 | super().__init__(*args, **kwargs)
31 |
32 | if not self.view_class:
33 | raise TestSetupConfigurationError(_('BaseViewPermissionTestMixin used without setting a "view_class".'))
34 |
35 | def get_view_instance(
36 | self, *, user: Union[AbstractBaseUser, AnonymousUser], kwargs: dict = None, method: str = 'GET'
37 | ):
38 | """
39 | Creates an instance of the given view class and injects a valid request.
40 | """
41 | request = self.get_request(user=user, method=method)
42 | view = self.view_class()
43 | view.kwargs = kwargs if kwargs else self.view_kwargs
44 | view.request = request
45 | return view
46 |
47 | def test_view_class_inherits_mixin(self):
48 | self.assertTrue(issubclass(self.view_class, DjangoPermissionRequiredMixin))
49 |
50 | def test_permissions_are_equal(self):
51 | # Sanity checks
52 | self.assertIsNotNone(self.permission_list, msg='Missing permission list declaration in test')
53 | self.assertIsNotNone(self.view_class.permission_list, msg='Missing permission list declaration in view')
54 |
55 | # Assert same amount of permissions
56 | self.assertEqual(len(self.permission_list), len(self.view_class.permission_list))
57 |
58 | # Compare target permissions to real permissions
59 | for permission in self.permission_list:
60 | self.assertIn(permission, list(self.view_class.permission_list))
61 |
62 | # Compare real permissions to target permissions
63 | for permission in self.view_class.permission_list:
64 | self.assertIn(permission, list(self.permission_list))
65 |
66 | def test_permissions_exist_in_database(self):
67 | for permission in self.permission_list:
68 | if '.' not in permission:
69 | raise TestSetupConfigurationError(
70 | f'View "{self.view_class}" contains ill-formatted permission ' f'"{permission}".'
71 | )
72 | app_label, codename = permission.split('.')
73 | permission_qs = Permission.objects.filter(content_type__app_label=app_label, codename=codename)
74 |
75 | if not permission_qs.exists():
76 | raise TestSetupConfigurationError(
77 | f'View "{self.view_class}" contains invalid permission ' f'"{permission}".'
78 | )
79 |
80 | def test_passes_login_barrier_is_called(self):
81 | with mock.patch.object(self.view_class, 'passes_login_barrier', return_value=False) as mock_method:
82 | view = self.get_view_instance(user=AnonymousUser())
83 | response = view.dispatch(request=view.request, **view.kwargs)
84 | # If a user is not logged in, he'll be forwarded to the login view
85 | self.assertEqual(response.status_code, 302)
86 |
87 | mock_method.assert_called_once()
88 |
89 | def test_has_permissions_is_called(self):
90 | with mock.patch.object(self.view_class, 'has_permissions', return_value=False) as mock_method:
91 | view = self.get_view_instance(user=self.user)
92 | response = view.dispatch(request=view.request, **view.kwargs)
93 | self.assertEqual(response.status_code, 403)
94 |
95 | mock_method.assert_called_once()
96 |
--------------------------------------------------------------------------------
/ai_django_core/view_layer/views.py:
--------------------------------------------------------------------------------
1 | from django.shortcuts import render
2 | from django.views import generic
3 | from django.views.defaults import ERROR_403_TEMPLATE_NAME
4 | from django.views.generic.detail import SingleObjectMixin
5 |
6 |
7 | class CustomPermissionMixin(generic.View):
8 | """
9 | This mixin provides the method `validate_permissions()` to create a space where custom, non-django-permissions
10 | can live. This method will be called in the `dispatch()` method to avoid executing unnecessary logic in the
11 | "permission denied" case.
12 | """
13 |
14 | def validate_permissions(self) -> bool:
15 | return True
16 |
17 | def dispatch(self, request, *args, **kwargs):
18 | if self.validate_permissions():
19 | return super().dispatch(request, *args, **kwargs)
20 | else:
21 | return render(self.request, ERROR_403_TEMPLATE_NAME, status=403)
22 |
23 |
24 | class RequestInFormKwargsMixin:
25 | """
26 | Injects the request in the form.
27 | Attention: Have to be removed in the init of the form (via .pop())
28 | """
29 |
30 | def get_form_kwargs(self):
31 | kwargs = super().get_form_kwargs()
32 | kwargs.update({'request': self.request})
33 | return kwargs
34 |
35 |
36 | class UserInFormKwargsMixin:
37 | """
38 | Injects the request user in the form.
39 | Attention: Have to be removed in the init of the form (via .pop())
40 | """
41 |
42 | def get_form_kwargs(self):
43 | kwargs = super().get_form_kwargs()
44 | kwargs.update({'user': self.request.user})
45 | return kwargs
46 |
47 |
48 | class ToggleView(SingleObjectMixin, generic.View):
49 | """
50 | Generic view for updating an object without any user data being sent. Therefore, we don't need a form to validate
51 | user input.
52 | Most common use-case is toggling a flag inside an object.
53 | """
54 |
55 | object = None
56 | http_method_names = ('post',)
57 |
58 | def post(self, request, *args, **kwargs):
59 | raise NotImplementedError
60 |
--------------------------------------------------------------------------------
/docs/Makefile:
--------------------------------------------------------------------------------
1 | # Minimal makefile for Sphinx documentation
2 | #
3 |
4 | # You can set these variables from the command line, and also
5 | # from the environment for the first two.
6 | SPHINXOPTS ?=
7 | SPHINXBUILD ?= sphinx-build
8 | SOURCEDIR = .
9 | BUILDDIR = _build
10 |
11 | # Put it first so that "make" without argument is like "make help".
12 | help:
13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
14 |
15 | .PHONY: help Makefile
16 |
17 | # Catch-all target: route all unknown targets to Sphinx using the new
18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
19 | %: Makefile
20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
21 |
--------------------------------------------------------------------------------
/docs/conf.py:
--------------------------------------------------------------------------------
1 | # Configuration file for the Sphinx documentation builder.
2 | #
3 | # This file only contains a selection of the most common options. For a full
4 | # list see the documentation:
5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html
6 |
7 | # -- Path setup --------------------------------------------------------------
8 |
9 | # If extensions (or modules to document with autodoc) are in another directory,
10 | # add these directories to sys.path here. If the directory is relative to the
11 | # documentation root, use os.path.abspath to make it absolute, like shown here.
12 | #
13 | import os
14 | import sys
15 |
16 | import django
17 | from django.conf import settings
18 |
19 | sys.path.insert(0, os.path.abspath('..')) # so that we can access the ai_django_core package
20 | settings.configure(
21 | INSTALLED_APPS=[
22 | 'django.contrib.admin',
23 | 'django.contrib.auth',
24 | 'django.contrib.contenttypes',
25 | 'django.contrib.sessions',
26 | 'django.contrib.messages',
27 | 'django.contrib.staticfiles',
28 | 'ai_django_core',
29 | ],
30 | EMAIL_BACKEND_REDIRECT_ADDRESS='',
31 | SECRET_KEY='ASDFjklö123456890',
32 | )
33 | django.setup()
34 |
35 | from ai_django_core import __version__ # noqa: E402
36 |
37 | # -- Project information -----------------------------------------------------
38 |
39 | project = 'ai-django-core'
40 | copyright = '2022, Ambient Innovation: GmbH' # noqa
41 | author = 'Ambient Innovation: GmbH '
42 | version = __version__
43 | release = __version__
44 |
45 | # -- General configuration ---------------------------------------------------
46 |
47 | # Add any Sphinx extension module names here, as strings. They can be
48 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
49 | # ones.
50 | extensions = [
51 | 'sphinx_rtd_theme',
52 | 'sphinx.ext.autodoc',
53 | 'm2r2',
54 | ]
55 |
56 | source_suffix = ['.rst', '.md']
57 |
58 | # Add any paths that contain templates here, relative to this directory.
59 | templates_path = ['_templates']
60 |
61 | # List of patterns, relative to source directory, that match files and
62 | # directories to ignore when looking for source files.
63 | # This pattern also affects html_static_path and html_extra_path.
64 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
65 |
66 |
67 | # -- Options for HTML output -------------------------------------------------
68 |
69 | # The theme to use for HTML and HTML Help pages. See the documentation for
70 | # a list of builtin themes.
71 | #
72 | # html_theme = 'alabaster'
73 | html_theme = 'sphinx_rtd_theme'
74 | html_theme_options = {
75 | 'display_version': False,
76 | 'style_external_links': False,
77 | }
78 |
79 | # Add any paths that contain custom static files (such as style sheets) here,
80 | # relative to this directory. They are copied after the builtin static files,
81 | # so a file named "default.css" will overwrite the builtin "default.css".
82 | html_static_path = ['_static']
83 |
84 | # Set master doc file
85 | master_doc = 'index'
86 |
--------------------------------------------------------------------------------
/docs/features/changelog.rst:
--------------------------------------------------------------------------------
1 |
2 | .. mdinclude:: ../../CHANGES.md
3 |
--------------------------------------------------------------------------------
/docs/features/context_manager.md:
--------------------------------------------------------------------------------
1 | # Context manager
2 |
3 | ## TempDisconnectSignal
4 |
5 | If you want to disable a signal from a django model temporarily, you can use this context manager. The great benefit
6 | over disabling models the regular way is, that you cannot forget to enable the signal again. Secondly, you save one
7 | line of code. Keep it DRY!
8 |
9 | Image you have this setup:
10 |
11 | ````
12 | from django.db import models
13 | from django.db.models.signals import pre_save
14 |
15 | class MyModel(models.Model):
16 | value = models.IntegerField()
17 | factor = models.IntegerField()
18 | product = models.IntegerField()
19 |
20 |
21 | @receiver(pre_save, sender=MyModel)
22 | def calculate_product(sender, instance, created, **kwargs):
23 | instance.product = value * factor
24 | ````
25 |
26 | Now you want to save an instance of ``MyModel`` without calling the signal, you have to define at first a dictionary
27 | with all parameters describing your signal:
28 |
29 | ````
30 | from django.db.models import signals
31 |
32 | signal_kwargs = {
33 | 'signal': signals.pre_save,
34 | 'receiver': calculate_product,
35 | 'sender': MyModel,
36 | }
37 | ````
38 |
39 | With this information set, you can easily disable the signal like this:
40 |
41 | ````
42 | my_obj = MyModel(value=1, factor=2)
43 | with TempDisconnectSignal(**signal_kwargs):
44 | my_obj.save()
45 | ````
46 |
47 | Now the attribute ``product`` will be not set / calculated.
48 |
49 | If you need to disable the signal on multiple locations throughout your code, it is convenient to declare a method in
50 | your model which provides the dictionary:
51 |
52 |
53 | ````
54 | class MyModel(models.Model):
55 | ...
56 |
57 | @staticmethod
58 | def get_calculate_product_kwargs():
59 | return {
60 | 'signal': signals.pre_save,
61 | 'receiver': calculate_product,
62 | 'sender': MyModel,
63 | }
64 |
65 | ...
66 | with TempDisconnectSignal(**MyModel.get_calculate_product_kwargs()):
67 | my_obj.save()
68 |
69 | ````
70 |
71 | Attention: If your signal has an explicit ``dispatch_uid``, you need to pass it to the context manager as well.
72 |
73 | ````
74 | @receiver(pre_save, sender=MyModel, dispatch_uid='mymodel.calculate_product')
75 | def calculate_product(sender, instance, created, **kwargs):
76 | instance.product = value * factor
77 |
78 | ...
79 |
80 | signal_kwargs = {
81 | 'signal': signals.pre_save,
82 | 'receiver': calculate_product,
83 | 'sender': MyModel,
84 | 'dispatch_uid: 'mymodel.calculate_product',
85 | }
86 |
87 | ...
88 | ````
89 |
--------------------------------------------------------------------------------
/docs/features/context_processors.md:
--------------------------------------------------------------------------------
1 | # Context processors
2 |
3 | ## Server Settings
4 |
5 | The function ``server_settings()`` puts the variables `DEBUG_MODE` and `SERVER_URL` in every django template.
6 |
7 | Register the context manager like this in your global settings file:
8 |
9 | ````
10 | TEMPLATES = [
11 | {
12 | ...
13 | 'OPTIONS': {
14 | 'context_processors': [
15 | ...
16 | 'ai_django_core.context_processors.server_settings',
17 | ]
18 |
19 | ````
20 |
21 | Afterwards, make sure that in the django settings the variables ``DEBUG`` and `SERVER_URL` are set.
22 |
--------------------------------------------------------------------------------
/docs/features/gitlab.md:
--------------------------------------------------------------------------------
1 | # Gitlab
2 |
3 | ## Test coverage service
4 |
5 | ### Motivation
6 |
7 | When using Gitlab, you can query your projects test coverage via the Gitlab API. This package contains a service which
8 | you can utilise within your pipeline as follows.
9 |
10 | The script will try to get the last commit inside your branch which came from your default branch (usually "develop")
11 | and looks for a successfully run pipeline. From there, it will query the code coverage and compare it to your coverage.
12 | If it has dropped, the step will return "1" which causes your pipeline to fail. If it can't find a valid pipeline, it
13 | will fall back to the default branches most recent successful pipeline.
14 |
15 | Take care that you have to set up Gitalb to recognise your coverage before you can use this functionality.
16 |
17 | ### Installation
18 |
19 | * Create a file called `scripts/validate_coverage.py` and add the following:
20 |
21 | ```python
22 | from ai_django_core.gitlab.coverage import CoverageService
23 |
24 | service = CoverageService()
25 | service.process()
26 | ```
27 |
28 | * Add this step to your `gitlab-ci.yml`. Make sure that you set the correct python version and that this stage is
29 | defined after the unittest stage.
30 |
31 | ```yaml
32 | # POST-TEST STAGE
33 | check coverage:
34 | image: ${CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX}/python:3.9
35 | stage: posttest
36 | needs:
37 | - unittest
38 | tags:
39 | - low-load
40 | except:
41 | - develop
42 | - master
43 | before_script:
44 | - pip install -U pip httpx ai_django_core
45 | script:
46 | - python scripts/validate_coverage.py
47 | ```
48 |
49 | * Create an access token for your repo having `developer` role and `read_api` permission (Settings -> Access Tokens)
50 |
51 | * Add two variables to your CI/CD inside your Gitlab repository (Settings -> CI/CD -> Variables). Insert the token from
52 | step 3 and define your default branch for comparison. Usually, this will be "develop".
53 |
54 | > GITLAB_CI_COVERAGE_PIPELINE_TOKEN = [token]
55 |
56 | > GITLAB_CI_COVERAGE_TARGET_BRANCH = develop
57 |
58 | * Done. Enjoy!
59 |
60 | Hint: For merge-commits (i.e.: Merge develop into master), or hotfixes into branches that are not your default, manually
61 | run a Pipeline on your source-branch (i.e.: `develop` if you want to merge `develop` into `master`) with the _Input
62 | variable key_ set to `GITLAB_CI_COVERAGE_TARGET_BRANCH` and the _Input variable value_ set to the target branch (
63 | i.e.: `master` if you want to merge `develop` into `master`).
64 |
65 | Hint: If you wish to not run coverage for a specific pipeline for whatever reason, simply run the pipeline
66 | with `GITLAB_CI_DISABLE_COVERAGE = True` and no coverage will be collected.
67 |
--------------------------------------------------------------------------------
/docs/features/models.md:
--------------------------------------------------------------------------------
1 | # Models
2 |
3 | ## Object ownership
4 |
5 | If you are interested in the backgrounds of this part, you can have a look at Medium,
6 | [we posted an article there some time ago](https://medium.com/ambient-innovation/automatic-and-reliable-handling-of-object-ownership-in-django-34d7ad9721e9).
7 |
8 |
9 | ### Abstract class
10 |
11 | If you want to keep track of the creator, creation time, last modificator and last modification time,
12 | you can use the abstract class `CommonInfo` like this:
13 |
14 | ````python
15 | from ai_django_core.models import CommonInfo
16 |
17 |
18 | class MyFancyModel(CommonInfo):
19 | ...
20 | ````
21 |
22 | You then get four fields: `created_by`, `created_at`, `lastmodified_by`, `lastmodified_at`.
23 |
24 | If you are interested in the details, just have a look at the code base.
25 |
26 | Note, that those fields will be automatically added to the `update_fields` if you choose to update only a subset of
27 | fields on saving your object. However, you can set the class attribute `ALWAYS_UPDATE_FIELDS` to `False`
28 | on your model to disable this behavior.
29 |
30 | ### Automatic object ownership
31 |
32 | If you want to keep track of object ownership automatically, you can use the `CurrentUserMiddleware`.
33 | Just make sure, you'll insert it **after** djangos `AuthenticationMiddleware`:
34 |
35 | ````python
36 | MIDDLEWARE = (
37 | ...
38 | 'django.contrib.auth.middleware.AuthenticationMiddleware',
39 | 'ai_django_core.middleware.current_user.CurrentUserMiddleware',
40 | )
41 | ````
42 |
43 | Using this middleware will automatically and thread-safe keep track of the ownership of all models,
44 | which derive from `CommonInfo`.
45 |
--------------------------------------------------------------------------------
/docs/features/sentry.md:
--------------------------------------------------------------------------------
1 | # Sentry
2 |
3 | Sentry is an open-source bugtracker. Read more at https://sentry.io/.
4 |
5 | ## Send GDPR-compliant user data
6 |
7 | When gathering data on a crash, the current user can be of major importance. Sentry offers a simple way of sending all
8 | user-related data to your Sentry instance. Unfortunately, this collides with GDPR because IP and email address are
9 | sensitive data. Luckily, the internal user id is not.
10 |
11 | So if you are using the default django authentication process, you can easily set up Sentry, so you get the user id in
12 | your error reports but nothing else (what might conflict with GDPR).
13 |
14 | ### Sentry client
15 |
16 | Install the latest `sentry-sdk` client from pypi.
17 |
18 | pip install -U sentry-sdk
19 |
20 | ### Settings
21 |
22 | Adjust in your main `settings.py` your sentry setup as follows:
23 |
24 | from ai_django_core.sentry.helpers import strip_sensitive_data_from_sentry_event
25 |
26 | sentry_sdk.init(
27 | ...
28 | send_default_pii=True,
29 | before_send=strip_sensitive_data_from_sentry_event,
30 | )
31 |
32 | And that's it! Have fun finding your bugs more easily!
33 |
--------------------------------------------------------------------------------
/docs/features/setup.md:
--------------------------------------------------------------------------------
1 | # Setup
2 |
3 | ## Default installation
4 |
5 | Setting up and getting started with this toolbox is very simple. At first make sure you are installing the latest
6 | version of `ai-django-core`:
7 |
8 | * Installation via pip:
9 |
10 | `pip install -U ai-django-core`
11 |
12 | * Installation via pipenv:
13 |
14 | `pipenv install ai-django-core`
15 |
16 | Afterwards, include the package in your ``INSTALLED_APPS`` within your main
17 | [django settings file](https://docs.djangoproject.com/en/dev/ref/settings/#std:setting-INSTALLED_APPS):
18 |
19 | ````
20 | INSTALLED_APPS = (
21 | ...
22 | 'ai_django_core',
23 | )
24 | ````
25 |
26 | ## Installing the djangorestframework extension
27 |
28 | If you wish to use the extensions for [djangorestframework](https://www.django-rest-framework.org/), simply install the
29 | toolbox with the desired extension:
30 |
31 | * Installation via pip:
32 |
33 | `pip install -U ai-django-core[drf]`
34 |
35 | * Installation via pipenv:
36 |
37 | `pipenv install ai-django-core[drf]`
38 |
39 | ## Installing the GraphQL extension
40 |
41 | If you wish to use the extensions for [django-graphene](https://pypi.org/project/graphene-django/), simply install the
42 | toolbox with the desired extension:
43 |
44 | * Installation via pip:
45 |
46 | `pip install -U ai-django-core[graphql]`
47 |
48 | * Installation via pipenv:
49 |
50 | `pipenv install ai-django-core[graphql]`
51 |
52 | # Installing multiple extensions
53 |
54 | In the case that you want to install more than one extension, just chain the extension with a comma:
55 |
56 | * Installation via pip:
57 |
58 | `pip install -U ai-django-core[drf,graphql]`
59 |
60 | * Installation via pipenv:
61 |
62 | `pipenv install ai-django-core[drf,graphql]`
63 |
--------------------------------------------------------------------------------
/docs/features/utils.rst:
--------------------------------------------------------------------------------
1 | Utilities
2 | ==============
3 |
4 | .. mdinclude:: ./utils/cache.md
5 | .. mdinclude:: ./utils/date.md
6 | .. mdinclude:: ./utils/math.md
7 | .. mdinclude:: ./utils/model.md
8 | .. mdinclude:: ./utils/named_tuple.md
9 | .. mdinclude:: ./utils/string.md
10 |
--------------------------------------------------------------------------------
/docs/features/utils/cache.md:
--------------------------------------------------------------------------------
1 | ## Cache
2 |
3 | ### Clear cache
4 |
5 | The function ``clear_cache()`` wraps all the functionality needed to totally empty the django cache. Especially
6 | convenient, if you put this function inside a management command, so you can flush the cache easily from the command
7 | line:
8 |
9 | ````
10 | # my_app/management/commands/clear_cache.py
11 |
12 | from django.core.management.base import BaseCommand
13 | from ai_django_core.utils import clear_cache
14 |
15 |
16 | class Command(BaseCommand):
17 | def handle(self, *args, **options):
18 | clear_cache()
19 | ````
20 |
--------------------------------------------------------------------------------
/docs/features/utils/math.md:
--------------------------------------------------------------------------------
1 | ## Math
2 |
3 | ### Round to decimal
4 |
5 | The helper function ``round_to_decimal(value, precision)`` will round a given value to a specific precision,
6 | for example \*.5. So 5.4 will be rounded to 5.5, and 5.6 to 5.5 as well. The result is always a float.
7 |
8 | ````
9 | result = round_to_decimal(5.4, 0.5)
10 | # result = 5.5
11 |
12 | result = round_to_decimal(5.6, 0.5)
13 | # result = 5.5
14 | ````
15 |
16 | ### Round up decimal
17 |
18 | The helper function ``round_up_decimal(value, precision)`` will round a given value **up** to a specific precision,
19 | for example *.5. So 5.4 will be rounded to 5.5, and 5.6 to 6 as well. The result is always a float.
20 |
21 | ````
22 | result = round_up_decimal(5.4, 0.5)
23 | # result = 5.5
24 |
25 | result = round_up_decimal(5.6, 0.5)
26 | # result = 6.0
27 | ````
28 |
--------------------------------------------------------------------------------
/docs/features/utils/model.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | ## Model
4 |
5 | ### Convert object to dictionary
6 |
7 | The function ``object_to_dict(obj, blacklisted_fields, include_id)`` takes an instance of a django model and
8 | extracts all attributes into a dictionary:
9 |
10 | ````
11 | from django.db import models
12 | class MyModel(models.Model):
13 | value1 = models.IntegerField()
14 | value2 = models.IntegerField()
15 |
16 | ....
17 |
18 | obj = MyModel.objects.create(value_1=19, value_2=9)
19 | result = object_to_dict(obj)
20 | # result = {'value_1': 19, 'value_2': 9}
21 | ````
22 |
23 | Optionally, fields can be excluded with the parameter ``blacklisted_fields``.
24 | Passing a list of field names as string will prevent them from ending up in the result dictionary.
25 |
26 | ````
27 | obj = MyModel.objects.create(value_1=19, value_2=9)
28 | result = object_to_dict(obj, ['value_2'])
29 | # result = {'value_1': 19}
30 | ````
31 |
32 | By default the model ID is not part of the result. If you want to change this, pass ``include_id=True`` to the function:
33 |
34 | ````
35 | obj = MyModel.objects.create(value_1=19, value_2=9)
36 | result = object_to_dict(obj, include_id=True)
37 | # result = {'id': 1, value_1': 19, 'value_2': 9}
38 | ````
39 |
40 |
--------------------------------------------------------------------------------
/docs/features/utils/named_tuple.md:
--------------------------------------------------------------------------------
1 | ## Named tuple
2 |
3 | ### Named tuple helper
4 |
5 | #### General
6 |
7 | Factory function for quickly making a namedtuple suitable for use in a Django model as a choices attribute on a field.
8 | It will preserve order.
9 |
10 | Here you'll find a basic example:
11 |
12 | ````
13 | class MyModel(models.Model):
14 | COLORS = get_namedtuple_choices('COLORS', (
15 | (0, 'black', 'Black'),
16 | (1, 'white', 'White'),
17 | ))
18 | colors = models.PositiveIntegerField(choices=COLORS)
19 |
20 | >>> MyModel.COLORS.black
21 | 0
22 | >>> MyModel.COLORS.get_choices()
23 | [(0, 'Black'), (1, 'White')]
24 |
25 | class OtherModel(models.Model):
26 | GRADES = get_namedtuple_choices('GRADES', (
27 | ('FR', 'fr', 'Freshman'),
28 | ('SR', 'sr', 'Senior'),
29 | ))
30 | grade = models.CharField(max_length=2, choices=GRADES)
31 |
32 | >>> OtherModel.GRADES.fr
33 | 'FR'
34 | >>> OtherModel.GRADES.get_choices()
35 | [('fr', 'Freshman'), ('sr', 'Senior')]
36 | ````
37 |
38 | #### Helpers
39 |
40 | ````
41 | # get_choices()
42 | >>> MyModel.COLORS.get_choices()
43 | [(0, 'Black'), (1, 'White')]
44 |
45 | # get_choices_dict()
46 | >>> MyModel.COLORS.get_choices_dict()
47 | OrderedDict([(1, 'Black'), (2, 'White')])
48 |
49 | # get_all()
50 | >>> lambda x: (print(color) for color in MyModel.COLORS.get_all())
51 | (1, 'black', 'Black')
52 | (2, 'white', 'White')
53 |
54 | # get_choices_tuple()
55 | >>> MyModel.COLORS.get_choices_tuple()
56 | ((1, 'black', 'Black'), (2, 'white', 'White'))
57 |
58 | # get_values()
59 | >>> MyModel.COLORS.get_values()
60 | [1, 2]
61 |
62 | # get_value_by_name()
63 | >>> MyModel.COLORS.get_value_by_name('white')
64 | 2
65 |
66 | # get_desc_by_value()
67 | >>> MyModel.COLORS.get_desc_by_value(1)
68 | Black
69 |
70 | # get_name_by_value()
71 | >>> MyModel.COLORS.get_name_by_value(1)
72 | black
73 |
74 | # is_valid()
75 | >>> MyModel.COLORS.is_valid(1)
76 | True
77 | ````
78 |
79 | ### Get value from tuple by key
80 |
81 | If you have a tuple ``my_tuple`` and you want to get the value for the key `my_key`, you can use the straight-forward
82 | helper function ``get_value_from_tuple_by_key()``:
83 |
84 | ```
85 | my_tuple = (
86 | 1, 'Value One',
87 | 2, 'Value Two',
88 | )
89 |
90 | my_value = get_value_from_tuple_by_key(my_tuple, 1)
91 | # my_value = 'Value One'
92 | ```
93 |
94 | ### Get key from tuple by value
95 |
96 | If you have a tuple ``my_tuple`` and you want to get the key for the value `my_key`, you can use the straight-forward
97 | helper function ``get_key_from_tuple_by_value()``:
98 |
99 | ```
100 | my_tuple = (
101 | 1, 'Value One',
102 | 2, 'Value Two',
103 | )
104 |
105 | my_value = get_key_from_tuple_by_value(my_tuple, 'Value One')
106 | # my_value = 1
107 | ```
108 |
--------------------------------------------------------------------------------
/docs/index.rst:
--------------------------------------------------------------------------------
1 | ai-django-core
2 | ==============
3 |
4 | Welcome to the Ambient Toolbox ``ai-django-core``!
5 |
6 | Since 2012 we are gathering tools and useful code snippets to share them across projects. The toolbox ``ai-django-core``
7 | provides utilities for a wide range of applications. Please have a look at the following chapters to familiarise
8 | yourself with them.
9 |
10 | The package is published at pypi under the following link: `https://pypi.org/project/ai-django-core/ `_
11 |
12 |
13 | .. toctree::
14 | :maxdepth: 1
15 | :caption: Contents:
16 |
17 | features/setup.md
18 | features/admin.md
19 | features/context_manager.md
20 | features/context_processors.md
21 | features/database_anonymisation.md
22 | features/djangorestframework.md
23 | features/gitlab.md
24 | features/graphql.md
25 | features/managers.md
26 | features/mail.md
27 | features/models.md
28 | features/mixins.md
29 | features/selectors.md
30 | features/sentry.md
31 | features/services.md
32 | features/tests.md
33 | features/utils.rst
34 | features/view-layer.rst
35 | features/changelog.md
36 |
37 | Indices and tables
38 | ==================
39 |
40 | * :ref:`genindex`
41 | * :ref:`modindex`
42 | * :ref:`search`
43 |
--------------------------------------------------------------------------------
/docs/make.bat:
--------------------------------------------------------------------------------
1 | @ECHO OFF
2 |
3 | pushd %~dp0
4 |
5 | REM Command file for Sphinx documentation
6 |
7 | if "%SPHINXBUILD%" == "" (
8 | set SPHINXBUILD=sphinx-build
9 | )
10 | set SOURCEDIR=.
11 | set BUILDDIR=_build
12 |
13 | if "%1" == "" goto help
14 |
15 | %SPHINXBUILD% >NUL 2>NUL
16 | if errorlevel 9009 (
17 | echo.
18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
19 | echo.installed, then set the SPHINXBUILD environment variable to point
20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you
21 | echo.may add the Sphinx directory to PATH.
22 | echo.
23 | echo.If you don't have Sphinx installed, grab it from
24 | echo.http://sphinx-doc.org/
25 | exit /b 1
26 | )
27 |
28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
29 | goto end
30 |
31 | :help
32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
33 |
34 | :end
35 | popd
36 |
--------------------------------------------------------------------------------
/manage.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | """Django's command-line utility for administrative tasks."""
3 | import os
4 | import sys
5 |
6 |
7 | def main():
8 | """Run administrative tasks."""
9 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'settings')
10 | try:
11 | from django.core.management import execute_from_command_line
12 | except ImportError as exc:
13 | raise ImportError(
14 | "Couldn't import Django. Are you sure it's installed and "
15 | "available on your PYTHONPATH environment variable? Did you "
16 | "forget to activate a virtual environment?"
17 | ) from exc
18 | execute_from_command_line(sys.argv)
19 |
20 |
21 | if __name__ == '__main__':
22 | main()
23 |
--------------------------------------------------------------------------------
/pytest.ini:
--------------------------------------------------------------------------------
1 | [pytest]
2 | python_files = tests.py test_*.py *_tests.py
3 |
--------------------------------------------------------------------------------
/scripts/publish_and_update_mirror.ps1:
--------------------------------------------------------------------------------
1 | echo "Publishing to test pypi"
2 | flit publish --repository testpypi
3 | echo "Publishing to production pypi"
4 | flit publish
5 | echo "Updating mirror repository"
6 | & $PSScriptRoot\update-mirror.ps1
7 |
--------------------------------------------------------------------------------
/scripts/update-mirror.ps1:
--------------------------------------------------------------------------------
1 | echo "Changing directory"
2 | cd ..
3 | echo "Entering mirror directory"
4 | cd ai-django-core-mirror
5 | echo "Fetching branches from upstream"
6 | git fetch upstream
7 | echo "Merging master"
8 | git merge upstream/master
9 | echo "Pushing changes to mirror"
10 | git push
11 | echo "Changing directory back"
12 | cd ../ai-django-core
13 |
--------------------------------------------------------------------------------
/settings.py:
--------------------------------------------------------------------------------
1 | from pathlib import Path
2 |
3 | BASE_PATH = Path(__file__).resolve(strict=True).parent
4 |
5 | INSTALLED_APPS = (
6 | 'django.contrib.admin',
7 | 'django.contrib.auth',
8 | 'django.contrib.contenttypes',
9 | 'django.contrib.sessions',
10 | 'django.contrib.messages',
11 | 'django.contrib.staticfiles',
12 | 'testapp',
13 | )
14 |
15 | DEBUG = False
16 |
17 | ALLOWED_HOSTS = ['localhost:8000']
18 |
19 | SECRET_KEY = 'ASDFjklö123456890'
20 |
21 | # Routing
22 | ROOT_URLCONF = 'testapp.urls'
23 |
24 | DEFAULT_AUTO_FIELD = 'django.db.models.AutoField'
25 |
26 | DATABASES = {
27 | 'default': {
28 | 'ENGINE': 'django.db.backends.sqlite3',
29 | 'NAME': 'db.sqlite',
30 | }
31 | }
32 |
33 | TEMPLATES = [
34 | {
35 | 'BACKEND': 'django.template.backends.django.DjangoTemplates',
36 | 'DIRS': ['templates'],
37 | 'APP_DIRS': True,
38 | 'OPTIONS': {
39 | 'context_processors': [
40 | 'django.template.context_processors.debug',
41 | 'django.template.context_processors.request',
42 | 'django.contrib.auth.context_processors.auth',
43 | 'django.contrib.messages.context_processors.messages',
44 | ],
45 | 'debug': True,
46 | },
47 | },
48 | ]
49 |
50 | MIDDLEWARE = (
51 | 'django.middleware.security.SecurityMiddleware',
52 | 'django.contrib.sessions.middleware.SessionMiddleware',
53 | 'django.middleware.common.CommonMiddleware',
54 | 'django.middleware.csrf.CsrfViewMiddleware',
55 | 'django.contrib.auth.middleware.AuthenticationMiddleware',
56 | 'django.contrib.messages.middleware.MessageMiddleware',
57 | 'django.middleware.clickjacking.XFrameOptionsMiddleware',
58 | )
59 |
60 | # Mail backend
61 | EMAIL_BACKEND_DOMAIN_WHITELIST = ''
62 | EMAIL_BACKEND_REDIRECT_ADDRESS = ''
63 |
64 | TIME_ZONE = 'UTC'
65 |
66 | LOCALE_PATHS = [str(BASE_PATH) + '/ai_django_core/locale']
67 |
--------------------------------------------------------------------------------
/setup.cfg:
--------------------------------------------------------------------------------
1 | [metadata]
2 | description-file = README.md
3 | license-file = LICENSE.md
4 |
--------------------------------------------------------------------------------
/testapp/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ambient-innovation/ai-django-core/35002db94652ba84ece8ec6ae959b12e79ce628f/testapp/__init__.py
--------------------------------------------------------------------------------
/testapp/api/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ambient-innovation/ai-django-core/35002db94652ba84ece8ec6ae959b12e79ce628f/testapp/api/__init__.py
--------------------------------------------------------------------------------
/testapp/api/serializers.py:
--------------------------------------------------------------------------------
1 | from rest_framework import serializers
2 |
3 | from testapp.models import MySingleSignalModel
4 |
5 |
6 | class MySingleSignalModelSerializer(serializers.ModelSerializer):
7 | class Meta:
8 | model = MySingleSignalModel
9 | fields = [
10 | 'id',
11 | 'value',
12 | ]
13 |
--------------------------------------------------------------------------------
/testapp/api/urls.py:
--------------------------------------------------------------------------------
1 | from rest_framework.routers import DefaultRouter
2 |
3 | from testapp.api.views import MySingleSignalModelViewSet
4 |
5 | model_router = DefaultRouter()
6 | model_router.register(r'my-single-signal-model', MySingleSignalModelViewSet, basename='my-single-signal-model')
7 |
--------------------------------------------------------------------------------
/testapp/api/views.py:
--------------------------------------------------------------------------------
1 | from rest_framework import mixins
2 | from rest_framework.permissions import IsAuthenticated
3 | from rest_framework.viewsets import GenericViewSet
4 |
5 | from testapp.api import serializers
6 | from testapp.models import MySingleSignalModel
7 |
8 |
9 | class MySingleSignalModelViewSet(mixins.ListModelMixin, GenericViewSet):
10 | permission_classes = [IsAuthenticated]
11 | serializer_class = serializers.MySingleSignalModelSerializer
12 |
13 | def get_queryset(self):
14 | return MySingleSignalModel.objects.visible_for(self.request.user)
15 |
--------------------------------------------------------------------------------
/testapp/forms.py:
--------------------------------------------------------------------------------
1 | from django import forms
2 |
3 | from testapp.models import CommonInfoBasedModel
4 |
5 |
6 | class CommonInfoBasedModelTestForm(forms.ModelForm):
7 | class Meta:
8 | model = CommonInfoBasedModel
9 | fields = ('value',)
10 |
--------------------------------------------------------------------------------
/testapp/managers.py:
--------------------------------------------------------------------------------
1 | from django.db.models import QuerySet
2 |
3 |
4 | class ModelWithSelectorQuerySet(QuerySet):
5 | pass
6 |
--------------------------------------------------------------------------------
/testapp/migrations/0001_initial.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 2.2.7 on 2020-10-29 17:08
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 | initial = True
8 |
9 | dependencies = []
10 |
11 | operations = [
12 | migrations.CreateModel(
13 | name='MyMultipleSignalModel',
14 | fields=[
15 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
16 | ('value', models.PositiveIntegerField(default=0)),
17 | ],
18 | ),
19 | migrations.CreateModel(
20 | name='MySingleSignalModel',
21 | fields=[
22 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
23 | ('value', models.PositiveIntegerField(default=0)),
24 | ],
25 | ),
26 | ]
27 |
--------------------------------------------------------------------------------
/testapp/migrations/0002_auto_210407.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.1.2 on 2021-03-26 09:37
2 | import django.db.models.deletion
3 | from django.conf import settings
4 | from django.db import migrations, models
5 |
6 |
7 | class Migration(migrations.Migration):
8 | dependencies = [
9 | ('testapp', '0001_initial'),
10 | ]
11 |
12 | operations = [
13 | migrations.CreateModel(
14 | name='ForeignKeyRelatedModel',
15 | fields=[
16 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
17 | (
18 | 'single_signal',
19 | models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='testapp.mysinglesignalmodel'),
20 | ),
21 | ],
22 | ),
23 | migrations.CreateModel(
24 | name='CommonInfoBasedModel',
25 | fields=[
26 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
27 | (
28 | 'created_at',
29 | models.DateTimeField(db_index=True, default=django.utils.timezone.now, verbose_name='Erstellt am'),
30 | ),
31 | (
32 | 'lastmodified_at',
33 | models.DateTimeField(
34 | db_index=True, default=django.utils.timezone.now, verbose_name='Zuletzt geändert am'
35 | ),
36 | ),
37 | ('value', models.PositiveIntegerField(default=0)),
38 | (
39 | 'created_by',
40 | models.ForeignKey(
41 | blank=True,
42 | null=True,
43 | on_delete=django.db.models.deletion.SET_NULL,
44 | related_name='testapp_commoninfobasedmodel_created',
45 | to=settings.AUTH_USER_MODEL,
46 | verbose_name='Erstellt von',
47 | ),
48 | ),
49 | (
50 | 'lastmodified_by',
51 | models.ForeignKey(
52 | blank=True,
53 | null=True,
54 | on_delete=django.db.models.deletion.SET_NULL,
55 | related_name='testapp_commoninfobasedmodel_lastmodified',
56 | to=settings.AUTH_USER_MODEL,
57 | verbose_name='Zuletzt geändert von',
58 | ),
59 | ),
60 | ],
61 | options={
62 | 'abstract': False,
63 | },
64 | ),
65 | ]
66 |
--------------------------------------------------------------------------------
/testapp/migrations/0003_modelwithfktoself.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.1 on 2021-04-19 14:16
2 |
3 | import django.db.models.deletion
4 | from django.db import migrations, models
5 |
6 |
7 | class Migration(migrations.Migration):
8 | dependencies = [
9 | ('testapp', '0002_auto_210407'),
10 | ]
11 |
12 | operations = [
13 | migrations.CreateModel(
14 | name='ModelWithFkToSelf',
15 | fields=[
16 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
17 | (
18 | 'parent',
19 | models.ForeignKey(
20 | on_delete=django.db.models.deletion.CASCADE,
21 | related_name='children',
22 | to='testapp.modelwithfktoself',
23 | ),
24 | ),
25 | ],
26 | ),
27 | migrations.AlterField(
28 | model_name='foreignkeyrelatedmodel',
29 | name='single_signal',
30 | field=models.ForeignKey(
31 | on_delete=django.db.models.deletion.CASCADE,
32 | related_name='foreign_key_related_models',
33 | to='testapp.mysinglesignalmodel',
34 | ),
35 | ),
36 | migrations.AlterField(
37 | model_name='modelwithfktoself',
38 | name='parent',
39 | field=models.ForeignKey(
40 | blank=True,
41 | null=True,
42 | on_delete=django.db.models.deletion.CASCADE,
43 | related_name='children',
44 | to='testapp.modelwithfktoself',
45 | ),
46 | ),
47 | migrations.CreateModel(
48 | name='ModelWithOneToOneToSelf',
49 | fields=[
50 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
51 | (
52 | 'peer',
53 | models.OneToOneField(
54 | blank=True,
55 | null=True,
56 | on_delete=django.db.models.deletion.CASCADE,
57 | related_name='related_peer',
58 | to='testapp.modelwithonetoonetoself',
59 | ),
60 | ),
61 | ],
62 | ),
63 | ]
64 |
--------------------------------------------------------------------------------
/testapp/migrations/0004_auto_20210511_1343.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.1 on 2021-05-11 13:43
2 |
3 | import django.db.models.deletion
4 | import django.utils.timezone
5 | from django.conf import settings
6 | from django.db import migrations, models
7 |
8 |
9 | class Migration(migrations.Migration):
10 | dependencies = [
11 | migrations.swappable_dependency(settings.AUTH_USER_MODEL),
12 | ('testapp', '0003_modelwithfktoself'),
13 | ]
14 |
15 | operations = [
16 | migrations.AlterField(
17 | model_name='commoninfobasedmodel',
18 | name='created_at',
19 | field=models.DateTimeField(db_index=True, default=django.utils.timezone.now, verbose_name='Created at'),
20 | ),
21 | migrations.AlterField(
22 | model_name='commoninfobasedmodel',
23 | name='created_by',
24 | field=models.ForeignKey(
25 | blank=True,
26 | null=True,
27 | on_delete=django.db.models.deletion.SET_NULL,
28 | related_name='testapp_commoninfobasedmodel_created',
29 | to=settings.AUTH_USER_MODEL,
30 | verbose_name='Created by',
31 | ),
32 | ),
33 | migrations.AlterField(
34 | model_name='commoninfobasedmodel',
35 | name='lastmodified_at',
36 | field=models.DateTimeField(
37 | db_index=True, default=django.utils.timezone.now, verbose_name='Last modified at'
38 | ),
39 | ),
40 | migrations.AlterField(
41 | model_name='commoninfobasedmodel',
42 | name='lastmodified_by',
43 | field=models.ForeignKey(
44 | blank=True,
45 | null=True,
46 | on_delete=django.db.models.deletion.SET_NULL,
47 | related_name='testapp_commoninfobasedmodel_lastmodified',
48 | to=settings.AUTH_USER_MODEL,
49 | verbose_name='Last modified by',
50 | ),
51 | ),
52 | ]
53 |
--------------------------------------------------------------------------------
/testapp/migrations/0005_modelwithcleanmixin.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.2.8 on 2022-05-19 15:28
2 |
3 | from django.db import migrations, models
4 |
5 | import ai_django_core.mixins.validation
6 |
7 |
8 | class Migration(migrations.Migration):
9 | dependencies = [
10 | ('testapp', '0004_auto_20210511_1343'),
11 | ]
12 |
13 | operations = [
14 | migrations.CreateModel(
15 | name='ModelWithCleanMixin',
16 | fields=[
17 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
18 | ],
19 | bases=(ai_django_core.mixins.validation.CleanOnSaveMixin, models.Model),
20 | ),
21 | ]
22 |
--------------------------------------------------------------------------------
/testapp/migrations/0006_modelwithselector.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.2.8 on 2022-05-19 15:28
2 |
3 | from django.db import migrations, models
4 |
5 | import ai_django_core.mixins.validation
6 |
7 |
8 | class Migration(migrations.Migration):
9 | dependencies = [
10 | ('testapp', '0005_modelwithcleanmixin'),
11 | ]
12 |
13 | operations = [
14 | migrations.CreateModel(
15 | name='ModelWithSelector',
16 | fields=[
17 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
18 | ('value', models.PositiveIntegerField(default=0)),
19 | ],
20 | bases=(ai_django_core.mixins.validation.CleanOnSaveMixin, models.Model),
21 | ),
22 | ]
23 |
--------------------------------------------------------------------------------
/testapp/migrations/0007_modelwithsavewithoutsignals.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.2.8 on 2022-05-19 15:28
2 |
3 | from django.db import migrations, models
4 |
5 | import ai_django_core.mixins.models
6 |
7 |
8 | class Migration(migrations.Migration):
9 | dependencies = [
10 | ('testapp', '0006_modelwithselector'),
11 | ]
12 |
13 | operations = [
14 | migrations.CreateModel(
15 | name='ModelWithSaveWithoutSignalsMixin',
16 | fields=[
17 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
18 | ('value', models.PositiveIntegerField(default=0)),
19 | ],
20 | bases=(ai_django_core.mixins.models.SaveWithoutSignalsMixin, models.Model),
21 | ),
22 | ]
23 |
--------------------------------------------------------------------------------
/testapp/migrations/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ambient-innovation/ai-django-core/35002db94652ba84ece8ec6ae959b12e79ce628f/testapp/migrations/__init__.py
--------------------------------------------------------------------------------
/testapp/models.py:
--------------------------------------------------------------------------------
1 | from django.core.mail import EmailMultiAlternatives
2 | from django.db import models
3 | from django.db.models.signals import post_save, pre_save
4 | from django.dispatch import receiver
5 |
6 | from ai_django_core.managers import GloballyVisibleQuerySet
7 | from ai_django_core.mixins.models import PermissionModelMixin, SaveWithoutSignalsMixin
8 | from ai_django_core.mixins.validation import CleanOnSaveMixin
9 | from ai_django_core.models import CommonInfo
10 | from testapp.managers import ModelWithSelectorQuerySet
11 | from testapp.selectors import ModelWithSelectorGloballyVisibleSelector
12 |
13 |
14 | class MySingleSignalModel(models.Model):
15 | value = models.PositiveIntegerField(default=0)
16 |
17 | objects = GloballyVisibleQuerySet.as_manager()
18 |
19 | def __str__(self):
20 | return str(self.value)
21 |
22 |
23 | class ForeignKeyRelatedModel(models.Model):
24 | single_signal = models.ForeignKey(
25 | MySingleSignalModel, on_delete=models.CASCADE, related_name='foreign_key_related_models'
26 | )
27 |
28 | objects = GloballyVisibleQuerySet.as_manager()
29 |
30 | def __str__(self):
31 | return str(self.id)
32 |
33 |
34 | @receiver(pre_save, sender=MySingleSignalModel)
35 | def increase_value_no_dispatch_uid(sender, instance, **kwargs):
36 | instance.value += 1
37 |
38 |
39 | class MyMultipleSignalModel(models.Model):
40 | value = models.PositiveIntegerField(default=0)
41 |
42 | def __str__(self):
43 | return str(self.value)
44 |
45 |
46 | @receiver(pre_save, sender=MyMultipleSignalModel, dispatch_uid='test.mysinglesignalmodel.increase_value_with_uuid')
47 | def increase_value_with_dispatch_uid(sender, instance, **kwargs):
48 | instance.value += 1
49 |
50 |
51 | @receiver(post_save, sender=MyMultipleSignalModel)
52 | def send_email(sender, instance, **kwargs):
53 | msg = EmailMultiAlternatives(
54 | 'Test Mail', 'I am content', from_email='test@example.com', to=['random.dude@example.com']
55 | )
56 | msg.send()
57 |
58 |
59 | class CommonInfoBasedModel(CommonInfo):
60 | value = models.PositiveIntegerField(default=0)
61 |
62 | def __str__(self):
63 | return str(self.value)
64 |
65 |
66 | class ModelWithSelector(models.Model):
67 | value = models.PositiveIntegerField(default=0)
68 |
69 | objects = ModelWithSelectorQuerySet.as_manager()
70 | selectors = ModelWithSelectorGloballyVisibleSelector()
71 |
72 | def __str__(self):
73 | return str(self.value)
74 |
75 |
76 | class ModelWithFkToSelf(models.Model):
77 | parent = models.ForeignKey('self', blank=True, null=True, related_name='children', on_delete=models.CASCADE)
78 |
79 | def __str__(self):
80 | return str(self.id)
81 |
82 |
83 | class ModelWithOneToOneToSelf(models.Model):
84 | peer = models.OneToOneField('self', blank=True, null=True, related_name='related_peer', on_delete=models.CASCADE)
85 |
86 | def __str__(self):
87 | return str(self.id)
88 |
89 |
90 | class ModelWithCleanMixin(CleanOnSaveMixin, models.Model):
91 | def __str__(self):
92 | return str(self.id)
93 |
94 | def clean(self):
95 | return True
96 |
97 |
98 | class MyPermissionModelMixin(PermissionModelMixin, models.Model):
99 | pass
100 |
101 | def __str__(self):
102 | return str(self.id)
103 |
104 |
105 | class ModelWithSaveWithoutSignalsMixin(SaveWithoutSignalsMixin, models.Model):
106 | value = models.PositiveIntegerField(default=0)
107 |
108 | def __str__(self):
109 | return str(self.value)
110 |
111 |
112 | @receiver(pre_save, sender=ModelWithSaveWithoutSignalsMixin)
113 | def increase_value_on_pre_save(sender, instance, **kwargs):
114 | instance.value += 1
115 |
--------------------------------------------------------------------------------
/testapp/selectors.py:
--------------------------------------------------------------------------------
1 | from ai_django_core.selectors.permission import GloballyVisibleSelector
2 |
3 |
4 | class ModelWithSelectorGloballyVisibleSelector(GloballyVisibleSelector):
5 | pass
6 |
--------------------------------------------------------------------------------
/testapp/templates/403.html:
--------------------------------------------------------------------------------
1 | {% load i18n static %}
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | 403 - Forbidden
10 |
48 |
49 |
50 |
51 | {% block body %}
52 |
53 |
{% trans "Error 403" %}
54 |
55 |
You don't have access to this page.
56 |
57 |
58 | {% endblock %}
59 |
60 |
--------------------------------------------------------------------------------
/testapp/templates/test_email.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Current date test: {% now "l, d. F Y" %}
5 |
6 |
7 | Variable test: {{ my_var }}
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/testapp/templates/test_email.txt:
--------------------------------------------------------------------------------
1 | I am a different content than the HTML part to validate that I am not rendered using the HTML template.
2 |
3 | And here is the variable: {{ my_var }}.
4 |
--------------------------------------------------------------------------------
/testapp/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ambient-innovation/ai-django-core/35002db94652ba84ece8ec6ae959b12e79ce628f/testapp/tests/__init__.py
--------------------------------------------------------------------------------
/testapp/tests/missing_init/test_ok.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ambient-innovation/ai-django-core/35002db94652ba84ece8ec6ae959b12e79ce628f/testapp/tests/missing_init/test_ok.py
--------------------------------------------------------------------------------
/testapp/tests/subdirectory/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ambient-innovation/ai-django-core/35002db94652ba84ece8ec6ae959b12e79ce628f/testapp/tests/subdirectory/__init__.py
--------------------------------------------------------------------------------
/testapp/tests/subdirectory/missing_test_prefix.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ambient-innovation/ai-django-core/35002db94652ba84ece8ec6ae959b12e79ce628f/testapp/tests/subdirectory/missing_test_prefix.py
--------------------------------------------------------------------------------
/testapp/tests/subdirectory/test_ok.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ambient-innovation/ai-django-core/35002db94652ba84ece8ec6ae959b12e79ce628f/testapp/tests/subdirectory/test_ok.py
--------------------------------------------------------------------------------
/testapp/urls.py:
--------------------------------------------------------------------------------
1 | from django.contrib import admin
2 | from django.urls import include, path
3 | from rest_framework import routers
4 |
5 | from testapp.api.urls import model_router
6 |
7 | router = routers.DefaultRouter()
8 | router.registry.extend(model_router.registry)
9 |
10 | urlpatterns = [
11 | # django Admin
12 | path('admin/', admin.site.urls),
13 | # REST Viewsets
14 | path('api/v1/', include(router.urls)),
15 | ]
16 |
--------------------------------------------------------------------------------
/testapp/views.py:
--------------------------------------------------------------------------------
1 | from django.views import generic
2 |
3 | from ai_django_core.view_layer.views import UserInFormKwargsMixin
4 | from testapp.forms import CommonInfoBasedModelTestForm
5 |
6 |
7 | class UserInFormKwargsMixinView(UserInFormKwargsMixin, generic.FormView):
8 | """
9 | Generic view for updating an object without any user data being sent. Therefore, we don't need a form to validate
10 | user input.
11 | Most common use-case is toggling a flag inside an object.
12 | """
13 |
14 | form_class = CommonInfoBasedModelTestForm
15 |
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ambient-innovation/ai-django-core/35002db94652ba84ece8ec6ae959b12e79ce628f/tests/__init__.py
--------------------------------------------------------------------------------
/tests/admin/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ambient-innovation/ai-django-core/35002db94652ba84ece8ec6ae959b12e79ce628f/tests/admin/__init__.py
--------------------------------------------------------------------------------
/tests/admin/model_admin_mixins/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ambient-innovation/ai-django-core/35002db94652ba84ece8ec6ae959b12e79ce628f/tests/admin/model_admin_mixins/__init__.py
--------------------------------------------------------------------------------
/tests/admin/model_admin_mixins/test_admin_common_info_mixin.py:
--------------------------------------------------------------------------------
1 | from unittest import mock
2 |
3 | from django import forms
4 | from django.contrib import admin
5 | from django.contrib.auth.models import User
6 | from django.test import TestCase
7 |
8 | from ai_django_core.admin.model_admins.mixins import CommonInfoAdminMixin
9 | from ai_django_core.tests.mixins import RequestProviderMixin
10 | from testapp.models import CommonInfoBasedModel
11 |
12 |
13 | class CommonInfoBasedModelForm(forms.ModelForm):
14 | class Meta:
15 | model = CommonInfoBasedModel
16 | fields = ('value',)
17 |
18 |
19 | class TestCommonInfoAdminMixinAdmin(CommonInfoAdminMixin, admin.ModelAdmin):
20 | pass
21 |
22 |
23 | class CommonInfoAdminMixinTest(RequestProviderMixin, TestCase):
24 | @classmethod
25 | def setUpTestData(cls):
26 | super().setUpTestData()
27 |
28 | cls.user = User.objects.create(username='my_user')
29 | cls.request = cls.get_request(cls.user)
30 |
31 | admin.site.register(CommonInfoBasedModel, TestCommonInfoAdminMixinAdmin)
32 |
33 | @classmethod
34 | def tearDownClass(cls):
35 | super().tearDownClass()
36 |
37 | admin.site.unregister(CommonInfoBasedModel)
38 |
39 | def test_readonly_fields_are_set(self):
40 | model_admin = TestCommonInfoAdminMixinAdmin(model=CommonInfoBasedModel, admin_site=admin.site)
41 |
42 | self.assertIn('created_by', model_admin.get_readonly_fields(self.request))
43 | self.assertIn('created_at', model_admin.get_readonly_fields(self.request))
44 |
45 | self.assertIn('lastmodified_by', model_admin.get_readonly_fields(self.request))
46 | self.assertIn('lastmodified_at', model_admin.get_readonly_fields(self.request))
47 |
48 | def test_created_by_is_set_on_creation(self):
49 | model_admin = TestCommonInfoAdminMixinAdmin(model=CommonInfoBasedModel, admin_site=admin.site)
50 |
51 | obj = model_admin.save_form(self.request, CommonInfoBasedModelForm(), False)
52 |
53 | self.assertEqual(obj.created_by, self.user)
54 | self.assertEqual(obj.lastmodified_by, self.user)
55 |
56 | def test_created_by_is_not_altered_on_update(self):
57 | model_admin = TestCommonInfoAdminMixinAdmin(model=CommonInfoBasedModel, admin_site=admin.site)
58 |
59 | other_user = User.objects.create(username='other_user')
60 | with mock.patch.object(CommonInfoBasedModel, 'get_current_user', return_value=other_user):
61 | obj = CommonInfoBasedModel.objects.create(value=1, created_by=other_user, lastmodified_by=other_user)
62 |
63 | form = CommonInfoBasedModelForm(instance=obj)
64 | obj = model_admin.save_form(self.request, form, True)
65 |
66 | self.assertEqual(obj.created_by, other_user)
67 | self.assertEqual(obj.lastmodified_by, self.user)
68 |
--------------------------------------------------------------------------------
/tests/admin/model_admin_mixins/test_admin_create_form_mixin.py:
--------------------------------------------------------------------------------
1 | from django.contrib import admin
2 | from django.contrib.auth.models import User
3 | from django.forms import forms
4 | from django.test import TestCase
5 |
6 | from ai_django_core.admin.model_admins.mixins import AdminCreateFormMixin
7 | from ai_django_core.tests.mixins import RequestProviderMixin
8 | from testapp.models import ForeignKeyRelatedModel, MySingleSignalModel
9 |
10 |
11 | class TestCreateForm(forms.Form):
12 | class Meta:
13 | custom_add_form = True
14 |
15 |
16 | class TestAdminCreateFormMixinAdmin(AdminCreateFormMixin, admin.ModelAdmin):
17 | add_form = TestCreateForm
18 |
19 |
20 | class AdminCreateFormMixinTest(RequestProviderMixin, TestCase):
21 | @classmethod
22 | def setUpTestData(cls):
23 | super().setUpTestData()
24 |
25 | cls.super_user = User.objects.create(username='super_user', is_superuser=True)
26 |
27 | admin.site.register(ForeignKeyRelatedModel, TestAdminCreateFormMixinAdmin)
28 |
29 | @classmethod
30 | def tearDownClass(cls):
31 | super().tearDownClass()
32 |
33 | admin.site.unregister(ForeignKeyRelatedModel)
34 |
35 | def test_add_form_used_in_create_case(self):
36 | model_admin = TestAdminCreateFormMixinAdmin(model=ForeignKeyRelatedModel, admin_site=admin.site)
37 |
38 | form = model_admin.get_form(self.get_request(self.super_user))
39 |
40 | # Use Meta attribute from custom form class to determine if form was used. Base form is being wrapped
41 | # so we cannot assert the class.
42 | self.assertTrue(form.Meta.custom_add_form)
43 |
44 | def test_add_form_not_used_in_edit_case(self):
45 | model_admin = TestAdminCreateFormMixinAdmin(model=ForeignKeyRelatedModel, admin_site=admin.site)
46 |
47 | form = model_admin.get_form(
48 | self.get_request(self.super_user), obj=ForeignKeyRelatedModel(single_signal=MySingleSignalModel(value=1))
49 | )
50 |
51 | with self.assertRaises(AttributeError):
52 | self.assertFalse(form.Meta.custom_add_form)
53 |
--------------------------------------------------------------------------------
/tests/admin/model_admin_mixins/test_admin_no_inlines_for_create_mixin.py:
--------------------------------------------------------------------------------
1 | from django.contrib import admin
2 | from django.contrib.auth.models import User
3 | from django.test import TestCase
4 |
5 | from ai_django_core.admin.model_admins.mixins import AdminNoInlinesForCreateMixin
6 | from ai_django_core.tests.mixins import RequestProviderMixin
7 | from testapp.models import ForeignKeyRelatedModel, MySingleSignalModel
8 |
9 |
10 | class ForeignKeyRelatedModelTabularInline(admin.TabularInline):
11 | model = ForeignKeyRelatedModel
12 |
13 |
14 | class TestAdminNoInlinesForCreateMixinAdmin(AdminNoInlinesForCreateMixin, admin.ModelAdmin):
15 | inlines = (ForeignKeyRelatedModelTabularInline,)
16 |
17 |
18 | class AdminNoInlinesForCreateMixinTest(RequestProviderMixin, TestCase):
19 | @classmethod
20 | def setUpTestData(cls):
21 | super().setUpTestData()
22 |
23 | cls.super_user = User.objects.create(username='super_user', is_superuser=True)
24 |
25 | admin.site.register(MySingleSignalModel, TestAdminNoInlinesForCreateMixinAdmin)
26 |
27 | @classmethod
28 | def tearDownClass(cls):
29 | super().tearDownClass()
30 |
31 | admin.site.unregister(MySingleSignalModel)
32 |
33 | def test_inlines_are_removed_on_create(self):
34 | model_admin = TestAdminNoInlinesForCreateMixinAdmin(model=MySingleSignalModel, admin_site=admin.site)
35 |
36 | self.assertEqual(model_admin.get_inline_instances(self.get_request(self.super_user), obj=None), [])
37 |
38 | def test_inlines_are_not_removed_on_edit(self):
39 | model_admin = TestAdminNoInlinesForCreateMixinAdmin(model=MySingleSignalModel, admin_site=admin.site)
40 |
41 | self.assertEqual(
42 | len(model_admin.get_inline_instances(self.get_request(self.super_user), obj=MySingleSignalModel(value=1))),
43 | 1,
44 | )
45 |
--------------------------------------------------------------------------------
/tests/admin/model_admin_mixins/test_admin_request_in_form_mixin.py:
--------------------------------------------------------------------------------
1 | from django.contrib import admin
2 | from django.contrib.auth.models import User
3 | from django.http import HttpRequest
4 | from django.test import TestCase
5 |
6 | from ai_django_core.admin.model_admins.mixins import AdminRequestInFormMixin
7 | from ai_django_core.tests.mixins import RequestProviderMixin
8 | from testapp.models import MySingleSignalModel
9 |
10 |
11 | class TestAdminRequestInFormMixinAdmin(AdminRequestInFormMixin, admin.ModelAdmin):
12 | pass
13 |
14 |
15 | class AdminRequestInFormMixinTest(RequestProviderMixin, TestCase):
16 | @classmethod
17 | def setUpTestData(cls):
18 | super().setUpTestData()
19 |
20 | cls.super_user = User.objects.create(username='super_user', is_superuser=True)
21 |
22 | admin.site.register(MySingleSignalModel, TestAdminRequestInFormMixinAdmin)
23 |
24 | @classmethod
25 | def tearDownClass(cls):
26 | super().tearDownClass()
27 |
28 | admin.site.unregister(MySingleSignalModel)
29 |
30 | def test_request_is_in_form(self):
31 | model_admin = TestAdminRequestInFormMixinAdmin(model=MySingleSignalModel, admin_site=admin.site)
32 | form = model_admin.get_form(self.get_request(self.super_user))
33 |
34 | self.assertIsInstance(form.request, HttpRequest)
35 |
--------------------------------------------------------------------------------
/tests/admin/model_admin_mixins/test_deactivatable_change_view_admin_mixin.py:
--------------------------------------------------------------------------------
1 | from unittest import mock
2 |
3 | from django.contrib import admin
4 | from django.contrib.auth.models import User
5 | from django.http import HttpResponseRedirect
6 | from django.test import TestCase
7 |
8 | from ai_django_core.admin.model_admins.mixins import DeactivatableChangeViewAdminMixin
9 | from ai_django_core.tests.mixins import RequestProviderMixin
10 |
11 |
12 | class TestAdmin(DeactivatableChangeViewAdminMixin, admin.ModelAdmin):
13 | pass
14 |
15 |
16 | class DeactivatableChangeViewAdminMixinTest(RequestProviderMixin, TestCase):
17 | @classmethod
18 | def setUpTestData(cls):
19 | super().setUpTestData()
20 |
21 | # Use random model for this meta test
22 | cls.user = User.objects.create(username="test_user", is_superuser=False)
23 | cls.super_user = User.objects.create(username="super_user", is_superuser=True)
24 |
25 | def test_can_see_change_view_positive_flag(self):
26 | admin_cls = TestAdmin(admin_site=None, model=User)
27 | self.assertTrue(admin_cls.can_see_change_view(request=self.get_request()))
28 |
29 | def test_can_see_change_view_negative_flag(self):
30 | admin_cls = TestAdmin(admin_site=None, model=User)
31 | admin_cls.enable_change_view = False
32 | self.assertFalse(admin_cls.can_see_change_view(request=self.get_request()))
33 |
34 | def test_get_list_display_links_can_see_method_called(self):
35 | admin_cls = TestAdmin(admin_site=None, model=User)
36 | with mock.patch.object(admin_cls, 'can_see_change_view', return_value=True) as mock_method:
37 | admin_cls.get_list_display_links(request=self.get_request(user=self.user), list_display=('first_name',))
38 |
39 | mock_method.assert_called_once()
40 |
41 | def test_get_list_display_links_can_see_method_positive_flag(self):
42 | admin_cls = TestAdmin(admin_site=None, model=User)
43 | field_tuple = ('first_name',)
44 | self.assertEqual(
45 | list(field_tuple),
46 | admin_cls.get_list_display_links(request=self.get_request(user=self.user), list_display=field_tuple),
47 | )
48 |
49 | def test_get_list_display_links_can_see_method_negative_flag(self):
50 | admin_cls = TestAdmin(admin_site=None, model=User)
51 | admin_cls.enable_change_view = False
52 | self.assertIsNone(
53 | admin_cls.get_list_display_links(request=self.get_request(user=self.user), list_display=('first_name',))
54 | )
55 |
56 | def test_change_view_can_see_method_called_because_of_positive_flag(self):
57 | admin_cls = TestAdmin(admin_site=None, model=User)
58 | with mock.patch.object(admin_cls, 'can_see_change_view', return_value=True) as mocked_can_see_method:
59 | with mock.patch('django.contrib.admin.ModelAdmin.change_view') as mocked_base_change_view:
60 | admin_cls.change_view(request=self.get_request(user=self.super_user), object_id=str(self.user.id))
61 |
62 | mocked_can_see_method.assert_called_once()
63 | mocked_base_change_view.assert_called_once()
64 |
65 | def test_change_view_can_see_method_not_called_because_of_negative_flag(self):
66 | admin_cls = TestAdmin(admin_site=None, model=User)
67 | with mock.patch.object(admin_cls, 'can_see_change_view', return_value=False) as mocked_can_see_method:
68 | with mock.patch('django.contrib.admin.ModelAdmin.change_view') as mocked_base_change_view:
69 | admin_cls.change_view(request=self.get_request(user=self.super_user), object_id=str(self.user.id))
70 |
71 | mocked_can_see_method.assert_called_once()
72 | mocked_base_change_view.assert_not_called()
73 |
74 | def test_change_view_can_see_method_not_called_but_redirect(self):
75 | admin_cls = TestAdmin(admin_site=None, model=User)
76 | admin_cls.enable_change_view = False
77 | result = admin_cls.change_view(request=self.get_request(user=self.super_user), object_id=str(self.user.id))
78 |
79 | self.assertIsInstance(result, HttpResponseRedirect)
80 |
--------------------------------------------------------------------------------
/tests/admin/model_admin_mixins/test_fetch_object_mixin.py:
--------------------------------------------------------------------------------
1 | from unittest import mock
2 |
3 | from django.contrib import admin
4 | from django.contrib.auth.models import User
5 | from django.test import TestCase
6 |
7 | from ai_django_core.admin.model_admins.mixins import FetchObjectMixin
8 | from ai_django_core.tests.mixins import RequestProviderMixin
9 | from testapp.models import MySingleSignalModel
10 |
11 |
12 | class TestFetchObjectMixinAdmin(FetchObjectMixin, admin.ModelAdmin):
13 | pass
14 |
15 |
16 | class MockResolverResponse:
17 | kwargs = None
18 |
19 |
20 | class FetchObjectMixinTest(RequestProviderMixin, TestCase):
21 | @classmethod
22 | def setUpTestData(cls):
23 | super().setUpTestData()
24 |
25 | cls.super_user = User.objects.create(username='super_user', is_superuser=True)
26 |
27 | admin.site.register(MySingleSignalModel, TestFetchObjectMixinAdmin)
28 |
29 | @classmethod
30 | def tearDownClass(cls):
31 | super().tearDownClass()
32 |
33 | admin.site.unregister(MySingleSignalModel)
34 |
35 | def test_model_is_set(self):
36 | obj = MySingleSignalModel.objects.create(value=1)
37 | model_admin = TestFetchObjectMixinAdmin(model=MySingleSignalModel, admin_site=admin.site)
38 |
39 | request = self.get_request(self.super_user)
40 |
41 | return_obj = MockResolverResponse()
42 | return_obj.kwargs = {'object_id': obj.id}
43 | with mock.patch('ai_django_core.admin.model_admins.mixins.resolve', return_value=return_obj):
44 | obj_from_request = model_admin.get_object_from_request(request)
45 |
46 | self.assertEqual(obj_from_request, obj)
47 |
--------------------------------------------------------------------------------
/tests/admin/model_admin_mixins/test_fetch_parent_object_inline_mixin.py:
--------------------------------------------------------------------------------
1 | from unittest import mock
2 |
3 | from django.contrib import admin
4 | from django.contrib.auth.models import User
5 | from django.test import TestCase
6 |
7 | from ai_django_core.admin.model_admins.mixins import FetchParentObjectInlineMixin
8 | from ai_django_core.tests.mixins import RequestProviderMixin
9 | from testapp.models import ForeignKeyRelatedModel, MySingleSignalModel
10 |
11 |
12 | class ForeignKeyRelatedModelTabularInline(FetchParentObjectInlineMixin, admin.TabularInline):
13 | model = ForeignKeyRelatedModel
14 |
15 |
16 | class TestFetchParentObjectInlineMixinAdmin(admin.ModelAdmin):
17 | inlines = (ForeignKeyRelatedModelTabularInline,)
18 |
19 |
20 | class MockResolverResponse:
21 | kwargs = None
22 |
23 |
24 | class FetchParentObjectInlineMixinTest(RequestProviderMixin, TestCase):
25 | @classmethod
26 | def setUpTestData(cls):
27 | super().setUpTestData()
28 |
29 | cls.super_user = User.objects.create(username='super_user', is_superuser=True)
30 |
31 | admin.site.register(MySingleSignalModel, TestFetchParentObjectInlineMixinAdmin)
32 |
33 | @classmethod
34 | def tearDownClass(cls):
35 | super().tearDownClass()
36 |
37 | admin.site.unregister(MySingleSignalModel)
38 |
39 | def test_parent_model_is_set(self):
40 | obj = MySingleSignalModel.objects.create(value=1)
41 | model_admin = TestFetchParentObjectInlineMixinAdmin(model=MySingleSignalModel, admin_site=admin.site)
42 |
43 | request = self.get_request(self.super_user)
44 | inline_list = model_admin.inlines
45 |
46 | self.assertGreater(len(inline_list), 0)
47 |
48 | inline = inline_list[0](parent_model=MySingleSignalModel, admin_site=admin.site)
49 |
50 | return_obj = MockResolverResponse()
51 | return_obj.kwargs = {'object_id': obj.id}
52 | with mock.patch.object(model_admin.inlines[0], '_resolve_url', return_value=return_obj):
53 | inline.get_formset(request=request, obj=obj)
54 |
55 | self.assertEqual(inline.parent_object, obj)
56 |
--------------------------------------------------------------------------------
/tests/ambient_toolbox/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ambient-innovation/ai-django-core/35002db94652ba84ece8ec6ae959b12e79ce628f/tests/ambient_toolbox/__init__.py
--------------------------------------------------------------------------------
/tests/drf/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ambient-innovation/ai-django-core/35002db94652ba84ece8ec6ae959b12e79ce628f/tests/drf/__init__.py
--------------------------------------------------------------------------------
/tests/drf/test_fields.py:
--------------------------------------------------------------------------------
1 | from django.test import TestCase
2 | from rest_framework import serializers
3 | from rest_framework.serializers import ListSerializer
4 |
5 | from ai_django_core.drf.fields import RecursiveField
6 | from testapp.models import ModelWithFkToSelf, ModelWithOneToOneToSelf
7 |
8 |
9 | class TestManyTrueSerializer(serializers.ModelSerializer):
10 | children = RecursiveField(many=True)
11 |
12 | class Meta:
13 | model = ModelWithFkToSelf
14 | fields = [
15 | 'id',
16 | 'children',
17 | ]
18 |
19 |
20 | class TestManyFalseSerializer(serializers.ModelSerializer):
21 | peer = RecursiveField()
22 |
23 | class Meta:
24 | model = ModelWithOneToOneToSelf
25 | fields = [
26 | 'id',
27 | 'peer',
28 | ]
29 |
30 |
31 | class RecursiveFieldTest(TestCase):
32 | def test_many_true_regular(self):
33 | serializer = TestManyTrueSerializer()
34 |
35 | self.assertIn('children', serializer.fields)
36 | self.assertIsInstance(serializer.fields['children'], ListSerializer)
37 | self.assertIsInstance(serializer.fields['children'].child, RecursiveField)
38 |
39 | def test_many_true_representation(self):
40 | mwfts_1 = ModelWithFkToSelf.objects.create(parent=None)
41 | mwfts_2 = ModelWithFkToSelf.objects.create(parent=mwfts_1)
42 |
43 | serializer = TestManyTrueSerializer(instance=mwfts_1)
44 | representation = serializer.to_representation(instance=mwfts_1)
45 |
46 | self.assertIsInstance(representation, dict)
47 | self.assertIn('children', representation)
48 | self.assertEqual(len(representation['children']), 1)
49 | self.assertEqual(representation['children'][0]['id'], mwfts_2.id)
50 | self.assertEqual(representation['children'][0]['children'], [])
51 |
52 | def test_many_false_regular(self):
53 | serializer = TestManyFalseSerializer()
54 |
55 | self.assertIn('peer', serializer.fields)
56 | self.assertIsInstance(serializer.fields['peer'], RecursiveField)
57 |
58 | def test_many_false_representation(self):
59 | mwotos_no_peer = ModelWithOneToOneToSelf.objects.create(peer=None)
60 | mwotos_has_peer = ModelWithOneToOneToSelf.objects.create(peer=mwotos_no_peer)
61 |
62 | serializer = TestManyFalseSerializer(instance=mwotos_has_peer)
63 | representation = serializer.to_representation(instance=mwotos_has_peer)
64 |
65 | self.assertIsInstance(representation, dict)
66 | self.assertIn('peer', representation)
67 | self.assertEqual(len(representation['peer']), 2)
68 | self.assertEqual(representation['peer']['id'], mwotos_no_peer.id)
69 | self.assertEqual(representation['peer']['peer'], None)
70 |
--------------------------------------------------------------------------------
/tests/files/testfile.txt:
--------------------------------------------------------------------------------
1 | I am an awesome test file.
2 |
--------------------------------------------------------------------------------
/tests/mixins/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ambient-innovation/ai-django-core/35002db94652ba84ece8ec6ae959b12e79ce628f/tests/mixins/__init__.py
--------------------------------------------------------------------------------
/tests/mixins/validation.py:
--------------------------------------------------------------------------------
1 | from unittest import mock
2 |
3 | from django.test import TestCase
4 |
5 | from testapp.models import ModelWithCleanMixin
6 |
7 |
8 | class CleanOnSaveMixinTest(TestCase):
9 | def test_clean_is_called(self):
10 | obj = ModelWithCleanMixin()
11 | with mock.patch.object(obj, 'clean') as mocked_method:
12 | obj.save()
13 |
14 | mocked_method.assert_called_once()
15 |
--------------------------------------------------------------------------------
/tests/selectors/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ambient-innovation/ai-django-core/35002db94652ba84ece8ec6ae959b12e79ce628f/tests/selectors/__init__.py
--------------------------------------------------------------------------------
/tests/selectors/test_base.py:
--------------------------------------------------------------------------------
1 | from django.db.models import manager
2 | from django.test import TestCase
3 |
4 | from ai_django_core.selectors.base import Selector
5 | from testapp.models import ModelWithSelector
6 |
7 |
8 | class SelectorTest(TestCase):
9 | def test_selector_inherits_from_django_manager(self):
10 | base_selector = Selector()
11 | self.assertIsInstance(base_selector, manager.Manager)
12 |
13 | def test_registering_in_model_is_possible(self):
14 | self.assertIsInstance(ModelWithSelector.selectors, manager.Manager)
15 |
--------------------------------------------------------------------------------
/tests/selectors/test_permission.py:
--------------------------------------------------------------------------------
1 | from django.test import TestCase
2 |
3 | from ai_django_core.selectors.permission import AbstractUserSpecificSelectorMixin
4 | from testapp.models import ModelWithSelector
5 |
6 |
7 | class AbstractUserSpecificSelectorMixinTest(TestCase):
8 | @classmethod
9 | def setUpTestData(cls):
10 | super().setUpTestData()
11 |
12 | cls.mixin = AbstractUserSpecificSelectorMixin()
13 |
14 | def test_visible_for_method_available(self):
15 | with self.assertRaises(NotImplementedError):
16 | self.mixin.visible_for(user_id=-1)
17 |
18 | def test_editable_for_method_available(self):
19 | with self.assertRaises(NotImplementedError):
20 | self.mixin.editable_for(user_id=-1)
21 |
22 | def test_deletable_for_method_available(self):
23 | with self.assertRaises(NotImplementedError):
24 | self.mixin.deletable_for(user_id=-1)
25 |
26 |
27 | class GloballyVisibleSelectorMixinTest(TestCase):
28 | @classmethod
29 | def setUpTestData(cls):
30 | super().setUpTestData()
31 |
32 | cls.obj = ModelWithSelector.objects.create(value=1)
33 |
34 | def test_visible_for_method_available(self):
35 | qs = ModelWithSelector.selectors.visible_for(user_id=-1)
36 |
37 | self.assertEqual(qs.count(), 1)
38 | self.assertIn(self.obj, qs)
39 |
40 | def test_editable_for_method_available(self):
41 | qs = ModelWithSelector.selectors.editable_for(user_id=-1)
42 |
43 | self.assertEqual(qs.count(), 1)
44 | self.assertIn(self.obj, qs)
45 |
46 | def test_deletable_for_method_available(self):
47 | qs = ModelWithSelector.selectors.deletable_for(user_id=-1)
48 |
49 | self.assertEqual(qs.count(), 1)
50 | self.assertIn(self.obj, qs)
51 |
--------------------------------------------------------------------------------
/tests/test_admin_forms.py:
--------------------------------------------------------------------------------
1 | from crispy_forms.helper import FormHelper
2 | from crispy_forms.layout import Layout
3 | from django.test import TestCase
4 |
5 | from ai_django_core.admin.views.forms import AdminCrispyForm
6 |
7 |
8 | class AdminFormTest(TestCase):
9 | def test_admin_crispy_form_regular(self):
10 | # Form provides mostly styling, so we just validate that it renders
11 | form = AdminCrispyForm()
12 |
13 | self.assertIsInstance(form.helper, FormHelper)
14 | self.assertIsInstance(form.helper.layout, Layout)
15 | self.assertEqual(form.helper.form_method, 'post')
16 |
--------------------------------------------------------------------------------
/tests/test_admin_inlines.py:
--------------------------------------------------------------------------------
1 | from django.contrib import admin
2 | from django.contrib.auth.models import User
3 | from django.test import TestCase
4 |
5 | from ai_django_core.admin.model_admins.inlines import ReadOnlyTabularInline
6 | from ai_django_core.tests.mixins import RequestProviderMixin
7 | from testapp.models import ForeignKeyRelatedModel, MySingleSignalModel
8 |
9 |
10 | class TestReadOnlyTabularInline(ReadOnlyTabularInline):
11 | model = ForeignKeyRelatedModel
12 |
13 |
14 | class AdminInlineTest(RequestProviderMixin, TestCase):
15 | @classmethod
16 | def setUpTestData(cls):
17 | super().setUpTestData()
18 |
19 | cls.super_user = User.objects.create(username='super_user', is_superuser=True)
20 |
21 | def test_read_only_tabular_inline_admin_all_fields_readonly(self):
22 | obj = MySingleSignalModel(value=1)
23 | fk_related_obj = ForeignKeyRelatedModel(single_signal=obj)
24 |
25 | admin_class = TestReadOnlyTabularInline(parent_model=MySingleSignalModel, admin_site=admin.site)
26 | readonly_fields = admin_class.get_readonly_fields(request=self.get_request(), obj=fk_related_obj)
27 |
28 | self.assertEqual(len(readonly_fields), 1)
29 | self.assertIn('single_signal', readonly_fields)
30 |
31 | def test_read_only_admin_tabular_inline_no_change_permissions(self):
32 | admin_class = TestReadOnlyTabularInline(parent_model=MySingleSignalModel, admin_site=admin.site)
33 |
34 | request = self.get_request(self.super_user)
35 |
36 | self.assertFalse(admin_class.has_add_permission(request))
37 | self.assertFalse(admin_class.has_change_permission(request))
38 | self.assertFalse(admin_class.has_delete_permission(request))
39 |
--------------------------------------------------------------------------------
/tests/test_admin_model_admins_classes.py:
--------------------------------------------------------------------------------
1 | from django.contrib import admin
2 | from django.contrib.auth.models import User
3 | from django.test import TestCase
4 |
5 | from ai_django_core.admin.model_admins.classes import EditableOnlyAdmin, ReadOnlyAdmin
6 | from ai_django_core.tests.mixins import RequestProviderMixin
7 | from testapp.models import MyMultipleSignalModel, MySingleSignalModel
8 |
9 |
10 | class TestReadOnlyAdmin(ReadOnlyAdmin):
11 | pass
12 |
13 |
14 | class TestEditableOnlyAdmin(EditableOnlyAdmin):
15 | pass
16 |
17 |
18 | class AdminClassesTest(RequestProviderMixin, TestCase):
19 | @classmethod
20 | def setUpTestData(cls):
21 | super().setUpTestData()
22 |
23 | cls.super_user = User.objects.create(username='super_user', is_superuser=True)
24 |
25 | admin.site.register(MySingleSignalModel, TestReadOnlyAdmin)
26 | admin.site.register(MyMultipleSignalModel, TestEditableOnlyAdmin)
27 |
28 | @classmethod
29 | def tearDownClass(cls):
30 | super().tearDownClass()
31 |
32 | admin.site.unregister(MySingleSignalModel)
33 | admin.site.unregister(MyMultipleSignalModel)
34 |
35 | def test_read_only_admin_all_fields_readonly(self):
36 | obj = MySingleSignalModel(value=1)
37 |
38 | admin_class = TestReadOnlyAdmin(model=obj, admin_site=admin.site)
39 | readonly_fields = admin_class.get_readonly_fields(request=self.get_request(), obj=obj)
40 |
41 | self.assertEqual(len(readonly_fields), 2)
42 | self.assertIn('id', readonly_fields)
43 | self.assertIn('value', readonly_fields)
44 |
45 | def test_read_only_admin_no_change_permissions(self):
46 | admin_class = TestReadOnlyAdmin(model=MySingleSignalModel, admin_site=admin.site)
47 |
48 | request = self.get_request(self.super_user)
49 |
50 | self.assertFalse(admin_class.has_add_permission(request))
51 | self.assertFalse(admin_class.has_change_permission(request))
52 | self.assertFalse(admin_class.has_delete_permission(request))
53 |
54 | def test_editable_only_admin_delete_action_removed(self):
55 | obj = MyMultipleSignalModel(value=1)
56 | admin_class = TestEditableOnlyAdmin(model=obj, admin_site=admin.site)
57 |
58 | request = self.get_request(self.super_user)
59 | actions = admin_class.get_actions(request=request)
60 |
61 | self.assertNotIn('delete_selected', actions)
62 |
63 | def test_editable_only_admin_no_change_permissions(self):
64 | admin_class = TestEditableOnlyAdmin(model=MyMultipleSignalModel, admin_site=admin.site)
65 |
66 | request = self.get_request(self.super_user)
67 |
68 | self.assertTrue(admin_class.has_change_permission(request))
69 |
70 | self.assertFalse(admin_class.has_add_permission(request))
71 | self.assertFalse(admin_class.has_delete_permission(request))
72 |
--------------------------------------------------------------------------------
/tests/test_admin_view_mixins.py:
--------------------------------------------------------------------------------
1 | from django.contrib.admin import AdminSite
2 | from django.contrib.auth.models import User
3 | from django.core.exceptions import PermissionDenied
4 | from django.test import TestCase
5 | from django.views import generic
6 |
7 | from ai_django_core.admin.views.mixins import AdminViewMixin
8 | from ai_django_core.tests.mixins import RequestProviderMixin
9 | from testapp.models import MySingleSignalModel
10 |
11 |
12 | class TestView(AdminViewMixin, generic.TemplateView):
13 | model = MySingleSignalModel
14 | admin_page_title = 'My fancy title'
15 | template_name = 'test_email.html'
16 |
17 |
18 | class AdminViewMixinTest(RequestProviderMixin, TestCase):
19 | @classmethod
20 | def setUpTestData(cls):
21 | super().setUpTestData()
22 |
23 | cls.view = TestView()
24 |
25 | cls.super_user = User.objects.create(username='super_user', is_superuser=True)
26 | cls.regular_user = User.objects.create(username='test_user', is_superuser=False)
27 |
28 | # View needs a request since django 3.2
29 | request = cls.get_request(cls.super_user)
30 | cls.view.request = request
31 |
32 | def test_admin_view_mixin_has_view_permission_positive_case(self):
33 | self.assertTrue(self.view.has_view_permission(self.super_user))
34 |
35 | def test_admin_view_mixin_has_view_permission_negative_case(self):
36 | self.assertFalse(self.view.has_view_permission(self.regular_user))
37 |
38 | def test_admin_view_mixin_access_allowed_for_superusers(self):
39 | request = self.get_request(self.super_user)
40 | self.view.request = request
41 |
42 | self.view.dispatch(request=request)
43 |
44 | def test_admin_view_mixin_access_blocked_for_non_superusers(self):
45 | request = self.get_request(self.regular_user)
46 |
47 | with self.assertRaises(PermissionDenied):
48 | self.view.dispatch(request=request)
49 |
50 | def test_admin_view_mixin_get_admin_site_regular(self):
51 | self.assertIsInstance(self.view.get_admin_site(), AdminSite)
52 |
53 | def test_admin_view_mixin_get_context_data_regular(self):
54 | context_data = self.view.get_context_data()
55 |
56 | # Simply assert custom fields are available
57 | self.assertIn('site_header', context_data)
58 | self.assertIn('site_title', context_data)
59 | self.assertIn('name', context_data)
60 | self.assertIn('original', context_data)
61 | self.assertIn('is_nav_sidebar_enabled', context_data)
62 | self.assertIn('available_apps', context_data)
63 | self.assertIn('opts', context_data)
64 | self.assertIn('app_label', context_data['opts'])
65 | self.assertIn('verbose_name', context_data['opts'])
66 | self.assertIn('verbose_name_plural', context_data['opts'])
67 | self.assertIn('model_name', context_data['opts'])
68 | self.assertIn('app_config', context_data['opts'])
69 | self.assertIn('verbose_name', context_data['opts']['app_config'])
70 | self.assertIn('has_permission', context_data)
71 |
--------------------------------------------------------------------------------
/tests/test_context_manager.py:
--------------------------------------------------------------------------------
1 | from django.core import mail
2 | from django.db.models import signals
3 | from django.test import TestCase
4 |
5 | from ai_django_core.context_manager import TempDisconnectSignal
6 | from testapp.models import (
7 | MyMultipleSignalModel,
8 | MySingleSignalModel,
9 | increase_value_no_dispatch_uid,
10 | increase_value_with_dispatch_uid,
11 | send_email,
12 | )
13 |
14 |
15 | class TempDisconnectSignalTest(TestCase):
16 | def test_single_signal_executed_regular(self):
17 | obj = MySingleSignalModel.objects.create()
18 | self.assertEqual(obj.value, 1)
19 |
20 | def test_single_signal_not_executed(self):
21 | kwargs = {
22 | 'signal': signals.pre_save,
23 | 'receiver': increase_value_no_dispatch_uid,
24 | 'sender': MySingleSignalModel,
25 | }
26 |
27 | with TempDisconnectSignal(**kwargs):
28 | obj = MySingleSignalModel.objects.create()
29 |
30 | self.assertEqual(obj.value, 0)
31 |
32 | def test_multiple_signals_not_executed(self):
33 | kwargs_pre = {
34 | 'signal': signals.pre_save,
35 | 'receiver': increase_value_with_dispatch_uid,
36 | 'sender': MyMultipleSignalModel,
37 | 'dispatch_uid': 'test.mysinglesignalmodel.increase_value_with_uuid',
38 | }
39 | kwargs_post = {
40 | 'signal': signals.post_save,
41 | 'receiver': send_email,
42 | 'sender': MyMultipleSignalModel,
43 | }
44 |
45 | with TempDisconnectSignal(**kwargs_pre):
46 | with TempDisconnectSignal(**kwargs_post):
47 | obj = MyMultipleSignalModel.objects.create()
48 |
49 | outbox = len(mail.outbox)
50 |
51 | self.assertEqual(obj.value, 0)
52 | self.assertEqual(outbox, 0)
53 |
54 | def test_multiple_signals_one_still_active(self):
55 | kwargs_pre = {
56 | 'signal': signals.pre_save,
57 | 'receiver': increase_value_with_dispatch_uid,
58 | 'sender': MyMultipleSignalModel,
59 | 'dispatch_uid': 'test.mysinglesignalmodel.increase_value_with_uuid',
60 | }
61 |
62 | with TempDisconnectSignal(**kwargs_pre):
63 | obj = MyMultipleSignalModel.objects.create()
64 |
65 | outbox = len(mail.outbox)
66 |
67 | self.assertEqual(obj.value, 0)
68 | self.assertEqual(outbox, 1)
69 |
--------------------------------------------------------------------------------
/tests/test_log_whodid.py:
--------------------------------------------------------------------------------
1 | from ai_django_core.utils import log_whodid
2 |
3 |
4 | def test_log_who_did_new_object(mocker):
5 | """
6 | Tests if a new object that doesn't have a value for the ``created_by`` and ``lastmodified_by`` fields, is handled
7 | properly.
8 | """
9 | user = mocker.MagicMock()
10 | obj = mocker.MagicMock()
11 | obj.created_by = None
12 | log_whodid(obj, user)
13 | assert obj.created_by == user
14 | assert obj.lastmodified_by == user
15 |
16 |
17 | def test_log_who_did_existing_values(mocker):
18 | """
19 | Tests if, for an object that already has a value for ``created_by``, only the ``lastmodified_by`` field is updated.
20 | """
21 | user = mocker.MagicMock()
22 | obj = mocker.MagicMock()
23 | old_user = obj.created_by
24 | log_whodid(obj, user)
25 | assert obj.created_by == old_user
26 | assert obj.lastmodified_by == user
27 |
--------------------------------------------------------------------------------
/tests/test_managers.py:
--------------------------------------------------------------------------------
1 | from django.contrib.auth.models import User
2 | from django.test import TestCase
3 |
4 | from testapp.models import MySingleSignalModel
5 |
6 |
7 | class GloballyVisibleQuerySetTest(TestCase):
8 | @classmethod
9 | def setUpTestData(cls):
10 | super().setUpTestData()
11 |
12 | # Create test user
13 | cls.user = User.objects.create(username='my-username')
14 |
15 | # Create list of objects
16 | cls.object_list = [
17 | MySingleSignalModel.objects.create(value=1),
18 | MySingleSignalModel.objects.create(value=2),
19 | ]
20 |
21 | def test_visible_for_regular(self):
22 | self.assertGreater(len(self.object_list), 0)
23 | self.assertEqual(MySingleSignalModel.objects.visible_for(self.user).count(), len(self.object_list))
24 |
25 | def test_editable_for_regular(self):
26 | self.assertGreater(len(self.object_list), 0)
27 | self.assertEqual(MySingleSignalModel.objects.editable_for(self.user).count(), len(self.object_list))
28 |
29 | def test_deletable_for_regular(self):
30 | self.assertGreater(len(self.object_list), 0)
31 | self.assertEqual(MySingleSignalModel.objects.deletable_for(self.user).count(), len(self.object_list))
32 |
--------------------------------------------------------------------------------
/tests/test_math.py:
--------------------------------------------------------------------------------
1 | from django.test import TestCase
2 |
3 | from ai_django_core.utils.math import round_to_decimal, round_up_decimal
4 |
5 |
6 | class MathTest(TestCase):
7 | def test_round_to_decimal_no_precision(self):
8 | self.assertEqual(round_to_decimal(5.2, 1), 5)
9 |
10 | def test_round_to_decimal_two_precisions(self):
11 | self.assertEqual(round_to_decimal(5.62, 0.05), 5.60)
12 |
13 | def test_round_to_decimal_down_lower(self):
14 | self.assertEqual(round_to_decimal(5.2), 5.0)
15 |
16 | def test_round_to_decimal_up_lower(self):
17 | self.assertEqual(round_to_decimal(5.4), 5.5)
18 |
19 | def test_round_to_decimal_down_upper(self):
20 | self.assertEqual(round_to_decimal(5.6), 5.5)
21 |
22 | def test_round_to_decimal_no_round(self):
23 | self.assertEqual(round_to_decimal(5.0), 5.0)
24 |
25 | def test_round_to_decimal_up_upper(self):
26 | self.assertEqual(round_to_decimal(5.8), 6.0)
27 |
28 | def test_round_up_decimal_no_precision(self):
29 | self.assertEqual(round_up_decimal(5.2, 1), 6)
30 |
31 | def test_round_up_decimal_two_precisions(self):
32 | self.assertEqual(round_up_decimal(5.62, 0.05), 5.65)
33 |
34 | def test_round_up_decimal_down_lower(self):
35 | self.assertEqual(round_up_decimal(5.2), 5.5)
36 |
37 | def test_round_up_decimal_up_lower(self):
38 | self.assertEqual(round_up_decimal(5.4), 5.5)
39 |
40 | def test_round_up_decimal_down_upper(self):
41 | self.assertEqual(round_up_decimal(5.6), 6.0)
42 |
43 | def test_round_up_decimal_no_round(self):
44 | self.assertEqual(round_up_decimal(5.0), 5.0)
45 |
46 | def test_round_up_decimal_up_upper(self):
47 | self.assertEqual(round_up_decimal(5.8), 6.0)
48 |
--------------------------------------------------------------------------------
/tests/test_middleware.py:
--------------------------------------------------------------------------------
1 | import threading
2 | import time
3 | from unittest.mock import Mock
4 |
5 | from django.http import HttpResponse
6 | from django.test import TestCase
7 |
8 | from ai_django_core.middleware.current_user import CurrentUserMiddleware
9 |
10 |
11 | class CurrentUserMiddlewareTest(TestCase):
12 | def test_current_user_is_none_if_no_user_given(self):
13 | self.assertIsNone(CurrentUserMiddleware.get_current_user())
14 |
15 | def test_current_user_is_none_if_request_user_is_none(self):
16 | set_current_user(user=None)
17 | self.assertIsNone(CurrentUserMiddleware.get_current_user())
18 |
19 | def test_current_user_is_same_as_request_user(self):
20 | new_user = Mock(user_name='test_user')
21 | set_current_user(user=new_user)
22 | current_user = CurrentUserMiddleware.get_current_user()
23 |
24 | self.assertIsNotNone(current_user)
25 | self.assertEqual(current_user, new_user)
26 | self.assertEqual(current_user.user_name, 'test_user')
27 |
28 | def test_current_user_is_thread_safe(self):
29 | user1 = Mock(user_name='user1')
30 | user2 = Mock(user_name='user2')
31 | current_users = []
32 |
33 | first_thread = threading.Thread(target=set_current_user, args=(user1, 0, 1, current_users))
34 | second_thread = threading.Thread(target=set_current_user, args=(user2, 0.5, 0, current_users))
35 | first_thread.start()
36 | second_thread.start()
37 | first_thread.join()
38 | second_thread.join()
39 |
40 | self.assertEqual(current_users[0], user2)
41 | self.assertEqual(current_users[0].user_name, 'user2')
42 | self.assertEqual(current_users[1], user1)
43 | self.assertEqual(current_users[1].user_name, 'user1')
44 |
45 | def test_user_is_cleared_after_request(self):
46 | user = Mock(user_name='test_user')
47 | request = Mock(user=user)
48 | middleware = CurrentUserMiddleware(get_response=lambda request: HttpResponse(status=200))
49 | response = middleware(request)
50 | self.assertEqual(response.status_code, 200)
51 | self.assertIsNone(CurrentUserMiddleware.get_current_user())
52 |
53 |
54 | def set_current_user(user=None, delay_before_request=0, delay_after_request=0, current_users=None):
55 | request = Mock()
56 | request.user = user
57 | middleware = CurrentUserMiddleware(get_response=lambda request: HttpResponse(status=200))
58 |
59 | if delay_before_request:
60 | time.sleep(delay_before_request)
61 | middleware.process_request(request)
62 | if delay_after_request > 0:
63 | time.sleep(delay_after_request)
64 |
65 | if current_users is not None:
66 | current_users.append(CurrentUserMiddleware.get_current_user())
67 |
--------------------------------------------------------------------------------
/tests/test_mixins_models.py:
--------------------------------------------------------------------------------
1 | from django.test import TestCase
2 |
3 | from testapp.models import ModelWithSaveWithoutSignalsMixin
4 |
5 |
6 | class SaveWithoutSignalsTestCase(TestCase):
7 | @classmethod
8 | def setUpTestData(cls):
9 | cls.instance = ModelWithSaveWithoutSignalsMixin.objects.create()
10 |
11 | def test_signals_are_executed_on_normal_save_call(self):
12 | value_before = self.instance.value
13 |
14 | self.instance.save()
15 | self.instance.refresh_from_db()
16 |
17 | self.assertEqual(value_before + 1, self.instance.value)
18 |
19 | def test_signals_are_not_executed_on_save_without_signals_call(self):
20 | value_before = self.instance.value
21 |
22 | self.instance.save_without_signals()
23 | self.instance.refresh_from_db()
24 |
25 | self.assertEqual(value_before, self.instance.value)
26 |
27 | def test_signals_are_not_executed_on_save_without_signals_call_but_then_reexecuted_on_normal_save_call(self):
28 | value_before = self.instance.value
29 |
30 | self.instance.save_without_signals()
31 | self.instance.refresh_from_db()
32 |
33 | self.assertEqual(value_before, self.instance.value)
34 |
35 | self.instance.save()
36 |
37 | self.assertEqual(value_before + 1, self.instance.value)
38 |
--------------------------------------------------------------------------------
/tests/test_models.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | from unittest.mock import PropertyMock, patch
3 |
4 | from django.test import TestCase
5 | from freezegun import freeze_time
6 |
7 | from testapp.models import CommonInfoBasedModel
8 |
9 |
10 | class CommonInfoTest(TestCase):
11 | @freeze_time('2022-06-26 10:00')
12 | def test_save_update_fields_common_fields_set(self):
13 | with freeze_time('2020-09-19'):
14 | obj = CommonInfoBasedModel.objects.create(value=1)
15 | obj.value = 2
16 | obj.save(update_fields=('value',))
17 |
18 | obj.refresh_from_db()
19 | self.assertEqual(obj.value, 2)
20 | self.assertEqual(obj.lastmodified_at, datetime.datetime(2022, 6, 26, 10))
21 |
22 | @patch('testapp.models.CommonInfoBasedModel.ALWAYS_UPDATE_FIELDS', new_callable=PropertyMock)
23 | @freeze_time('2022-06-26 10:00')
24 | def test_save_update_fields_common_fields_set_without_always_update(self, always_update_mock):
25 | always_update_mock.return_value = False
26 | with freeze_time('2020-09-19'):
27 | obj = CommonInfoBasedModel.objects.create(value=1)
28 | obj.value = 2
29 | obj.save(update_fields=('value',))
30 |
31 | obj.refresh_from_db()
32 | self.assertEqual(obj.value, 2)
33 | self.assertEqual(obj.lastmodified_at, datetime.datetime(2020, 9, 19))
34 |
35 | @freeze_time('2022-06-26 10:00')
36 | def test_save_common_fields_set_without_update_fields(self):
37 | with freeze_time('2020-09-19'):
38 | obj = CommonInfoBasedModel.objects.create(value=1)
39 | obj.value = 2
40 | obj.save()
41 |
42 | obj.refresh_from_db()
43 | self.assertEqual(obj.value, 2)
44 | self.assertEqual(obj.lastmodified_at, datetime.datetime(2022, 6, 26, 10))
45 |
--------------------------------------------------------------------------------
/tests/test_rest_api_mixins.py:
--------------------------------------------------------------------------------
1 | from django.contrib.auth.models import AbstractUser, User
2 | from django.test import TestCase
3 | from rest_framework import status
4 | from rest_framework.reverse import reverse
5 |
6 | from ai_django_core.drf.tests import BaseViewSetTestMixin
7 | from testapp.api import views
8 | from testapp.models import MySingleSignalModel
9 |
10 |
11 | class BaseApiTest(BaseViewSetTestMixin, TestCase):
12 | def get_default_api_user(self) -> AbstractUser:
13 | return User.objects.create(username='my-username', is_active=True)
14 |
15 |
16 | class MySingleSignalModelApiViewTest(BaseApiTest):
17 | view_class = views.MySingleSignalModelViewSet
18 |
19 | @classmethod
20 | def setUpTestData(cls):
21 | super().setUpTestData()
22 |
23 | # Create list of objects
24 | cls.object_list = [
25 | MySingleSignalModel.objects.create(value=1),
26 | MySingleSignalModel.objects.create(value=2),
27 | ]
28 |
29 | def test_action_not_activated(self):
30 | with self.assertRaises(AttributeError):
31 | self.execute_request(
32 | method='post',
33 | url=reverse('my-single-signal-model-list'),
34 | viewset_kwargs={'post': 'create'},
35 | user=self.default_api_user,
36 | )
37 |
38 | def test_list_authentication_required(self):
39 | self.validate_authentication_required(url=reverse('my-single-signal-model-list'), method='get', view='list')
40 |
41 | def test_list_regular(self):
42 | response = self.execute_request(
43 | method='get',
44 | url=reverse('my-single-signal-model-list'),
45 | viewset_kwargs={'get': 'list'},
46 | user=self.default_api_user,
47 | )
48 |
49 | self.assertEqual(response.status_code, status.HTTP_200_OK)
50 | self.assertEqual(len(response.data), len(self.object_list))
51 |
--------------------------------------------------------------------------------
/tests/test_scrubbing_service.py:
--------------------------------------------------------------------------------
1 | from django.test import TestCase, override_settings
2 |
3 | from ai_django_core.services.custom_scrubber import AbstractScrubbingService
4 |
5 |
6 | class AbstractScrubbingServiceTest(TestCase):
7 | @classmethod
8 | def setUpClass(cls):
9 | super().setUpClass()
10 |
11 | cls.service = AbstractScrubbingService()
12 |
13 | @override_settings(DEBUG=False)
14 | def test_scrubber_debug_mode_needs_to_be_active(self):
15 | self.assertEqual(self.service.process(), False)
16 |
17 | @override_settings(DEBUG=True, INSTALLED_APPS=[])
18 | def test_scrubber_needs_to_be_installed(self):
19 | self.assertEqual(self.service.process(), False)
20 |
21 | # todo write more tests
22 |
--------------------------------------------------------------------------------
/tests/test_sentry_helper.py:
--------------------------------------------------------------------------------
1 | from django.test import TestCase
2 |
3 | from ai_django_core.sentry.helpers import strip_sensitive_data_from_sentry_event
4 |
5 |
6 | class SentryHelperTest(TestCase):
7 | def test_strip_sensitive_data_from_sentry_event_regular(self):
8 | event = {'user': {'email': 'mymail@example.com', 'ip_address': '127.0.0.1', 'username': 'my-user'}}
9 |
10 | self.assertIsInstance(strip_sensitive_data_from_sentry_event(event, None), dict)
11 |
12 | def test_strip_sensitive_data_from_sentry_event_missing_key_email(self):
13 | event = {'user': {'ip_address': '127.0.0.1', 'username': 'my-user'}}
14 |
15 | self.assertIsInstance(strip_sensitive_data_from_sentry_event(event, None), dict)
16 |
17 | def test_strip_sensitive_data_from_sentry_event_missing_key_ip_address(self):
18 | event = {'user': {'email': 'mymail@example.com', 'username': 'my-user'}}
19 |
20 | self.assertIsInstance(strip_sensitive_data_from_sentry_event(event, None), dict)
21 |
22 | def test_strip_sensitive_data_from_sentry_event_missing_key_username(self):
23 | event = {'user': {'email': 'mymail@example.com', 'ip_address': '127.0.0.1'}}
24 |
25 | self.assertIsInstance(strip_sensitive_data_from_sentry_event(event, None), dict)
26 |
--------------------------------------------------------------------------------
/tests/test_utils_cache.py:
--------------------------------------------------------------------------------
1 | from django.test import TestCase
2 |
3 | from ai_django_core.utils import clear_cache
4 |
5 |
6 | class CacheUtilTest(TestCase):
7 | def test_clear_cache_pseudo(self):
8 | # This test just executes the method to ensure the api from django is still as we expect it to be
9 | clear_cache()
10 |
--------------------------------------------------------------------------------
/tests/test_utils_file.py:
--------------------------------------------------------------------------------
1 | import pytest
2 |
3 | from ai_django_core.utils.file import crc, md5_checksum
4 |
5 |
6 | @pytest.fixture
7 | def gen_test_file(tmp_path):
8 | def inner(content):
9 | test_file = tmp_path / 'test_file.txt'
10 | test_file.write_text(content)
11 | return test_file
12 |
13 | return inner
14 |
15 |
16 | @pytest.mark.parametrize('test_func', [crc, md5_checksum])
17 | def test_closes_file(mocker, test_func):
18 | """
19 | Tests if the CRC and MD5 checksum functions use a context manager to open the file, to guarantee that the opened
20 | file descriptor is closed.
21 | """
22 | open_mock = mocker.patch('ai_django_core.utils.file.open')
23 | open_mock.return_value.__enter__.return_value.read.return_value = None # to make f.read() return None.
24 | file_mock = mocker.Mock()
25 | test_func(file_mock)
26 | assert open_mock.call_args[0][0] == file_mock
27 | assert open_mock.return_value.__exit__.call_count == 1
28 |
29 |
30 | @pytest.mark.parametrize(
31 | 'content, crc_result, md5_result',
32 | [
33 | ('The answer to life, the universe, and everything.\n', '0988D2CD', '4efb6393969f501be5c1ba7571f0c09f'),
34 | ('The quick brown fox jumps over the lazy dog', '414FA339', '9e107d9d372bb6826bd81d3542a419d6'),
35 | ('', '00000000', 'd41d8cd98f00b204e9800998ecf8427e'),
36 | ],
37 | )
38 | def test_crc_and_md5(gen_test_file, content, crc_result, md5_result):
39 | """Generates the CRC and MD5 of a test file and checks its result"""
40 | test_file = str(gen_test_file(content))
41 | result = crc(test_file)
42 | assert result == crc_result
43 | result = md5_checksum(test_file)
44 | assert result == md5_result
45 |
--------------------------------------------------------------------------------
/tests/test_utils_model.py:
--------------------------------------------------------------------------------
1 | from django.test import TestCase
2 |
3 | from ai_django_core.utils import object_to_dict
4 | from testapp.models import MySingleSignalModel
5 |
6 |
7 | class UtilModelTest(TestCase):
8 | def test_object_to_dict_regular(self):
9 | obj = MySingleSignalModel.objects.create(value=17)
10 | self.assertEqual(object_to_dict(obj), {'value': obj.value})
11 |
12 | def test_object_to_dict_blacklist(self):
13 | obj = MySingleSignalModel.objects.create(value=17)
14 | self.assertEqual(object_to_dict(obj, ['value']), {})
15 |
16 | def test_object_to_dict_with_id_with_blacklist(self):
17 | obj = MySingleSignalModel.objects.create(value=17)
18 | self.assertEqual(object_to_dict(obj, ['value'], True), {'id': obj.id})
19 |
20 | def test_object_to_dict_with_id_no_blacklist(self):
21 | obj = MySingleSignalModel.objects.create(value=17)
22 | self.assertEqual(object_to_dict(obj, include_id=True), {'id': obj.id, 'value': obj.value})
23 |
--------------------------------------------------------------------------------
/tests/test_utils_named_tuple.py:
--------------------------------------------------------------------------------
1 | from collections import OrderedDict
2 |
3 | from django.test import TestCase
4 |
5 | from ai_django_core.utils import get_key_from_tuple_by_value, get_namedtuple_choices, get_value_from_tuple_by_key
6 |
7 |
8 | class UtilsNamedTupleTest(TestCase):
9 | @classmethod
10 | def setUpClass(cls):
11 | super().setUpClass()
12 |
13 | cls.MY_CHOICE_ONE = 1
14 | cls.MY_CHOICE_TWO = 2
15 | cls.MY_CHOICE_THREE = 3
16 |
17 | cls.MY_CHOICE_LIST = (
18 | (cls.MY_CHOICE_ONE, "Choice 1"),
19 | (cls.MY_CHOICE_TWO, "Choice 2"),
20 | (cls.MY_CHOICE_THREE, "Choice 3"),
21 | )
22 |
23 | cls.colors_choices = get_namedtuple_choices(
24 | 'COLORS',
25 | (
26 | (1, 'black', 'Black'),
27 | (2, 'white', 'White'),
28 | ),
29 | )
30 |
31 | def test_get_namedtuple_choices_regular(self):
32 | self.assertEqual(self.colors_choices.black, 1)
33 | self.assertEqual(self.colors_choices.white, 2)
34 |
35 | def test_get_namedtuple_choices_get_choices_regular(self):
36 | self.assertEqual(self.colors_choices.get_choices(), [(1, 'Black'), (2, 'White')])
37 |
38 | def test_get_namedtuple_choices_get_choices_dict_regular(self):
39 | self.assertEqual(self.colors_choices.get_choices_dict(), OrderedDict([(1, 'Black'), (2, 'White')]))
40 |
41 | def test_get_namedtuple_choices_get_all_regular(self):
42 | for index, color in enumerate(self.colors_choices.get_all()):
43 | if index == 0:
44 | expected_tuple = (1, 'black', 'Black')
45 | elif index == 1:
46 | expected_tuple = (2, 'white', 'White')
47 | else:
48 | expected_tuple = 'invalid data'
49 | self.assertEqual(color, expected_tuple)
50 |
51 | def test_get_namedtuple_choices_get_choices_tuple_regular(self):
52 | self.assertEqual(self.colors_choices.get_choices_tuple(), ((1, 'black', 'Black'), (2, 'white', 'White')))
53 |
54 | def test_get_namedtuple_choices_get_values_regular(self):
55 | self.assertEqual(self.colors_choices.get_values(), [1, 2])
56 |
57 | def test_get_namedtuple_choices_get_value_by_name_regular(self):
58 | self.assertEqual(self.colors_choices.get_value_by_name('black'), 1)
59 | self.assertEqual(self.colors_choices.get_value_by_name('white'), 2)
60 | self.assertFalse(self.colors_choices.get_value_by_name('no-existing'))
61 |
62 | def test_get_namedtuple_choices_get_desc_by_value_regular(self):
63 | self.assertEqual(self.colors_choices.get_desc_by_value(1), 'Black')
64 | self.assertEqual(self.colors_choices.get_desc_by_value(2), 'White')
65 | self.assertFalse(self.colors_choices.get_desc_by_value(-1))
66 |
67 | def test_get_namedtuple_choices_get_name_by_value_regular(self):
68 | self.assertEqual(self.colors_choices.get_name_by_value(1), 'black')
69 | self.assertEqual(self.colors_choices.get_name_by_value(2), 'white')
70 | self.assertFalse(self.colors_choices.get_name_by_value(-1))
71 |
72 | def test_get_namedtuple_choices_is_valid_regular(self):
73 | self.assertTrue(self.colors_choices.is_valid(1))
74 | self.assertTrue(self.colors_choices.is_valid(2))
75 | self.assertFalse(self.colors_choices.is_valid(-1))
76 |
77 | def test_get_value_from_tuple_by_key_found(self):
78 | self.assertEqual(get_value_from_tuple_by_key(self.MY_CHOICE_LIST, self.MY_CHOICE_TWO), 'Choice 2')
79 |
80 | def test_get_value_from_tuple_by_key_not_found(self):
81 | self.assertEqual(get_value_from_tuple_by_key(self.MY_CHOICE_LIST, 99), '-')
82 |
83 | def test_get_key_from_tuple_by_value_found(self):
84 | self.assertEqual(get_key_from_tuple_by_value(self.MY_CHOICE_LIST, 'Choice 2'), self.MY_CHOICE_TWO)
85 |
86 | def test_get_key_from_tuple_by_value_not_found(self):
87 | self.assertEqual(get_key_from_tuple_by_value(self.MY_CHOICE_LIST, 'Something odd'), '-')
88 |
--------------------------------------------------------------------------------
/tests/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ambient-innovation/ai-django-core/35002db94652ba84ece8ec6ae959b12e79ce628f/tests/tests/__init__.py
--------------------------------------------------------------------------------
/tests/tests/mixins/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ambient-innovation/ai-django-core/35002db94652ba84ece8ec6ae959b12e79ce628f/tests/tests/mixins/__init__.py
--------------------------------------------------------------------------------
/tests/tests/mixins/models.py:
--------------------------------------------------------------------------------
1 | from django.test import TestCase
2 |
3 | from testapp.models import MyPermissionModelMixin
4 |
5 |
6 | class PermissionModelMixinTest(TestCase):
7 | def test_meta_managed_false(self):
8 | self.assertFalse(MyPermissionModelMixin.Meta.managed)
9 |
10 | def test_meta_no_default_permissions(self):
11 | self.assertEqual(len(MyPermissionModelMixin.Meta.default_permissions), 0)
12 |
--------------------------------------------------------------------------------
/tests/tests/mixins/test_django_message_framework.py:
--------------------------------------------------------------------------------
1 | from django.contrib import messages
2 | from django.test import TestCase
3 |
4 | from ai_django_core.tests.mixins import DjangoMessagingFrameworkTestMixin, RequestProviderMixin
5 |
6 |
7 | class DjangoMessagingFrameworkTestMixinTest(RequestProviderMixin, DjangoMessagingFrameworkTestMixin, TestCase):
8 | @classmethod
9 | def setUpTestData(cls):
10 | super().setUpTestData()
11 |
12 | cls.request = cls.get_request()
13 |
14 | def test_full_message_found(self):
15 | messages.add_message(self.request, messages.INFO, 'My message')
16 | self.assert_full_message_in_request(request=self.request, message='My message')
17 |
18 | def test_partial_message_found(self):
19 | messages.add_message(self.request, messages.INFO, 'My message')
20 | self.assert_partial_message_in_request(request=self.request, message='My')
21 |
--------------------------------------------------------------------------------
/tests/tests/mixins/test_request_provider_mixin.py:
--------------------------------------------------------------------------------
1 | from django.contrib import messages
2 | from django.contrib.auth.models import AnonymousUser, User
3 | from django.contrib.sessions.backends.base import SessionBase
4 | from django.http import HttpRequest
5 | from django.test import TestCase
6 |
7 | from ai_django_core.tests.mixins import RequestProviderMixin
8 | from testapp.models import MySingleSignalModel
9 |
10 |
11 | class RequestProviderMixinTest(RequestProviderMixin, TestCase):
12 | def test_request_is_request(self):
13 | request = self.get_request(None)
14 | self.assertIsInstance(request, HttpRequest)
15 |
16 | def test_request_user_set(self):
17 | user = User.objects.create(username='albertus_magnus')
18 | request = self.get_request(user)
19 | self.assertEqual(request.user, user)
20 |
21 | def test_request_user_is_none_working(self):
22 | request = self.get_request(None)
23 | self.assertEqual(request.user, None)
24 |
25 | def test_django_messages_set_up_correctly(self):
26 | request = self.get_request(None)
27 |
28 | # This would fail if the django messages were not set up correctly
29 | messages.add_message(request, messages.SUCCESS, 'I am a great message!')
30 |
31 | self.assertIsInstance(request.session, SessionBase)
32 |
33 | def test_django_session_set_up_correctly(self):
34 | request = self.get_request(None)
35 | request.session['my_val'] = 27
36 | request.session.modified = True
37 |
38 | self.assertEqual(request.session['my_val'], 27)
39 |
40 | def test_passed_user_is_none(self):
41 | request = self.get_request(None)
42 | self.assertIsNone(request.user)
43 |
44 | def test_passed_user_is_regular_user(self):
45 | user = User.objects.create(username='albertus_magnus')
46 | request = self.get_request(user)
47 | self.assertEqual(request.user, user)
48 |
49 | def test_passed_user_is_anonymous_user(self):
50 | anonymous_user = AnonymousUser()
51 | request = self.get_request(anonymous_user)
52 | self.assertEqual(request.user, anonymous_user)
53 |
54 | def test_passed_user_is_other_type(self):
55 | wrong_object = MySingleSignalModel()
56 | with self.assertRaises(ValueError):
57 | self.get_request(wrong_object)
58 |
59 | def test_default_url_used(self):
60 | request = self.get_request()
61 | self.assertEqual(request.build_absolute_uri(), 'http://testserver/')
62 |
63 | def test_passed_url_used(self):
64 | request = self.get_request(url='my-url')
65 | self.assertEqual(request.build_absolute_uri(), 'http://testserver/my-url')
66 |
--------------------------------------------------------------------------------
/tests/tests/test_mail_backends.py:
--------------------------------------------------------------------------------
1 | from django.core.mail import EmailMultiAlternatives
2 | from django.test import TestCase, override_settings
3 |
4 | from ai_django_core.mail.backends.whitelist_smtp import WhitelistEmailBackend
5 |
6 |
7 | @override_settings(
8 | EMAIL_BACKEND='ai_django_core.mail.backends.whitelist_smtp.WhitelistEmailBackend',
9 | EMAIL_BACKEND_DOMAIN_WHITELIST=['valid.domain'],
10 | EMAIL_BACKEND_REDIRECT_ADDRESS='%s@testuser.valid.domain',
11 | )
12 | class MailBackendWhitelistBackendTest(TestCase):
13 | def test_whitify_mail_addresses_replace(self):
14 | email_1 = 'albertus.magnus@example.com'
15 | email_2 = 'thomas_von_aquin@example.com'
16 | processed_list = WhitelistEmailBackend.whitify_mail_addresses(mail_address_list=[email_1, email_2])
17 |
18 | self.assertEqual(len(processed_list), 2)
19 | self.assertEqual(processed_list[0], 'albertus.magnus_example.com@testuser.valid.domain')
20 | self.assertEqual(processed_list[1], 'thomas_von_aquin_example.com@testuser.valid.domain')
21 |
22 | def test_whitify_mail_addresses_whitelisted_domain(self):
23 | email = 'platon@valid.domain'
24 | processed_list = WhitelistEmailBackend.whitify_mail_addresses(mail_address_list=[email])
25 |
26 | self.assertEqual(len(processed_list), 1)
27 | self.assertEqual(processed_list[0], email)
28 |
29 | @override_settings(EMAIL_BACKEND_REDIRECT_ADDRESS='')
30 | def test_whitify_mail_addresses_no_redirect_configured(self):
31 | email = 'sokrates@example.com'
32 | processed_list = WhitelistEmailBackend.whitify_mail_addresses(mail_address_list=[email])
33 |
34 | self.assertEqual(len(processed_list), 0)
35 |
36 | def test_process_recipients_regular(self):
37 | mail = EmailMultiAlternatives(
38 | 'Test subject', 'Here is the message.', 'from@example.com', ['to@example.com'], connection=None
39 | )
40 |
41 | backend = WhitelistEmailBackend()
42 | message_list = backend._process_recipients([mail])
43 | self.assertEqual(len(message_list), 1)
44 | self.assertEqual(message_list[0].to, ['to_example.com@testuser.valid.domain'])
45 |
--------------------------------------------------------------------------------
/tests/view_layer/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ambient-innovation/ai-django-core/35002db94652ba84ece8ec6ae959b12e79ce628f/tests/view_layer/__init__.py
--------------------------------------------------------------------------------
/tests/view_layer/test_formset_mixins.py:
--------------------------------------------------------------------------------
1 | from django import forms
2 | from django.forms import BaseInlineFormSet, inlineformset_factory
3 | from django.test import TestCase
4 |
5 | from ai_django_core.view_layer.formset_mixins import CountChildrenFormsetMixin
6 | from testapp.models import ForeignKeyRelatedModel, MySingleSignalModel
7 |
8 |
9 | class ForeignKeyRelatedModelForm(forms.ModelForm):
10 | class Meta:
11 | model = ForeignKeyRelatedModel
12 | fields = ('single_signal',)
13 |
14 |
15 | class MySingleSignalModelFormset(CountChildrenFormsetMixin, BaseInlineFormSet):
16 | pass
17 |
18 |
19 | class CountChildrenFormsetMixinTest(TestCase):
20 | def test_simple_no_data(self):
21 | formset_class = inlineformset_factory(
22 | MySingleSignalModel,
23 | ForeignKeyRelatedModel,
24 | form=ForeignKeyRelatedModelForm,
25 | formset=MySingleSignalModelFormset,
26 | extra=3,
27 | can_delete=True,
28 | max_num=3,
29 | )
30 |
31 | formset = formset_class()
32 | self.assertEqual(formset.get_number_of_children(), 0)
33 |
34 | def test_regular_with_data(self):
35 | mssm = MySingleSignalModel.objects.create(value=27)
36 | ForeignKeyRelatedModel.objects.create(single_signal=mssm)
37 | ForeignKeyRelatedModel.objects.create(single_signal=mssm)
38 |
39 | formset_class = inlineformset_factory(
40 | MySingleSignalModel,
41 | ForeignKeyRelatedModel,
42 | form=ForeignKeyRelatedModelForm,
43 | formset=MySingleSignalModelFormset,
44 | extra=3,
45 | can_delete=True,
46 | max_num=3,
47 | )
48 |
49 | formset = formset_class(
50 | {'fkrm-INITIAL_FORMS': '2', 'fkrm-MIN_NUM_FORMS': '2', 'fkrm-MAX_NUM_FORMS': '3', 'fkrm-TOTAL_FORMS': '2'},
51 | None,
52 | instance=mssm,
53 | prefix='fkrm',
54 | )
55 |
56 | formset.is_valid()
57 | self.assertEqual(formset.get_number_of_children(), 2)
58 |
--------------------------------------------------------------------------------
/tests/view_layer/test_htmx_response_mixin.py:
--------------------------------------------------------------------------------
1 | from django.contrib.auth.models import AnonymousUser
2 | from django.test import TestCase
3 | from django.views import generic
4 |
5 | from ai_django_core.tests.mixins import RequestProviderMixin
6 | from ai_django_core.view_layer.htmx_mixins import HtmxResponseMixin
7 |
8 |
9 | class HtmxResponseMixinTest(RequestProviderMixin, TestCase):
10 | class TestView(HtmxResponseMixin, generic.View):
11 | hx_redirect_url = 'https://my-url.com'
12 | hx_trigger = 'myEvent'
13 |
14 | class TestViewWithTriggerDict(HtmxResponseMixin, generic.View):
15 | hx_trigger = {'myEvent': None}
16 |
17 | def test_dispatch_functional(self):
18 | view = self.TestView()
19 |
20 | response = view.dispatch(request=self.get_request(user=AnonymousUser()))
21 |
22 | self.assertIn('HX-Redirect', response)
23 | self.assertEqual(response['HX-Redirect'], 'https://my-url.com')
24 |
25 | self.assertIn('HX-Trigger', response)
26 | self.assertEqual(response['HX-Trigger'], 'myEvent')
27 |
28 | def test_dispatch_trigger_with_dict(self):
29 | view = self.TestViewWithTriggerDict()
30 |
31 | response = view.dispatch(request=self.get_request(user=AnonymousUser()))
32 |
33 | self.assertIn('HX-Trigger', response)
34 | self.assertEqual(response['HX-Trigger'], "{\"myEvent\": null}")
35 |
36 | def test_get_hx_redirect_url_regular(self):
37 | view = self.TestView()
38 |
39 | self.assertEqual(view.get_hx_redirect_url(), 'https://my-url.com')
40 |
41 | def test_get_hx_trigger_regular(self):
42 | view = self.TestView()
43 |
44 | self.assertEqual(view.get_hx_trigger(), 'myEvent')
45 |
--------------------------------------------------------------------------------
/tests/view_layer/test_meta_mixins.py:
--------------------------------------------------------------------------------
1 | from django.contrib.auth.models import AnonymousUser, Permission, User
2 | from django.http import HttpResponse
3 | from django.test import TestCase
4 | from django.views import generic
5 |
6 | from ai_django_core.tests.mixins import RequestProviderMixin
7 | from ai_django_core.view_layer.mixins import DjangoPermissionRequiredMixin
8 |
9 |
10 | class MetaDjangoPermissionRequiredMixinTest(RequestProviderMixin, TestCase):
11 | class TestViewNoPerms(DjangoPermissionRequiredMixin, generic.View):
12 | pass
13 |
14 | class TestViewSinglePerm(DjangoPermissionRequiredMixin, generic.View):
15 | permission_list = ['auth.change_user']
16 |
17 | def get(self, *args, **kwargs):
18 | return HttpResponse(status=200)
19 |
20 | class TestViewMultiplePerms(DjangoPermissionRequiredMixin, generic.View):
21 | permission_list = ['auth.change_user', 'auth.add_user']
22 |
23 | def get_login_url(self):
24 | return 'login/'
25 |
26 | @classmethod
27 | def setUpTestData(cls):
28 | super().setUpTestData()
29 |
30 | cls.permission = Permission.objects.get_by_natural_key(app_label='auth', codename='change_user', model='user')
31 |
32 | def setUp(self) -> None:
33 | super().setUp()
34 | self.user = User.objects.create(username='test_user', email='test.user@ai-django-core.com')
35 |
36 | def test_get_login_url(self):
37 | self.assertEqual(self.TestViewMultiplePerms().get_login_url(), 'login/')
38 |
39 | def test_permissions_are_set_validation(self):
40 | with self.assertRaises(RuntimeError):
41 | self.TestViewNoPerms()
42 |
43 | def test_has_permissions_correct_permission(self):
44 | self.user.user_permissions.add(self.permission)
45 |
46 | self.assertTrue(self.TestViewSinglePerm().has_permissions(self.user))
47 |
48 | def test_has_permissions_missing_permission(self):
49 | self.assertFalse(self.TestViewSinglePerm().has_permissions(self.user))
50 |
51 | def test_has_permissions_multiple_permissions_one_missing(self):
52 | self.user.user_permissions.add(self.permission)
53 |
54 | self.assertFalse(self.TestViewMultiplePerms().has_permissions(self.user))
55 |
56 | def test_passes_login_barrier_no_login_required(self):
57 | view = self.TestViewSinglePerm()
58 | view.login_required = False
59 |
60 | self.assertTrue(view.passes_login_barrier(self.user))
61 |
62 | def test_passes_login_barrier_user_logged_in(self):
63 | self.assertTrue(self.TestViewSinglePerm().passes_login_barrier(self.user))
64 |
65 | def test_passes_login_barrier_user_not_logged_in(self):
66 | self.assertFalse(self.TestViewSinglePerm().passes_login_barrier(AnonymousUser()))
67 |
68 | def test_has_permissions_is_superuser_is_not_blocked(self):
69 | self.user.is_superuser = True
70 | self.assertTrue(self.TestViewSinglePerm().has_permissions(self.user))
71 |
72 | def test_has_permissions_django_superuser_is_always_allowed(self):
73 | self.user.is_superuser = True
74 | self.assertTrue(self.TestViewSinglePerm().has_permissions(self.user))
75 |
76 | def test_dispatch_lockout_on_missing_permissions(self):
77 | response = self.TestViewSinglePerm().dispatch(request=self.get_request(self.user))
78 |
79 | self.assertEqual(response.status_code, 403)
80 |
81 | def test_dispatch_working_on_having_permissions(self):
82 | self.user.user_permissions.add(self.permission)
83 | view = self.TestViewSinglePerm()
84 | response = view.dispatch(request=self.get_request(self.user))
85 |
86 | self.assertEqual(response.status_code, 200)
87 |
--------------------------------------------------------------------------------
/tests/view_layer/test_views.py:
--------------------------------------------------------------------------------
1 | from django.contrib.auth.models import User
2 | from django.test import TestCase
3 | from django.views.generic import View
4 | from django.views.generic.detail import SingleObjectMixin
5 |
6 | from ai_django_core.tests.mixins import RequestProviderMixin
7 | from ai_django_core.view_layer.views import ToggleView
8 | from testapp.views import UserInFormKwargsMixinView
9 |
10 |
11 | class UserInFormKwargsMixinTest(RequestProviderMixin, TestCase):
12 | def test_get_form_kwargs_regular(self):
13 | user = User(username='my-user')
14 |
15 | view = UserInFormKwargsMixinView()
16 | view.request = self.get_request(user=user)
17 | form_kwargs = view.get_form_kwargs()
18 |
19 | self.assertIn('user', form_kwargs)
20 | self.assertEqual(form_kwargs['user'], user)
21 |
22 |
23 | class ToggleViewTest(RequestProviderMixin, TestCase):
24 | def test_http_method_set_correctly(self):
25 | self.assertEqual(ToggleView.http_method_names, ('post',))
26 |
27 | def test_post_raises_not_implemented_error(self):
28 | with self.assertRaises(NotImplementedError):
29 | view = ToggleView()
30 | view.post(request=self.get_request())
31 |
32 | def test_class_inherits_from_single_object_mixin(self):
33 | self.assertTrue(issubclass(ToggleView, SingleObjectMixin))
34 |
35 | def test_class_inherits_from_generic_view(self):
36 | self.assertTrue(issubclass(ToggleView, View))
37 |
--------------------------------------------------------------------------------