├── .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 | --------------------------------------------------------------------------------